search pills: Direct message group pill support.

This supports adding users to DM pills by simply typing
a user's name in the text input directly after a complete
dm pill. It only works for DM pills at the end of search
input.

Clicking the X button removes a user from its user pill
container, and if that user was the last user left in the
container, then the whole container is also removed.
This commit is contained in:
evykassirer 2024-04-10 21:15:50 -07:00 committed by Tim Abbott
parent 278edb1b3f
commit 11bb44c7cc
10 changed files with 348 additions and 164 deletions

View File

@ -121,6 +121,7 @@ EXEMPT_FILES = make_set(
"web/src/inbox_util.ts", "web/src/inbox_util.ts",
"web/src/info_overlay.ts", "web/src/info_overlay.ts",
"web/src/information_density.ts", "web/src/information_density.ts",
"web/src/input_pill.ts",
"web/src/integration_url_modal.ts", "web/src/integration_url_modal.ts",
"web/src/invite.ts", "web/src/invite.ts",
"web/src/invite_stream_picker_pill.ts", "web/src/invite_stream_picker_pill.ts",
@ -199,6 +200,7 @@ EXEMPT_FILES = make_set(
"web/src/scroll_util.ts", "web/src/scroll_util.ts",
"web/src/search.ts", "web/src/search.ts",
"web/src/search_pill.ts", "web/src/search_pill.ts",
"web/src/search_suggestion.ts",
"web/src/sent_messages.ts", "web/src/sent_messages.ts",
"web/src/sentry.ts", "web/src/sentry.ts",
"web/src/server_events.js", "web/src/server_events.js",

View File

@ -4,10 +4,12 @@ import $ from "jquery";
import assert from "minimalistic-assert"; import assert from "minimalistic-assert";
import render_input_pill from "../templates/input_pill.hbs"; import render_input_pill from "../templates/input_pill.hbs";
import render_search_user_pill from "../templates/search_user_pill.hbs";
import * as blueslip from "./blueslip"; import * as blueslip from "./blueslip";
import type {EmojiRenderingDetails} from "./emoji"; import type {EmojiRenderingDetails} from "./emoji";
import * as keydown_util from "./keydown_util"; import * as keydown_util from "./keydown_util";
import type {SearchUserPill} from "./search_pill";
import * as ui_util from "./ui_util"; import * as ui_util from "./ui_util";
// See https://zulip.readthedocs.io/en/latest/subsystems/input-pills.html // See https://zulip.readthedocs.io/en/latest/subsystems/input-pills.html
@ -21,6 +23,8 @@ export type InputPillItem<T> = {
should_add_guest_user_indicator?: boolean; should_add_guest_user_indicator?: boolean;
user_id?: number; user_id?: number;
group_id?: number; group_id?: number;
// Used for search pills
operator?: string;
} & T; } & T;
export type InputPillConfig = { export type InputPillConfig = {
@ -158,36 +162,39 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
blueslip.error("no type defined for the item"); blueslip.error("no type defined for the item");
return; return;
} }
let pill_html;
if (item.type === "search_user") {
pill_html = render_search_user_pill(item);
} else {
const has_image = item.img_src !== undefined;
const has_image = item.img_src !== undefined; const opts: InputPillRenderingDetails = {
display_value: item.display_value,
has_image,
deactivated: item.deactivated,
should_add_guest_user_indicator: item.should_add_guest_user_indicator,
};
const opts: InputPillRenderingDetails = { if (item.user_id) {
display_value: item.display_value, opts.user_id = item.user_id;
has_image, }
deactivated: item.deactivated, if (item.group_id) {
should_add_guest_user_indicator: item.should_add_guest_user_indicator, opts.group_id = item.group_id;
};
if (item.user_id) {
opts.user_id = item.user_id;
}
if (item.group_id) {
opts.group_id = item.group_id;
}
if (has_image) {
opts.img_src = item.img_src;
}
if (store.pill_config?.show_user_status_emoji === true) {
const has_status = item.status_emoji_info !== undefined;
if (has_status) {
opts.status_emoji_info = item.status_emoji_info;
} }
opts.has_status = has_status;
}
const pill_html = render_input_pill(opts); if (has_image) {
opts.img_src = item.img_src;
}
if (store.pill_config?.show_user_status_emoji === true) {
const has_status = item.status_emoji_info !== undefined;
if (has_status) {
opts.status_emoji_info = item.status_emoji_info;
}
opts.has_status = has_status;
}
pill_html = render_input_pill(opts);
}
const payload: InputPill<T> = { const payload: InputPill<T> = {
item, item,
$element: $(pill_html), $element: $(pill_html),
@ -249,6 +256,59 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
return undefined; return undefined;
}, },
// TODO: This function is only used for the search input supporting multiple user
// pills within an individual top-level pill. Ideally, we'd encapsulate it in a
// subclass used only for search so that this code can be part of search_pill.ts.
removeUserPill(user_container: HTMLElement, user_id: number) {
// First get the outer pill that contains the user pills.
let container_idx: number | undefined;
for (let x = 0; x < store.pills.length; x += 1) {
if (store.pills[x]!.$element[0] === user_container) {
container_idx = x;
}
}
assert(container_idx !== undefined);
assert(store.pills[container_idx]!.item.type === "search_user");
// TODO: Figure out how to get this typed correctly.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const user_pill_container = store.pills[container_idx]!
.item as unknown as InputPillItem<SearchUserPill>;
// If there's only one user in this pill, delete the whole pill.
if (user_pill_container.users.length === 1) {
assert(user_pill_container.users[0]!.user_id === user_id);
this.removePill(user_container);
return;
}
// Remove the user id from the pill data.
let user_idx: number | undefined;
for (let x = 0; x < user_pill_container.users.length; x += 1) {
if (user_pill_container.users[x]!.user_id === user_id) {
user_idx = x;
}
}
assert(user_idx !== undefined);
user_pill_container.users.splice(user_idx, 1);
const sign = user_pill_container.negated ? "-" : "";
const search_string =
sign +
user_pill_container.operator +
":" +
user_pill_container.users.map((user) => user.email).join(",");
user_pill_container.display_value = search_string;
// Remove the user pill from the DOM.
const $user_pill = $(store.pills[container_idx]!.$element.children(".pill")[user_idx]!);
assert($user_pill.data("user-id") === user_id);
$user_pill.remove();
// This is needed to run the "change" event handler registered in
// compose_recipient.js, which calls the `update_on_recipient_change` to update
// the compose_fade state.
store.$input.trigger("change");
},
// this will remove the last pill in the container -- by default tied // this will remove the last pill in the container -- by default tied
// to the "Backspace" key when the value of the input is empty. // to the "Backspace" key when the value of the input is empty.
// If quiet is a truthy value, the event handler associated with the // If quiet is a truthy value, the event handler associated with the
@ -444,6 +504,18 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
// when the "×" is clicked on a pill, it should delete that pill and then // when the "×" is clicked on a pill, it should delete that pill and then
// select the next pill (or input). // select the next pill (or input).
store.$parent.on("click", ".exit", function (this: HTMLElement, e) { store.$parent.on("click", ".exit", function (this: HTMLElement, e) {
const $user_pill_container = $(this).parents(".user-pill-container");
if ($user_pill_container.length) {
// The user-pill-container container class is used exclusively for
// group-DM search pills, where multiple user pills sit inside a larger
// pill. The exit icons in those individual user pills should remove
// just that pill, not the outer pill.
// TODO: Figure out how to move this code into search_pill.ts.
const user_id = $(this).closest(".pill").attr("data-user-id");
assert(user_id !== undefined);
funcs.removeUserPill($user_pill_container[0]!, Number.parseInt(user_id, 10));
return;
}
e.stopPropagation(); e.stopPropagation();
const $pill = $(this).closest(".pill"); const $pill = $(this).closest(".pill");
const $next = $pill.next(); const $next = $pill.next();

View File

@ -83,6 +83,10 @@ export function initialize({on_narrow_search}: {on_narrow_search: OnNarrowSearch
search_input_has_changed = true; search_input_has_changed = true;
}); });
$search_query_box.on("change", () => {
search_typeahead.lookup(false);
});
// Data storage for the typeahead. // Data storage for the typeahead.
// This maps a search string to an object with a "description_html" field. // This maps a search string to an object with a "description_html" field.
// (It's a bit of legacy that we have an object with only one important // (It's a bit of legacy that we have an object with only one important

View File

@ -1,13 +1,42 @@
import assert from "minimalistic-assert";
import {Filter} from "./filter"; import {Filter} from "./filter";
import * as input_pill from "./input_pill"; import * as input_pill from "./input_pill";
import type {InputPillContainer} from "./input_pill"; import type {InputPillContainer} from "./input_pill";
import * as people from "./people";
import type {User} from "./people";
import type {NarrowTerm} from "./state_data"; import type {NarrowTerm} from "./state_data";
import * as user_status from "./user_status";
import type {UserStatusEmojiInfo} from "./user_status";
type SearchPill = { export type SearchUserPill = {
type: "search_user";
operator: string;
// TODO: It would be nice if we just call this `search_string` instead of
// `display_value`, because we don't actually display this value for user
// pills, but `display_value` is needed to hook into the generic input pill
// logic and it would be a decent amount of work to change that.
display_value: string; display_value: string;
type: string; negated: boolean;
description_html: string; users: {
display_value: string;
user_id: number;
email: string;
img_src: string;
status_emoji_info: UserStatusEmojiInfo | undefined;
should_add_guest_user_indicator: boolean;
deactivated: boolean;
}[];
}; };
type SearchPill =
| {
type: "search";
display_value: string;
description_html: string;
}
| SearchUserPill;
export type SearchPillWidget = InputPillContainer<SearchPill>; export type SearchPillWidget = InputPillContainer<SearchPill>;
export function create_item_from_search_string(search_string: string): SearchPill { export function create_item_from_search_string(search_string: string): SearchPill {
@ -34,6 +63,36 @@ export function create_pills($pill_container: JQuery): SearchPillWidget {
return pills; return pills;
} }
function append_user_pill(
users: User[],
pill_widget: SearchPillWidget,
operator: string,
negated: boolean,
): void {
const sign = negated ? "-" : "";
const search_string = sign + operator + ":" + users.map((user) => user.email).join(",");
const pill_data: SearchUserPill = {
type: "search_user",
operator,
display_value: search_string,
negated,
users: users.map((user) => ({
display_value: user.full_name,
user_id: user.user_id,
email: user.email,
img_src: people.small_avatar_url_for_person(user),
status_emoji_info: user_status.get_status_emoji(user.user_id),
should_add_guest_user_indicator: people.should_add_guest_user_indicator(user.user_id),
deactivated: !people.is_person_active(user.user_id) && !user.is_inaccessible_user,
})),
};
pill_widget.appendValidatedData(pill_data);
pill_widget.clear_text();
}
const user_pill_operators = new Set(["dm", "dm-including", "sender"]);
export function set_search_bar_contents( export function set_search_bar_contents(
search_terms: NarrowTerm[], search_terms: NarrowTerm[],
pill_widget: SearchPillWidget, pill_widget: SearchPillWidget,
@ -42,6 +101,16 @@ export function set_search_bar_contents(
pill_widget.clear(); pill_widget.clear();
let partial_pill = ""; let partial_pill = "";
for (const term of search_terms) { for (const term of search_terms) {
if (user_pill_operators.has(term.operator) && term.operand !== "") {
const user_emails = term.operand.split(",");
const users = user_emails.map((email) => {
const user = people.get_by_email(email);
assert(user !== undefined);
return user;
});
append_user_pill(users, pill_widget, term.operator, term.negated ?? false);
continue;
}
const input = Filter.unparse([term]); const input = Filter.unparse([term]);
// If the last term looks something like `dm:`, we // If the last term looks something like `dm:`, we
// don't want to make it a pill, since it isn't isn't // don't want to make it a pill, since it isn't isn't

View File

@ -181,9 +181,40 @@ function get_channel_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggest
} }
function get_group_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggestion[] { function get_group_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggestion[] {
// We only suggest groups once a term with a valid user already exists
if (terms.length === 0) {
return [];
}
const last_complete_term = terms.at(-1)!;
// For users with "pm-with" in their muscle memory, still // For users with "pm-with" in their muscle memory, still
// have group direct message suggestions with "dm:" operator. // have group direct message suggestions with "dm:" operator.
if (!check_validity(last, terms, ["dm", "pm-with"], [{operator: "channel"}])) { if (
!check_validity(
last_complete_term,
terms.slice(-1),
["dm", "pm-with"],
[{operator: "channel"}],
)
) {
return [];
}
// If they started typing since a user pill, we'll parse that as "search"
// but they might actually want to parse that as a user instead to add to
// the most recent pill. So we shuffle some things around to support that.
if (last.operator === "search") {
const text_input = last.operand;
const operand = `${last_complete_term.operand},${text_input}`;
last = {
...last_complete_term,
operand,
};
terms = terms.slice(-1);
} else if (last.operator === "") {
last = last_complete_term;
} else {
// If they already started another term with an other operator, we're
// no longer dealing with a group DM situation.
return []; return [];
} }
@ -197,14 +228,17 @@ function get_group_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggestio
// we only use the last part to generate suggestions. // we only use the last part to generate suggestions.
const last_comma_index = operand.lastIndexOf(","); const last_comma_index = operand.lastIndexOf(",");
let all_but_last_part;
let last_part;
if (last_comma_index < 0) { if (last_comma_index < 0) {
return []; all_but_last_part = operand;
last_part = "";
} else {
// Neither all_but_last_part nor last_part include the final comma.
all_but_last_part = operand.slice(0, last_comma_index);
last_part = operand.slice(last_comma_index + 1);
} }
// Neither all_but_last_part nor last_part include the final comma.
const all_but_last_part = operand.slice(0, last_comma_index);
const last_part = operand.slice(last_comma_index + 1);
// We don't suggest a person if their email is already present in the // We don't suggest a person if their email is already present in the
// operand (not including the last part). // operand (not including the last part).
const parts = [...all_but_last_part.split(","), people.my_current_email()]; const parts = [...all_but_last_part.split(","), people.my_current_email()];
@ -827,7 +861,24 @@ class Attacher {
attach_many(suggestions: Suggestion[]): void { attach_many(suggestions: Suggestion[]): void {
for (const suggestion of suggestions) { for (const suggestion of suggestions) {
const suggestion_line = [...this.base, suggestion]; let suggestion_line;
if (this.base.length === 0) {
suggestion_line = [suggestion];
} else {
// When we add a user to a user group, we
// replace the last pill.
const last_base_term = this.base.at(-1)!;
const last_base_string = last_base_term.search_string;
const new_search_string = suggestion.search_string;
if (
new_search_string.startsWith("dm:") &&
new_search_string.includes(last_base_string)
) {
suggestion_line = [...this.base.slice(0, -1), suggestion];
} else {
suggestion_line = [...this.base, suggestion];
}
}
this.push(suggestion_line); this.push(suggestion_line);
} }
} }
@ -949,15 +1000,19 @@ export function get_search_result(query_from_pills: string, query_from_text: str
// Remember to update the spectator list when changing this. // Remember to update the spectator list when changing this.
let filterers = [ let filterers = [
// This should show before other `get_people` suggestions
// because both are valid suggestions for typing a user's
// name, and if there's already has a DM pill then the
// searching user probably is looking to make a group DM.
get_group_suggestions,
get_channels_filter_suggestions, get_channels_filter_suggestions,
get_is_filter_suggestions, get_is_filter_suggestions,
get_sent_by_me_suggestions, get_sent_by_me_suggestions,
get_channel_suggestions, get_channel_suggestions,
get_people("sender"),
get_people("dm"), get_people("dm"),
get_people("sender"),
get_people("dm-including"), get_people("dm-including"),
get_people("from"), get_people("from"),
get_group_suggestions,
get_topic_suggestions, get_topic_suggestions,
get_operator_suggestions, get_operator_suggestions,
get_has_filter_suggestions, get_has_filter_suggestions,

View File

@ -567,6 +567,7 @@
--color-background-exit-hover-deactivated-user-pill: hsl( --color-background-exit-hover-deactivated-user-pill: hsl(
4deg 75% 53% / 15% 4deg 75% 53% / 15%
); );
--color-background-user-pill: hsla(0deg 0% 100% / 85%);
/* Inbox view constants - Values from Figma design */ /* Inbox view constants - Values from Figma design */
--height-inbox-search: 26px; --height-inbox-search: 26px;
@ -949,6 +950,7 @@
--color-focus-outline-deactivated-user-pill: hsl(0deg 0% 100% / 70%); --color-focus-outline-deactivated-user-pill: hsl(0deg 0% 100% / 70%);
--color-close-deactivated-user-pill: hsl(7deg 100% 74%); --color-close-deactivated-user-pill: hsl(7deg 100% 74%);
--color-background-exit-hover-deactivated-user-pill: hsl(0deg 0% 100% / 7%); --color-background-exit-hover-deactivated-user-pill: hsl(0deg 0% 100% / 7%);
--color-background-user-pill: hsl(0deg 0% 0% / 40%);
/* Inbox view */ /* Inbox view */
--color-background-inbox: var(--color-background); --color-background-inbox: var(--color-background);

View File

@ -68,7 +68,7 @@
.search-input-and-pills { .search-input-and-pills {
grid-area: search-pills; grid-area: search-pills;
display: flex; display: flex;
padding: 3px 0; padding: 0;
flex-wrap: wrap; flex-wrap: wrap;
gap: 2px 2px; gap: 2px 2px;
align-self: center; align-self: center;
@ -222,6 +222,7 @@
.pill { .pill {
margin: 0; margin: 0;
min-width: unset; min-width: unset;
height: 26px;
} }
&:not(.focused) { &:not(.focused) {
@ -233,6 +234,33 @@
overflow: hidden; overflow: hidden;
} }
} }
.user-pill-container {
padding: 2px;
height: 22px;
min-width: fit-content;
> .pill-label {
min-width: fit-content;
white-space: nowrap;
width: fit-content;
}
.pill {
height: 22px;
margin: 2px;
border: none;
&:not(.deactivated-pill) {
background-color: var(--color-background-user-pill);
}
}
.pill-image {
width: 22px;
height: 22px;
}
}
} }
@media (width >= $md_min) { @media (width >= $md_min) {

View File

@ -0,0 +1,22 @@
<div class="user-pill-container pill" tabindex=0>
<span class="pill-label">
{{~#if this.negated}}-{{~/if~}}
{{ operator }}:
</span>
{{#each users}}
<div class="pill{{#if deactivated}} deactivated-pill{{/if}}" data-user-id="{{this.user_id}}">
<img class="pill-image" src="{{this.img_src}}" />
<span class="pill-label">
<span class="pill-value">{{ this.display_value }}</span>
{{~#if this.should_add_guest_user_indicator}}&nbsp;<i>({{t 'guest'}})</i>{{~/if~}}
{{~#if deactivated}}&nbsp;({{t 'deactivated'}}){{~/if~}}
{{~#if this.status_emoji_info~}}
{{~> status_emoji this.status_emoji_info~}}
{{~/if~}}
</span>
<div class="exit">
<a role="button" class="zulip-icon zulip-icon-close pill-close-button"></a>
</div>
</div>
{{/each}}
</div>

View File

@ -529,6 +529,9 @@ run_test("exit button on pill", ({mock_template}) => {
assert.equal(sel, ".pill"); assert.equal(sel, ".pill");
return $curr_pill_stub; return $curr_pill_stub;
}, },
parents() {
return [];
},
}), }),
}; };

View File

@ -68,8 +68,8 @@ function init() {
stream_data.clear_subscriptions(); stream_data.clear_subscriptions();
} }
function get_suggestions(query) { function get_suggestions(query, pill_query = "") {
return search.get_suggestions("", query); return search.get_suggestions(pill_query, query);
} }
function test(label, f) { function test(label, f) {
@ -151,8 +151,8 @@ test("dm_suggestions", ({override, mock_template}) => {
expected = [ expected = [
"is:dm al", "is:dm al",
"is:dm is:alerted", "is:dm is:alerted",
"is:dm sender:alice@zulip.com",
"is:dm dm:alice@zulip.com", "is:dm dm:alice@zulip.com",
"is:dm sender:alice@zulip.com",
"is:dm dm-including:alice@zulip.com", "is:dm dm-including:alice@zulip.com",
"is:dm", "is:dm",
]; ];
@ -234,8 +234,8 @@ test("dm_suggestions", ({override, mock_template}) => {
expected = [ expected = [
"is:starred has:link is:dm al", "is:starred has:link is:dm al",
"is:starred has:link is:dm is:alerted", "is:starred has:link is:dm is:alerted",
"is:starred has:link is:dm sender:alice@zulip.com",
"is:starred has:link is:dm dm:alice@zulip.com", "is:starred has:link is:dm dm:alice@zulip.com",
"is:starred has:link is:dm sender:alice@zulip.com",
"is:starred has:link is:dm dm-including:alice@zulip.com", "is:starred has:link is:dm dm-including:alice@zulip.com",
"is:starred has:link is:dm", "is:starred has:link is:dm",
"is:starred has:link", "is:starred has:link",
@ -272,90 +272,73 @@ test("group_suggestions", ({mock_template}) => {
mock_template("search_description.hbs", true, (_data, html) => html); mock_template("search_description.hbs", true, (_data, html) => html);
mock_template("user_pill.hbs", true, (_data, html) => html); mock_template("user_pill.hbs", true, (_data, html) => html);
// Entering a comma in a "dm:" query should immediately // If there's an existing completed user pill right before
// generate suggestions for the next person. // the input string, we suggest a user group as one of the
let query = "dm:bob@zulip.com,"; // suggestions.
let suggestions = get_suggestions(query); let pill_query = "dm:bob@zulip.com";
let query = "alice";
let suggestions = get_suggestions(query, pill_query);
let expected = [ let expected = [
"dm:bob@zulip.com,", "dm:bob@zulip.com alice",
"dm:bob@zulip.com,alice@zulip.com", "dm:bob@zulip.com,alice@zulip.com",
"dm:bob@zulip.com,jeff@zulip.com", "dm:bob@zulip.com sender:alice@zulip.com",
"dm:bob@zulip.com,ted@zulip.com", "dm:bob@zulip.com dm-including:alice@zulip.com",
"dm:bob@zulip.com",
]; ];
assert.deepEqual(suggestions.strings, expected); assert.deepEqual(suggestions.strings, expected);
// Only the last part of a comma-separated "dm" query // Do not suggest "myself@zulip.com" (the name of the current user) for dms
// should be used to generate suggestions. pill_query = "dm:ted@zulip.com";
query = "dm:bob@zulip.com,t"; query = "my";
suggestions = get_suggestions(query); suggestions = get_suggestions(query, pill_query);
expected = ["dm:bob@zulip.com,t", "dm:bob@zulip.com,ted@zulip.com"]; expected = [
assert.deepEqual(suggestions.strings, expected); "dm:ted@zulip.com my",
"dm:ted@zulip.com sender:myself@zulip.com",
// Smit should also generate ted@zulip.com (Ted Smith) as a suggestion. "dm:ted@zulip.com dm-including:myself@zulip.com",
query = "dm:bob@zulip.com,Smit"; "dm:ted@zulip.com",
suggestions = get_suggestions(query); ];
expected = ["dm:bob@zulip.com,Smit", "dm:bob@zulip.com,ted@zulip.com"];
assert.deepEqual(suggestions.strings, expected);
// Do not suggest "myself@zulip.com" (the name of the current user)
query = "dm:ted@zulip.com,my";
suggestions = get_suggestions(query);
expected = ["dm:ted@zulip.com,my"];
assert.deepEqual(suggestions.strings, expected);
// No superfluous suggestions should be generated.
query = "dm:bob@zulip.com,red";
suggestions = get_suggestions(query);
expected = ["dm:bob@zulip.com,red"];
assert.deepEqual(suggestions.strings, expected); assert.deepEqual(suggestions.strings, expected);
// "is:dm" should be properly prepended to each suggestion // "is:dm" should be properly prepended to each suggestion
// if the "dm" operator is negated. // if the "dm" operator is negated.
query = "-dm:bob@zulip.com,"; query = "-dm:bob@zulip.co";
suggestions = get_suggestions(query); suggestions = get_suggestions(query);
expected = [ expected = [
"-dm:bob@zulip.com,", "-dm:bob@zulip.co",
"is:dm -dm:bob@zulip.com,alice@zulip.com", "is:dm -dm:bob@zulip.com",
"is:dm -dm:bob@zulip.com,jeff@zulip.com",
"is:dm -dm:bob@zulip.com,ted@zulip.com",
]; ];
assert.deepEqual(suggestions.strings, expected); assert.deepEqual(suggestions.strings, expected);
query = "-dm:bob@zulip.com,t";
suggestions = get_suggestions(query);
expected = ["-dm:bob@zulip.com,t", "is:dm -dm:bob@zulip.com,ted@zulip.com"];
assert.deepEqual(suggestions.strings, expected);
query = "-dm:bob@zulip.com,Smit";
suggestions = get_suggestions(query);
expected = ["-dm:bob@zulip.com,Smit", "is:dm -dm:bob@zulip.com,ted@zulip.com"];
assert.deepEqual(suggestions.strings, expected);
query = "-dm:bob@zulip.com,red"; query = "-dm:bob@zulip.com,red";
suggestions = get_suggestions(query); suggestions = get_suggestions(query);
expected = ["-dm:bob@zulip.com,red"]; expected = ["-dm:bob@zulip.com,red"];
assert.deepEqual(suggestions.strings, expected); assert.deepEqual(suggestions.strings, expected);
// If user types "pm-with" operator, an email and a comma, // If user types "pm-with" operator, show suggestions for
// show suggestions for group direct messages with the "dm" // group direct messages with the "dm" operator.
// operator. pill_query = "pm-with:bob@zulip.com";
query = "pm-with:bob@zulip.com,"; query = "alice";
suggestions = get_suggestions(query); suggestions = get_suggestions(query, pill_query);
expected = [ expected = [
"dm:bob@zulip.com,", "dm:bob@zulip.com alice",
"dm:bob@zulip.com,alice@zulip.com", "dm:bob@zulip.com,alice@zulip.com",
"dm:bob@zulip.com,jeff@zulip.com", "dm:bob@zulip.com sender:alice@zulip.com",
"dm:bob@zulip.com,ted@zulip.com", "dm:bob@zulip.com dm-including:alice@zulip.com",
"dm:bob@zulip.com",
]; ];
assert.deepEqual(suggestions.strings, expected); assert.deepEqual(suggestions.strings, expected);
// Test multiple terms // Test multiple terms
query = "is:starred has:link dm:bob@zulip.com,Smit"; pill_query = "is:starred has:link dm:bob@zulip.com";
suggestions = get_suggestions(query); query = "Smit";
suggestions = get_suggestions(query, pill_query);
expected = [ expected = [
"is:starred has:link dm:bob@zulip.com,Smit", "is:starred has:link dm:bob@zulip.com Smit",
"is:starred has:link dm:bob@zulip.com,ted@zulip.com", "is:starred has:link dm:bob@zulip.com,ted@zulip.com",
"is:starred has:link dm:bob@zulip.com sender:ted@zulip.com",
"is:starred has:link dm:bob@zulip.com dm-including:ted@zulip.com",
"is:starred has:link dm:bob@zulip.com",
"is:starred has:link", "is:starred has:link",
"is:starred", "is:starred",
]; ];
@ -377,62 +360,6 @@ test("group_suggestions", ({mock_template}) => {
suggestions = get_suggestions(query); suggestions = get_suggestions(query);
expected = ["has:link dm:invalid@zulip.com,Smit", "has:link"]; expected = ["has:link dm:invalid@zulip.com,Smit", "has:link"];
assert.deepEqual(suggestions.strings, expected); assert.deepEqual(suggestions.strings, expected);
function message(user_ids, timestamp) {
return {
type: "private",
display_recipient: user_ids.map((id) => ({
id,
})),
timestamp,
};
}
direct_message_group_data.process_loaded_messages([
message([bob.user_id, ted.user_id], 99),
message([bob.user_id, ted.user_id, jeff.user_id], 98),
]);
// Simulate a past group direct message which should now
// prioritize ted over alice
query = "dm:bob@zulip.com,";
suggestions = get_suggestions(query);
expected = [
"dm:bob@zulip.com,",
"dm:bob@zulip.com,ted@zulip.com",
"dm:bob@zulip.com,alice@zulip.com",
"dm:bob@zulip.com,jeff@zulip.com",
];
assert.deepEqual(suggestions.strings, expected);
// bob, ted, and jeff are already an existing group direct message,
// so prioritize this one
query = "dm:bob@zulip.com,ted@zulip.com,";
suggestions = get_suggestions(query);
expected = [
"dm:bob@zulip.com,ted@zulip.com,",
"dm:bob@zulip.com,ted@zulip.com,jeff@zulip.com",
"dm:bob@zulip.com,ted@zulip.com,alice@zulip.com",
];
assert.deepEqual(suggestions.strings, expected);
// bob, ted, and jeff are already an existing group direct message,
// but if we start with just jeff, then don't prioritize ted over
// alice because it doesn't complete the full group direct message.
query = "dm:jeff@zulip.com,";
suggestions = get_suggestions(query);
expected = [
"dm:jeff@zulip.com,",
"dm:jeff@zulip.com,alice@zulip.com",
"dm:jeff@zulip.com,bob@zulip.com",
"dm:jeff@zulip.com,ted@zulip.com",
];
assert.deepEqual(suggestions.strings, expected);
query = "dm:jeff@zulip.com,ted@zulip.com hi";
suggestions = get_suggestions(query);
expected = ["dm:jeff@zulip.com,ted@zulip.com hi", "dm:jeff@zulip.com,ted@zulip.com"];
assert.deepEqual(suggestions.strings, expected);
}); });
test("empty_query_suggestions", () => { test("empty_query_suggestions", () => {
@ -560,8 +487,8 @@ test("check_is_suggestions", ({override, mock_template}) => {
"is:alerted", "is:alerted",
"is:unread", "is:unread",
"is:resolved", "is:resolved",
"sender:alice@zulip.com",
"dm:alice@zulip.com", "dm:alice@zulip.com",
"sender:alice@zulip.com",
"dm-including:alice@zulip.com", "dm-including:alice@zulip.com",
"has:image", "has:image",
]; ];
@ -733,7 +660,7 @@ test("topic_suggestions", ({override, mock_template}) => {
stream_data.add_sub({stream_id: office_id, name: "office", subscribed: true}); stream_data.add_sub({stream_id: office_id, name: "office", subscribed: true});
suggestions = get_suggestions("te"); suggestions = get_suggestions("te");
expected = ["te", "sender:ted@zulip.com", "dm:ted@zulip.com", "dm-including:ted@zulip.com"]; expected = ["te", "dm:ted@zulip.com", "sender:ted@zulip.com", "dm-including:ted@zulip.com"];
assert.deepEqual(suggestions.strings, expected); assert.deepEqual(suggestions.strings, expected);
stream_topic_history.add_message({ stream_topic_history.add_message({
@ -751,8 +678,8 @@ test("topic_suggestions", ({override, mock_template}) => {
suggestions = get_suggestions("te"); suggestions = get_suggestions("te");
expected = [ expected = [
"te", "te",
"sender:ted@zulip.com",
"dm:ted@zulip.com", "dm:ted@zulip.com",
"sender:ted@zulip.com",
"dm-including:ted@zulip.com", "dm-including:ted@zulip.com",
"channel:office topic:team", "channel:office topic:team",
"channel:office topic:test", "channel:office topic:test",
@ -929,10 +856,10 @@ test("people_suggestions", ({override, mock_template}) => {
let expected = [ let expected = [
"te", "te",
"sender:bob@zulip.com",
"sender:ted@zulip.com",
"dm:bob@zulip.com", // bob térry "dm:bob@zulip.com", // bob térry
"dm:ted@zulip.com", "dm:ted@zulip.com",
"sender:bob@zulip.com",
"sender:ted@zulip.com",
"dm-including:bob@zulip.com", "dm-including:bob@zulip.com",
"dm-including:ted@zulip.com", "dm-including:ted@zulip.com",
]; ];
@ -949,12 +876,12 @@ test("people_suggestions", ({override, mock_template}) => {
expected = [ expected = [
"te", "te",
"sender:bob@zulip.com",
"sender:ted@zulip.com",
"sender:user299@zulipdev.com",
"dm:bob@zulip.com", "dm:bob@zulip.com",
"dm:ted@zulip.com", "dm:ted@zulip.com",
"dm:user299@zulipdev.com", "dm:user299@zulipdev.com",
"sender:bob@zulip.com",
"sender:ted@zulip.com",
"sender:user299@zulipdev.com",
"dm-including:bob@zulip.com", "dm-including:bob@zulip.com",
"dm-including:ted@zulip.com", "dm-including:ted@zulip.com",
"dm-including:user299@zulipdev.com", "dm-including:user299@zulipdev.com",
@ -1035,7 +962,7 @@ test("people_suggestions", ({override, mock_template}) => {
suggestions = get_suggestions("Ted "); // note space suggestions = get_suggestions("Ted "); // note space
expected = ["Ted", "sender:ted@zulip.com", "dm:ted@zulip.com", "dm-including:ted@zulip.com"]; expected = ["Ted", "dm:ted@zulip.com", "sender:ted@zulip.com", "dm-including:ted@zulip.com"];
assert.deepEqual(suggestions.strings, expected); assert.deepEqual(suggestions.strings, expected);