mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 14:03:30 +00:00 
			
		
		
		
	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:
		@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -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<T> = {
 | 
			
		||||
    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<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
 | 
			
		||||
                blueslip.error("no type defined for the item");
 | 
			
		||||
                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 = {
 | 
			
		||||
                display_value: item.display_value,
 | 
			
		||||
                has_image,
 | 
			
		||||
                deactivated: item.deactivated,
 | 
			
		||||
                should_add_guest_user_indicator: item.should_add_guest_user_indicator,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            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;
 | 
			
		||||
                if (item.user_id) {
 | 
			
		||||
                    opts.user_id = item.user_id;
 | 
			
		||||
                }
 | 
			
		||||
                if (item.group_id) {
 | 
			
		||||
                    opts.group_id = item.group_id;
 | 
			
		||||
                }
 | 
			
		||||
                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> = {
 | 
			
		||||
                item,
 | 
			
		||||
                $element: $(pill_html),
 | 
			
		||||
@@ -249,6 +256,59 @@ export function create<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
 | 
			
		||||
            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
 | 
			
		||||
        // 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<T>(opts: InputPillCreateOptions<T>): InputPillContainer<T
 | 
			
		||||
        // when the "×" is clicked on a pill, it should delete that pill and then
 | 
			
		||||
        // select the next pill (or input).
 | 
			
		||||
        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();
 | 
			
		||||
            const $pill = $(this).closest(".pill");
 | 
			
		||||
            const $next = $pill.next();
 | 
			
		||||
 
 | 
			
		||||
@@ -83,6 +83,10 @@ export function initialize({on_narrow_search}: {on_narrow_search: OnNarrowSearch
 | 
			
		||||
        search_input_has_changed = true;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $search_query_box.on("change", () => {
 | 
			
		||||
        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
 | 
			
		||||
 
 | 
			
		||||
@@ -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<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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
 
 | 
			
		||||
@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										22
									
								
								web/templates/search_user_pill.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								web/templates/search_user_pill.hbs
									
									
									
									
									
										Normal 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}} <i>({{t 'guest'}})</i>{{~/if~}}
 | 
			
		||||
                {{~#if deactivated}} ({{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>
 | 
			
		||||
@@ -529,6 +529,9 @@ run_test("exit button on pill", ({mock_template}) => {
 | 
			
		||||
                assert.equal(sel, ".pill");
 | 
			
		||||
                return $curr_pill_stub;
 | 
			
		||||
            },
 | 
			
		||||
            parents() {
 | 
			
		||||
                return [];
 | 
			
		||||
            },
 | 
			
		||||
        }),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user