diff --git a/web/src/typeahead_helper.js b/web/src/typeahead_helper.ts similarity index 69% rename from web/src/typeahead_helper.js rename to web/src/typeahead_helper.ts index 0dcae8218a..62238d0fba 100644 --- a/web/src/typeahead_helper.js +++ b/web/src/typeahead_helper.ts @@ -8,28 +8,35 @@ import * as buddy_data from "./buddy_data"; import * as compose_state from "./compose_state"; import {page_params} from "./page_params"; import * as people from "./people"; +import type {PseudoMentionUser, User} from "./people"; import * as pm_conversations from "./pm_conversations"; import * as pygments_data from "./pygments_data"; import * as recent_senders from "./recent_senders"; import * as stream_data from "./stream_data"; import * as stream_list_sort from "./stream_list_sort"; +import type {StreamSubscription} from "./sub_store"; import * as user_groups from "./user_groups"; +import type {UserGroup} from "./user_groups"; 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}); + // Returns an array of direct message recipients, removing empty elements. // For example, "a,,b, " => ["a", "b"] -export function get_cleaned_pm_recipients(query_string) { +export function get_cleaned_pm_recipients(query_string: string): string[] { let recipients = util.extract_pm_recipients(query_string); recipients = recipients.filter((elem) => elem.match(/\S/)); return recipients; } -export function build_highlight_regex(query) { +export function build_highlight_regex(query: string): RegExp { const regex = new RegExp("(" + _.escapeRegExp(query) + ")", "ig"); return regex; } -export function highlight_with_escaping_and_regex(regex, item) { +export function highlight_with_escaping_and_regex(regex: RegExp, item: string): string { // if regex is empty return entire item highlighted and escaped if (regex.source === "()") { return "" + Handlebars.Utils.escapeExpression(item) + ""; @@ -57,7 +64,7 @@ export function highlight_with_escaping_and_regex(regex, item) { return result; } -export function make_query_highlighter(query) { +export function make_query_highlighter(query: string): (phrase: string) => string { query = query.toLowerCase(); const regex = build_highlight_regex(query); @@ -67,22 +74,48 @@ export function make_query_highlighter(query) { }; } -export function render_typeahead_item(args) { - args.has_image = args.img_src !== undefined; - args.has_status = args.status_emoji_info !== undefined; - args.has_secondary = args.secondary !== undefined; - args.has_pronouns = args.pronouns !== undefined; - return render_typeahead_list_item(args); +type StreamData = { + invite_only: boolean; + is_web_public: boolean; + color: string; + name: string; + description: string; + subscribed: boolean; +}; + +export function render_typeahead_item(args: { + primary?: string; + is_person?: boolean; + img_src?: string; + status_emoji_info?: UserStatusEmojiInfo; + secondary?: string | null; + pronouns?: string; + is_user_group?: boolean; + stream?: StreamData; + is_unsubscribed?: boolean; + emoji_code?: string; +}): string { + const has_image = args.img_src !== undefined; + const has_status = args.status_emoji_info !== undefined; + const has_secondary = args.secondary !== undefined; + const has_pronouns = args.pronouns !== undefined; + return render_typeahead_list_item({ + ...args, + has_image, + has_status, + has_secondary, + has_pronouns, + }); } -export function render_person(person) { - const user_circle_class = buddy_data.get_user_circle_class(person.user_id); - if (person.special_item_text) { +export function render_person(person: UserOrMention): string { + if (person.is_broadcast) { return render_typeahead_item({ primary: person.special_item_text, is_person: true, }); } + const user_circle_class = buddy_data.get_user_circle_class(person.user_id); const avatar_url = people.small_avatar_url_for_person(person); @@ -101,13 +134,13 @@ export function render_person(person) { status_emoji_info, should_add_guest_user_indicator: people.should_add_guest_user_indicator(person.user_id), pronouns, + secondary: person.delivery_email, }; - typeahead_arguments.secondary = person.delivery_email; return render_typeahead_item(typeahead_arguments); } -export function render_user_group(user_group) { +export function render_user_group(user_group: {name: string; description: string}): string { return render_typeahead_item({ primary: user_group.name, secondary: user_group.description, @@ -115,7 +148,9 @@ export function render_user_group(user_group) { }); } -export function render_person_or_user_group(item) { +export function render_person_or_user_group( + item: UserGroup | (UserOrMention & {members: undefined}), +): string { if (user_groups.is_user_group(item)) { return render_user_group(item); } @@ -123,7 +158,7 @@ export function render_person_or_user_group(item) { return render_person(item); } -export function render_stream(stream) { +export function render_stream(stream: StreamData): string { let desc = stream.description; const short_desc = desc.slice(0, 35); @@ -138,27 +173,34 @@ export function render_stream(stream) { }); } -export function render_emoji(item) { +export function render_emoji(item: { + emoji_name: string; + emoji_url: string; + emoji_code: string; +}): string { const args = { is_emoji: true, primary: item.emoji_name.replaceAll("_", " "), }; if (item.emoji_url) { - args.img_src = item.emoji_url; - } else { - args.emoji_code = item.emoji_code; + return render_typeahead_item({ + ...args, + img_src: item.emoji_url, + }); } - - return render_typeahead_item(args); + return render_typeahead_item({ + ...args, + emoji_code: item.emoji_code, + }); } -export function sorter(query, objs, get_item) { +export function sorter(query: string, objs: T[], get_item: (x: T) => string): T[] { const results = typeahead.triage(query, objs, get_item); return [...results.matches, ...results.rest]; } -export function compare_by_pms(user_a, user_b) { +export function compare_by_pms(user_a: User, user_b: User): number { const count_a = people.get_recipient_count(user_a); const count_b = people.get_recipient_count(user_b); @@ -185,11 +227,11 @@ export function compare_by_pms(user_a, user_b) { } export function compare_people_for_relevance( - person_a, - person_b, - tertiary_compare, - current_stream_id, -) { + person_a: UserOrMention, + person_b: UserOrMention, + tertiary_compare: (user_a: User, user_b: User) => number, + current_stream_id?: number, +): number { // give preference to "all", "everyone" or "stream" // We use is_broadcast for a quick check. It will // true for all/everyone/stream and undefined (falsy) @@ -241,7 +283,11 @@ export function compare_people_for_relevance( return tertiary_compare(person_a, person_b); } -export function sort_people_for_relevance(objs, current_stream_id, current_topic) { +export function sort_people_for_relevance( + objs: UserOrMention[], + current_stream_id: number, + current_topic: string, +): UserOrMention[] { // If sorting for recipientbox typeahead and not viewing a stream / topic, then current_stream = "" let current_stream = null; if (current_stream_id) { @@ -271,7 +317,7 @@ export function sort_people_for_relevance(objs, current_stream_id, current_topic return objs; } -function compare_language_by_popularity(lang_a, lang_b) { +function compare_language_by_popularity(lang_a: string, lang_b: string): number { const lang_a_data = pygments_data.langs[lang_a]; const lang_b_data = pygments_data.langs[lang_b]; @@ -320,7 +366,7 @@ function compare_language_by_popularity(lang_a, lang_b) { // This function compares two languages first by their popularity, then if // there is a tie on popularity, then compare alphabetically to break the tie. -export function compare_language(lang_a, lang_b) { +export function compare_language(lang_a: string, lang_b: string): number { let diff = compare_language_by_popularity(lang_a, lang_b); // Check to see if there is a tie. If there is, then use alphabetical order @@ -332,7 +378,7 @@ export function compare_language(lang_a, lang_b) { return diff; } -function retain_unique_language_aliases(matches) { +function retain_unique_language_aliases(matches: string[]): string[] { // We make the typeahead a little more nicer but only showing one alias per language. // For example if the user searches for prefix "j", then the typeahead list should contain // "javascript" only, and not "js" and "javascript". @@ -350,7 +396,7 @@ function retain_unique_language_aliases(matches) { return unique_aliases; } -export function sort_languages(matches, query) { +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]); @@ -363,8 +409,15 @@ export function sort_recipients({ current_topic, groups = [], max_num_items = 20, -}) { - function sort_relevance(items) { +}: { + users: UserOrMention[]; + query: string; + current_stream_id: number; + current_topic: string; + groups: UserGroup[]; + max_num_items: number; +}): (UserOrMention | UserGroup)[] { + function sort_relevance(items: UserOrMention[]): UserOrMention[] { return sort_people_for_relevance(items, current_stream_id, current_topic); } @@ -374,44 +427,85 @@ export function sort_recipients({ const groups_results = typeahead.triage(query, groups, (g) => g.name); - const best_users = () => sort_relevance(users_name_results.matches); - const best_groups = () => groups_results.matches; - const ok_users = () => sort_relevance(email_results.matches); - const worst_users = () => sort_relevance(email_results.rest); - const worst_groups = () => groups_results.rest; + const best_users = (): UserOrMention[] => sort_relevance(users_name_results.matches); + const best_groups = (): UserGroup[] => groups_results.matches; + const ok_users = (): UserOrMention[] => sort_relevance(email_results.matches); + const worst_users = (): UserOrMention[] => sort_relevance(email_results.rest); + const worst_groups = (): UserGroup[] => groups_results.rest; - const getters = [best_users, best_groups, ok_users, worst_users, worst_groups]; - - /* - The following optimization is important for large realms. - If we know we're only showing 5 suggestions, and we - get 5 matches from `best_users`, then we want to avoid - calling the expensive sorts for `ok_users` and `worst_users`, - since they just get dropped. - */ - - let items = []; - - for (const getter of getters) { - if (items.length < max_num_items) { - items = [...items, ...getter()]; - } - } + const getters: ( + | { + getter: () => UserOrMention[]; + type: "users"; + } + | { + getter: () => UserGroup[]; + type: "groups"; + } + )[] = [ + { + getter: best_users, + type: "users", + }, + { + getter: best_groups, + type: "groups", + }, + { + getter: ok_users, + type: "users", + }, + { + getter: worst_users, + type: "users", + }, + { + getter: worst_groups, + type: "groups", + }, + ]; // We suggest only the first matching stream wildcard mention, // irrespective of how many equivalent stream wildcard mentions match. - const recipients = []; + const recipients: (UserOrMention | UserGroup)[] = []; let stream_wildcard_mention_included = false; - for (const item of items) { - const topic_wildcard_mention = item.email === "topic"; - if (!item.is_broadcast || topic_wildcard_mention || !stream_wildcard_mention_included) { - recipients.push(item); - if (item.is_broadcast && !topic_wildcard_mention) { - stream_wildcard_mention_included = true; + + function add_user_recipients(items: UserOrMention[]): void { + for (const item of items) { + const topic_wildcard_mention = item.email === "topic"; + if (!item.is_broadcast || topic_wildcard_mention || !stream_wildcard_mention_included) { + recipients.push(item); + if (item.is_broadcast && !topic_wildcard_mention) { + stream_wildcard_mention_included = true; + } } } } + function add_group_recipients(items: UserGroup[]): void { + for (const item of items) { + recipients.push(item); + } + } + + for (const getter of getters) { + /* + The following optimization is important for large realms. + If we know we're only showing 5 suggestions, and we + get 5 matches from `best_users`, then we want to avoid + calling the expensive sorts for `ok_users` and `worst_users`, + since they just get dropped. + */ + if (recipients.length >= max_num_items) { + break; + } + if (getter.type === "users") { + add_user_recipients(getter.getter()); + } else { + add_group_recipients(getter.getter()); + } + } + // We don't push exact matches to the top, like we do with other // typeaheads, because in open organizations, it's not uncommon to // have a bunch of inactive users with display names that are just @@ -420,7 +514,14 @@ export function sort_recipients({ return recipients.slice(0, max_num_items); } -function slash_command_comparator(slash_command_a, slash_command_b) { +type SlashCommand = { + name: string; +}; + +function slash_command_comparator( + slash_command_a: SlashCommand, + slash_command_b: SlashCommand, +): number { if (slash_command_a.name < slash_command_b.name) { return -1; } else if (slash_command_a.name > slash_command_b.name) { @@ -430,7 +531,7 @@ function slash_command_comparator(slash_command_a, slash_command_b) { return 0; } -export function sort_slash_commands(matches, query) { +export function sort_slash_commands(matches: SlashCommand[], query: string): SlashCommand[] { // 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); @@ -439,7 +540,7 @@ export function sort_slash_commands(matches, query) { } // Gives stream a score from 0 to 3 based on its activity -function activity_score(sub) { +function activity_score(sub: StreamSubscription): number { let stream_score = 0; if (!sub.subscribed) { stream_score = -1; @@ -458,19 +559,22 @@ function activity_score(sub) { // Sort streams by ranking them by activity. If activity is equal, // as defined bv activity_score, decide based on our weekly traffic // stats. -export function compare_by_activity(stream_a, stream_b) { +export function compare_by_activity( + stream_a: StreamSubscription, + stream_b: StreamSubscription, +): number { let diff = activity_score(stream_b) - activity_score(stream_a); if (diff !== 0) { return diff; } - diff = (stream_b.stream_weekly_traffic || 0) - (stream_a.stream_weekly_traffic || 0); + diff = (stream_b.stream_weekly_traffic ?? 0) - (stream_a.stream_weekly_traffic ?? 0); if (diff !== 0) { return diff; } return util.strcmp(stream_a.name, stream_b.name); } -export function sort_streams(matches, query) { +export function sort_streams(matches: StreamSubscription[], query: string): StreamSubscription[] { const name_results = typeahead.triage(query, matches, (x) => x.name, compare_by_activity); const desc_results = typeahead.triage( query, diff --git a/web/src/user_groups.ts b/web/src/user_groups.ts index 5d6372c59b..832acd1b6b 100644 --- a/web/src/user_groups.ts +++ b/web/src/user_groups.ts @@ -2,8 +2,8 @@ import * as blueslip from "./blueslip"; import {FoldDict} from "./fold_dict"; import * as group_permission_settings from "./group_permission_settings"; import {page_params} from "./page_params"; -import type {User} from "./people"; import * as settings_config from "./settings_config"; +import type {UserOrMention} from "./typeahead_helper"; import type {UserGroupUpdateEvent} from "./types"; export type UserGroup = { @@ -167,7 +167,9 @@ export function initialize(params: {realm_user_groups: UserGroupRaw[]}): void { } } -export function is_user_group(item: User | UserGroup): item is UserGroup { +export function is_user_group( + item: (UserOrMention & {members: undefined}) | UserGroup, +): item is UserGroup { return item.members !== undefined; } diff --git a/web/tests/typeahead_helper.test.js b/web/tests/typeahead_helper.test.js index c4a336d46d..068e967456 100644 --- a/web/tests/typeahead_helper.test.js +++ b/web/tests/typeahead_helper.test.js @@ -722,6 +722,7 @@ test("render_person special_item_text", ({mock_template}) => { is_bot: false, user_id: 7, special_item_text: "special_text", + is_broadcast: true, }; rendered = false;