mirror of https://github.com/zulip/zulip.git
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:
parent
278edb1b3f
commit
11bb44c7cc
|
@ -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",
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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");
|
assert.equal(sel, ".pill");
|
||||||
return $curr_pill_stub;
|
return $curr_pill_stub;
|
||||||
},
|
},
|
||||||
|
parents() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue