diff --git a/tools/test-js-with-node b/tools/test-js-with-node index dc9b852c79..5d7d495c34 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -117,7 +117,7 @@ EXEMPT_FILES = make_set( "web/src/hashchange.js", "web/src/hbs.d.ts", "web/src/hotkey.js", - "web/src/inbox_ui.js", + "web/src/inbox_ui.ts", "web/src/inbox_util.ts", "web/src/info_overlay.ts", "web/src/information_density.ts", diff --git a/web/src/inbox_ui.js b/web/src/inbox_ui.ts similarity index 75% rename from web/src/inbox_ui.js rename to web/src/inbox_ui.ts index 9bf8f9d4e6..8b748b97bc 100644 --- a/web/src/inbox_ui.js +++ b/web/src/inbox_ui.ts @@ -1,5 +1,8 @@ import $ from "jquery"; import _ from "lodash"; +import assert from "minimalistic-assert"; +import type * as tippy from "tippy.js"; +import {z} from "zod"; import render_inbox_row from "../templates/inbox_view/inbox_row.hbs"; import render_inbox_stream_container from "../templates/inbox_view/inbox_stream_container.hbs"; @@ -19,6 +22,7 @@ import * as keydown_util from "./keydown_util"; import * as left_sidebar_navigation_area from "./left_sidebar_navigation_area"; import {localstorage} from "./localstorage"; import * as message_store from "./message_store"; +import type {Message} from "./message_store"; import * as modals from "./modals"; import * as onboarding_steps from "./onboarding_steps"; import * as overlays from "./overlays"; @@ -37,9 +41,103 @@ import * as user_topics_ui from "./user_topics_ui"; import * as util from "./util"; import * as views_util from "./views_util"; -let dms_dict = new Map(); -let topics_dict = new Map(); -let streams_dict = new Map(); +type DirectMessageContext = { + conversation_key: string; + is_direct: boolean; + rendered_dm_with: string; + is_group: boolean; + user_circle_class: string | false | undefined; + is_bot: boolean; + dm_url: string; + user_ids_string: string; + unread_count: number; + is_hidden: boolean; + is_collapsed: boolean; + latest_msg_id: number; +}; + +const direct_message_context_properties: (keyof DirectMessageContext)[] = [ + "conversation_key", + "is_direct", + "rendered_dm_with", + "is_group", + "user_circle_class", + "is_bot", + "dm_url", + "user_ids_string", + "unread_count", + "is_hidden", + "is_collapsed", + "latest_msg_id", +]; + +type StreamContext = { + is_stream: boolean; + invite_only: boolean; + is_web_public: boolean; + stream_name: string; + pin_to_top: boolean | undefined; + is_muted: boolean; + stream_color: string; + stream_header_color: string; + stream_url: string; + stream_id: number; + is_hidden: boolean; + is_collapsed: boolean; + mention_in_unread: boolean; + unread_count?: number; +}; + +const stream_context_properties: (keyof StreamContext)[] = [ + "is_stream", + "invite_only", + "is_web_public", + "stream_name", + "pin_to_top", + "is_muted", + "stream_color", + "stream_header_color", + "stream_url", + "stream_id", + "is_hidden", + "is_collapsed", + "mention_in_unread", + "unread_count", +]; + +type TopicContext = { + is_topic: boolean; + stream_id: number; + topic_name: string; + unread_count: number; + conversation_key: string; + topic_url: string; + is_hidden: boolean; + is_collapsed: boolean; + mention_in_unread: boolean; + latest_msg_id: number; + all_visibility_policies: typeof user_topics.all_visibility_policies; + visibility_policy: number | false; +}; + +const topic_context_properties: (keyof TopicContext)[] = [ + "is_topic", + "stream_id", + "topic_name", + "unread_count", + "conversation_key", + "topic_url", + "is_hidden", + "is_collapsed", + "mention_in_unread", + "latest_msg_id", + "all_visibility_policies", + "visibility_policy", +]; + +let dms_dict = new Map(); +let topics_dict = new Map>(); +let streams_dict = new Map(); let update_triggered_by_user = false; let filters_dropdown_widget; @@ -58,12 +156,12 @@ const ls_collapsed_containers_key = "inbox_collapsed_containers"; const ls = localstorage(); let filters = new Set([views_util.FILTERS.UNMUTED_TOPICS]); -let collapsed_containers = new Set(); +let collapsed_containers = new Set(); let search_keyword = ""; const INBOX_SEARCH_ID = "inbox-search"; const INBOX_FILTERS_DROPDOWN_ID = "inbox-filter_widget"; -export let current_focus_id; +export let current_focus_id: string | undefined; const STREAM_HEADER_PREFIX = "inbox-stream-header-"; const CONVERSATION_ID_PREFIX = "inbox-row-conversation-"; @@ -71,16 +169,16 @@ const CONVERSATION_ID_PREFIX = "inbox-row-conversation-"; const LEFT_NAVIGATION_KEYS = ["left_arrow", "shift_tab", "vim_left"]; const RIGHT_NAVIGATION_KEYS = ["right_arrow", "tab", "vim_right"]; -function get_row_from_conversation_key(key) { +function get_row_from_conversation_key(key: string): JQuery { return $(`#${CSS.escape(CONVERSATION_ID_PREFIX + key)}`); } -function save_data_to_ls() { +function save_data_to_ls(): void { ls.set(ls_filter_key, [...filters]); ls.set(ls_collapsed_containers_key, [...collapsed_containers]); } -export function show() { +export function show(): void { // Avoid setting col_focus to recipient when moving to inbox from other narrows. // We prefer to focus entire row instead of stream name for inbox-header. // Since inbox-row doesn't has a collapse button, focus on COLUMNS.COLLAPSE_BUTTON @@ -109,7 +207,9 @@ export function show() { html_heading: $t_html({defaultMessage: "Welcome to your inbox!"}), html_body, html_submit_button: $t_html({defaultMessage: "Continue"}), - on_click() {}, + on_click() { + // Do nothing + }, single_footer_button: true, focus_submit_on_open: true, }); @@ -117,39 +217,39 @@ export function show() { } } -export function hide() { +export function hide(): void { views_util.hide({ $view: $("#inbox-view"), set_visible, }); } -function get_topic_key(stream_id, topic) { +function get_topic_key(stream_id: number, topic: string): string { return stream_id + ":" + topic; } -function get_stream_key(stream_id) { +function get_stream_key(stream_id: number): string { return "stream_" + stream_id; } -function get_stream_container(stream_key) { +function get_stream_container(stream_key: string): JQuery { return $(`#${CSS.escape(stream_key)}`); } -function get_topics_container(stream_id) { +function get_topics_container(stream_id: number): JQuery { const $topics_container = get_stream_header_row(stream_id) .next(".inbox-topic-container") .expectOne(); return $topics_container; } -function get_stream_header_row(stream_id) { +function get_stream_header_row(stream_id: number): JQuery { const $stream_header_row = $(`#${CSS.escape(STREAM_HEADER_PREFIX + stream_id)}`); return $stream_header_row; } -function load_data_from_ls() { - const saved_filters = new Set(ls.get(ls_filter_key)); +function load_data_from_ls(): void { + const saved_filters = new Set(z.array(z.string()).optional().parse(ls.get(ls_filter_key))); const valid_filters = new Set(Object.values(views_util.FILTERS)); // If saved filters are not in the list of valid filters, we reset to default. const is_subset = [...saved_filters].every((filter) => valid_filters.has(filter)); @@ -158,10 +258,16 @@ function load_data_from_ls() { } else { filters = saved_filters; } - collapsed_containers = new Set(ls.get(ls_collapsed_containers_key)); + collapsed_containers = new Set( + z.array(z.string()).optional().parse(ls.get(ls_collapsed_containers_key)), + ); } -function format_dm(user_ids_string, unread_count, latest_msg_id) { +function format_dm( + user_ids_string: string, + unread_count: number, + latest_msg_id: number, +): DirectMessageContext { const recipient_ids = people.user_ids_string_to_ids_array(user_ids_string); if (!recipient_ids.length) { // Self DM @@ -169,6 +275,7 @@ function format_dm(user_ids_string, unread_count, latest_msg_id) { } const reply_to = people.user_ids_string_to_emails_string(user_ids_string); + assert(reply_to !== undefined); const rendered_dm_with = recipient_ids .map((recipient_id) => ({ name: people.get_display_full_name(recipient_id), @@ -177,7 +284,7 @@ function format_dm(user_ids_string, unread_count, latest_msg_id) { .sort((a, b) => util.strcmp(a.name, b.name)) .map((user_info) => render_user_with_status_icon(user_info)); - let user_circle_class; + let user_circle_class: string | false | undefined; let is_bot = false; if (recipient_ids.length === 1) { is_bot = people.get_by_user_id(recipient_ids[0]).is_bot; @@ -202,7 +309,7 @@ function format_dm(user_ids_string, unread_count, latest_msg_id) { return context; } -function insert_dms(keys_to_insert) { +function insert_dms(keys_to_insert: string[]): void { const sorted_keys = [...dms_dict.keys()]; // If we need to insert at the top, we do it separately to avoid edge case in loop below. if (keys_to_insert.includes(sorted_keys[0])) { @@ -223,7 +330,11 @@ function insert_dms(keys_to_insert) { } } -function rerender_dm_inbox_row_if_needed(new_dm_data, old_dm_data, dm_keys_to_insert) { +function rerender_dm_inbox_row_if_needed( + new_dm_data: DirectMessageContext, + old_dm_data: DirectMessageContext | undefined, + dm_keys_to_insert: string[], +): void { if (old_dm_data === undefined) { // This row is not rendered yet. dm_keys_to_insert.push(new_dm_data.conversation_key); @@ -238,7 +349,7 @@ function rerender_dm_inbox_row_if_needed(new_dm_data, old_dm_data, dm_keys_to_in } // If row's latest_msg_id didn't change, we can inplace rerender it, if needed. - for (const property in new_dm_data) { + for (const property of direct_message_context_properties) { if (new_dm_data[property] !== old_dm_data[property]) { const $rendered_row = get_row_from_conversation_key(new_dm_data.conversation_key); $rendered_row.replaceWith($(render_inbox_row(new_dm_data))); @@ -247,10 +358,11 @@ function rerender_dm_inbox_row_if_needed(new_dm_data, old_dm_data, dm_keys_to_in } } -function format_stream(stream_id) { +function format_stream(stream_id: number): StreamContext { // NOTE: Unread count is not included in this function as it is more // efficient for the callers to calculate it based on filters. const stream_info = sub_store.get(stream_id); + assert(stream_info !== undefined); return { is_stream: true, @@ -270,28 +382,35 @@ function format_stream(stream_id) { }; } -function update_stream_data(stream_id, stream_key, topic_dict) { - topics_dict.set(stream_key, new Map()); +function update_stream_data( + stream_id: number, + stream_key: string, + topic_dict: Map, +): void { + const stream_topics_data = new Map(); const stream_data = format_stream(stream_id); let stream_post_filter_unread_count = 0; for (const [topic, {topic_count, latest_msg_id}] of topic_dict) { const topic_key = get_topic_key(stream_id, topic); if (topic_count) { const topic_data = format_topic(stream_id, topic, topic_count, latest_msg_id); - topics_dict.get(stream_key).set(topic_key, topic_data); + stream_topics_data.set(topic_key, topic_data); if (!topic_data.is_hidden) { stream_post_filter_unread_count += topic_data.unread_count; } } } - topics_dict.set(stream_key, get_sorted_row_dict(topics_dict.get(stream_key))); + topics_dict.set(stream_key, get_sorted_row_dict(stream_topics_data)); stream_data.is_hidden = stream_post_filter_unread_count === 0; stream_data.unread_count = stream_post_filter_unread_count; streams_dict.set(stream_key, stream_data); } -function rerender_stream_inbox_header_if_needed(new_stream_data, old_stream_data) { - for (const property in new_stream_data) { +function rerender_stream_inbox_header_if_needed( + new_stream_data: StreamContext, + old_stream_data: StreamContext, +): void { + for (const property of stream_context_properties) { if (new_stream_data[property] !== old_stream_data[property]) { const $rendered_row = get_stream_header_row(new_stream_data.stream_id); $rendered_row.replaceWith($(render_inbox_row(new_stream_data))); @@ -300,7 +419,12 @@ function rerender_stream_inbox_header_if_needed(new_stream_data, old_stream_data } } -function format_topic(stream_id, topic, topic_unread_count, latest_msg_id) { +function format_topic( + stream_id: number, + topic: string, + topic_unread_count: number, + latest_msg_id: number, +): TopicContext { const context = { is_topic: true, stream_id, @@ -322,7 +446,10 @@ function format_topic(stream_id, topic, topic_unread_count, latest_msg_id) { return context; } -function insert_stream(stream_id, topic_dict) { +function insert_stream( + stream_id: number, + topic_dict: Map, +): boolean { const stream_key = get_stream_key(stream_id); update_stream_data(stream_id, stream_key, topic_dict); const sorted_stream_keys = get_sorted_stream_keys(); @@ -338,11 +465,12 @@ function insert_stream(stream_id, topic_dict) { const previous_stream_key = sorted_stream_keys[stream_index - 1]; $(rendered_stream).insertAfter(get_stream_container(previous_stream_key)); } - return !streams_dict.get(stream_key).is_hidden; + return !streams_dict.get(stream_key)!.is_hidden; } -function insert_topics(keys, stream_key) { +function insert_topics(keys: string[], stream_key: string): void { const stream_topics_data = topics_dict.get(stream_key); + assert(stream_topics_data !== undefined); const sorted_keys = [...stream_topics_data.keys()]; // If we need to insert at the top, we do it separately to avoid edge case in loop below. if (keys.includes(sorted_keys[0])) { @@ -364,7 +492,11 @@ function insert_topics(keys, stream_key) { } } -function rerender_topic_inbox_row_if_needed(new_topic_data, old_topic_data, topic_keys_to_insert) { +function rerender_topic_inbox_row_if_needed( + new_topic_data: TopicContext, + old_topic_data: TopicContext | undefined, + topic_keys_to_insert: string[], +): void { if (old_topic_data === undefined) { // This row is not rendered yet. topic_keys_to_insert.push(new_topic_data.conversation_key); @@ -377,7 +509,7 @@ function rerender_topic_inbox_row_if_needed(new_topic_data, old_topic_data, topi topic_keys_to_insert.push(new_topic_data.conversation_key); } - for (const property in new_topic_data) { + for (const property of topic_context_properties) { if (new_topic_data[property] !== old_topic_data[property]) { const $rendered_row = get_row_from_conversation_key(new_topic_data.conversation_key); $rendered_row.replaceWith($(render_inbox_row(new_topic_data))); @@ -386,10 +518,11 @@ function rerender_topic_inbox_row_if_needed(new_topic_data, old_topic_data, topi } } -function get_sorted_stream_keys() { - function compare_function(a, b) { +function get_sorted_stream_keys(): string[] { + function compare_function(a: string, b: string): number { const stream_a = streams_dict.get(a); const stream_b = streams_dict.get(b); + assert(stream_a !== undefined && stream_b !== undefined); // If one of the streams is pinned, they are sorted higher. if (stream_a.pin_to_top && !stream_b.pin_to_top) { @@ -418,32 +551,28 @@ function get_sorted_stream_keys() { return [...topics_dict.keys()].sort(compare_function); } -function get_sorted_stream_topic_dict() { +function get_sorted_stream_topic_dict(): Map> { const sorted_stream_keys = get_sorted_stream_keys(); - const sorted_topic_dict = new Map(); + const sorted_topic_dict = new Map>(); for (const sorted_stream_key of sorted_stream_keys) { - sorted_topic_dict.set(sorted_stream_key, topics_dict.get(sorted_stream_key)); + sorted_topic_dict.set(sorted_stream_key, topics_dict.get(sorted_stream_key)!); } return sorted_topic_dict; } -function get_sorted_row_keys(row_dict) { - return [...row_dict.keys()].sort( - (a, b) => row_dict.get(b).latest_msg_id - row_dict.get(a).latest_msg_id, - ); +function get_sorted_row_dict( + row_dict: Map, +): Map { + return new Map([...row_dict].sort(([, a], [, b]) => b.latest_msg_id - a.latest_msg_id)); } -function get_sorted_row_dict(row_dict) { - const sorted_row_keys = get_sorted_row_keys(row_dict); - const sorted_row_dict = new Map(); - for (const row_key of sorted_row_keys) { - sorted_row_dict.set(row_key, row_dict.get(row_key)); - } - return sorted_row_dict; -} - -function reset_data() { +function reset_data(): { + unread_dms_count: number; + is_dms_collapsed: boolean; + has_dms_post_filter: boolean; + has_visible_unreads: boolean; +} { dms_dict = new Map(); topics_dict = new Map(); streams_dict = new Map(); @@ -478,7 +607,7 @@ function reset_data() { const stream_key = get_stream_key(stream_id); if (stream_unread_count > 0) { update_stream_data(stream_id, stream_key, topic_dict); - if (!streams_dict.get(stream_key).is_hidden) { + if (!streams_dict.get(stream_key)!.is_hidden) { has_topics_post_filter = true; } } else { @@ -499,7 +628,7 @@ function reset_data() { }; } -function show_empty_inbox_text(has_visible_unreads) { +function show_empty_inbox_text(has_visible_unreads: boolean): void { if (!has_visible_unreads) { $("#inbox-list").css("border-width", 0); if (search_keyword) { @@ -516,11 +645,16 @@ function show_empty_inbox_text(has_visible_unreads) { } } -function filter_click_handler(event, dropdown, widget) { +function filter_click_handler( + event: JQuery.TriggeredEvent, + dropdown: tippy.Instance, + widget: dropdown_widget.DropdownWidget, +): void { event.preventDefault(); event.stopPropagation(); const filter_id = $(event.currentTarget).attr("data-unique-id"); + assert(filter_id !== undefined); // We don't support multiple filters yet, so we clear existing and add the new filter. filters = new Set([filter_id]); save_data_to_ls(); @@ -529,7 +663,7 @@ function filter_click_handler(event, dropdown, widget) { update(); } -export function complete_rerender() { +export function complete_rerender(): void { if (!is_visible()) { return; } @@ -556,18 +690,19 @@ export function complete_rerender() { revive_current_focus(); }, 0); + const first_filter = filters.values().next(); filters_dropdown_widget = new dropdown_widget.DropdownWidget({ ...views_util.COMMON_DROPDOWN_WIDGET_PARAMS, widget_name: "inbox-filter", item_click_callback: filter_click_handler, $events_container: $("#inbox-main"), - default_id: filters.values().next().value, + default_id: first_filter.done ? undefined : first_filter.value, }); filters_dropdown_widget.setup(); } -export function search_and_update() { - const new_keyword = $("#inbox-search").val() || ""; +export function search_and_update(): void { + const new_keyword = $("input#inbox-search").val() ?? ""; if (new_keyword === search_keyword) { return; } @@ -577,7 +712,7 @@ export function search_and_update() { update(); } -function row_in_search_results(keyword, text) { +function row_in_search_results(keyword: string, text: string): boolean { if (keyword === "") { return true; } @@ -585,7 +720,7 @@ function row_in_search_results(keyword, text) { return search_words.every((word) => text.includes(word)); } -function filter_should_hide_dm_row({dm_key}) { +function filter_should_hide_dm_row({dm_key}: {dm_key: string}): boolean { const recipients_string = people.get_recipients(dm_key); const text = recipients_string.toLowerCase(); @@ -596,9 +731,15 @@ function filter_should_hide_dm_row({dm_key}) { return false; } -function filter_should_hide_stream_row({stream_id, topic}) { +function filter_should_hide_stream_row({ + stream_id, + topic, +}: { + stream_id: number; + topic: string; +}): boolean { const sub = sub_store.get(stream_id); - if (sub === undefined || !sub.subscribed) { + if (!sub?.subscribed) { return true; } @@ -626,7 +767,7 @@ function filter_should_hide_stream_row({stream_id, topic}) { return false; } -export function collapse_or_expand(container_id) { +export function collapse_or_expand(container_id: string): void { let $toggle_icon; let $container; if (container_id === "inbox-dm-header") { @@ -634,7 +775,7 @@ export function collapse_or_expand(container_id) { $container.children().toggleClass("collapsed_container"); $toggle_icon = $("#inbox-dm-header .toggle-inbox-header-icon"); } else { - const stream_id = container_id.slice(STREAM_HEADER_PREFIX.length); + const stream_id = Number(container_id.slice(STREAM_HEADER_PREFIX.length)); $container = get_topics_container(stream_id); $container.children().toggleClass("collapsed_container"); $toggle_icon = $( @@ -652,35 +793,39 @@ export function collapse_or_expand(container_id) { save_data_to_ls(); } -function focus_current_id() { +function focus_current_id(): void { + assert(current_focus_id !== undefined); $(`#${CSS.escape(current_focus_id)}`).trigger("focus"); } -function focus_inbox_search() { +function focus_inbox_search(): void { current_focus_id = INBOX_SEARCH_ID; focus_current_id(); } -function is_list_focused() { - return ![INBOX_SEARCH_ID, INBOX_FILTERS_DROPDOWN_ID].includes(current_focus_id); +function is_list_focused(): boolean { + return ( + current_focus_id === undefined || + ![INBOX_SEARCH_ID, INBOX_FILTERS_DROPDOWN_ID].includes(current_focus_id) + ); } -function get_all_rows() { +function get_all_rows(): JQuery { return $(".inbox-header, .inbox-row").not(".hidden_by_filters, .collapsed_container"); } -function get_row_index($elt) { +function get_row_index($elt: JQuery): number { const $all_rows = get_all_rows(); const $row = $elt.closest(".inbox-row, .inbox-header"); return $all_rows.index($row); } -function focus_clicked_list_element($elt) { +function focus_clicked_list_element($elt: JQuery): void { row_focus = get_row_index($elt); update_triggered_by_user = true; } -function revive_current_focus() { +function revive_current_focus(): void { if (is_list_focused()) { set_list_focus(); } else { @@ -688,7 +833,7 @@ function revive_current_focus() { } } -function update_closed_compose_text($row, is_header_row) { +function update_closed_compose_text($row: JQuery, is_header_row: boolean): void { // TODO: This fake "message" object is designed to allow using the // get_recipient_label helper inside compose_closed_ui. Surely // there's a more readable way to write this code. @@ -708,66 +853,75 @@ function update_closed_compose_text($row, is_header_row) { } else { const $stream = $row.parent(".inbox-topic-container").prev(".inbox-header"); message = { - stream_id: Number.parseInt($stream.attr("data-stream-id"), 10), + stream_id: Number($stream.attr("data-stream-id")), topic: $row.find(".inbox-topic-name a").text(), }; } compose_closed_ui.update_reply_recipient_label(message); } -export function get_focused_row_message() { +export function get_focused_row_message(): {message?: Message} & ( + | {msg_type: "private"; private_message_recipient?: string} + | {msg_type: "stream"; stream_id: number; topic?: string} + | {msg_type?: never} +) { if (!is_list_focused()) { return {message: undefined}; } const $all_rows = get_all_rows(); - const $focused_row = $($all_rows.get(row_focus)); + const focused_row = $all_rows.get(row_focus); + assert(focused_row !== undefined); + const $focused_row = $(focused_row); if (is_row_a_header($focused_row)) { const is_dm_header = $focused_row.attr("id") === "inbox-dm-header"; if (is_dm_header) { return {message: undefined, msg_type: "private"}; } - const stream_id = Number.parseInt($focused_row.attr("data-stream-id"), 10); + const stream_id = Number($focused_row.attr("data-stream-id")); compose_state.set_compose_recipient_id(stream_id); return {message: undefined, msg_type: "stream", stream_id}; } const is_dm = $focused_row.parent("#inbox-direct-messages-container").length > 0; - const conversation_key = $focused_row.attr("id").slice(CONVERSATION_ID_PREFIX.length); - let row_info; - if (is_dm) { - row_info = dms_dict.get(conversation_key); - } else { - const $stream = $focused_row.parent(".inbox-topic-container").parent(); - const stream_key = $stream.attr("id"); - row_info = topics_dict.get(stream_key).get(conversation_key); - } + const conversation_key = $focused_row.attr("id")!.slice(CONVERSATION_ID_PREFIX.length); - const message = message_store.get(row_info.latest_msg_id); - // Since inbox is populated based on unread data which is part - // of /register request, it is possible that we don't have the - // actual message in our message_store. In that case, we return - // a fake message object. - if (message === undefined) { - if (is_dm) { + if (is_dm) { + const row_info = dms_dict.get(conversation_key); + assert(row_info !== undefined); + const message = message_store.get(row_info.latest_msg_id); + if (message === undefined) { const recipients = people.user_ids_string_to_emails_string(row_info.user_ids_string); return { msg_type: "private", private_message_recipient: recipients, }; } + return {message}; + } + + const $stream = $focused_row.parent(".inbox-topic-container").parent(); + const stream_key = $stream.attr("id"); + assert(stream_key !== undefined); + const row_info = topics_dict.get(stream_key)!.get(conversation_key); + assert(row_info !== undefined); + const message = message_store.get(row_info.latest_msg_id); + // Since inbox is populated based on unread data which is part + // of /register request, it is possible that we don't have the + // actual message in our message_store. In that case, we return + // a fake message object. + if (message === undefined) { return { msg_type: "stream", stream_id: row_info.stream_id, topic: row_info.topic_name, }; } - return {message}; } -export function toggle_topic_visibility_policy() { +export function toggle_topic_visibility_policy(): boolean { const inbox_message = get_focused_row_message(); if (inbox_message.message !== undefined) { user_topics_ui.toggle_topic_visibility_policy(inbox_message.message); @@ -782,11 +936,11 @@ export function toggle_topic_visibility_policy() { return false; } -function is_row_a_header($row) { +function is_row_a_header($row: JQuery): boolean { return $row.hasClass("inbox-header"); } -function set_list_focus(input_key) { +function set_list_focus(input_key?: string): void { // This function is used for both revive_current_focus and // setting focus after modify col_focus and row_focus as per // hotkey pressed by user. @@ -808,7 +962,9 @@ function set_list_focus(input_key) { row_focus = 0; } - const $row_to_focus = $($all_rows.get(row_focus)); + const row_to_focus = $all_rows.get(row_focus); + assert(row_to_focus !== undefined); + const $row_to_focus = $(row_to_focus); // This includes a fake collapse button for `inbox-row` and a fake topic visibility // button for `inbox-header`. The fake buttons help simplify code here and // `$($cols_to_focus[col_focus]).trigger("focus");` at the end of this function. @@ -827,18 +983,18 @@ function set_list_focus(input_key) { // Since header rows always have a collapse button, other rows have one less element to focus. if (col_focus === COLUMNS.COLLAPSE_BUTTON) { - if (!is_header_row && LEFT_NAVIGATION_KEYS.includes(input_key)) { + if (!is_header_row && input_key !== undefined && LEFT_NAVIGATION_KEYS.includes(input_key)) { // In `inbox-row` user pressed left on COLUMNS.RECIPIENT, so // go to the last column. col_focus = total_cols - 1; } } else if (!is_header_row && col_focus === COLUMNS.RECIPIENT) { - if (RIGHT_NAVIGATION_KEYS.includes(input_key)) { + if (input_key !== undefined && RIGHT_NAVIGATION_KEYS.includes(input_key)) { // In `inbox-row` user pressed right on COLUMNS.COLLAPSE_BUTTON. // Since `inbox-row` has no collapse button, user wants to go // to the unread count button. col_focus = COLUMNS.UNREAD_COUNT; - } else if (LEFT_NAVIGATION_KEYS.includes(input_key)) { + } else if (input_key !== undefined && LEFT_NAVIGATION_KEYS.includes(input_key)) { // In `inbox-row` user pressed left on COLUMNS.UNREAD_COUNT, // we move focus to COLUMNS.COLLAPSE_BUTTON so that moving // up or down to `inbox-header` keeps the entire row focused for the @@ -853,7 +1009,7 @@ function set_list_focus(input_key) { } else if (is_header_row && col_focus === COLUMNS.TOPIC_VISIBILITY) { // `inbox-header` doesn't have a topic visibility indicator, so focus on // button around it instead. - if (LEFT_NAVIGATION_KEYS.includes(input_key)) { + if (input_key !== undefined && LEFT_NAVIGATION_KEYS.includes(input_key)) { col_focus = COLUMNS.UNREAD_COUNT; } else { col_focus = COLUMNS.ACTION_MENU; @@ -863,22 +1019,23 @@ function set_list_focus(input_key) { $($cols_to_focus[col_focus]).trigger("focus"); } -function focus_filters_dropdown() { +function focus_filters_dropdown(): void { current_focus_id = INBOX_FILTERS_DROPDOWN_ID; $(`#${CSS.escape(INBOX_FILTERS_DROPDOWN_ID)}`).trigger("focus"); } -function is_search_focused() { +function is_search_focused(): boolean { return current_focus_id === INBOX_SEARCH_ID; } -function is_filters_dropdown_focused() { +function is_filters_dropdown_focused(): boolean { return current_focus_id === INBOX_FILTERS_DROPDOWN_ID; } -function get_page_up_down_delta() { +function get_page_up_down_delta(): number { const element_above = document.querySelector("#inbox-filters"); const element_down = document.querySelector("#compose"); + assert(element_above !== null && element_down !== null); const visible_top = element_above.getBoundingClientRect().bottom; const visible_bottom = element_down.getBoundingClientRect().top; // One usually wants PageDown to move what had been the bottom row @@ -892,7 +1049,7 @@ function get_page_up_down_delta() { return delta; } -function page_up_navigation() { +function page_up_navigation(): void { const delta = get_page_up_down_delta(); const scroll_element = document.documentElement; const new_scrollTop = scroll_element.scrollTop - delta; @@ -903,13 +1060,13 @@ function page_up_navigation() { set_list_focus(); } -function page_down_navigation() { +function page_down_navigation(): void { const delta = get_page_up_down_delta(); const scroll_element = document.documentElement; const new_scrollTop = scroll_element.scrollTop + delta; const $all_rows = get_all_rows(); const $last_row = $all_rows.last(); - const last_row_bottom = $last_row.offset().top + $last_row.outerHeight(); + const last_row_bottom = ($last_row.offset()?.top ?? 0) + ($last_row.outerHeight() ?? 0); // Move focus to last row if it is visible and we are at the bottom. if (last_row_bottom <= new_scrollTop) { row_focus = get_all_rows().length - 1; @@ -918,11 +1075,12 @@ function page_down_navigation() { set_list_focus(); } -export function change_focused_element(input_key) { +export function change_focused_element(input_key: string): boolean { if (is_search_focused()) { - const textInput = $(`#${CSS.escape(INBOX_SEARCH_ID)}`).get(0); - const start = textInput.selectionStart; - const end = textInput.selectionEnd; + const textInput = $(`input#${CSS.escape(INBOX_SEARCH_ID)}`).get(0); + assert(textInput !== undefined); + const start = textInput.selectionStart ?? 0; + const end = textInput.selectionEnd ?? 0; const text_length = textInput.value.length; let is_selected = false; if (end - start > 0) { @@ -1021,7 +1179,7 @@ export function change_focused_element(input_key) { return false; } -export function update() { +export function update(): void { if (!is_visible()) { return; } @@ -1034,7 +1192,7 @@ export function update() { const unread_streams_dict = unread_stream_message.topic_counts; let has_dms_post_filter = false; - const dm_keys_to_insert = []; + const dm_keys_to_insert: string[] = []; for (const [key, {count, latest_msg_id}] of unread_dms_dict) { if (count !== 0) { const old_dm_data = dms_dict.get(key); @@ -1071,8 +1229,10 @@ export function update() { const stream_key = get_stream_key(stream_id); let stream_post_filter_unread_count = 0; if (stream_unread_count > 0) { + const stream_topics_data = topics_dict.get(stream_key); + // Stream isn't rendered. - if (topics_dict.get(stream_key) === undefined) { + if (stream_topics_data === undefined) { const is_stream_visible = insert_stream(stream_id, topic_dict); if (is_stream_visible) { has_topics_post_filter = true; @@ -1080,19 +1240,19 @@ export function update() { continue; } - const topic_keys_to_insert = []; + const topic_keys_to_insert: string[] = []; const new_stream_data = format_stream(stream_id); for (const [topic, {topic_count, latest_msg_id}] of topic_dict) { const topic_key = get_topic_key(stream_id, topic); if (topic_count) { - const old_topic_data = topics_dict.get(stream_key).get(topic_key); + const old_topic_data = stream_topics_data.get(topic_key); const new_topic_data = format_topic( stream_id, topic, topic_count, latest_msg_id, ); - topics_dict.get(stream_key).set(topic_key, new_topic_data); + stream_topics_data.set(topic_key, new_topic_data); rerender_topic_inbox_row_if_needed( new_topic_data, old_topic_data, @@ -1105,16 +1265,17 @@ export function update() { } else { // Remove old topic data since it can act as false data for renamed / a new // topic having the same name as old topic. - topics_dict.get(stream_key).delete(topic_key); + stream_topics_data.delete(topic_key); get_row_from_conversation_key(topic_key).remove(); } } const old_stream_data = streams_dict.get(stream_key); + assert(old_stream_data !== undefined); new_stream_data.is_hidden = stream_post_filter_unread_count === 0; new_stream_data.unread_count = stream_post_filter_unread_count; streams_dict.set(stream_key, new_stream_data); rerender_stream_inbox_header_if_needed(new_stream_data, old_stream_data); - topics_dict.set(stream_key, get_sorted_row_dict(topics_dict.get(stream_key))); + topics_dict.set(stream_key, get_sorted_row_dict(stream_topics_data)); insert_topics(topic_keys_to_insert, stream_key); } else { topics_dict.delete(stream_key); @@ -1132,7 +1293,7 @@ export function update() { } } -function get_focus_class_for_header() { +function get_focus_class_for_header(): string { let focus_class = ".collapsible-button"; switch (col_focus) { @@ -1152,7 +1313,7 @@ function get_focus_class_for_header() { return focus_class; } -function get_focus_class_for_row() { +function get_focus_class_for_row(): string { let focus_class = ".inbox-left-part"; switch (col_focus) { case COLUMNS.UNREAD_COUNT: { @@ -1171,9 +1332,10 @@ function get_focus_class_for_row() { return focus_class; } -function is_element_visible(element_position) { +function is_element_visible(element_position: DOMRect): boolean { const element_above = document.querySelector("#inbox-filters"); const element_down = document.querySelector("#compose"); + assert(element_above !== null && element_down !== null); const visible_top = element_above.getBoundingClientRect().bottom; const visible_bottom = element_down.getBoundingClientRect().top; @@ -1183,7 +1345,7 @@ function is_element_visible(element_position) { return false; } -function center_focus_if_offscreen() { +function center_focus_if_offscreen(): void { // Move focused to row to visible area so to avoid // it being under compose box or inbox filters. const $elt = $(".inbox-row:focus, .inbox-header:focus"); @@ -1201,7 +1363,7 @@ function center_focus_if_offscreen() { $elt[0].scrollIntoView({block: "center"}); } -function move_focus_to_visible_area() { +function move_focus_to_visible_area(): void { // Focus on the row below inbox filters if the focused // row is not visible. if (!is_list_focused()) { @@ -1229,18 +1391,20 @@ function move_focus_to_visible_area() { const inbox_center_x = (position.left + position.right) / 2; // We are aiming to get the first row if it is completely visible or the second row. const inbox_row_below_filters = position.bottom + INBOX_ROW_HEIGHT; - const $element_in_row = $(document.elementFromPoint(inbox_center_x, inbox_row_below_filters)); + const element_in_row = document.elementFromPoint(inbox_center_x, inbox_row_below_filters); + assert(element_in_row !== null); + const $element_in_row = $(element_in_row); let $inbox_row = $element_in_row.closest(".inbox-row"); if (!$inbox_row.length) { $inbox_row = $element_in_row.closest(".inbox-header"); } - row_focus = $all_rows.index($inbox_row); + row_focus = $all_rows.index($inbox_row.get(0)); revive_current_focus(); } -export function is_in_focus() { +export function is_in_focus(): boolean { // Check if user is focused on // inbox return ( @@ -1254,7 +1418,7 @@ export function is_in_focus() { ); } -export function initialize() { +export function initialize(): void { $(document).on( "scroll", _.throttle(() => { @@ -1284,14 +1448,19 @@ export function initialize() { } }); - $("body").on("click", "#inbox-list .inbox-header .collapsible-button", (e) => { - const $elt = $(e.currentTarget); - const container_id = $elt.parents(".inbox-header").attr("id"); - col_focus = COLUMNS.COLLAPSE_BUTTON; - focus_clicked_list_element($elt); - collapse_or_expand(container_id); - e.stopPropagation(); - }); + $("body").on( + "click", + "#inbox-list .inbox-header .collapsible-button", + function (this: HTMLElement, e) { + const $elt = $(this); + const container_id = $elt.parents(".inbox-header").attr("id"); + assert(container_id !== undefined); + col_focus = COLUMNS.COLLAPSE_BUTTON; + focus_clicked_list_element($elt); + collapse_or_expand(container_id); + e.stopPropagation(); + }, + ); $("body").on("keydown", ".inbox-row", (e) => { if (e.metaKey || e.ctrlKey) { @@ -1307,21 +1476,23 @@ export function initialize() { } }); - $("body").on("click", "#inbox-list .inbox-left-part-wrapper", (e) => { + $("body").on("click", "#inbox-list .inbox-left-part-wrapper", function (this: HTMLElement, e) { if (e.metaKey || e.ctrlKey || e.shiftKey) { return; } - const $elt = $(e.currentTarget); + const $elt = $(this); col_focus = COLUMNS.RECIPIENT; focus_clicked_list_element($elt); - window.location.href = $elt.find("a").attr("href"); + const href = $elt.find("a").attr("href"); + assert(href !== undefined); + window.location.href = href; }); - $("body").on("click", "#inbox-list .on_hover_dm_read", (e) => { + $("body").on("click", "#inbox-list .on_hover_dm_read", function (this: HTMLElement, e) { e.stopPropagation(); e.preventDefault(); - const $elt = $(e.currentTarget); + const $elt = $(this); col_focus = COLUMNS.UNREAD_COUNT; focus_clicked_list_element($elt); const user_ids_string = $elt.attr("data-user-ids-string"); @@ -1335,16 +1506,20 @@ export function initialize() { e.stopPropagation(); e.preventDefault(); const unread_dms_msg_ids = unread.get_msg_ids_for_private(); - const unread_dms_messages = unread_dms_msg_ids.map((msg_id) => message_store.get(msg_id)); + const unread_dms_messages = unread_dms_msg_ids.map((msg_id) => { + const message = message_store.get(msg_id); + assert(message !== undefined); + return message; + }); unread_ops.notify_server_messages_read(unread_dms_messages); focus_inbox_search(); update_triggered_by_user = true; }); - $("body").on("click", "#inbox-list .on_hover_topic_read", (e) => { + $("body").on("click", "#inbox-list .on_hover_topic_read", function (this: HTMLElement, e) { e.stopPropagation(); e.preventDefault(); - const $elt = $(e.currentTarget); + const $elt = $(this); col_focus = COLUMNS.UNREAD_COUNT; focus_clicked_list_element($elt); const user_ids_string = $elt.attr("data-user-ids-string"); @@ -1353,7 +1528,7 @@ export function initialize() { unread_ops.mark_pm_as_read(user_ids_string); return; } - const stream_id = Number.parseInt($elt.attr("data-stream-id"), 10); + const stream_id = Number($elt.attr("data-stream-id")); const topic = $elt.attr("data-topic-name"); if (topic) { unread_ops.mark_topic_as_read(stream_id, topic); @@ -1374,40 +1549,56 @@ export function initialize() { }); // Mute topic in a unmuted stream - $("body").on("click", "#inbox-list .stream_unmuted.on_hover_topic_mute", (e) => { - e.stopPropagation(); - user_topics.set_visibility_policy_for_element( - $(e.target), - user_topics.all_visibility_policies.MUTED, - ); - }); + $("body").on( + "click", + "#inbox-list .stream_unmuted.on_hover_topic_mute", + function (this: HTMLElement, e) { + e.stopPropagation(); + user_topics.set_visibility_policy_for_element( + $(this), + user_topics.all_visibility_policies.MUTED, + ); + }, + ); // Unmute topic in a unmuted stream - $("body").on("click", "#inbox-list .stream_unmuted.on_hover_topic_unmute", (e) => { - e.stopPropagation(); - user_topics.set_visibility_policy_for_element( - $(e.target), - user_topics.all_visibility_policies.INHERIT, - ); - }); + $("body").on( + "click", + "#inbox-list .stream_unmuted.on_hover_topic_unmute", + function (this: HTMLElement, e) { + e.stopPropagation(); + user_topics.set_visibility_policy_for_element( + $(this), + user_topics.all_visibility_policies.INHERIT, + ); + }, + ); // Unmute topic in a muted stream - $("body").on("click", "#inbox-list .stream_muted.on_hover_topic_unmute", (e) => { - e.stopPropagation(); - user_topics.set_visibility_policy_for_element( - $(e.target), - user_topics.all_visibility_policies.UNMUTED, - ); - }); + $("body").on( + "click", + "#inbox-list .stream_muted.on_hover_topic_unmute", + function (this: HTMLElement, e) { + e.stopPropagation(); + user_topics.set_visibility_policy_for_element( + $(this), + user_topics.all_visibility_policies.UNMUTED, + ); + }, + ); // Mute topic in a muted stream - $("body").on("click", "#inbox-list .stream_muted.on_hover_topic_mute", (e) => { - e.stopPropagation(); - user_topics.set_visibility_policy_for_element( - $(e.target), - user_topics.all_visibility_policies.INHERIT, - ); - }); + $("body").on( + "click", + "#inbox-list .stream_muted.on_hover_topic_mute", + function (this: HTMLElement, e) { + e.stopPropagation(); + user_topics.set_visibility_policy_for_element( + $(this), + user_topics.all_visibility_policies.INHERIT, + ); + }, + ); $(document).on("compose_canceled.zulip", () => { if (is_visible()) { diff --git a/web/src/recent_view_ui.ts b/web/src/recent_view_ui.ts index c62a4dde9c..055fe8272e 100644 --- a/web/src/recent_view_ui.ts +++ b/web/src/recent_view_ui.ts @@ -551,7 +551,7 @@ type ConversationContext = { topic: string; topic_url: string; mention_in_unread: boolean; - visibility_policy: number | boolean; + visibility_policy: number | false; all_visibility_policies: { INHERIT: number; MUTED: number; diff --git a/web/src/user_topics.ts b/web/src/user_topics.ts index 1a7840bfb4..637f47cf94 100644 --- a/web/src/user_topics.ts +++ b/web/src/user_topics.ts @@ -70,7 +70,7 @@ export function update_user_topics( } } -export function get_topic_visibility_policy(stream_id: number, topic: string): number | boolean { +export function get_topic_visibility_policy(stream_id: number, topic: string): number | false { if (stream_id === undefined) { return false; }