diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 7febdb6368..15335e25d4 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -121,6 +121,7 @@ EXEMPT_FILES = make_set( "web/src/inbox_util.ts", "web/src/info_overlay.ts", "web/src/information_density.ts", + "web/src/input_pill.ts", "web/src/integration_url_modal.ts", "web/src/invite.ts", "web/src/invite_stream_picker_pill.ts", @@ -199,6 +200,7 @@ EXEMPT_FILES = make_set( "web/src/scroll_util.ts", "web/src/search.ts", "web/src/search_pill.ts", + "web/src/search_suggestion.ts", "web/src/sent_messages.ts", "web/src/sentry.ts", "web/src/server_events.js", diff --git a/web/src/input_pill.ts b/web/src/input_pill.ts index 0ad2cde20e..910853f26a 100644 --- a/web/src/input_pill.ts +++ b/web/src/input_pill.ts @@ -4,10 +4,12 @@ import $ from "jquery"; import assert from "minimalistic-assert"; 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 type {EmojiRenderingDetails} from "./emoji"; import * as keydown_util from "./keydown_util"; +import type {SearchUserPill} from "./search_pill"; import * as ui_util from "./ui_util"; // See https://zulip.readthedocs.io/en/latest/subsystems/input-pills.html @@ -21,6 +23,8 @@ export type InputPillItem = { should_add_guest_user_indicator?: boolean; user_id?: number; group_id?: number; + // Used for search pills + operator?: string; } & T; export type InputPillConfig = { @@ -158,36 +162,39 @@ export function create(opts: InputPillCreateOptions): InputPillContainer = { item, $element: $(pill_html), @@ -249,6 +256,59 @@ export function create(opts: InputPillCreateOptions): InputPillContainer; + + // 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 // to the "Backspace" key when the value of the input is empty. // If quiet is a truthy value, the event handler associated with the @@ -444,6 +504,18 @@ export function create(opts: InputPillCreateOptions): InputPillContainer { + search_typeahead.lookup(false); + }); + // Data storage for the typeahead. // 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 diff --git a/web/src/search_pill.ts b/web/src/search_pill.ts index 0213727f9b..1f811fa483 100644 --- a/web/src/search_pill.ts +++ b/web/src/search_pill.ts @@ -1,13 +1,42 @@ +import assert from "minimalistic-assert"; + import {Filter} from "./filter"; import * as input_pill 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 * 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; - type: string; - description_html: string; + negated: boolean; + 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; 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; } +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( search_terms: NarrowTerm[], pill_widget: SearchPillWidget, @@ -42,6 +101,16 @@ export function set_search_bar_contents( pill_widget.clear(); let partial_pill = ""; 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]); // If the last term looks something like `dm:`, we // don't want to make it a pill, since it isn't isn't diff --git a/web/src/search_suggestion.ts b/web/src/search_suggestion.ts index 786ba049fd..8e5065d899 100644 --- a/web/src/search_suggestion.ts +++ b/web/src/search_suggestion.ts @@ -181,9 +181,40 @@ function get_channel_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggest } 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 // 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 []; } @@ -197,14 +228,17 @@ function get_group_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggestio // we only use the last part to generate suggestions. const last_comma_index = operand.lastIndexOf(","); + let all_but_last_part; + let last_part; 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 // operand (not including the last part). const parts = [...all_but_last_part.split(","), people.my_current_email()]; @@ -827,7 +861,24 @@ class Attacher { attach_many(suggestions: Suggestion[]): void { 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); } } @@ -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. 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_is_filter_suggestions, get_sent_by_me_suggestions, get_channel_suggestions, - get_people("sender"), get_people("dm"), + get_people("sender"), get_people("dm-including"), get_people("from"), - get_group_suggestions, get_topic_suggestions, get_operator_suggestions, get_has_filter_suggestions, diff --git a/web/styles/app_variables.css b/web/styles/app_variables.css index 1bf85e5e1d..90e7f74a74 100644 --- a/web/styles/app_variables.css +++ b/web/styles/app_variables.css @@ -567,6 +567,7 @@ --color-background-exit-hover-deactivated-user-pill: hsl( 4deg 75% 53% / 15% ); + --color-background-user-pill: hsla(0deg 0% 100% / 85%); /* Inbox view constants - Values from Figma design */ --height-inbox-search: 26px; @@ -949,6 +950,7 @@ --color-focus-outline-deactivated-user-pill: hsl(0deg 0% 100% / 70%); --color-close-deactivated-user-pill: hsl(7deg 100% 74%); --color-background-exit-hover-deactivated-user-pill: hsl(0deg 0% 100% / 7%); + --color-background-user-pill: hsl(0deg 0% 0% / 40%); /* Inbox view */ --color-background-inbox: var(--color-background); diff --git a/web/styles/search.css b/web/styles/search.css index fa9762cdd8..457ee5c604 100644 --- a/web/styles/search.css +++ b/web/styles/search.css @@ -68,7 +68,7 @@ .search-input-and-pills { grid-area: search-pills; display: flex; - padding: 3px 0; + padding: 0; flex-wrap: wrap; gap: 2px 2px; align-self: center; @@ -222,6 +222,7 @@ .pill { margin: 0; min-width: unset; + height: 26px; } &:not(.focused) { @@ -233,6 +234,33 @@ 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) { diff --git a/web/templates/search_user_pill.hbs b/web/templates/search_user_pill.hbs new file mode 100644 index 0000000000..6a5ba322a3 --- /dev/null +++ b/web/templates/search_user_pill.hbs @@ -0,0 +1,22 @@ +
+ + {{~#if this.negated}}-{{~/if~}} + {{ operator }}: + + {{#each users}} +
+ + + {{ this.display_value }} + {{~#if this.should_add_guest_user_indicator}} ({{t 'guest'}}){{~/if~}} + {{~#if deactivated}} ({{t 'deactivated'}}){{~/if~}} + {{~#if this.status_emoji_info~}} + {{~> status_emoji this.status_emoji_info~}} + {{~/if~}} + +
+ +
+
+ {{/each}} +
diff --git a/web/tests/input_pill.test.js b/web/tests/input_pill.test.js index 55bb0d276f..ab2e85d8b7 100644 --- a/web/tests/input_pill.test.js +++ b/web/tests/input_pill.test.js @@ -529,6 +529,9 @@ run_test("exit button on pill", ({mock_template}) => { assert.equal(sel, ".pill"); return $curr_pill_stub; }, + parents() { + return []; + }, }), }; diff --git a/web/tests/search_suggestion.test.js b/web/tests/search_suggestion.test.js index 4e1802e80a..b44b9d02bc 100644 --- a/web/tests/search_suggestion.test.js +++ b/web/tests/search_suggestion.test.js @@ -68,8 +68,8 @@ function init() { stream_data.clear_subscriptions(); } -function get_suggestions(query) { - return search.get_suggestions("", query); +function get_suggestions(query, pill_query = "") { + return search.get_suggestions(pill_query, query); } function test(label, f) { @@ -151,8 +151,8 @@ test("dm_suggestions", ({override, mock_template}) => { expected = [ "is:dm al", "is:dm is:alerted", - "is:dm sender:alice@zulip.com", "is:dm dm:alice@zulip.com", + "is:dm sender:alice@zulip.com", "is:dm dm-including:alice@zulip.com", "is:dm", ]; @@ -234,8 +234,8 @@ test("dm_suggestions", ({override, mock_template}) => { expected = [ "is:starred has:link is:dm al", "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 sender:alice@zulip.com", "is:starred has:link is:dm dm-including:alice@zulip.com", "is:starred has:link is:dm", "is:starred has:link", @@ -272,90 +272,73 @@ test("group_suggestions", ({mock_template}) => { mock_template("search_description.hbs", true, (_data, html) => html); mock_template("user_pill.hbs", true, (_data, html) => html); - // Entering a comma in a "dm:" query should immediately - // generate suggestions for the next person. - let query = "dm:bob@zulip.com,"; - let suggestions = get_suggestions(query); + // If there's an existing completed user pill right before + // the input string, we suggest a user group as one of the + // suggestions. + let pill_query = "dm:bob@zulip.com"; + let query = "alice"; + let suggestions = get_suggestions(query, pill_query); let expected = [ - "dm:bob@zulip.com,", + "dm:bob@zulip.com alice", "dm:bob@zulip.com,alice@zulip.com", - "dm:bob@zulip.com,jeff@zulip.com", - "dm:bob@zulip.com,ted@zulip.com", + "dm:bob@zulip.com sender:alice@zulip.com", + "dm:bob@zulip.com dm-including:alice@zulip.com", + "dm:bob@zulip.com", ]; assert.deepEqual(suggestions.strings, expected); - // Only the last part of a comma-separated "dm" query - // should be used to generate suggestions. - query = "dm:bob@zulip.com,t"; - suggestions = get_suggestions(query); - expected = ["dm:bob@zulip.com,t", "dm:bob@zulip.com,ted@zulip.com"]; - assert.deepEqual(suggestions.strings, expected); - - // Smit should also generate ted@zulip.com (Ted Smith) as a suggestion. - query = "dm:bob@zulip.com,Smit"; - 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"]; + // Do not suggest "myself@zulip.com" (the name of the current user) for dms + pill_query = "dm:ted@zulip.com"; + query = "my"; + suggestions = get_suggestions(query, pill_query); + expected = [ + "dm:ted@zulip.com my", + "dm:ted@zulip.com sender:myself@zulip.com", + "dm:ted@zulip.com dm-including:myself@zulip.com", + "dm:ted@zulip.com", + ]; assert.deepEqual(suggestions.strings, expected); // "is:dm" should be properly prepended to each suggestion // if the "dm" operator is negated. - query = "-dm:bob@zulip.com,"; + query = "-dm:bob@zulip.co"; suggestions = get_suggestions(query); expected = [ - "-dm:bob@zulip.com,", - "is:dm -dm:bob@zulip.com,alice@zulip.com", - "is:dm -dm:bob@zulip.com,jeff@zulip.com", - "is:dm -dm:bob@zulip.com,ted@zulip.com", + "-dm:bob@zulip.co", + "is:dm -dm:bob@zulip.com", ]; 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"; suggestions = get_suggestions(query); expected = ["-dm:bob@zulip.com,red"]; assert.deepEqual(suggestions.strings, expected); - // If user types "pm-with" operator, an email and a comma, - // show suggestions for group direct messages with the "dm" - // operator. - query = "pm-with:bob@zulip.com,"; - suggestions = get_suggestions(query); + // If user types "pm-with" operator, show suggestions for + // group direct messages with the "dm" operator. + pill_query = "pm-with:bob@zulip.com"; + query = "alice"; + suggestions = get_suggestions(query, pill_query); expected = [ - "dm:bob@zulip.com,", + "dm:bob@zulip.com alice", "dm:bob@zulip.com,alice@zulip.com", - "dm:bob@zulip.com,jeff@zulip.com", - "dm:bob@zulip.com,ted@zulip.com", + "dm:bob@zulip.com sender:alice@zulip.com", + "dm:bob@zulip.com dm-including:alice@zulip.com", + "dm:bob@zulip.com", ]; assert.deepEqual(suggestions.strings, expected); // Test multiple terms - query = "is:starred has:link dm:bob@zulip.com,Smit"; - suggestions = get_suggestions(query); + pill_query = "is:starred has:link dm:bob@zulip.com"; + query = "Smit"; + suggestions = get_suggestions(query, pill_query); 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 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", ]; @@ -377,62 +360,6 @@ test("group_suggestions", ({mock_template}) => { suggestions = get_suggestions(query); expected = ["has:link dm:invalid@zulip.com,Smit", "has:link"]; 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", () => { @@ -560,8 +487,8 @@ test("check_is_suggestions", ({override, mock_template}) => { "is:alerted", "is:unread", "is:resolved", - "sender:alice@zulip.com", "dm:alice@zulip.com", + "sender:alice@zulip.com", "dm-including:alice@zulip.com", "has:image", ]; @@ -733,7 +660,7 @@ test("topic_suggestions", ({override, mock_template}) => { stream_data.add_sub({stream_id: office_id, name: "office", subscribed: true}); 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); stream_topic_history.add_message({ @@ -751,8 +678,8 @@ test("topic_suggestions", ({override, mock_template}) => { suggestions = get_suggestions("te"); expected = [ "te", - "sender:ted@zulip.com", "dm:ted@zulip.com", + "sender:ted@zulip.com", "dm-including:ted@zulip.com", "channel:office topic:team", "channel:office topic:test", @@ -929,10 +856,10 @@ test("people_suggestions", ({override, mock_template}) => { let expected = [ "te", - "sender:bob@zulip.com", - "sender:ted@zulip.com", "dm:bob@zulip.com", // bob térry "dm:ted@zulip.com", + "sender:bob@zulip.com", + "sender:ted@zulip.com", "dm-including:bob@zulip.com", "dm-including:ted@zulip.com", ]; @@ -949,12 +876,12 @@ test("people_suggestions", ({override, mock_template}) => { expected = [ "te", - "sender:bob@zulip.com", - "sender:ted@zulip.com", - "sender:user299@zulipdev.com", "dm:bob@zulip.com", "dm:ted@zulip.com", "dm:user299@zulipdev.com", + "sender:bob@zulip.com", + "sender:ted@zulip.com", + "sender:user299@zulipdev.com", "dm-including:bob@zulip.com", "dm-including:ted@zulip.com", "dm-including:user299@zulipdev.com", @@ -1035,7 +962,7 @@ test("people_suggestions", ({override, mock_template}) => { 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);