From 96c99501156dbc31817eaa49c95f4a1ab41b52a5 Mon Sep 17 00:00:00 2001 From: evykassirer Date: Thu, 25 Apr 2024 14:03:00 -0700 Subject: [PATCH] composebox_typeahead: Convert module to typescript. --- docs/subsystems/markdown.md | 2 +- docs/subsystems/slash-commands.md | 2 +- tools/test-js-with-node | 2 +- web/shared/src/typeahead.ts | 2 +- web/src/compose.js | 2 +- web/src/compose_ui.ts | 5 +- ...x_typeahead.js => composebox_typeahead.ts} | 453 +++++++++---- web/src/emoji.ts | 2 +- web/src/pill_typeahead.ts | 10 +- web/src/typeahead_helper.ts | 71 +- web/src/user_group_pill.ts | 5 +- web/tests/composebox_typeahead.test.js | 612 +++++++++++------- web/tests/typeahead_helper.test.js | 28 +- zerver/lib/emoji.py | 2 +- 14 files changed, 776 insertions(+), 422 deletions(-) rename web/src/{composebox_typeahead.js => composebox_typeahead.ts} (75%) diff --git a/docs/subsystems/markdown.md b/docs/subsystems/markdown.md index 303072d493..bf645e0b46 100644 --- a/docs/subsystems/markdown.md +++ b/docs/subsystems/markdown.md @@ -104,7 +104,7 @@ places: - The frontend Markdown processor (`web/src/markdown.ts` and sometimes `web/third/marked/lib/marked.js`), or `markdown.contains_backend_only_syntax` if your changes won't be supported in the frontend processor. -- If desired, the typeahead logic in `web/src/composebox_typeahead.js`. +- If desired, the typeahead logic in `web/src/composebox_typeahead.ts`. - The test suite, probably via adding entries to `zerver/tests/fixtures/markdown_test_cases.json`. - The in-app Markdown documentation (`markdown_help_rows` in `web/src/info_overlay.ts`). - The list of changes to Markdown at the end of this document. diff --git a/docs/subsystems/slash-commands.md b/docs/subsystems/slash-commands.md index affda95dae..f77ff1aefa 100644 --- a/docs/subsystems/slash-commands.md +++ b/docs/subsystems/slash-commands.md @@ -52,4 +52,4 @@ send a message with the raw content. ## Typeahead Typeahead for both slash commands (and widgets) is implemented -via the `slash_commands` object in `web/src/composebox_typeahead.js`. +via the `slash_commands` object in `web/src/composebox_typeahead.ts`. diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 8dd8d38187..458fde7b41 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -82,7 +82,7 @@ EXEMPT_FILES = make_set( "web/src/compose_tooltips.ts", "web/src/compose_ui.ts", "web/src/compose_validate.ts", - "web/src/composebox_typeahead.js", + "web/src/composebox_typeahead.ts", "web/src/condense.ts", "web/src/confirm_dialog.ts", "web/src/copied_tooltip.ts", diff --git a/web/shared/src/typeahead.ts b/web/shared/src/typeahead.ts index 9d18be37aa..9e3e84af67 100644 --- a/web/shared/src/typeahead.ts +++ b/web/shared/src/typeahead.ts @@ -30,7 +30,7 @@ export const popular_emojis = [ const unicode_marks = /\p{M}/gu; -type Emoji = +export type Emoji = | { emoji_name: string; reaction_type: "realm_emoji" | "zulip_extra_emoji"; diff --git a/web/src/compose.js b/web/src/compose.js index 003c7924af..bc2a743b25 100644 --- a/web/src/compose.js +++ b/web/src/compose.js @@ -96,7 +96,7 @@ export function create_message_object(message_content = compose_state.message_co message.topic = ""; if (message.type === "private") { - // TODO: this should be collapsed with the code in composebox_typeahead.js + // TODO: this should be collapsed with the code in composebox_typeahead.ts const recipient = compose_state.private_message_recipient(); const emails = util.extract_pm_recipients(recipient); message.to = emails; diff --git a/web/src/compose_ui.ts b/web/src/compose_ui.ts index 1cd705cc13..cf46fc6273 100644 --- a/web/src/compose_ui.ts +++ b/web/src/compose_ui.ts @@ -13,6 +13,7 @@ import { import type {Typeahead} from "./bootstrap_typeahead"; import * as bulleted_numbered_list_util from "./bulleted_numbered_list_util"; import * as common from "./common"; +import type {TypeaheadSuggestion} from "./composebox_typeahead"; import {$t} from "./i18n"; import * as loading from "./loading"; import * as markdown from "./markdown"; @@ -59,10 +60,10 @@ type SelectedLinesSections = { export let compose_spinner_visible = false; export let shift_pressed = false; // true or false export let code_formatting_button_triggered = false; // true or false -export let compose_textarea_typeahead: Typeahead | undefined; +export let compose_textarea_typeahead: Typeahead | undefined; let full_size_status = false; // true or false -export function set_compose_textarea_typeahead(typeahead: Typeahead): void { +export function set_compose_textarea_typeahead(typeahead: Typeahead): void { compose_textarea_typeahead = typeahead; } diff --git a/web/src/composebox_typeahead.js b/web/src/composebox_typeahead.ts similarity index 75% rename from web/src/composebox_typeahead.js rename to web/src/composebox_typeahead.ts index 91e69587fb..fb348ca54c 100644 --- a/web/src/composebox_typeahead.js +++ b/web/src/composebox_typeahead.ts @@ -1,16 +1,20 @@ import $ from "jquery"; import _ from "lodash"; +import assert from "minimalistic-assert"; import * as typeahead from "../shared/src/typeahead"; +import type {Emoji, EmojiSuggestion} from "../shared/src/typeahead"; import render_topic_typeahead_hint from "../templates/topic_typeahead_hint.hbs"; import {Typeahead} from "./bootstrap_typeahead"; +import type {TypeaheadInputElement} from "./bootstrap_typeahead"; import * as bulleted_numbered_list_util from "./bulleted_numbered_list_util"; import * as compose_pm_pill from "./compose_pm_pill"; import * as compose_state from "./compose_state"; import * as compose_ui from "./compose_ui"; import * as compose_validate from "./compose_validate"; import * as emoji from "./emoji"; +import type {EmojiDict} from "./emoji"; import * as flatpickr from "./flatpickr"; import {$t} from "./i18n"; import * as keydown_util from "./keydown_util"; @@ -18,17 +22,23 @@ import * as message_store from "./message_store"; import * as muted_users from "./muted_users"; import {page_params} from "./page_params"; import * as people from "./people"; +import type {PseudoMentionUser, User} from "./people"; import * as realm_playground from "./realm_playground"; import * as rows from "./rows"; import * as settings_data from "./settings_data"; import {realm} from "./state_data"; import * as stream_data from "./stream_data"; +import type {StreamPillData} from "./stream_pill"; import * as stream_topic_history from "./stream_topic_history"; import * as stream_topic_history_util from "./stream_topic_history_util"; import * as timerender from "./timerender"; import * as typeahead_helper from "./typeahead_helper"; +import type {UserOrMention, UserOrMentionPillData} from "./typeahead_helper"; +import type {UserGroupPillData} from "./user_group_pill"; import * as user_groups from "./user_groups"; +import type {UserGroup} from "./user_groups"; import * as user_pill from "./user_pill"; +import type {UserPillData} from "./user_pill"; import {user_settings} from "./user_settings"; // ********************************** @@ -42,41 +52,98 @@ import {user_settings} from "./user_settings"; // highlighter that escapes (i.e. one that calls // typeahead_helper.highlight_with_escaping). +// ---------------- TYPE DECLARATIONS ---------------- +// There are many types of suggestions that can show +// up in the composebox typeahead, but they are never +// mixed together. So a user can see a list of stream +// suggestions in one situation, and a list of user +// suggestions in another, but never both at the same +// time. +// We use types with a "type" field to keep track +// of and differentiate each kind of suggestion, +// because we handle multiple kinds of suggestions +// within shared code blocks. +type SlashCommand = { + text: string; + name: string; + aliases: NamedCurve; + placeholder?: string; +}; +export type SlashCommandSuggestion = SlashCommand & {type: "slash"}; + +export type LanguageSuggestion = { + language: string; + type: "syntax"; +}; + +type TopicSuggestion = { + topic: string; + type: "topic_list"; +}; + +type TimeJumpSuggestion = { + message: string; + type: "time_jump"; +}; + +type TopicJumpSuggestion = { + message: string; + type: "topic_jump"; +}; + +export type TypeaheadSuggestion = + | UserGroupPillData + | UserOrMentionPillData + | StreamPillData + | TopicJumpSuggestion + | TimeJumpSuggestion + | LanguageSuggestion + | TopicSuggestion + | EmojiSuggestion + | SlashCommandSuggestion; + // This is what we use for direct message/compose typeaheads. // We export it to allow tests to mock it. export const max_num_items = 8; -export let emoji_collection = []; +export let emoji_collection: Emoji[] = []; -let completing; -let token; +// This has mostly been replaced with `type` fields on +// the typeahead items, but is still used for the stream>topic +// flow and for `get_header_html`. It would be great if we could +// get rid of it altogether. +let completing: string | null; +let token: string; -export function get_or_set_token_for_testing(val) { +export function get_or_set_token_for_testing(val?: string): string { if (val !== undefined) { token = val; } return token; } -export function get_or_set_completing_for_tests(val) { +export function get_or_set_completing_for_tests(val?: string): string | null { if (val !== undefined) { completing = val; } return completing; } -export function update_emoji_data(initial_emojis) { +export function update_emoji_data(initial_emojis: EmojiDict[]): void { emoji_collection = []; for (const emoji_dict of initial_emojis) { const {reaction_type} = emoji.get_emoji_details_by_name(emoji_dict.name); - if (emoji_dict.is_realm_emoji === true) { + if (emoji_dict.is_realm_emoji) { + assert(reaction_type !== "unicode_emoji"); emoji_collection.push({ reaction_type, emoji_name: emoji_dict.name, emoji_url: emoji_dict.url, is_realm_emoji: true, + emoji_code: undefined, }); } else { + assert(reaction_type === "unicode_emoji"); for (const alias of emoji_dict.aliases) { emoji_collection.push({ reaction_type, @@ -89,36 +156,43 @@ export function update_emoji_data(initial_emojis) { } } -export function topics_seen_for(stream_id) { +export function topics_seen_for(stream_id?: number): string[] { if (!stream_id) { return []; } // Fetch topic history from the server, in case we will need it soon. - stream_topic_history_util.get_server_history(stream_id, () => {}); + stream_topic_history_util.get_server_history(stream_id, () => { + // We'll use the extended results in the next keypress, but we + // don't try to live-update what's already shown, because the + // click target moving can be annoying if onewas about to + // select/click an option. + }); return stream_topic_history.get_recent_topic_names(stream_id); } -export function get_language_matcher(query) { +export function get_language_matcher(query: string): (language: string) => boolean { query = query.toLowerCase(); - return function (lang) { - return lang.includes(query); + return function (language: string): boolean { + return language.includes(query); }; } -export function get_stream_or_user_group_matcher(query) { +export function get_stream_or_user_group_matcher( + query: string, +): (user_group_or_stream: UserGroupPillData | StreamPillData) => boolean { // Case-insensitive. query = typeahead.clean_query_lowercase(query); - return function (user_group_or_stream) { + return function (user_group_or_stream: UserGroupPillData | StreamPillData) { return typeahead_helper.query_matches_name(query, user_group_or_stream); }; } -export function get_slash_matcher(query) { +export function get_slash_matcher(query: string): (item: SlashCommand) => boolean { query = typeahead.clean_query_lowercase(query); - return function (item) { + return function (item: SlashCommand) { return ( typeahead.query_matches_string_in_order(query, item.name, " ") || typeahead.query_matches_string_in_order(query, item.aliases, " ") @@ -126,19 +200,15 @@ export function get_slash_matcher(query) { }; } -function get_topic_matcher(query) { +function get_topic_matcher(query: string): (topic: string) => boolean { query = typeahead.clean_query_lowercase(query); - return function (topic) { - const obj = { - topic, - }; - - return typeahead.query_matches_string_in_order(query, obj.topic, " "); + return function (topic: string): boolean { + return typeahead.query_matches_string_in_order(query, topic, " "); }; } -export function should_enter_send(e) { +export function should_enter_send(e: JQuery.KeyDownEvent): boolean { const has_non_shift_modifier_key = e.ctrlKey || e.metaKey || e.altKey; const has_modifier_key = e.shiftKey || has_non_shift_modifier_key; let this_enter_sends; @@ -160,13 +230,18 @@ export function should_enter_send(e) { return this_enter_sends; } -function handle_bulleting_or_numbering($textarea, e) { +function handle_bulleting_or_numbering( + $textarea: JQuery, + e: JQuery.KeyDownEvent, +): void { // We only want this functionality if the cursor is not in a code block if (compose_ui.cursor_inside_code_block($textarea)) { return; } // handles automatic insertion or removal of bulleting or numbering - const before_text = split_at_cursor($textarea.val(), $textarea)[0]; + const val = $textarea.val(); + assert(val !== undefined); + const before_text = split_at_cursor(val, $textarea)[0]; const previous_line = bulleted_numbered_list_util.get_last_line(before_text); let to_append = ""; // if previous line was bulleted, automatically add a bullet to the new line @@ -207,7 +282,7 @@ function handle_bulleting_or_numbering($textarea, e) { e.preventDefault(); } -export function handle_enter($textarea, e) { +export function handle_enter($textarea: JQuery, e: JQuery.KeyDownEvent): void { // Used only if Enter doesn't send. We need to emulate the // browser's native "Enter" behavior because this code path // includes `Ctrl+Enter` and other modifier key variants that @@ -240,17 +315,20 @@ export function handle_enter($textarea, e) { // We can't focus at the time of keydown because we need to wait for typeahead. // And we can't compute where to focus at the time of keyup because only the keydown // has reliable information about whether it was a Tab or a Shift+Tab. -let $nextFocus = false; +let $nextFocus: JQuery | undefined; -function handle_keydown(e, {on_enter_send}) { +function handle_keydown( + e: JQuery.KeyDownEvent, + on_enter_send: (scheduling_message?: boolean) => boolean | undefined, +): void { const key = e.key; if (keydown_util.is_enter_event(e) || (key === "Tab" && !e.shiftKey)) { // Enter key or Tab key let target_sel; - - if (e.target.id) { - target_sel = `#${CSS.escape(e.target.id)}`; + const target_id = $(e.target).attr("id"); + if (target_id) { + target_sel = `#${CSS.escape(target_id)}`; } const on_topic = target_sel === "input#stream_message_recipient_topic"; @@ -297,26 +375,26 @@ function handle_keydown(e, {on_enter_send}) { } } -function handle_keyup(e) { +function handle_keyup(e: JQuery.KeyUpEvent): void { if ( // Enter key or Tab key (keydown_util.is_enter_event(e) || (e.key === "Tab" && !e.shiftKey)) && - $nextFocus + $nextFocus !== undefined ) { $nextFocus.trigger("focus"); - $nextFocus = false; + $nextFocus = undefined; // Prevent the form from submitting e.preventDefault(); } } -export function split_at_cursor(query, $input) { +export function split_at_cursor(query: string, $input: JQuery): [string, string] { const cursor = $input.caret(); return [query.slice(0, cursor), query.slice(cursor)]; } -export function tokenize_compose_str(s) { +export function tokenize_compose_str(s: string): string { // This basically finds a token like "@alic" or // "#Veron" as close to the end of the string as it // can find it. It wants to find white space or @@ -384,7 +462,7 @@ export function tokenize_compose_str(s) { return ""; } -function get_wildcard_string(mention) { +function get_wildcard_string(mention: string): string { if (compose_state.get_message_type() === "private") { return $t({defaultMessage: "Notify recipients"}); } @@ -394,8 +472,8 @@ function get_wildcard_string(mention) { return $t({defaultMessage: "Notify channel"}); } -export function broadcast_mentions() { - let wildcard_mention_array = []; +export function broadcast_mentions(): PseudoMentionUser[] { + let wildcard_mention_array: string[] = []; if (compose_state.get_message_type() === "private") { wildcard_mention_array = ["all", "everyone"]; } else if (compose_validate.stream_wildcard_mention_allowed()) { @@ -422,7 +500,7 @@ export function broadcast_mentions() { })); } -function filter_mention_name(current_token) { +function filter_mention_name(current_token: string): string | undefined { if (current_token.startsWith("**")) { current_token = current_token.slice(2); } else if (current_token.startsWith("*")) { @@ -439,7 +517,7 @@ function filter_mention_name(current_token) { return current_token; } -function should_show_custom_query(query, items) { +function should_show_custom_query(query: string, items: string[]): boolean { // returns true if the custom query doesn't match one of the // choices in the items list. if (!query) { @@ -483,19 +561,28 @@ export const slash_commands = [ }, ]; -export const all_slash_commands = [...dev_only_slash_commands, ...slash_commands]; +export const all_slash_commands: SlashCommand[] = [...dev_only_slash_commands, ...slash_commands]; -export function filter_and_sort_mentions(is_silent, query, opts) { - opts = { +export function filter_and_sort_mentions( + is_silent: boolean, + query: string, + opts: { + stream_id: number | undefined; + topic: string; + }, +): (UserGroupPillData | UserOrMentionPillData)[] { + return get_person_suggestions(query, { want_broadcast: !is_silent, filter_pills: false, filter_groups_for_mention: !is_silent, ...opts, - }; - return get_person_suggestions(query, opts); + }).map((item) => ({ + ...item, + is_silent, + })); } -export function get_pm_people(query) { +export function get_pm_people(query: string): (UserGroupPillData | UserPillData)[] { const opts = { want_broadcast: false, filter_pills: true, @@ -503,13 +590,41 @@ export function get_pm_people(query) { topic: compose_state.topic(), filter_groups_for_guests: true, }; - return get_person_suggestions(query, opts); + const suggestions = get_person_suggestions(query, opts); + // We know these aren't mentions because `want_broadcast` was `false`. + // TODO: In the future we should separate user and mention so we don't have + // to do this. + const user_suggestions: (UserGroupPillData | UserPillData)[] = []; + for (const suggestion of suggestions) { + if (suggestion.type === "user_or_mention") { + assert(suggestion.is_broadcast === undefined); + user_suggestions.push({ + ...suggestion, + type: "user", + }); + } else { + user_suggestions.push(suggestion); + } + } + return user_suggestions; } -export function get_person_suggestions(query, opts) { +type PersonSuggestionOpts = { + want_broadcast: boolean; + filter_pills: boolean; + stream_id: number | undefined; + topic: string; + filter_groups_for_guests?: boolean; + filter_groups_for_mention?: boolean; +}; + +export function get_person_suggestions( + query: string, + opts: PersonSuggestionOpts, +): (UserOrMentionPillData | UserGroupPillData)[] { query = typeahead.clean_query_lowercase(query); - function filter_persons(all_persons) { + function filter_persons(all_persons: User[]): UserOrMentionPillData[] { let persons; if (opts.filter_pills) { @@ -519,15 +634,15 @@ export function get_person_suggestions(query, opts) { } // Exclude muted users from typeaheads. persons = muted_users.filter_muted_users(persons); + let user_or_mention_list: UserOrMention[] = persons.map((person) => ({ + ...person, + is_broadcast: undefined, + })); if (opts.want_broadcast) { - persons = [...persons, ...broadcast_mentions()]; + user_or_mention_list = [...user_or_mention_list, ...broadcast_mentions()]; } - // `sort_recipients` and other functions like `user_pill.get_user_ids` - // are shared with the pill typeahead which has only users, and we - // need a way to differentiate these mentons-or-users from just users, - // to help with typing. - const person_items = persons.map((person) => ({ + const person_items: UserOrMentionPillData[] = user_or_mention_list.map((person) => ({ ...person, type: "user_or_mention", })); @@ -535,7 +650,7 @@ export function get_person_suggestions(query, opts) { return person_items.filter((item) => typeahead_helper.query_matches_person(query, item)); } - let groups; + let groups: UserGroup[]; if (opts.filter_groups_for_mention) { groups = user_groups.get_user_groups_allowed_to_mention(); } else if (opts.filter_groups_for_guests && !settings_data.user_can_access_all_other_users()) { @@ -553,7 +668,12 @@ export function get_person_suggestions(query, opts) { groups = user_groups.get_realm_user_groups(); } - const filtered_groups = groups.filter((item) => + const group_pill_data: UserGroupPillData[] = groups.map((group) => ({ + ...group, + type: "user_group", + })); + + const filtered_groups = group_pill_data.filter((item) => typeahead_helper.query_matches_name(query, item), ); @@ -583,7 +703,7 @@ export function get_person_suggestions(query, opts) { const filtered_message_persons = filter_persons(people.get_active_message_people()); - let filtered_persons; + let filtered_persons: UserOrMentionPillData[]; if (filtered_message_persons.length >= cutoff_length) { filtered_persons = filtered_message_persons; @@ -601,21 +721,30 @@ export function get_person_suggestions(query, opts) { }); } -export function get_stream_topic_data(input_element) { - const opts = {}; +function get_stream_topic_data(input_element: TypeaheadInputElement): { + stream_id: number | undefined; + topic: string; +} { + let stream_id; + let topic; const $message_row = input_element.$element.closest(".message_row"); if ($message_row.length === 1) { // we are editing a message so we try to use its keys. const msg = message_store.get(rows.id($message_row)); + assert(msg !== undefined); if (msg.type === "stream") { - opts.stream_id = msg.stream_id; - opts.topic = msg.topic; + stream_id = msg.stream_id; + topic = msg.topic; } } else { - opts.stream_id = compose_state.stream_id(); - opts.topic = compose_state.topic(); + stream_id = compose_state.stream_id(); + topic = compose_state.topic(); } - return opts; + assert(topic !== undefined); + return { + stream_id, + topic, + }; } const ALLOWED_MARKDOWN_FEATURES = { @@ -629,9 +758,12 @@ const ALLOWED_MARKDOWN_FEATURES = { timestamp: true, }; -export function get_candidates(query, input_element) { +export function get_candidates( + query: string, + input_element: TypeaheadInputElement, +): TypeaheadSuggestion[] { const split = split_at_cursor(query, input_element.$element); - let current_token = tokenize_compose_str(split[0]); + let current_token: string | boolean = tokenize_compose_str(split[0]); if (current_token === "") { return []; } @@ -678,7 +810,11 @@ export function get_candidates(query, input_element) { compose_ui.set_code_formatting_button_triggered(false); const matcher = get_language_matcher(token); const matches = language_list.filter((item) => matcher(item)); - return typeahead_helper.sort_languages(matches, token); + const matches_list: LanguageSuggestion[] = matches.map((language) => ({ + language, + type: "syntax", + })); + return typeahead_helper.sort_languages(matches_list, token); } // Only start the emoji autocompleter if : is directly after one @@ -697,8 +833,12 @@ export function get_candidates(query, input_element) { } completing = "emoji"; token = current_token.slice(1); + const candidate_list: EmojiSuggestion[] = emoji_collection.map((emoji_dict) => ({ + ...emoji_dict, + type: "emoji", + })); const matcher = typeahead.get_emoji_matcher(token); - const matches = emoji_collection.filter((item) => matcher(item)); + const matches = candidate_list.filter((item) => matcher(item)); return typeahead.sort_emojis(matches, token); } @@ -712,17 +852,18 @@ export function get_candidates(query, input_element) { is_silent = true; current_token = current_token.slice(1); } - current_token = filter_mention_name(current_token); - if (current_token === undefined) { + const filtered_current_token = filter_mention_name(current_token); + if (filtered_current_token === undefined) { completing = null; return []; } - token = current_token; + token = filtered_current_token; + const opts = get_stream_topic_data(input_element); return filter_and_sort_mentions(is_silent, token, opts); } - function get_slash_commands_data() { + function get_slash_commands_data(): SlashCommand[] { const commands = page_params.development_environment ? all_slash_commands : slash_commands; return commands; } @@ -732,10 +873,13 @@ export function get_candidates(query, input_element) { completing = "slash"; token = current_token; - const slash_commands = get_slash_commands_data(); const matcher = get_slash_matcher(token); - const matches = slash_commands.filter((item) => matcher(item)); - return typeahead_helper.sort_slash_commands(matches, token); + const matches = get_slash_commands_data().filter((item) => matcher(item)); + const matches_list: SlashCommandSuggestion[] = matches.map((slash_command) => ({ + ...slash_command, + type: "slash", + })); + return typeahead_helper.sort_slash_commands(matches_list, token); } if (ALLOWED_MARKDOWN_FEATURES.stream && current_token.startsWith("#")) { @@ -755,9 +899,12 @@ export function get_candidates(query, input_element) { completing = "stream"; token = current_token; - const subs = stream_data.get_unsorted_subs(); + const candidate_list: StreamPillData[] = stream_data.get_unsorted_subs().map((sub) => ({ + ...sub, + type: "stream", + })); const matcher = get_stream_or_user_group_matcher(token); - const matches = subs.filter((item) => matcher(item)); + const matches = candidate_list.filter((item) => matcher(item)); return typeahead_helper.sort_streams(matches, token); } @@ -770,7 +917,12 @@ export function get_candidates(query, input_element) { completing = "topic_jump"; token = ">"; // We return something so that the typeahead is shown, but ultimately - return [""]; + return [ + { + message: "", + type: "topic_jump", + }, + ]; } // Matches '#**stream name>some text' at the end of a split. @@ -779,6 +931,7 @@ export function get_candidates(query, input_element) { if (should_begin_typeahead) { completing = "topic_list"; const tokens = stream_topic_regex.exec(split[0]); + assert(tokens !== null); if (tokens[1]) { const stream_name = tokens[1]; token = tokens[2] || ""; @@ -795,7 +948,11 @@ export function get_candidates(query, input_element) { } const matcher = get_topic_matcher(token); const matches = topic_list.filter((item) => matcher(item)); - return typeahead_helper.sorter(token, matches, (x) => x); + const matches_list: TopicSuggestion[] = matches.map((topic) => ({ + topic, + type: "topic_list", + })); + return typeahead_helper.sorter(token, matches_list, (x) => x.topic); } } } @@ -803,19 +960,23 @@ export function get_candidates(query, input_element) { const time_jump_regex = /]*?)>?)?$/; if (time_jump_regex.test(split[0])) { completing = "time_jump"; - return [$t({defaultMessage: "Mention a time-zone-aware time"})]; + return [ + { + message: $t({defaultMessage: "Mention a time-zone-aware time"}), + type: "time_jump", + }, + ]; } } return []; } -export function content_highlighter_html(item) { - switch (completing) { +export function content_highlighter_html(item: TypeaheadSuggestion): string | undefined { + switch (item.type) { case "emoji": return typeahead_helper.render_emoji(item); - case "mention": - return typeahead_helper.render_person_or_user_group(item); - case "silent_mention": + case "user_group": + case "user_or_mention": return typeahead_helper.render_person_or_user_group(item); case "slash": return typeahead_helper.render_typeahead_item({ @@ -824,30 +985,49 @@ export function content_highlighter_html(item) { case "stream": return typeahead_helper.render_stream(item); case "syntax": - return typeahead_helper.render_typeahead_item({primary: item}); + return typeahead_helper.render_typeahead_item({primary: item.language}); case "topic_jump": - return typeahead_helper.render_typeahead_item({primary: item}); + return typeahead_helper.render_typeahead_item({primary: item.message}); case "topic_list": - return typeahead_helper.render_typeahead_item({primary: item}); + return typeahead_helper.render_typeahead_item({primary: item.topic}); case "time_jump": - return typeahead_helper.render_typeahead_item({primary: item}); + return typeahead_helper.render_typeahead_item({primary: item.message}); default: return undefined; } } -export function content_typeahead_selected(item, query, input_element, event) { +export function content_typeahead_selected( + item: TypeaheadSuggestion, + query: string, + input_element: TypeaheadInputElement, + event?: JQuery.ClickEvent | JQuery.KeyUpEvent | JQuery.KeyDownEvent, +): string { const pieces = split_at_cursor(query, input_element.$element); let beginning = pieces[0]; let rest = pieces[1]; - const $textbox = input_element.$element; + assert(input_element.type === "textarea"); + const $textbox: JQuery = input_element.$element; // Accepting some typeahead selections, like polls, will generate // placeholder text that is selected, in order to clarify for the // user what a given parameter is for. This object stores the // highlight offsets for that purpose. - const highlight = {}; - - switch (completing) { + const highlight: { + start?: number; + end?: number; + } = {}; + // `topic_jump` means that the user just typed a stream name + // and then `>` to start typing a topic. But the `item` coming + // from the typeahead is still the stream item, so here we do + // a reassignment so that the switch statement does the right + // thing. + if (completing === "topic_jump") { + item = { + type: "topic_jump", + message: "", + }; + } + switch (item.type) { case "emoji": // leading and trailing spaces are required for emoji, // except if it begins a message or a new line. @@ -861,9 +1041,9 @@ export function content_typeahead_selected(item, query, input_element, event) { beginning = beginning.slice(0, -token.length - 1) + " :" + item.emoji_name + ": "; } break; - case "silent_mention": - case "mention": { - const is_silent = completing === "silent_mention"; + case "user_group": + case "user_or_mention": { + const is_silent = item.is_silent; beginning = beginning.slice(0, -token.length - 1); if (beginning.endsWith("@_*")) { beginning = beginning.slice(0, -3); @@ -872,7 +1052,7 @@ export function content_typeahead_selected(item, query, input_element, event) { } else if (beginning.endsWith("@")) { beginning = beginning.slice(0, -1); } - if (user_groups.is_user_group(item)) { + if (item.type === "user_group") { let user_group_mention_text = is_silent ? "@_*" : "@*"; user_group_mention_text += item.name + "* "; beginning += user_group_mention_text; @@ -882,11 +1062,8 @@ export function content_typeahead_selected(item, query, input_element, event) { // that functionality yet, and we haven't gotten much // feedback on this being an actual pitfall. } else { - let mention_text = people.get_mention_syntax( - item.full_name, - item.user_id, - is_silent, - ); + const user_id = item.is_broadcast ? undefined : item.user_id; + let mention_text = people.get_mention_syntax(item.full_name, user_id, is_silent); if (!is_silent && !item.is_broadcast) { compose_validate.warn_if_mentioning_unsubscribed_user(item, $textbox); mention_text = compose_validate.convert_mentions_to_silent_in_direct_messages( @@ -927,8 +1104,8 @@ export function content_typeahead_selected(item, query, input_element, event) { // Isolate the end index of the triple backticks/tildes, including // possibly a space afterward const backticks = beginning.length - token.length; - beginning = beginning.slice(0, backticks) + item; - if (item === "spoiler") { + beginning = beginning.slice(0, backticks) + item.language; + if (item.language === "spoiler") { // to add in and highlight placeholder "Header" const placeholder = $t({defaultMessage: "Header"}); highlight.start = beginning.length + 1; @@ -960,7 +1137,7 @@ export function content_typeahead_selected(item, query, input_element, event) { // with the topic and the final **. Note that token.length can be 0 // if we are completing from `**streamname>`. const start = beginning.length - token.length; - beginning = beginning.slice(0, start) + item + "** "; + beginning = beginning.slice(0, start) + item.topic + "** "; break; } case "time_jump": { @@ -970,7 +1147,7 @@ export function content_typeahead_selected(item, query, input_element, event) { } const timestamp = timerender.get_timestamp_for_flatpickr(timestring); - const on_timestamp_selection = (val) => { + const on_timestamp_selection = (val: string): void => { const datestr = val; beginning = beginning.slice(0, Math.max(0, beginning.lastIndexOf(" just after // a stream mention, to begin stream+topic mention typeahead (topic_list). @@ -1012,7 +1189,7 @@ export function compose_automated_selection() { return false; } -export function compose_trigger_selection(event) { +function compose_trigger_selection(event: JQuery.KeyDownEvent): boolean { if (completing === "stream" && event.key === ">") { // complete stream typeahead partially to immediately start the topic_list typeahead. return true; @@ -1020,24 +1197,28 @@ export function compose_trigger_selection(event) { return false; } -export function initialize_topic_edit_typeahead(form_field, stream_name, dropup) { - const bootstrap_typeahead_input = { +export function initialize_topic_edit_typeahead( + form_field: JQuery, + stream_name: string, + dropup: boolean, +): Typeahead { + const bootstrap_typeahead_input: TypeaheadInputElement = { $element: form_field, type: "input", }; return new Typeahead(bootstrap_typeahead_input, { dropup, - highlighter_html(item) { + highlighter_html(item: string): string { return typeahead_helper.render_typeahead_item({primary: item}); }, - sorter(items, query) { + sorter(items: string[], query: string): string[] { const sorted = typeahead_helper.sorter(query, items, (x) => x); if (sorted.length > 0 && !sorted.includes(query)) { sorted.unshift(query); } return sorted; }, - source() { + source(): string[] { const stream_id = stream_data.get_stream_id(stream_name); return topics_seen_for(stream_id); }, @@ -1045,7 +1226,7 @@ export function initialize_topic_edit_typeahead(form_field, stream_name, dropup) }); } -function get_header_html() { +function get_header_html(): string | false { let tip_text = ""; switch (completing) { case "stream": @@ -1069,8 +1250,8 @@ function get_header_html() { return `${_.escape(tip_text)}`; } -export function initialize_compose_typeahead(selector) { - const bootstrap_typeahead_input = { +export function initialize_compose_typeahead(selector: string): void { + const bootstrap_typeahead_input: TypeaheadInputElement = { $element: $(selector), type: "textarea", }; @@ -1100,31 +1281,37 @@ export function initialize_compose_typeahead(selector) { ); } -export function initialize({on_enter_send}) { +export function initialize({ + on_enter_send, +}: { + on_enter_send: (scheduling_message?: boolean) => boolean | undefined; +}): void { // These handlers are at the "form" level so that they are called after typeahead - $("form#send_message_form").on("keydown", (e) => handle_keydown(e, {on_enter_send})); + $("form#send_message_form").on("keydown", (e) => { + handle_keydown(e, on_enter_send); + }); $("form#send_message_form").on("keyup", handle_keyup); - const stream_message_typeahead_input = { + const stream_message_typeahead_input: TypeaheadInputElement = { $element: $("input#stream_message_recipient_topic"), type: "input", }; new Typeahead(stream_message_typeahead_input, { - source() { + source(): string[] { return topics_seen_for(compose_state.stream_id()); }, items: 3, - highlighter_html(item) { + highlighter_html(item: string): string { return typeahead_helper.render_typeahead_item({primary: item}); }, - sorter(items, query) { + sorter(items: string[], query: string): string[] { const sorted = typeahead_helper.sorter(query, items, (x) => x); if (sorted.length > 0 && !sorted.includes(query)) { sorted.unshift(query); } return sorted; }, - option_label(matching_items, item) { + option_label(matching_items: string[], item: string): string | false { if (!matching_items.includes(item)) { return `${$t({defaultMessage: "New"})}`; } @@ -1133,7 +1320,7 @@ export function initialize({on_enter_send}) { header_html: render_topic_typeahead_hint, }); - const private_message_typeahead_input = { + const private_message_typeahead_input: TypeaheadInputElement = { $element: $("#private_message_recipient"), type: "contenteditable", }; @@ -1141,17 +1328,17 @@ export function initialize({on_enter_send}) { source: get_pm_people, items: max_num_items, dropup: true, - highlighter_html(item) { + highlighter_html(item: UserGroupPillData | UserPillData) { return typeahead_helper.render_person_or_user_group(item); }, - matcher() { + matcher(): boolean { return true; }, - sorter(items) { + sorter(items: (UserGroupPillData | UserPillData)[]): (UserGroupPillData | UserPillData)[] { return items; }, - updater(item) { - if (user_groups.is_user_group(item)) { + updater(item: UserGroupPillData | UserPillData): undefined { + if (item.type === "user_group") { for (const user_id of item.members) { const user = people.get_by_user_id(user_id); // filter out inactive users, inserted users and current user diff --git a/web/src/emoji.ts b/web/src/emoji.ts index d8c97e6a9b..2787e71b87 100644 --- a/web/src/emoji.ts +++ b/web/src/emoji.ts @@ -46,7 +46,7 @@ type RealmEmoji = { }; // Data structure which every widget(like Emoji Picker) in the web app is supposed to use for displaying emojis. -type EmojiDict = { +export type EmojiDict = { name: string; display_name: string; aliases: string[]; diff --git a/web/src/pill_typeahead.ts b/web/src/pill_typeahead.ts index 57aa9a267b..f836b02250 100644 --- a/web/src/pill_typeahead.ts +++ b/web/src/pill_typeahead.ts @@ -11,17 +11,16 @@ import * as typeahead_helper from "./typeahead_helper"; import type {CombinedPillContainer} from "./typeahead_helper"; import * as user_group_pill from "./user_group_pill"; import type {UserGroupPillData} from "./user_group_pill"; -import type {UserGroup} from "./user_groups"; import * as user_pill from "./user_pill"; import type {UserPillData} from "./user_pill"; -function person_matcher(query: string, item: User): boolean { +function person_matcher(query: string, item: UserPillData): boolean { return ( people.is_known_user_id(item.user_id) && typeahead_helper.query_matches_person(query, item) ); } -function group_matcher(query: string, item: UserGroup): boolean { +function group_matcher(query: string, item: UserGroupPillData): boolean { return typeahead_helper.query_matches_name(query, item); } @@ -99,10 +98,7 @@ export function set_up( // handles `include_users` cases along with // default cases. assert(item.type === "user"); - return typeahead_helper.render_person({ - ...item, - is_broadcast: undefined, - }); + return typeahead_helper.render_person(item); }, matcher(item: TypeaheadItem, query: string): boolean { query = query.toLowerCase(); diff --git a/web/src/typeahead_helper.ts b/web/src/typeahead_helper.ts index 6cd658c6fb..8bd42e84c3 100644 --- a/web/src/typeahead_helper.ts +++ b/web/src/typeahead_helper.ts @@ -3,10 +3,12 @@ import _ from "lodash"; import assert from "minimalistic-assert"; import * as typeahead from "../shared/src/typeahead"; +import type {EmojiSuggestion} from "../shared/src/typeahead"; import render_typeahead_list_item from "../templates/typeahead_list_item.hbs"; import * as buddy_data from "./buddy_data"; import * as compose_state from "./compose_state"; +import type {LanguageSuggestion, SlashCommandSuggestion} from "./composebox_typeahead"; import type {InputPillContainer, InputPillItem} from "./input_pill"; import * as people from "./people"; import type {PseudoMentionUser, User} from "./people"; @@ -19,15 +21,16 @@ import * as stream_list_sort from "./stream_list_sort"; import type {StreamPill, StreamPillData} from "./stream_pill"; import type {StreamSubscription} from "./sub_store"; import type {UserGroupPill, UserGroupPillData} from "./user_group_pill"; -import * as user_groups from "./user_groups"; -import type {UserGroup} from "./user_groups"; import type {UserPill, UserPillData} from "./user_pill"; import * as user_status from "./user_status"; import type {UserStatusEmojiInfo} from "./user_status"; import * as util from "./util"; export type UserOrMention = PseudoMentionUser | (User & {is_broadcast: undefined}); -export type UserOrMentionPillData = UserOrMention & {type: "user_or_mention"}; +export type UserOrMentionPillData = UserOrMention & { + type: "user_or_mention"; + is_silent?: boolean; +}; export type CombinedPillContainer = InputPillContainer; @@ -98,7 +101,7 @@ export function render_typeahead_item(args: { is_user_group?: boolean; stream?: StreamData; is_unsubscribed?: boolean; - emoji_code?: string; + emoji_code?: string | undefined; }): string { const has_image = args.img_src !== undefined; const has_status = args.status_emoji_info !== undefined; @@ -113,8 +116,8 @@ export function render_typeahead_item(args: { }); } -export function render_person(person: UserOrMention): string { - if (person.is_broadcast) { +export function render_person(person: UserPillData | UserOrMentionPillData): string { + if (person.type === "user_or_mention" && person.is_broadcast) { return render_typeahead_item({ primary: person.special_item_text, is_person: true, @@ -154,9 +157,9 @@ export function render_user_group(user_group: {name: string; description: string } export function render_person_or_user_group( - item: UserGroup | (UserOrMention & {members: undefined}), + item: UserGroupPillData | UserPillData | UserOrMentionPillData, ): string { - if (user_groups.is_user_group(item)) { + if (item.type === "user_group") { return render_user_group(item); } @@ -178,11 +181,7 @@ export function render_stream(stream: StreamData): string { }); } -export function render_emoji(item: { - emoji_name: string; - emoji_url: string; - emoji_code: string; -}): string { +export function render_emoji(item: EmojiSuggestion): string { const args = { is_emoji: true, primary: item.emoji_name.replaceAll("_", " "), @@ -419,10 +418,14 @@ function retain_unique_language_aliases(matches: string[]): string[] { return unique_aliases; } -export function sort_languages(matches: string[], query: string): string[] { - const results = typeahead.triage(query, matches, (x) => x, compare_language); - - return retain_unique_language_aliases([...results.matches, ...results.rest]); +export function sort_languages(matches: LanguageSuggestion[], query: string): LanguageSuggestion[] { + const languages = matches.map((object) => object.language); + const results = typeahead.triage(query, languages, (x) => x, compare_language); + const unique_languages = retain_unique_language_aliases([...results.matches, ...results.rest]); + return unique_languages.map((language) => ({ + language, + type: "syntax", + })); } export function sort_recipients({ @@ -583,7 +586,10 @@ function slash_command_comparator( return 0; } -export function sort_slash_commands(matches: SlashCommand[], query: string): SlashCommand[] { +export function sort_slash_commands( + matches: SlashCommandSuggestion[], + query: string, +): SlashCommandSuggestion[] { // We will likely want to in the future make this sort the // just-`/` commands by something approximating usefulness. const results = typeahead.triage(query, matches, (x) => x.name, slash_command_comparator); @@ -647,14 +653,29 @@ export function sort_streams(matches: StreamPillData[], query: string): StreamPi return [...name_results.matches, ...desc_results.matches, ...desc_results.rest]; } -export function query_matches_person(query: string, person: User): boolean { - return ( - typeahead.query_matches_string_in_order(query, person.full_name, " ") || - (Boolean(person.delivery_email) && - typeahead.query_matches_string_in_order(query, people.get_visible_email(person), " ")) - ); +export function query_matches_person( + query: string, + person: UserPillData | UserOrMentionPillData, +): boolean { + if (typeahead.query_matches_string_in_order(query, person.full_name, " ")) { + return true; + } + if ( + (person.type === "user" || person.is_broadcast === undefined) && + Boolean(person.delivery_email) + ) { + return typeahead.query_matches_string_in_order( + query, + people.get_visible_email(person), + " ", + ); + } + return false; } -export function query_matches_name(query: string, user_group_or_stream: UserGroup): boolean { +export function query_matches_name( + query: string, + user_group_or_stream: UserGroupPillData | StreamPillData, +): boolean { return typeahead.query_matches_string_in_order(query, user_group_or_stream.name, " "); } diff --git a/web/src/user_group_pill.ts b/web/src/user_group_pill.ts index 92a077cb06..29b9cee28d 100644 --- a/web/src/user_group_pill.ts +++ b/web/src/user_group_pill.ts @@ -11,7 +11,10 @@ export type UserGroupPill = { type UserGroupPillWidget = InputPillContainer; -export type UserGroupPillData = UserGroup & {type: "user_group"}; +export type UserGroupPillData = UserGroup & { + type: "user_group"; + is_silent?: boolean; +}; function display_pill(group: UserGroup): string { return `${group.name}: ${group.members.size} users`; diff --git a/web/tests/composebox_typeahead.test.js b/web/tests/composebox_typeahead.test.js index 01858086ab..555d4c3bab 100644 --- a/web/tests/composebox_typeahead.test.js +++ b/web/tests/composebox_typeahead.test.js @@ -63,11 +63,48 @@ ct.__Rewire__("max_num_items", 15); function user_or_mention_item(item) { return { + is_broadcast: undefined, // default, overridden by `item` ...item, type: "user_or_mention", }; } +function user_item(item) { + return { + ...item, + is_broadcast: undefined, + type: "user", + }; +} + +function slash_item(slash) { + return { + ...slash, + type: "slash", + }; +} + +function stream_item(stream) { + return { + ...stream, + type: "stream", + }; +} + +function user_group_item(item) { + return { + ...item, + type: "user_group", + }; +} + +function language_item(language) { + return { + language, + type: "syntax", + }; +} + run_test("verify wildcard mentions typeahead for stream message", () => { compose_state.set_message_type("stream"); const mention_all = ct.broadcast_mentions()[0]; @@ -194,61 +231,64 @@ const emojis_by_name = new Map( }), ); -const me_slash = { +const me_command = { name: "me", aliases: "", text: "translated: /me (Action message)", placeholder: "translated: is …", }; +const me_command_item = slash_item(me_command); -const my_slash = { +const my_command_item = slash_item({ name: "my", aliases: "", text: "translated: /my (Test)", -}; +}); -const dark_slash = { +const dark_command = { name: "dark", aliases: "night", text: "translated: /dark (Switch to the dark theme)", }; +const dark_command_item = slash_item(dark_command); -const light_slash = { +const light_command = { name: "light", aliases: "day", text: "translated: /light (Switch to light theme)", }; +const light_command_item = slash_item(light_command); -const sweden_stream = { +const sweden_stream = stream_item({ name: "Sweden", description: "Cold, mountains and home decor.", stream_id: 1, subscribed: true, -}; -const denmark_stream = { +}); +const denmark_stream = stream_item({ name: "Denmark", description: "Vikings and boats, in a serene and cold weather.", stream_id: 2, subscribed: true, -}; -const netherland_stream = { +}); +const netherland_stream = stream_item({ name: "The Netherlands", description: "The Netherlands, city of dream.", stream_id: 3, subscribed: false, -}; -const mobile_stream = { +}); +const mobile_stream = stream_item({ name: "Mobile", description: "Mobile development", stream_id: 4, subscribed: false, -}; -const mobile_team_stream = { +}); +const mobile_team_stream = stream_item({ name: "Mobile team", description: "Mobile development team", stream_id: 5, subscribed: true, -}; +}); stream_data.add_sub(sweden_stream); stream_data.add_sub(denmark_stream); @@ -284,7 +324,10 @@ for (const [key, val] of emojis_by_name.entries()) { emoji.emojis_by_name.set(key, val); } emoji_picker.rebuild_catalog(); -const emoji_list = composebox_typeahead.emoji_collection; +const emoji_list = composebox_typeahead.emoji_collection.map((emoji) => ({ + ...emoji, + type: "emoji", +})); const emoji_list_by_name = new Map(emoji_list.map((emoji) => [emoji.emoji_name, emoji])); function emoji_objects(emoji_names) { return emoji_names.map((emoji_name) => emoji_list_by_name.get(emoji_name)); @@ -375,7 +418,7 @@ const harry = user_or_mention_item({ email: "harry@zulip.com", }); -const hamletcharacters = { +const hamletcharacters = user_group_item({ name: "hamletcharacters", id: 1, description: "Characters of Hamlet", @@ -383,9 +426,9 @@ const hamletcharacters = { is_system_group: false, direct_subgroup_ids: new Set([]), can_mention_group: 2, -}; +}); -const backend = { +const backend = user_group_item({ name: "Backend", id: 2, description: "Backend team", @@ -393,9 +436,9 @@ const backend = { is_system_group: false, direct_subgroup_ids: new Set([1]), can_mention_group: 1, -}; +}); -const call_center = { +const call_center = user_group_item({ name: "Call Center", id: 3, description: "folks working in support", @@ -403,12 +446,13 @@ const call_center = { is_system_group: false, direct_subgroup_ids: new Set([]), can_mention_group: 2, -}; +}); const make_emoji = (emoji_dict) => ({ emoji_name: emoji_dict.name, emoji_code: emoji_dict.emoji_code, reaction_type: "unicode_emoji", + type: "emoji", }); // Sorted by name @@ -510,6 +554,7 @@ test("content_typeahead_selected", ({override}) => { ct.get_or_set_token_for_testing("octo"); const item = { emoji_name: "octopus", + type: "emoji", }; let actual_value = ct.content_typeahead_selected(item, query, input_element); @@ -592,11 +637,15 @@ test("content_typeahead_selected", ({override}) => { // silent mention ct.get_or_set_completing_for_tests("silent_mention"); + const silent_hamlet = { + ...hamlet, + is_silent: true, + }; query = "@_kin"; ct.get_or_set_token_for_testing("kin"); with_overrides(({disallow}) => { disallow(compose_validate, "warn_if_mentioning_unsubscribed_user"); - actual_value = ct.content_typeahead_selected(hamlet, query, input_element); + actual_value = ct.content_typeahead_selected(silent_hamlet, query, input_element); }); expected_value = "@_**King Hamlet** "; @@ -604,64 +653,68 @@ test("content_typeahead_selected", ({override}) => { query = "Hello @_kin"; ct.get_or_set_token_for_testing("kin"); - actual_value = ct.content_typeahead_selected(hamlet, query, input_element); + actual_value = ct.content_typeahead_selected(silent_hamlet, query, input_element); expected_value = "Hello @_**King Hamlet** "; assert.equal(actual_value, expected_value); query = "@_*kin"; ct.get_or_set_token_for_testing("kin"); - actual_value = ct.content_typeahead_selected(hamlet, query, input_element); + actual_value = ct.content_typeahead_selected(silent_hamlet, query, input_element); expected_value = "@_**King Hamlet** "; assert.equal(actual_value, expected_value); query = "@_**kin"; ct.get_or_set_token_for_testing("kin"); - actual_value = ct.content_typeahead_selected(hamlet, query, input_element); + actual_value = ct.content_typeahead_selected(silent_hamlet, query, input_element); expected_value = "@_**King Hamlet** "; assert.equal(actual_value, expected_value); query = "@_back"; ct.get_or_set_token_for_testing("back"); + const silent_backend = { + ...backend, + is_silent: true, + }; with_overrides(({disallow}) => { disallow(compose_validate, "warn_if_mentioning_unsubscribed_user"); - actual_value = ct.content_typeahead_selected(backend, query, input_element); + actual_value = ct.content_typeahead_selected(silent_backend, query, input_element); }); expected_value = "@_*Backend* "; assert.equal(actual_value, expected_value); query = "@_*back"; ct.get_or_set_token_for_testing("back"); - actual_value = ct.content_typeahead_selected(backend, query, input_element); + actual_value = ct.content_typeahead_selected(silent_backend, query, input_element); expected_value = "@_*Backend* "; assert.equal(actual_value, expected_value); query = "/m"; ct.get_or_set_completing_for_tests("slash"); - actual_value = ct.content_typeahead_selected(me_slash, query, input_element); + actual_value = ct.content_typeahead_selected(me_command_item, query, input_element); expected_value = "/me translated: is …"; assert.equal(actual_value, expected_value); query = "/da"; ct.get_or_set_completing_for_tests("slash"); - actual_value = ct.content_typeahead_selected(dark_slash, query, input_element); + actual_value = ct.content_typeahead_selected(dark_command_item, query, input_element); expected_value = "/dark "; assert.equal(actual_value, expected_value); query = "/ni"; ct.get_or_set_completing_for_tests("slash"); - actual_value = ct.content_typeahead_selected(dark_slash, query, input_element); + actual_value = ct.content_typeahead_selected(dark_command_item, query, input_element); expected_value = "/dark "; assert.equal(actual_value, expected_value); query = "/li"; ct.get_or_set_completing_for_tests("slash"); - actual_value = ct.content_typeahead_selected(light_slash, query, input_element); + actual_value = ct.content_typeahead_selected(light_command_item, query, input_element); expected_value = "/light "; assert.equal(actual_value, expected_value); query = "/da"; ct.get_or_set_completing_for_tests("slash"); - actual_value = ct.content_typeahead_selected(light_slash, query, input_element); + actual_value = ct.content_typeahead_selected(light_command_item, query, input_element); expected_value = "/light "; assert.equal(actual_value, expected_value); @@ -696,13 +749,27 @@ test("content_typeahead_selected", ({override}) => { query = "Hello #**Sweden>test"; ct.get_or_set_token_for_testing("test"); - actual_value = ct.content_typeahead_selected("testing", query, input_element); + actual_value = ct.content_typeahead_selected( + { + topic: "testing", + type: "topic_list", + }, + query, + input_element, + ); expected_value = "Hello #**Sweden>testing** "; assert.equal(actual_value, expected_value); query = "Hello #**Sweden>"; ct.get_or_set_token_for_testing(""); - actual_value = ct.content_typeahead_selected("testing", query, input_element); + actual_value = ct.content_typeahead_selected( + { + topic: "testing", + type: "topic_list", + }, + query, + input_element, + ); expected_value = "Hello #**Sweden>testing** "; assert.equal(actual_value, expected_value); @@ -711,25 +778,25 @@ test("content_typeahead_selected", ({override}) => { query = "~~~p"; ct.get_or_set_token_for_testing("p"); - actual_value = ct.content_typeahead_selected("python", query, input_element); + actual_value = ct.content_typeahead_selected(language_item("python"), query, input_element); expected_value = "~~~python\n\n~~~"; assert.equal(actual_value, expected_value); query = "Hello ~~~p"; ct.get_or_set_token_for_testing("p"); - actual_value = ct.content_typeahead_selected("python", query, input_element); + actual_value = ct.content_typeahead_selected(language_item("python"), query, input_element); expected_value = "Hello ~~~python\n\n~~~"; assert.equal(actual_value, expected_value); query = "```p"; ct.get_or_set_token_for_testing("p"); - actual_value = ct.content_typeahead_selected("python", query, input_element); + actual_value = ct.content_typeahead_selected(language_item("python"), query, input_element); expected_value = "```python\n\n```"; assert.equal(actual_value, expected_value); query = "```spo"; ct.get_or_set_token_for_testing("spo"); - actual_value = ct.content_typeahead_selected("spoiler", query, input_element); + actual_value = ct.content_typeahead_selected(language_item("spoiler"), query, input_element); expected_value = "```spoiler translated: Header\n\n```"; assert.equal(actual_value, expected_value); @@ -737,7 +804,7 @@ test("content_typeahead_selected", ({override}) => { query = "```p\nsome existing code"; ct.get_or_set_token_for_testing("p"); input_element.$element.caret = () => 4; // Put cursor right after ```p - actual_value = ct.content_typeahead_selected("python", query, input_element); + actual_value = ct.content_typeahead_selected(language_item("python"), query, input_element); expected_value = "```python\nsome existing code"; assert.equal(actual_value, expected_value); @@ -877,17 +944,17 @@ test("initialize", ({override, override_rewire, mock_template}) => { // This should match the users added at the beginning of this test file. let actual_value = options.source(""); let expected_value = [ - ali, - alice, - cordelia, - hal, - gael, - harry, - hamlet, - lear, - twin1, - twin2, - othello, + user_item(ali), + user_item(alice), + user_item(cordelia), + user_item(hal), + user_item(gael), + user_item(harry), + user_item(hamlet), + user_item(lear), + user_item(twin1), + user_item(twin2), + user_item(othello), hamletcharacters, backend, call_center, @@ -936,6 +1003,7 @@ test("initialize", ({override, override_rewire, mock_template}) => { assert.equal(matcher(query, othello), false); assert.equal(matcher(query, cordelia), false); + // Matching by email query = "oth"; deactivated_user.delivery_email = null; assert.equal(matcher(query, deactivated_user), false); @@ -1041,7 +1109,6 @@ test("initialize", ({override, override_rewire, mock_template}) => { caret_called = true; return 7; }; - input_element.$element.closest = () => []; let actual_value = options.source("test #s", input_element); assert.deepEqual(sorted_names_from(actual_value), ["Sweden", "The Netherlands"]); assert.ok(caret_called); @@ -1072,16 +1139,16 @@ test("initialize", ({override, override_rewire, mock_template}) => { // matching let matcher = typeahead.get_emoji_matcher("ta"); - assert.equal(matcher(make_emoji(emoji_tada), matcher), true); - assert.equal(matcher(make_emoji(emoji_moneybag), matcher), false); + assert.equal(matcher(make_emoji(emoji_tada)), true); + assert.equal(matcher(make_emoji(emoji_moneybag)), false); matcher = ct.get_stream_or_user_group_matcher("swed"); - assert.equal(matcher(sweden_stream, matcher), true); - assert.equal(matcher(denmark_stream, matcher), false); + assert.equal(matcher(sweden_stream), true); + assert.equal(matcher(denmark_stream), false); matcher = ct.get_language_matcher("py"); - assert.equal(matcher("python", matcher), true); - assert.equal(matcher("javascript", matcher), false); + assert.equal(matcher("python"), true); + assert.equal(matcher("javascript"), false); // options.sorter() actual_value = typeahead.sort_emojis( @@ -1105,15 +1172,18 @@ test("initialize", ({override, override_rewire, mock_template}) => { expected_value = [make_emoji(emoji_heart), make_emoji(emoji_headphones)]; assert.deepEqual(actual_value, expected_value); - actual_value = typeahead_helper.sort_slash_commands([my_slash, me_slash], "m"); - expected_value = [me_slash, my_slash]; + actual_value = typeahead_helper.sort_slash_commands( + [my_command_item, me_command_item], + "m", + ); + expected_value = [me_command_item, my_command_item]; assert.deepEqual(actual_value, expected_value); actual_value = typeahead_helper.sort_slash_commands( - [dark_slash, light_slash], + [dark_command_item, light_command_item], "da", ); - expected_value = [dark_slash, light_slash]; + expected_value = [dark_command_item, light_command_item]; assert.deepEqual(actual_value, expected_value); actual_value = typeahead_helper.sort_streams([sweden_stream, denmark_stream], "de"); @@ -1127,8 +1197,11 @@ test("initialize", ({override, override_rewire, mock_template}) => { expected_value = [sweden_stream, denmark_stream]; assert.deepEqual(actual_value, expected_value); - actual_value = typeahead_helper.sort_languages(["abap", "applescript"], "ap"); - expected_value = ["applescript", "abap"]; + actual_value = typeahead_helper.sort_languages( + [language_item("abap"), language_item("applescript")], + "ap", + ); + expected_value = [language_item("applescript"), language_item("abap")]; assert.deepEqual(actual_value, expected_value); const serbia_stream = { @@ -1136,6 +1209,7 @@ test("initialize", ({override, override_rewire, mock_template}) => { description: "Snow and cold", stream_id: 3, subscribed: false, + type: "stream", }; // Subscribed stream is active override( @@ -1188,20 +1262,20 @@ test("initialize", ({override, override_rewire, mock_template}) => { // the UI of selecting a stream is tested in puppeteer tests. compose_state.set_stream_id(sweden_stream.stream_id); + const $stub_target = $.create(""); let event = { type: "keydown", key: "Tab", shiftKey: false, - target: { - id: "stream_message_recipient_topic", - }, + target: "", preventDefault: noop, stopPropagation: noop, }; + $stub_target.attr("id", "stream_message_recipient_topic"); $("form#send_message_form").trigger(event); - event.target.id = "compose-textarea"; + $stub_target.attr("id", "compose-textarea"); $("form#send_message_form").trigger(event); - event.target.id = "some_non_existing_id"; + $stub_target.attr("id", "some_non_existing_id"); $("form#send_message_form").trigger(event); $("textarea#compose-textarea")[0] = { @@ -1214,9 +1288,9 @@ test("initialize", ({override, override_rewire, mock_template}) => { $("textarea#compose-textarea").caret = () => $("textarea#compose-textarea")[0].selectionStart; event.key = "Enter"; - event.target.id = "stream_message_recipient_topic"; + $stub_target.attr("id", "stream_message_recipient_topic"); $("form#send_message_form").trigger(event); - event.target.id = "compose-textarea"; + $stub_target.attr("id", "compose-textarea"); user_settings.enter_sends = false; event.metaKey = true; @@ -1285,7 +1359,7 @@ test("initialize", ({override, override_rewire, mock_template}) => { event.altKey = false; event.metaKey = true; $("form#send_message_form").trigger(event); - event.target.id = "private_message_recipient"; + $stub_target.attr("id", "private_message_recipient"); $("form#send_message_form").trigger(event); event.key = "a"; @@ -1297,11 +1371,10 @@ test("initialize", ({override, override_rewire, mock_template}) => { event = { type: "keydown", key: "Enter", - target: { - id: "stream_message_recipient_topic", - }, + target: "", preventDefault: noop, }; + $stub_target.attr("id", "stream_message_recipient_topic"); // We trigger keydown in order to make nextFocus !== false $("form#send_message_form").trigger(event); $("input#stream_message_recipient_topic").off("mouseup"); @@ -1338,10 +1411,11 @@ test("begins_typeahead", ({override, override_rewire}) => { override(stream_topic_history_util, "get_server_history", noop); const input_element = { - $element: {}, + $element: { + closest: () => [], + }, type: "input", }; - input_element.$element.closest = () => []; function get_values(input, rest) { // Stub out split_at_cursor that uses $(':focus') @@ -1383,23 +1457,34 @@ test("begins_typeahead", ({override, override_rewire}) => { // Make sure that the last token is the one we read. assert_typeahead_equals("~~~ @zulip", []); // zulip isn't set up as a user group assert_typeahead_equals("@zulip :ta", emoji_objects(["tada", "stadium"])); - assert_typeahead_equals("#foo\n~~~py", [ - "py", - "py+ul4", - "py2", - "py2tb", - "py3tb", - "pycon", - "pypy", - "pyrex", - "antlr-python", - "bst-pybtex", - "ipython", - "ipython3", - "ipythonconsole", - "numpy", + function language_objects(languages) { + return languages.map((language) => language_item(language)); + } + assert_typeahead_equals( + "#foo\n~~~py", + language_objects([ + "py", + "py+ul4", + "py2", + "py2tb", + "py3tb", + "pycon", + "pypy", + "pyrex", + "antlr-python", + "bst-pybtex", + "ipython", + "ipython3", + "ipythonconsole", + "numpy", + ]), + ); + assert_typeahead_equals(":tada: { call_center, // "folks working in support" ]; const mention_everyone = user_or_mention_item(ct.broadcast_mentions()[1]); - assert_typeahead_equals("@", users_and_all_mention); + function mentions_with_silent_marker(mentions, is_silent) { + return mentions.map((item) => ({ + ...item, + is_silent, + })); + } + assert_typeahead_equals("@", mentions_with_silent_marker(users_and_all_mention, false)); // The user we're testing for is only allowed to do silent mentions of groups - assert_typeahead_equals("@_", users_and_user_groups); - assert_typeahead_equals(" @", users_and_all_mention); - assert_typeahead_equals(" @_", users_and_user_groups); - assert_typeahead_equals("@*", users_and_all_mention); - assert_typeahead_equals("@_*", users_and_user_groups); - assert_typeahead_equals("@**", users_and_all_mention); - assert_typeahead_equals("@_**", users_and_user_groups); - assert_typeahead_equals("test @**o", [othello, cordelia, mention_everyone]); - assert_typeahead_equals("test @_**o", [othello, cordelia]); - assert_typeahead_equals("test @*o", [othello, cordelia, mention_everyone]); - assert_typeahead_equals("test @_*k", [hamlet, lear, twin1, twin2, backend]); - assert_typeahead_equals("test @*h", [harry, hal, hamlet, cordelia, othello]); - assert_typeahead_equals("test @_*h", [harry, hal, hamlet, hamletcharacters, cordelia, othello]); - assert_typeahead_equals("test @", users_and_all_mention); - assert_typeahead_equals("test @_", users_and_user_groups); + assert_typeahead_equals("@", mentions_with_silent_marker(users_and_all_mention, false)); + // The user we're testing for is only allowed to do silent mentions of groups + assert_typeahead_equals("@_", mentions_with_silent_marker(users_and_user_groups, true)); + assert_typeahead_equals(" @", mentions_with_silent_marker(users_and_all_mention, false)); + assert_typeahead_equals(" @_", mentions_with_silent_marker(users_and_user_groups, true)); + assert_typeahead_equals("@*", mentions_with_silent_marker(users_and_all_mention, false)); + assert_typeahead_equals("@_*", mentions_with_silent_marker(users_and_user_groups, true)); + assert_typeahead_equals("@**", mentions_with_silent_marker(users_and_all_mention, false)); + assert_typeahead_equals("@_**", mentions_with_silent_marker(users_and_user_groups, true)); + assert_typeahead_equals( + "test @**o", + mentions_with_silent_marker([othello, cordelia, mention_everyone], false), + ); + assert_typeahead_equals("test @_**o", mentions_with_silent_marker([othello, cordelia], true)); + assert_typeahead_equals( + "test @*o", + mentions_with_silent_marker([othello, cordelia, mention_everyone], false), + ); + assert_typeahead_equals( + "test @_*k", + mentions_with_silent_marker([hamlet, lear, twin1, twin2, backend], true), + ); + assert_typeahead_equals( + "test @*h", + mentions_with_silent_marker([harry, hal, hamlet, cordelia, othello], false), + ); + assert_typeahead_equals( + "test @_*h", + mentions_with_silent_marker( + [harry, hal, hamlet, hamletcharacters, cordelia, othello], + true, + ), + ); + assert_typeahead_equals("test @", mentions_with_silent_marker(users_and_all_mention, false)); + assert_typeahead_equals("test @_", mentions_with_silent_marker(users_and_user_groups, true)); assert_typeahead_equals("test no@o", []); assert_typeahead_equals("test no@_k", []); assert_typeahead_equals("@ ", []); @@ -1436,59 +1547,45 @@ test("begins_typeahead", ({override, override_rewire}) => { assert_typeahead_equals("@_* ", []); assert_typeahead_equals("@** ", []); assert_typeahead_equals("@_** ", []); - assert_typeahead_equals("test\n@i", [ - ali, - alice, - cordelia, - gael, - hamlet, - lear, - twin1, - twin2, - othello, - ]); - assert_typeahead_equals("test\n@_i", [ - ali, - alice, - cordelia, - gael, - hamlet, - lear, - twin1, - twin2, - othello, - ]); - assert_typeahead_equals("test\n @l", [ - cordelia, - lear, - ali, - alice, - hal, - gael, - hamlet, - othello, - mention_all, - ]); - assert_typeahead_equals("test\n @_l", [ - cordelia, - lear, - ali, - alice, - hal, - gael, - hamlet, - othello, - hamletcharacters, - call_center, - ]); + assert_typeahead_equals( + "test\n@i", + mentions_with_silent_marker( + [ali, alice, cordelia, gael, hamlet, lear, twin1, twin2, othello], + false, + ), + ); + assert_typeahead_equals( + "test\n@_i", + mentions_with_silent_marker( + [ali, alice, cordelia, gael, hamlet, lear, twin1, twin2, othello], + true, + ), + ); + assert_typeahead_equals( + "test\n @l", + mentions_with_silent_marker( + [cordelia, lear, ali, alice, hal, gael, hamlet, othello, mention_all], + false, + ), + ); + assert_typeahead_equals( + "test\n @_l", + mentions_with_silent_marker( + [cordelia, lear, ali, alice, hal, gael, hamlet, othello, hamletcharacters, call_center], + true, + ), + ); assert_typeahead_equals("@zuli", []); assert_typeahead_equals("@_zuli", []); assert_typeahead_equals("@ zuli", []); assert_typeahead_equals("@_ zuli", []); assert_typeahead_equals(" @zuli", []); assert_typeahead_equals(" @_zuli", []); - assert_typeahead_equals("test @o", [othello, cordelia, mention_everyone]); - assert_typeahead_equals("test @_o", [othello, cordelia]); + assert_typeahead_equals( + "test @o", + mentions_with_silent_marker([othello, cordelia, mention_everyone], false), + ); + assert_typeahead_equals("test @_o", mentions_with_silent_marker([othello, cordelia], true)); assert_typeahead_equals("test @z", []); assert_typeahead_equals("test @_z", []); @@ -1535,27 +1632,23 @@ test("begins_typeahead", ({override, override_rewire}) => { assert_typeahead_equals("test # a", []); assert_typeahead_equals("test no#o", []); - const me_command = { - text: "translated: /me (Action message)", - name: "me", - aliases: "", - placeholder: "translated: is …", - }; const poll_command = { text: "translated: /poll (Create a poll)", name: "poll", aliases: "", placeholder: "translated: Question", + type: "slash", }; const todo_command = { text: "translated: /todo (Create a collaborative to-do list)", name: "todo", aliases: "", placeholder: "translated: Task list", + type: "slash", }; - assert_typeahead_equals("/", [me_command, poll_command, todo_command]); - assert_typeahead_equals("/m", [me_command]); + assert_typeahead_equals("/", [me_command_item, poll_command, todo_command]); + assert_typeahead_equals("/m", [me_command_item]); // Slash commands can only occur at the start of a message assert_typeahead_equals(" /m", []); assert_typeahead_equals("abc/me", []); @@ -1585,16 +1678,15 @@ test("begins_typeahead", ({override, override_rewire}) => { // The typeahead displays languages sorted by popularity, so to // avoid typing out all of them here we'll just test that the // first several match up. - assert_typeahead_starts_with("```b", ["bash", "b3d", "bare", "basemake", "basic", "bat"]); - assert_typeahead_starts_with("``` d", [ - "d", - "dart", - "d-objdump", - "dasm16", - "dax", - "debcontrol", - ]); - const p_langs = ["python", "powershell", "php", "perl", "pacmanconf", "pan"]; + assert_typeahead_starts_with( + "```b", + language_objects(["bash", "b3d", "bare", "basemake", "basic", "bat"]), + ); + assert_typeahead_starts_with( + "``` d", + language_objects(["d", "dart", "d-objdump", "dasm16", "dax", "debcontrol"]), + ); + const p_langs = language_objects(["python", "powershell", "php", "perl", "pacmanconf", "pan"]); assert_typeahead_starts_with("test\n``` p", p_langs); // Too many spaces between ``` and the p to // trigger the typeahead. @@ -1607,15 +1699,14 @@ test("begins_typeahead", ({override, override_rewire}) => { assert_typeahead_equals("test ~~~", []); assert_typeahead_equals("test ~~~p", []); assert_typeahead_equals("test\n~~~", []); - assert_typeahead_starts_with("~~~e", [ - "earl-grey", - "easytrieve", - "ebnf", - "ec", - "ecl", - "eiffel", - ]); - assert_typeahead_starts_with("~~~ f", ["f#", "f90", "factor", "fan", "fancy", "fc"]); + assert_typeahead_starts_with( + "~~~e", + language_objects(["earl-grey", "easytrieve", "ebnf", "ec", "ecl", "eiffel"]), + ); + assert_typeahead_starts_with( + "~~~ f", + language_objects(["f#", "f90", "factor", "fan", "fancy", "fc"]), + ); assert_typeahead_starts_with("test\n~~~ p", p_langs); // Too many spaces before the p assert_typeahead_equals("test\n~~~ p", []); @@ -1623,25 +1714,42 @@ test("begins_typeahead", ({override, override_rewire}) => { // topic_jump assert_typeahead_equals("@**a person**>", []); assert_typeahead_equals("@**a person** >", []); - assert_typeahead_equals("#**stream**>", [""]); // this is deliberately a blank choice. - assert_typeahead_equals("#**stream** >", [""]); + const topic_jump = [ + { + // this is deliberately a blank choice. + message: "", + type: "topic_jump", + }, + ]; + assert_typeahead_equals("#**stream**>", topic_jump); + assert_typeahead_equals("#**stream** >", topic_jump); assert_typeahead_equals("#**Sweden>some topic** >", []); // Already completed a topic. // topic_list // includes "more ice" - assert_typeahead_equals("#**Sweden>more ice", ["more ice", "even more ice"]); - assert_typeahead_equals("#**Sweden>totally new topic", ["totally new topic"]); + function typed_topics(topics) { + return topics.map((topic) => ({ + type: "topic_list", + topic, + })); + } + assert_typeahead_equals("#**Sweden>more ice", typed_topics(["more ice", "even more ice"])); + assert_typeahead_equals("#**Sweden>totally new topic", typed_topics(["totally new topic"])); // time_jump + const time_jump = [ + { + message: "translated: Mention a time-zone-aware time", + type: "time_jump", + }, + ]; assert_typeahead_equals(" ", [ - "translated: Mention a time-zone-aware time", - ]); - assert_typeahead_equals("", ["translated: Mention a time-zone-aware time"]); + assert_typeahead_equals(" ", time_jump); + assert_typeahead_equals("", time_jump); assert_typeahead_equals(" ", []); // Already completed the mention // Following tests place the cursor before the second string @@ -1652,7 +1760,7 @@ test("begins_typeahead", ({override, override_rewire}) => { assert_typeahead_equals("~~~test", "ing", []); const terminal_symbols = ",.;?!()[]> \"'\n\t"; for (const symbol of terminal_symbols.split()) { - assert_typeahead_equals("@othello", symbol, [othello]); + assert_typeahead_equals("@othello", symbol, mentions_with_silent_marker([othello], false)); assert_typeahead_equals(":tada", symbol, emoji_objects(["tada"])); assert_typeahead_starts_with("```p", symbol, p_langs); assert_typeahead_starts_with("~~~p", symbol, p_langs); @@ -1689,7 +1797,7 @@ test("tokenizing", () => { test("content_highlighter_html", ({override_rewire}) => { ct.get_or_set_completing_for_tests("emoji"); - const emoji = {emoji_name: "person shrugging", emoji_url: "¯\\_(ツ)_/¯"}; + const emoji = {emoji_name: "person shrugging", emoji_url: "¯\\_(ツ)_/¯", type: "emoji"}; let th_render_typeahead_item_called = false; override_rewire(typeahead_helper, "render_emoji", (item) => { assert.deepEqual(item, emoji); @@ -1717,6 +1825,7 @@ test("content_highlighter_html", ({override_rewire}) => { let th_render_slash_command_called = false; const me_slash = { text: "/me (Action message)", + type: "slash", }; override_rewire(typeahead_helper, "render_typeahead_item", (item) => { assert.deepEqual(item, { @@ -1740,10 +1849,7 @@ test("content_highlighter_html", ({override_rewire}) => { assert.deepEqual(item, {primary: "py"}); th_render_typeahead_item_called = true; }); - ct.content_highlighter_html("py"); - - ct.get_or_set_completing_for_tests("something-else"); - assert.ok(!ct.content_highlighter_html()); + ct.content_highlighter_html({type: "syntax", language: "py"}); // Verify that all stub functions have been called. assert.ok(th_render_typeahead_item_called); @@ -1754,6 +1860,13 @@ test("content_highlighter_html", ({override_rewire}) => { assert.ok(th_render_slash_command_called); }); +function possibly_silent_list(list, is_silent) { + return list.map((item) => ({ + ...item, + is_silent, + })); +} + test("filter_and_sort_mentions (normal)", () => { compose_state.set_message_type("stream"); const is_silent = false; @@ -1761,20 +1874,26 @@ test("filter_and_sort_mentions (normal)", () => { let suggestions = ct.filter_and_sort_mentions(is_silent, "al"); const mention_all = user_or_mention_item(ct.broadcast_mentions()[0]); - assert.deepEqual(suggestions, [mention_all, ali, alice, hal, call_center]); + assert.deepEqual( + suggestions, + possibly_silent_list([mention_all, ali, alice, hal, call_center], is_silent), + ); // call_center group is shown in typeahead even when user is member of // one of the subgroups of can_mention_group. current_user.user_id = 104; suggestions = ct.filter_and_sort_mentions(is_silent, "al"); - assert.deepEqual(suggestions, [mention_all, ali, alice, hal, call_center]); + assert.deepEqual( + suggestions, + possibly_silent_list([mention_all, ali, alice, hal, call_center], is_silent), + ); // call_center group is not shown in typeahead when user is neither // a direct member of can_mention_group nor a member of any of its // recursive subgroups. current_user.user_id = 102; suggestions = ct.filter_and_sort_mentions(is_silent, "al"); - assert.deepEqual(suggestions, [mention_all, ali, alice, hal]); + assert.deepEqual(suggestions, possibly_silent_list([mention_all, ali, alice, hal], is_silent)); }); test("filter_and_sort_mentions (silent)", () => { @@ -1782,14 +1901,14 @@ test("filter_and_sort_mentions (silent)", () => { let suggestions = ct.filter_and_sort_mentions(is_silent, "al"); - assert.deepEqual(suggestions, [ali, alice, hal, call_center]); + assert.deepEqual(suggestions, possibly_silent_list([ali, alice, hal, call_center], is_silent)); // call_center group is shown in typeahead irrespective of whether // user is member of can_mention_group or its subgroups for a // silent mention. current_user.user_id = 102; suggestions = ct.filter_and_sort_mentions(is_silent, "al"); - assert.deepEqual(suggestions, [ali, alice, hal, call_center]); + assert.deepEqual(suggestions, possibly_silent_list([ali, alice, hal, call_center], is_silent)); }); test("typeahead_results", () => { @@ -1806,6 +1925,7 @@ test("typeahead_results", () => { const returned = emoji_list.filter((item) => matcher(item)); assert.deepEqual(returned, expected); } + function assert_mentions_matches(input, expected) { const is_silent = false; const returned = ct.filter_and_sort_mentions(is_silent, input); @@ -1828,12 +1948,14 @@ test("typeahead_results", () => { emoji_code: "1f43c", reaction_type: "unicode_emoji", is_realm_emoji: false, + type: "emoji", }, { emoji_name: "tada", emoji_code: "1f389", reaction_type: "unicode_emoji", is_realm_emoji: false, + type: "emoji", }, ]); assert_emoji_matches("da_", []); @@ -1844,6 +1966,7 @@ test("typeahead_results", () => { emoji_code: "1f43c", reaction_type: "unicode_emoji", is_realm_emoji: false, + type: "emoji", }, ]); assert_emoji_matches("panda_", [ @@ -1852,6 +1975,7 @@ test("typeahead_results", () => { emoji_code: "1f43c", reaction_type: "unicode_emoji", is_realm_emoji: false, + type: "emoji", }, ]); assert_emoji_matches("japanese_post_", [ @@ -1860,6 +1984,7 @@ test("typeahead_results", () => { emoji_code: "1f3e3", reaction_type: "unicode_emoji", is_realm_emoji: false, + type: "emoji", }, ]); assert_emoji_matches("japanese post ", [ @@ -1868,68 +1993,80 @@ test("typeahead_results", () => { emoji_code: "1f3e3", reaction_type: "unicode_emoji", is_realm_emoji: false, + type: "emoji", }, ]); assert_emoji_matches("notaemoji", []); // Autocomplete user mentions by user name. - assert_mentions_matches("cordelia", [cordelia]); - assert_mentions_matches("cordelia, le", [cordelia]); + function not_silent(item) { + return { + ...item, + is_silent: false, + }; + } + assert_mentions_matches("cordelia", [not_silent(cordelia)]); + assert_mentions_matches("cordelia, le", [not_silent(cordelia)]); assert_mentions_matches("cordelia, le ", []); - assert_mentions_matches("moor", [othello]); - assert_mentions_matches("moor ", [othello]); - assert_mentions_matches("moor of", [othello]); - assert_mentions_matches("moor of ven", [othello]); - assert_mentions_matches("oor", [othello]); + assert_mentions_matches("moor", [not_silent(othello)]); + assert_mentions_matches("moor ", [not_silent(othello)]); + assert_mentions_matches("moor of", [not_silent(othello)]); + assert_mentions_matches("moor of ven", [not_silent(othello)]); + assert_mentions_matches("oor", [not_silent(othello)]); assert_mentions_matches("oor ", []); assert_mentions_matches("oor o", []); assert_mentions_matches("oor of venice", []); - assert_mentions_matches("King ", [hamlet, lear]); - assert_mentions_matches("King H", [hamlet]); - assert_mentions_matches("King L", [lear]); + assert_mentions_matches("King ", [not_silent(hamlet), not_silent(lear)]); + assert_mentions_matches("King H", [not_silent(hamlet)]); + assert_mentions_matches("King L", [not_silent(lear)]); assert_mentions_matches("delia lear", []); - assert_mentions_matches("Mark Tw", [twin1, twin2]); + assert_mentions_matches("Mark Tw", [not_silent(twin1), not_silent(twin2)]); // Earlier user group and stream mentions were autocompleted by their // description too. This is now removed as it often led to unexpected // behaviour, and did not have any great discoverability advantage. current_user.user_id = 101; // Autocomplete user group mentions by group name. - assert_mentions_matches("hamletchar", [hamletcharacters]); + assert_mentions_matches("hamletchar", [not_silent(hamletcharacters)]); // Verify we're not matching on a terms that only appear in the description. assert_mentions_matches("characters of", []); // Verify we suggest only the first matching stream wildcard mention, // irrespective of how many equivalent stream wildcard mentions match. - const mention_everyone = user_or_mention_item(ct.broadcast_mentions()[1]); + const mention_everyone = not_silent(user_or_mention_item(ct.broadcast_mentions()[1])); // Here, we suggest only "everyone" instead of both the matching // "everyone" and "stream" wildcard mentions. assert_mentions_matches("e", [ - mention_everyone, - hal, - alice, - cordelia, - gael, - hamlet, - lear, - othello, - hamletcharacters, - call_center, + not_silent(mention_everyone), + not_silent(hal), + not_silent(alice), + not_silent(cordelia), + not_silent(gael), + not_silent(hamlet), + not_silent(lear), + not_silent(othello), + not_silent(hamletcharacters), + not_silent(call_center), ]); // Verify we suggest both 'the first matching stream wildcard' and // 'topic wildcard' mentions. Not only one matching wildcard mention. const mention_topic = user_or_mention_item(ct.broadcast_mentions()[4]); // Here, we suggest both "everyone" and "topic". - assert_mentions_matches("o", [othello, mention_everyone, mention_topic, cordelia]); + assert_mentions_matches("o", [ + not_silent(othello), + not_silent(mention_everyone), + not_silent(mention_topic), + not_silent(cordelia), + ]); // Autocomplete by slash commands. - assert_slash_matches("me", [me_slash]); - assert_slash_matches("dark", [dark_slash]); - assert_slash_matches("night", [dark_slash]); - assert_slash_matches("light", [light_slash]); - assert_slash_matches("day", [light_slash]); + assert_slash_matches("me", [me_command]); + assert_slash_matches("dark", [dark_command]); + assert_slash_matches("night", [dark_command]); + assert_slash_matches("light", [light_command]); + assert_slash_matches("day", [light_command]); // Autocomplete stream by stream name assert_stream_matches("den", [denmark_stream, sweden_stream]); @@ -2025,12 +2162,13 @@ test("direct message recipients sorted according to stream / topic being viewed" // When viewing no stream, sorting is alphabetical compose_state.set_stream_id(""); results = ct.get_pm_people("li"); - assert.deepEqual(results, [ali, alice, cordelia]); + // `get_pm_people` can't return mentions, so the items are all user items. + assert.deepEqual(results, [user_item(ali), user_item(alice), user_item(cordelia)]); // When viewing denmark stream, subscriber cordelia is placed higher compose_state.set_stream_id(denmark_stream.stream_id); results = ct.get_pm_people("li"); - assert.deepEqual(results, [cordelia, ali, alice]); + assert.deepEqual(results, [user_item(cordelia), user_item(ali), user_item(alice)]); // Simulating just alice being subscribed to denmark. override_rewire( @@ -2042,5 +2180,5 @@ test("direct message recipients sorted according to stream / topic being viewed" // When viewing denmark stream to which alice is subscribed, ali is not // 1st despite having an exact name match with the query. results = ct.get_pm_people("ali"); - assert.deepEqual(results, [alice, ali]); + assert.deepEqual(results, [user_item(alice), user_item(ali)]); }); diff --git a/web/tests/typeahead_helper.test.js b/web/tests/typeahead_helper.test.js index b3bfd1cf97..c1f9fa310f 100644 --- a/web/tests/typeahead_helper.test.js +++ b/web/tests/typeahead_helper.test.js @@ -304,6 +304,13 @@ test("sort_streams", ({override, override_rewire}) => { assert.deepEqual(test_streams[5].name, "Mew"); // Unsubscribed and no match }); +function language_items(languages) { + return languages.map((language) => ({ + type: "syntax", + language, + })); +} + test("sort_languages", ({override_rewire}) => { override_rewire(pygments_data, "langs", { python: {priority: 26}, @@ -314,18 +321,18 @@ test("sort_languages", ({override_rewire}) => { css: {priority: 21}, }); - let test_langs = ["pascal", "perl", "php", "python", "javascript"]; + let test_langs = language_items(["pascal", "perl", "php", "python", "javascript"]); test_langs = th.sort_languages(test_langs, "p"); // Sort languages by matching first letter, and then by popularity - assert.deepEqual(test_langs, ["python", "php", "pascal", "perl", "javascript"]); + assert.deepEqual(test_langs, language_items(["python", "php", "pascal", "perl", "javascript"])); // Test if popularity between two languages are the same pygments_data.langs.php = {priority: 26}; - test_langs = ["pascal", "perl", "php", "python", "javascript"]; + test_langs = language_items(["pascal", "perl", "php", "python", "javascript"]); test_langs = th.sort_languages(test_langs, "p"); - assert.deepEqual(test_langs, ["php", "python", "pascal", "perl", "javascript"]); + assert.deepEqual(test_langs, language_items(["php", "python", "pascal", "perl", "javascript"])); }); test("sort_languages on actual data", () => { @@ -334,23 +341,23 @@ test("sort_languages on actual data", () => { // We may eventually want to use human-readable names like // "JavaScript" with several machine-readable aliases for what the // user typed, which might help provide a better user experience. - let test_langs = ["j", "java", "javascript", "js"]; + let test_langs = language_items(["j", "java", "javascript", "js"]); // Sort according to priority only. test_langs = th.sort_languages(test_langs, "jav"); - assert.deepEqual(test_langs, ["javascript", "java", "j"]); + assert.deepEqual(test_langs, language_items(["javascript", "java", "j"])); // Push exact matches to top, regardless of priority test_langs = th.sort_languages(test_langs, "java"); - assert.deepEqual(test_langs, ["java", "javascript", "j"]); + assert.deepEqual(test_langs, language_items(["java", "javascript", "j"])); test_langs = th.sort_languages(test_langs, "j"); - assert.deepEqual(test_langs, ["j", "javascript", "java"]); + assert.deepEqual(test_langs, language_items(["j", "javascript", "java"])); // (Only one alias should be shown per language // (e.g. searching for "js" shouldn't show "javascript") - test_langs = ["js", "javascript", "java"]; + test_langs = language_items(["js", "javascript", "java"]); test_langs = th.sort_languages(test_langs, "js"); - assert.deepEqual(test_langs, ["js", "java"]); + assert.deepEqual(test_langs, language_items(["js", "java"])); }); function get_typeahead_result(query, current_stream_id, current_topic) { @@ -817,6 +824,7 @@ test("render_person special_item_text", ({mock_template}) => { user_id: 7, special_item_text: "special_text", is_broadcast: true, + type: "user_or_mention", }; rendered = false; diff --git a/zerver/lib/emoji.py b/zerver/lib/emoji.py index 8ad9f2b2c1..7782fee314 100644 --- a/zerver/lib/emoji.py +++ b/zerver/lib/emoji.py @@ -33,7 +33,7 @@ EMOTICON_CONVERSIONS = emoji_codes["emoticon_conversions"] possible_emoticons = EMOTICON_CONVERSIONS.keys() possible_emoticon_regexes = (re.escape(emoticon) for emoticon in possible_emoticons) -terminal_symbols = r",.;?!()\[\] \"'\n\t" # from composebox_typeahead.js +terminal_symbols = r",.;?!()\[\] \"'\n\t" # from composebox_typeahead.ts EMOTICON_RE = ( rf"(?(" + r")|(".join(possible_emoticon_regexes)