/
githubmirror
/
zulip
Обзор
Документация
Войти
/
githubmirror
/
zulip
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
web/src/recent_view_ui.ts
2 561 строка
96 KB
Anders Kaseorg
eslint: Fix unicorn/prefer-iterator-to-array.
29 июл 2026, 02:35
29 июл 2026, 02:35
4902086
Код
Авторство
О чём код?
import {$} from "jquery"; import _ from "lodash"; import assert from "minimalistic-assert"; import type * as tippy from "tippy.js"; import * as z from "zod/mini"; import render_introduce_zulip_view_modal from "../templates/introduce_zulip_view_modal.hbs"; import render_recent_view_empty_list_widget_for_table from "../templates/recent_view_empty_list_widget_for_table.hbs"; import render_recent_view_filters from "../templates/recent_view_filters.hbs"; import render_recent_view_row from "../templates/recent_view_row.hbs"; import render_recent_view_body from "../templates/recent_view_table.hbs"; import render_users_with_status_icons from "../templates/users_with_status_icons.hbs"; import * as activity from "./activity.ts"; import * as blueslip from "./blueslip.ts"; import * as buddy_data from "./buddy_data.ts"; import * as channel_folders from "./channel_folders.ts"; import * as compose_actions from "./compose_actions.ts"; import * as compose_closed_ui from "./compose_closed_ui.ts"; import * as dialog_widget from "./dialog_widget.ts"; import * as dropdown_widget from "./dropdown_widget.ts"; import type {DropdownWidget} from "./dropdown_widget.ts"; import * as focus_outline_util from "./focus_outline_util.ts"; import * as folder_dropdown_widget from "./folder_dropdown_widget.ts"; import * as hash_util from "./hash_util.ts"; import {$t, $t_html} from "./i18n.ts"; import * as keydown_util from "./keydown_util.ts"; import * as left_sidebar_navigation_area from "./left_sidebar_navigation_area.ts"; import * as list_widget from "./list_widget.ts"; import type {ListWidget} from "./list_widget.ts"; import * as loading from "./loading.ts"; import {localstorage} from "./localstorage.ts"; import type {MessageListData} from "./message_list_data.ts"; import * as message_store from "./message_store.ts"; import type {DisplayRecipientUser, Message} from "./message_store.ts"; import * as message_util from "./message_util.ts"; import * as muted_users from "./muted_users.ts"; import * as onboarding_steps from "./onboarding_steps.ts"; import {page_params} from "./page_params.ts"; import * as people from "./people.ts"; import * as recent_senders from "./recent_senders.ts"; import * as recent_view_data from "./recent_view_data.ts"; import type {ConversationData} from "./recent_view_data.ts"; import * as recent_view_util from "./recent_view_util.ts"; import * as stream_data from "./stream_data.ts"; import * as stream_topic_history from "./stream_topic_history.ts"; import * as sub_store from "./sub_store.ts"; import * as timerender from "./timerender.ts"; import * as typeahead from "./typeahead.ts"; import * as unread from "./unread.ts"; import {user_settings} from "./user_settings.ts"; import * as user_status from "./user_status.ts"; import * as user_topics from "./user_topics.ts"; import * as util from "./util.ts"; import * as views_util from "./views_util.ts"; type Row = { last_msg_id: number; participated: boolean; type: "private" | "stream"; }; let topics_widget: ListWidget<ConversationData, Row> | undefined; let filters_dropdown_widget: dropdown_widget.DropdownWidget; export let is_backfill_in_progress = false; // Sets the number of avatars to display. // Rest of the avatars, if present, are displayed as {+x} let max_avatars = 4; const MAX_EXTRA_SENDERS = 10; // Use this to set the focused element. // // We set it's value to `table` in case the // focus in one of the table rows, since the // table rows are constantly updated and tracking // the selected element in them would be tedious via // jquery. // // So, we use table as a grid system and // track the coordinates of the focus element via // `row_focus` and `col_focus`. export let $current_focus_elem: JQuery | "table" = "table"; // If user clicks a topic in Recent Conversations, then // we store that topic here so that we can restore focus // to that topic when user revisits. let last_visited_topic: string | undefined; export const COLUMNS = { topic: 0, read: 1, mute: 2, }; let row_focus = 0; // Start focus on the topic column, so Down+Enter works to visit a topic. let col_focus = COLUMNS.topic; // The number of selectable actions in a Recent Conversations view. // Used to implement wraparound of elements with the right/left keys. // Must be increased when we add new actions, or rethought if we add // optional actions that only appear in some rows. const MAX_SELECTABLE_TOPIC_COLS = 3; const MAX_SELECTABLE_DIRECT_MESSAGE_COLS = 2; // we use localstorage to persist the recent topic filters const ls_key = "recent_topic_filters"; const ls_dropdown_key = "recent_topic_dropdown_filters"; const ls_folder_filter_key = "recent_topic_folder_filter"; const ls = localstorage(); let filters = new Set<string>(); let dropdown_filters = new Set<string>(); let folder_filter_value: number = folder_dropdown_widget.FOLDER_FILTERS.ANY_FOLDER_DROPDOWN_OPTION; let folder_filter_dropdown_widget: dropdown_widget.DropdownWidget | undefined; const recent_conversation_key_prefix = "recent_conversation:"; let is_initial_message_fetch_pending = true; // We wait for rows to render and restore focus before processing // any new events. let is_waiting_for_revive_current_focus = true; // Used to store the last scroll position of the recent view before // it is hidden to avoid scroll jumping when it is shown again. let last_scroll_offset: number | undefined; let hide_other_views_callback: (() => void) | undefined; // Row focus and viewport offset of the focused row before search, // so we can restore both focus and scroll position when search is // cleared. let pre_search_row_focus: number | undefined; let pre_search_topic_viewport_offset: number | undefined; let previous_search_term = ""; export function set_hide_other_views(callback: () => void): void { hide_other_views_callback = callback; } // Track the row's interaction state so we can swap the // `.visibility-status-icon` to a vdots glyph when the row is hovered, // keyboard-focused, or has the topic-actions popover open. We swap the // underlying icon-font class (rather than overlaying via mask-image) // so the rendered glyph matches every other vdots in the app exactly. const ORIGINAL_ICON_CLASS_DATA_ATTR = "data-vdots-original-icon-class"; function swap_visibility_icon_to_vdots($icon: JQuery): void { if ($icon.hasClass("recent-view-row-topic-menu")) { return; } if ($icon.attr(ORIGINAL_ICON_CLASS_DATA_ATTR) !== undefined) { return; } const icon_element = $icon.get(0); if (icon_element === undefined) { return; } const original_class = [...icon_element.classList].find( (cls) => cls.startsWith("zulip-icon-") && cls !== "zulip-icon-more-vertical", ); if (original_class === undefined) { return; } $icon.attr(ORIGINAL_ICON_CLASS_DATA_ATTR, original_class); $icon.removeClass(original_class).addClass("zulip-icon-more-vertical"); } function restore_visibility_icon($icon: JQuery): void { const original_class = $icon.attr(ORIGINAL_ICON_CLASS_DATA_ATTR); if (original_class === undefined) { return; } $icon.removeClass("zulip-icon-more-vertical").addClass(original_class); $icon.removeAttr(ORIGINAL_ICON_CLASS_DATA_ATTR); } export function update_visibility_icon_swap_state($wrapper: JQuery): void { const $icon = $wrapper.find(".visibility-status-icon"); if ($icon.length === 0) { return; } const wrapper_element = $wrapper.get(0); if (wrapper_element === undefined) { return; } // Gate the focus-driven swap on the recent-view's own keyboard- // visibility tracking rather than browser `:focus-visible`. The // `no-visible-focus-outlines` class on `#recent_view` stays on // until the first keyboard navigation key (see focus_outline_util), // which means programmatic focus from view entry, sidebar clicks, // or scroll-driven row re-focus shouldn't reveal the vdots glyph // — the row has focus, but the user hasn't acted on the keyboard // yet, so it would be a surprising reveal. Hover and popover-open // remain unconditional. const keyboard_focus_visible = !$("#recent_view").hasClass("no-visible-focus-outlines"); const should_swap = $wrapper.hasClass("topic-popover-visible") || wrapper_element.matches(":hover") || (keyboard_focus_visible && wrapper_element.matches(":focus")); if (should_swap) { swap_visibility_icon_to_vdots($icon); } else { restore_visibility_icon($icon); } } export function set_initial_message_fetch_status(value: boolean): void { is_initial_message_fetch_pending = value; } export function set_backfill_in_progress(value: boolean): void { is_backfill_in_progress = value; update_load_more_banner(); } export function clear_for_tests(): void { filters.clear(); dropdown_filters.clear(); folder_filter_value = folder_dropdown_widget.FOLDER_FILTERS.ANY_FOLDER_DROPDOWN_OPTION; recent_view_data.conversations.clear(); topics_widget = undefined; } export function set_filters_for_tests(new_filters = [views_util.FILTERS.UNMUTED_TOPICS]): void { dropdown_filters = new Set(new_filters); } export function set_folder_filter_for_tests(value: number): void { folder_filter_value = value; } export function save_filters(): void { ls.set(ls_key, [...filters]); ls.set(ls_dropdown_key, [...dropdown_filters]); ls.set(ls_folder_filter_key, folder_filter_value); } export function is_in_focus(): boolean { // Check if user is focused on Recent Conversations. return recent_view_util.is_visible() && views_util.is_in_focus(); } export function set_default_focus(): void { // If at any point we are confused about the currently // focused element, we switch focus to search. $current_focus_elem = $("#recent_view_search"); $current_focus_elem.trigger("focus"); compose_closed_ui.set_standard_text_for_reply_button(); } // When there are no messages loaded, we don't show a banner yet. const NO_MESSAGES_LOADED = 0; // When some messages are loaded, but we're still loading newer messages, // we show a simple loading banner. const SOME_MESSAGES_LOADED = 1; // Once we've found the newest message, we allow the user to load // more messages further back in time. const SOME_MESSAGES_LOADED_INCLUDING_NEWEST = 2; // Once all messages are loaded, we hide the banner. const ALL_MESSAGES_LOADED = 3; let loading_state = NO_MESSAGES_LOADED; let oldest_message_timestamp = Infinity; function set_oldest_message_date(msg_list_data: MessageListData): void { const has_found_oldest = msg_list_data.fetch_status.has_found_oldest(); const has_found_newest = msg_list_data.fetch_status.has_found_newest(); const oldest_message_in_data = msg_list_data.first_including_muted(); if (oldest_message_in_data) { oldest_message_timestamp = Math.min( oldest_message_in_data.timestamp, oldest_message_timestamp, ); } if (oldest_message_timestamp === Infinity && !has_found_oldest) { // This should only happen either very early in loading the // message list, since it requires the msg_list_data object // being empty, without having server confirmation that's the // case. Wait for server data to do anything in that // situation. return; } if (has_found_oldest) { loading_state = ALL_MESSAGES_LOADED; } else if (has_found_newest) { loading_state = SOME_MESSAGES_LOADED_INCLUDING_NEWEST; } else { loading_state = SOME_MESSAGES_LOADED; } // We might be loading messages in another narrow before the recent view // is shown, so we keep the state updated and update the banner only // once it's actually rendered. if ($("#recent-view-content-tbody tr").length > 0) { update_load_more_banner(); } } function get_time_string(): string { const time_obj = new Date(oldest_message_timestamp * 1000); return timerender.get_localized_date_or_time_for_format( time_obj, "full_weekday_dayofyear_year_time", ); } function get_loaded_messages_text(): string { const time_string = get_time_string(); return $t({defaultMessage: "Showing messages since {time_string}."}, {time_string}); } function render_recent_view_empty_list_widget_for_table_with_load_more(context: { empty_list_message: string; column_count: number; }): string { return render_recent_view_empty_list_widget_for_table({ ...context, load_more_button_text: $t({defaultMessage: "Load more"}), }); } function update_load_more_banner(): void { if (loading_state === NO_MESSAGES_LOADED) { return; } if (loading_state === ALL_MESSAGES_LOADED) { $(".recent-view-load-more-container").toggleClass("notvisible", true); $(".recent-view-empty-load-more").toggleClass("notvisible", true); return; } if (!topics_widget?.all_rendered()) { $(".recent-view-load-more-container").toggleClass("notvisible", true); return; } // Until we've found the newest message, we only show the banner // explaining we're still fetching messages. We don't allow the // user to fetch more messages. if (loading_state === SOME_MESSAGES_LOADED) { $(".recent-view-load-more-container").toggleClass("notvisible", false); return; } // When the table is empty, show the load-more text inline below // "No conversations match your filters." and hide the banner. // Otherwise, show the banner and hide the inline load-more. assert(topics_widget !== undefined); const is_table_empty = topics_widget.get_current_list().length === 0; $(".recent-view-load-more-container").toggleClass("notvisible", is_table_empty); $(".recent-view-empty-load-more").toggleClass("notvisible", !is_table_empty); const $container = is_table_empty ? $(".recent-view-empty-load-more") : $(".recent-view-load-more-container"); $container.find(".last-fetched-message").text(get_loaded_messages_text()); if (is_backfill_in_progress) { // Keep the button disabled and the loading indicator running // until we've finished our recursive backfill. return; } const $button = $container.find(".fetch-messages-button"); $button.toggleClass("notvisible", false); $button.find(".button-label").toggleClass("invisible", false); $button.prop("disabled", false); loading.destroy_indicator($button.find(".loading-indicator")); } function get_min_load_count(already_rendered_count: number, load_count: number): number { const extra_rows_for_viewing_pleasure = 15; if (row_focus > already_rendered_count + load_count) { return row_focus + extra_rows_for_viewing_pleasure - already_rendered_count; } return load_count; } function is_table_focused(): boolean { return $current_focus_elem === "table"; } function row_has_mute_column(row: number, $topic_rows: JQuery): boolean { const type = get_row_type(row); if (type === "private") { return false; } const $topic_row = $topic_rows.eq(row); return !$topic_row.hasClass("recent-view-archived-channel-row"); } function get_row_type(row: number): string { // Return "private" or "stream" // We use CSS method for finding row type until topics_widget gets initialized. if (!topics_widget) { const $topic_rows = $("#recent-view-content-tbody tr"); const $topic_row = $topic_rows.eq(row); const is_private = $topic_row.hasClass("private_conversation_row"); if (is_private) { return "private"; } return "stream"; } const current_list = topics_widget.get_current_list(); const current_row = current_list[row]; assert(current_row !== undefined); return current_row.type; } function get_max_selectable_cols(row: number): number { // returns maximum number of columns in stream message or direct message row. const type = get_row_type(row); if (type === "private") { return MAX_SELECTABLE_DIRECT_MESSAGE_COLS; } return MAX_SELECTABLE_TOPIC_COLS; } function set_table_focus(row: number, col: number, using_keyboard = false): boolean { assert(topics_widget !== undefined); if (topics_widget.get_current_list().length === 0) { // If there are no topics to show, we don't want to focus on the table. set_default_focus(); return true; } // If the target row is beyond what's currently rendered but // exists in the full list, render more rows to include it. // `get_min_load_count` ensures enough rows are rendered based // on `row_focus`. if ( row >= 0 && row >= topics_widget.get_rendered_list().length && row < topics_widget.get_current_list().length && !topics_widget.all_rendered() ) { row_focus = row; topics_widget.render(); } const $topic_rows = $("#recent-view-content-tbody tr"); if ($topic_rows.length === 0 || row < 0 || row >= $topic_rows.length) { row_focus = 0; // return focus back to filters if we cannot focus on the table. set_default_focus(); return true; } const unread = has_unread(row); if (col === COLUMNS.read && !unread) { col = COLUMNS.topic; col_focus = COLUMNS.topic; } const $topic_row = $topic_rows.eq(row); const type = get_row_type(row); if (col === COLUMNS.mute && !row_has_mute_column(row, $topic_rows)) { col = unread ? COLUMNS.read : COLUMNS.topic; col_focus = col; } // We need to allow table to render first before setting focus. setTimeout( () => $topic_row.find(".recent_view_focusable").addBack().eq(col).trigger("focus"), 0, ); $current_focus_elem = "table"; if (using_keyboard) { const scroll_element = util.the($(":root")); const half_height_of_visible_area = scroll_element.offsetHeight / 2; const topic_offset = topic_offset_to_visible_area($topic_row); if (topic_offset === "above") { scroll_element.scrollBy({top: -half_height_of_visible_area}); } else if (topic_offset === "below") { scroll_element.scrollBy({top: half_height_of_visible_area}); } } let reply_recipient_information: compose_closed_ui.ReplyRecipientInformation; if (type === "private") { const $recipients_info = $topic_row.find(".recent-view-table-link"); const narrow_url = $recipients_info.attr("href"); assert(narrow_url !== undefined); const recipient_ids = hash_util.decode_dm_recipient_user_ids_from_narrow_url(narrow_url); if (recipient_ids) { reply_recipient_information = { user_ids: recipient_ids, }; } else { reply_recipient_information = { display_reply_to: $recipients_info.text(), }; } } else { const stream_name = $topic_row.find(".recent-view-channel-name").text(); const stream = stream_data.get_sub_by_name(stream_name); reply_recipient_information = { stream_id: stream?.stream_id, topic: $topic_row.find(".recent-view-conversation-link").text(), }; } compose_closed_ui.update_reply_button(reply_recipient_information); return true; } export function get_focused_row_message(): Message | undefined { if (is_table_focused()) { assert(topics_widget !== undefined); if (topics_widget.get_current_list().length === 0) { return undefined; } const $topic_rows = $("#recent-view-content-tbody tr"); const $topic_row = $topic_rows.eq(row_focus); if ($topic_row.length === 0) { // There are less items in the table than `row_focus`. // We don't reset `row_focus` here since that is not the // purpose of this function. return undefined; } const topic_id = $topic_row.attr("id"); assert(topic_id !== undefined); const conversation_id = topic_id.slice(recent_conversation_key_prefix.length); const last_conversation = recent_view_data.conversations.get(conversation_id); assert(last_conversation !== undefined); const topic_last_msg_id = last_conversation.last_msg_id; return message_store.get(topic_last_msg_id); } return undefined; } export function revive_current_focus(): boolean { // After re-render, the current_focus_elem is no longer linked // to the focused element, this function attempts to revive the // link and focus to the element prior to the rerender. // We want to set focus on table by default, but we have to wait for // initial fetch for rows to appear otherwise focus is set to search input. if (is_initial_message_fetch_pending) { return false; } // We try to avoid setting focus when user // is not focused on Recent Conversations. if (!is_in_focus()) { return false; } if (!$current_focus_elem) { set_default_focus(); return false; } if (is_table_focused()) { assert(topics_widget !== undefined); if (last_visited_topic !== undefined) { // If the only message in the topic was deleted, // then the topic will not be in Recent Conversations data. if (recent_view_data.conversations.has(last_visited_topic)) { const last_conversation = recent_view_data.conversations.get(last_visited_topic); assert(last_conversation !== undefined); const topic_last_msg_id = last_conversation.last_msg_id; const current_list = topics_widget.get_current_list(); const last_visited_topic_index = current_list.findIndex( (topic) => topic.last_msg_id === topic_last_msg_id, ); // Only restore focus to the topic if it hasn't moved // too far from where the user left off. A topic can // shift significantly due to new messages arriving // (sorted by time), topic renames (sorted by topic), // or marking as read (sorted by unread count), which // would disorient the user by jumping them far from // their previous scroll position. const max_focus_shift = 10; if ( last_visited_topic_index !== -1 && Math.abs(last_visited_topic_index - row_focus) <= max_focus_shift ) { row_focus = last_visited_topic_index; } } last_visited_topic = undefined; } set_table_focus(row_focus, col_focus); return true; } assert($current_focus_elem !== "table"); if ($current_focus_elem.hasClass("dropdown-widget-button")) { $("#recent-view-filter_widget").trigger("focus"); return true; } if ($current_focus_elem.hasClass("recent-view-folder-filter-button")) { $("#recent_view_folder_filter_button").trigger("focus"); return true; } const filter_button = $current_focus_elem.attr("data-filter"); if (!filter_button) { set_default_focus(); } else { $current_focus_elem = $("#recent_view_filter_buttons").find( `[data-filter='${CSS.escape(filter_button)}']`, ); $current_focus_elem.trigger("focus"); } return true; } export function show_loading_indicator(): void { $("#recent-view-content-table").hide(); loading.make_indicator($("#recent_view_loading_messages_indicator")); } export function hide_loading_indicator(): void { $("#recent-view-content-table").show(); $("#recent_view_bottom_whitespace").hide(); loading.destroy_indicator($("#recent_view_loading_messages_indicator")); } export function process_messages( messages: Message[], rows_order_changed = true, msg_list_data?: MessageListData, ): void { // Always synced with messages in recent_view_messages_data. let conversation_data_updated = false; const updated_rows = new Set<string>(); if (messages.length > 0) { for (const msg of messages) { if (recent_view_data.process_message(msg)) { conversation_data_updated = true; const key = recent_view_util.get_key_from_message(msg); updated_rows.add(key); } } } if (msg_list_data) { // Update the recent view UI's understanding of which messages // we have available for the recent view. set_oldest_message_date(msg_list_data); } // Only rerender if conversation data actually changed. if (conversation_data_updated) { if (!rows_order_changed) { // If rows order didn't change, we can just rerender the affected rows. bulk_inplace_rerender([...updated_rows]); } else { complete_rerender(); } } } function message_to_conversation_unread_count(msg: Message): number { if (msg.type === "private") { return unread.num_unread_for_user_ids_string(msg.to_user_ids); } return unread.num_unread_for_topic(msg.stream_id, msg.topic); } export function get_pm_tooltip_data(user_ids_string: string): buddy_data.TitleData { const user_id = Number.parseInt(user_ids_string, 10); const person = people.get_by_user_id(user_id); if (person.is_bot) { const bot_owner = people.get_bot_owner_user(person); if (bot_owner) { const bot_owner_name = $t( {defaultMessage: "Owner: {name}"}, {name: bot_owner.full_name}, ); return { first_line: person.full_name, second_line: bot_owner_name, third_line: "", }; } // Bot does not have an owner. return { first_line: person.full_name, second_line: "", third_line: "", }; } const last_seen = buddy_data.user_last_seen_time_status(user_id); // Users does not have a status. return { first_line: last_seen, second_line: "", third_line: "", }; } type AvatarsContext = { senders: people.SenderInfo[]; other_sender_names_html: string; other_senders_count: number; }; function get_avatars_context(all_senders: number[]): AvatarsContext { // Show the all avatars rather than `max_avatars` + 1. const max_space_for_avatars = max_avatars + 1; if (all_senders.length <= max_space_for_avatars) { return { senders: people.sender_info_for_recent_view_row(all_senders), other_sender_names_html: "", other_senders_count: 0, }; } const senders = all_senders.slice(-max_avatars); const extra_sender_ids = all_senders.slice(0, -max_avatars); const displayed_other_senders = extra_sender_ids.slice(-MAX_EXTRA_SENDERS); const other_senders_count = Math.max(0, all_senders.length - max_avatars); // Collect extra sender fullname for tooltip const displayed_other_names = people.get_display_full_names( displayed_other_senders.toReversed(), ); if (extra_sender_ids.length > MAX_EXTRA_SENDERS) { // We display only 10 extra senders in tooltips, // and just display remaining number of senders. const remaining_senders = extra_sender_ids.length - MAX_EXTRA_SENDERS; // Pluralization syntax from: // https://formatjs.github.io/docs/core-concepts/icu-syntax#plural-format displayed_other_names.push( $t( { defaultMessage: "and {remaining_senders, plural, one {1 other} other {# others}}.", }, {remaining_senders}, ), ); } return { senders: people.sender_info_for_recent_view_row(senders), other_sender_names_html: displayed_other_names.map((name) => _.escape(name)).join("<br />"), other_senders_count, }; } type ConversationContext = { full_last_msg_date_time: string; conversation_key: string; unread_count: number; last_msg_time: string; senders: people.SenderInfo[]; other_senders_count: number; other_sender_names_html: string; last_msg_url: string; is_spectator: boolean; } & ( | { is_private: true; user_ids_string: string; rendered_pm_with_html: string; pm_users_as_plain: string; pm_url: string; is_group: boolean; is_bot: boolean; user_circle_class: string | undefined; has_unread_mention: boolean; } | { is_private: false; stream_id: number; stream_name: string; stream_color: string; stream_url: string; invite_only: boolean; is_web_public: boolean; is_archived: boolean; topic: string; topic_display_name: string; is_empty_string_topic: boolean; topic_url: string; mention_in_unread: boolean; visibility_policy: number | false; all_visibility_policies: { INHERIT: number; MUTED: number; UNMUTED: number; FOLLOWED: number; }; } ); function format_conversation(conversation_data: ConversationData): ConversationContext { const last_msg = message_store.get(conversation_data.last_msg_id); assert(last_msg !== undefined); const time = new Date(last_msg.timestamp * 1000); const type = last_msg.type; const full_last_msg_date_time = timerender.get_full_datetime_clarification(time); const conversation_key = recent_view_util.get_key_from_message(last_msg); const unread_count = message_to_conversation_unread_count(last_msg); const last_msg_time = timerender.relative_time_string_from_date(time, true); const is_private = last_msg.type === "private"; let all_senders; let stream_context; let dm_context; if (type === "stream") { // Stream info const stream_info = sub_store.get(last_msg.stream_id); assert(stream_info !== undefined); const stream_id = last_msg.stream_id; const stream_name = stream_data.get_stream_name_from_id(last_msg.stream_id); const stream_color = stream_info.color; const stream_url = hash_util.channel_url_by_user_setting(stream_id); const invite_only = stream_info.invite_only; const is_web_public = stream_info.is_web_public; const is_archived = stream_info.is_archived; // Topic info const topic = last_msg.topic; const topic_display_name = util.get_final_topic_display_name(topic); const is_empty_string_topic = topic === ""; const topic_url = stream_topic_history.channel_topic_permalink_hash(stream_id, topic); // We hide the row according to filters or if it's muted. // We only supply the data to the topic rows and let jquery // display / hide them according to filters instead of // doing complete re-render. const mention_in_unread = unread.topic_has_any_unread_mentions(stream_id, topic); const visibility_policy = user_topics.get_topic_visibility_policy(stream_id, topic); // The following field is not specific to this context, but this is the // easiest way we've figured out for passing the data to the template rendering. const all_visibility_policies = user_topics.all_visibility_policies; // Since the css for displaying senders in reverse order is much simpler, // we provide our handlebars with senders in opposite order. // Display in most recent sender first order. all_senders = recent_senders.get_topic_recent_senders(stream_id, topic).toReversed(); stream_context = { stream_id, stream_name, stream_color, stream_url, invite_only, is_web_public, is_archived, topic, topic_display_name, is_empty_string_topic, topic_url, mention_in_unread, visibility_policy, all_visibility_policies, }; } else { // Direct message info const user_ids_string = last_msg.to_user_ids; assert(typeof last_msg.display_recipient !== "string"); const sorted_pm_users = last_msg.display_recipient .filter( (recipient: DisplayRecipientUser) => !people.is_my_user_id(recipient.id) || last_msg.display_recipient.length === 1, ) .map((user: DisplayRecipientUser) => ({ name: people.get_display_full_name(user.id), status_emoji_info: user_status.get_status_emoji(user.id), })) .toSorted((a, b) => util.strcmp(a.name, b.name)); const rendered_pm_with_html = render_users_with_status_icons({ users: sorted_pm_users, }); const sorted_pm_usernames_only = Array.from(sorted_pm_users, (user) => user.name); const pm_users_as_plain = util.format_array_as_list_with_conjunction( sorted_pm_usernames_only, "long", ); const pm_url = last_msg.pm_with_url; const is_group = last_msg.display_recipient.length > 2; const has_unread_mention = unread.num_unread_mentions_for_user_ids_strings(user_ids_string) > 0; let is_bot = false; let user_circle_class; if (!is_group) { const user_id = Number.parseInt(last_msg.to_user_ids, 10); const is_deactivated = !people.is_active_user_or_system_bot(user_id); if (people.is_valid_bot_user(user_id)) { // We display the bot icon rather than a user circle for bots. is_bot = true; } else { user_circle_class = buddy_data.get_user_circle_class(user_id, is_deactivated); } } // Since the css for displaying senders in reverse order is much simpler, // we provide our handlebars with senders in opposite order. // Display in most recent sender first order. // To match the behavior for streams, we display the set of users who've actually // participated, with the most recent participants first. It could make sense to // display the other recipients on the direct message conversation with different // styling, but it's important to not destroy the information of "who's actually // talked". all_senders = recent_senders .get_pm_recent_senders(user_ids_string) .participants.toReversed(); dm_context = { user_ids_string, rendered_pm_with_html, pm_users_as_plain, pm_url, is_group, is_bot, user_circle_class, has_unread_mention, }; } const shared_context = { full_last_msg_date_time, conversation_key, unread_count, last_msg_time, last_msg_url: hash_util.by_conversation_and_time_url(last_msg), is_spectator: page_params.is_spectator, column_indexes: COLUMNS, ...get_avatars_context(all_senders), }; if (is_private) { assert(dm_context !== undefined); return { ...shared_context, is_private: true, ...dm_context, }; } assert(stream_context !== undefined); return { ...shared_context, is_private: false, ...stream_context, }; } function get_topic_row(topic_data: ConversationData): JQuery { const msg = message_store.get(topic_data.last_msg_id); assert(msg !== undefined); const topic_key = recent_view_util.get_key_from_message(msg); return $(`#${CSS.escape(recent_conversation_key_prefix + topic_key)}`); } export function process_topic_edit( old_stream_id: number, old_topic: string, new_topic: string, new_stream_id: number, ): void { // See `recent_senders.process_topic_edit` for // logic behind this and important notes on use of this function. recent_view_data.conversations.delete(recent_view_util.get_topic_key(old_stream_id, old_topic)); const old_topic_msgs = message_util.get_loaded_messages_in_topic(old_stream_id, old_topic); new_stream_id ||= old_stream_id; const new_topic_msgs = message_util.get_loaded_messages_in_topic(new_stream_id, new_topic); for (const msg of [...old_topic_msgs, ...new_topic_msgs]) { recent_view_data.process_message(msg); } // It is best to re-render the complete UI instead of // handling all edge cases that can arise due to topic edit. complete_rerender(); } export function topic_in_search_results( keyword: string, stream_name: string, topic: string, ): boolean { if (keyword === "") { return true; } const topic_display_name = util.get_final_topic_display_name(topic); const text = (stream_name + " " + topic_display_name).toLowerCase(); return typeahead.query_matches_string_in_any_order(keyword, text, " "); } export function update_topics_of_deleted_message_ids(message_ids: number[]): void { const topics_to_rerender = message_util.get_topics_for_message_ids(message_ids); const msgs_to_process = []; for (const [stream_id, topic] of topics_to_rerender.values()) { recent_view_data.conversations.delete(recent_view_util.get_topic_key(stream_id, topic)); const msgs = message_util.get_loaded_messages_in_topic(stream_id, topic); msgs_to_process.push(...msgs); } const dm_conversations_to_rerender = new Set<string>(); for (const msg_id of message_ids) { const msg = message_store.get(msg_id); if (msg === undefined) { continue; } if (msg.type === "private") { const key = recent_view_util.get_key_from_message(msg); // Important to assert since we use the key in get_messages_in_dm_conversation. assert(key === msg.to_user_ids); dm_conversations_to_rerender.add(key); } } for (const key of dm_conversations_to_rerender) { recent_view_data.conversations.delete(key); } if (dm_conversations_to_rerender.size > 0) { const dm_messages_to_process = message_util.get_messages_in_dm_conversations( dm_conversations_to_rerender, ); msgs_to_process.push(...dm_messages_to_process); } if (msgs_to_process.length > 0) { process_messages(msgs_to_process); } else { complete_rerender(); } } export function filters_should_hide_row(topic_data: ConversationData): boolean { const msg = message_store.get(topic_data.last_msg_id); assert(msg !== undefined); if (msg.type === "stream") { const sub = sub_store.get(msg.stream_id); if (!sub?.subscribed && topic_data.type === "stream") { // Never try to process deactivated & unsubscribed stream msgs. return true; } } const folder_filters = folder_dropdown_widget.FOLDER_FILTERS; if (folder_filter_value !== folder_filters.ANY_FOLDER_DROPDOWN_OPTION) { // When a folder filter is active, hide all DMs. if (msg.type === "private") { return true; } const sub = sub_store.get(msg.stream_id); if (folder_filter_value === folder_filters.UNCATEGORIZED_DROPDOWN_OPTION) { if (sub?.folder_id !== null) { return true; } } else if (sub?.folder_id !== folder_filter_value) { return true; } } if (filters.has("unread")) { const unread_count = message_to_conversation_unread_count(msg); if (unread_count === 0) { return true; } } if (!topic_data.participated && filters.has("participated")) { return true; } if (dropdown_filters.has(views_util.FILTERS.UNMUTED_TOPICS) && msg.type === "stream") { // We want to show the unmuted or followed topics within muted // streams in Recent Conversations. const topic_unmuted_or_followed = user_topics.is_topic_unmuted_or_followed( msg.stream_id, msg.topic, ); const topic_muted = user_topics.is_topic_muted(msg.stream_id, msg.topic); const stream_muted = stream_data.is_muted(msg.stream_id); if (topic_muted || (stream_muted && !topic_unmuted_or_followed)) { return true; } } if (!filters.has("include_private") && topic_data.type === "private") { return true; } if (filters.has("include_private") && msg.type === "private") { const recipients = people.split_to_ints(msg.to_user_ids); if (recipients.every((id) => muted_users.is_user_muted(id))) { return true; } } if ( dropdown_filters.has(views_util.FILTERS.FOLLOWED_TOPICS) && msg.type === "stream" && !user_topics.is_topic_followed(msg.stream_id, msg.topic) ) { return true; } if ( dropdown_filters.has(views_util.FILTERS.UNMUTED_TOPICS) && msg.type === "stream" && (user_topics.is_topic_muted(msg.stream_id, msg.topic) || stream_data.is_muted(msg.stream_id)) && !user_topics.is_topic_unmuted_or_followed(msg.stream_id, msg.topic) ) { return true; } const search_keyword = $<HTMLInputElement>("#recent_view_search").val(); assert(search_keyword !== undefined); if (msg.type === "stream") { const stream_name = stream_data.get_stream_name_from_id(msg.stream_id); if (!topic_in_search_results(search_keyword, stream_name, msg.topic)) { return true; } } else { assert(msg.type === "private"); // Display recipient contains user information for DMs. assert(typeof msg.display_recipient !== "string"); const participants = [...msg.display_recipient].map((recipient) => people.get_by_user_id(recipient.id), ); return people.filter_people_by_search_terms(participants, search_keyword).size === 0; } return false; } export function bulk_inplace_rerender(row_keys: string[]): void { if (!topics_widget || !recent_view_util.is_visible()) { return; } // When doing bulk rerender, we assume that order of rows are not going // to change by default. Row insertion can still change the order but // we ensure the list remains sorted after insertion. // // Save whether all rows were rendered before updating the data, // so we know if it's safe to use render() for new items below. const was_all_rendered = topics_widget.all_rendered(); topics_widget.replace_list_data(get_list_data_for_widget(), false); topics_widget.filter_and_sort(); // Iterate in the order in which the rows should be present so that // we are not inserting rows without any rows being present around them. let processed_count = 0; for (const topic_data of topics_widget.get_rendered_list()) { if (processed_count >= row_keys.length) { break; } const msg = message_store.get(topic_data.last_msg_id); assert(msg !== undefined); const topic_key = recent_view_util.get_key_from_message(msg); if (row_keys.includes(topic_key)) { inplace_rerender(topic_key, true); processed_count += 1; } } // New conversations from backfilled old messages sort at the end // of the list, beyond the current render offset. Use render() to // efficiently batch-append them in a single DOM operation, rather // than inserting one at a time via insert_rendered_row. // // We can only use render() when the DOM already had all rows up // to the render offset (was_all_rendered), ensuring new items // start right at the offset boundary with no gap. if (processed_count < row_keys.length && was_all_rendered) { topics_widget.render(row_keys.length - processed_count); } update_unread_sort_header_state(); setTimeout(revive_current_focus, 0); } export let inplace_rerender = (topic_key: string, is_bulk_rerender?: boolean): boolean => { if (!recent_view_util.is_visible()) { return false; } if (!recent_view_data.conversations.has(topic_key)) { return false; } const topic_data = recent_view_data.conversations.get(topic_key); assert(topic_data !== undefined); const $topic_row = get_topic_row(topic_data); // We cannot rely on `topic_widget.meta.filtered_list` to know // if a topic is rendered since the `filtered_list` might have // already been updated via other calls. const is_topic_rendered = $topic_row.length; assert(topics_widget !== undefined); if (!is_bulk_rerender) { // Resorting the topics_widget is important for the case where we // are rerendering because of message editing or new messages // arriving, since those operations often change the sort key. // // NOTE: This doesn't add any new entry to the original list but updates the filtered list // based on the current filters and updated row data. topics_widget.filter_and_sort(); } const current_topics_list = topics_widget.get_current_list(); if (is_topic_rendered && filters_should_hide_row(topic_data)) { // Since the row needs to be removed from DOM, we need to adjust `row_focus` // if the row being removed is focused and is the last row in the list. // This prevents the row_focus either being reset to the first row or // middle of the visible table rows. // We need to get the current focused row details from DOM since we cannot // rely on `current_topics_list` since it has already been updated and row // doesn't exist inside it. const row_is_focused = get_focused_row_message()?.id === topic_data.last_msg_id; if (row_is_focused && row_focus >= current_topics_list.length) { row_focus = current_topics_list.length - 1; } topics_widget.remove_rendered_row($topic_row); } else if (!is_topic_rendered && filters_should_hide_row(topic_data)) { // In case `topic_row` is not present, our job is already done here // since it has not been rendered yet and we already removed it from // the filtered list in `topic_widget`. So, it won't be displayed in // the future too. } else if (is_topic_rendered && !filters_should_hide_row(topic_data)) { // Only a re-render is required in this case. topics_widget.render_item(topic_data); } else { // Final case: !is_topic_rendered && !filters_should_hide_row(topic_data). topics_widget.insert_rendered_row(topic_data, () => current_topics_list.findIndex( (list_item) => list_item.last_msg_id === topic_data.last_msg_id, ), ); } if (!is_bulk_rerender) { update_unread_sort_header_state(); setTimeout(revive_current_focus, 0); } return true; }; export function rewire_inplace_rerender(value: typeof inplace_rerender): void { inplace_rerender = value; } export function update_topic_visibility_policy(stream_id: number, topic: string): boolean { const key = recent_view_util.get_topic_key(stream_id, topic); if (!recent_view_data.conversations.has(key)) { // we receive mute request for a topic we are // not tracking currently return false; } inplace_rerender(key); return true; } export function update_topic_unread_count(message: Message): void { const topic_key = recent_view_util.get_key_from_message(message); inplace_rerender(topic_key); } export function set_filter(filter: string): void { // This function updates the `filters` variable // after user clicks on one of the filter buttons // based on `button-recent-selected` class and current // set `filters`. // Get the button which was clicked. const $filter_elem = $("#recent_view_filter_buttons").find( `[data-filter="${CSS.escape(filter)}"]`, ); if ($filter_elem.hasClass("button-recent-selected")) { filters.delete(filter); // If the button was not selected, we add the filter. } else { filters.add(filter); } save_filters(); } function show_selected_filters(): void { // Add `button-recent-selected` to the buttons to show // which filters are applied. for (const filter of filters) { $("#recent_view_filter_buttons") .find(`[data-filter="${CSS.escape(filter)}"]`) .addClass("button-recent-selected") .attr("aria-checked", "true"); } // Toggle class so CSS can hide the unread marker bar when // every visible row is already unread. $("#recent_view").toggleClass("recent-view-filtered-by-unread", filters.has("unread")); } function get_recent_view_filters_params(): { filter_unread: boolean; filter_participated: boolean; filter_muted: boolean; filter_pm: boolean; is_spectator: boolean; show_folder_filter: boolean; folder_filter_tooltip: string; } { return { filter_unread: filters.has("unread"), filter_participated: filters.has("participated"), filter_muted: filters.has("include_muted"), filter_pm: filters.has("include_private"), is_spectator: page_params.is_spectator, show_folder_filter: channel_folders.user_has_folders(), folder_filter_tooltip: folder_dropdown_widget.get_tooltip_text_for_folder_filter(folder_filter_value), }; } function setup_dropdown_filters_widget(): void { const dropdown_filter = dropdown_filters.values().next(); assert(dropdown_filter.done === false); filters_dropdown_widget = new dropdown_widget.DropdownWidget({ ...views_util.COMMON_DROPDOWN_WIDGET_PARAMS, widget_name: "recent-view-filter", item_click_callback: dropdown_filter_click_handler, $events_container: $("#recent_view_filter_buttons"), default_id: dropdown_filter.value, }); filters_dropdown_widget.setup(); } function update_recent_view_folder_filter_button(): void { const folder_filters = folder_dropdown_widget.FOLDER_FILTERS; if (folder_filter_value === folder_filters.ANY_FOLDER_DROPDOWN_OPTION) { $("#recent_view_folder_filter_button").addClass("icon-button-neutral"); $("#recent_view_folder_filter_button").removeClass("icon-button-brand"); $("#recent_view_folder_filter_button .zulip-icon") .removeClass("zulip-icon-folder-search") .addClass("zulip-icon-folder-chevron"); } else { $("#recent_view_folder_filter_button").removeClass("icon-button-neutral"); $("#recent_view_folder_filter_button").addClass("icon-button-brand"); $("#recent_view_folder_filter_button .zulip-icon") .removeClass("zulip-icon-folder-chevron") .addClass("zulip-icon-folder-search"); } const recent_view_folder_filter_wrapper: tippy.ReferenceElement | undefined = $( "#recent_view_folder_filter_container", ).get(0); assert(recent_view_folder_filter_wrapper !== undefined); recent_view_folder_filter_wrapper._tippy?.setContent( folder_dropdown_widget.get_tooltip_text_for_folder_filter(folder_filter_value), ); } function hard_redraw_with_scroll_to_top(): void { row_focus = 0; assert(topics_widget !== undefined); topics_widget.hard_redraw(); window.scrollTo(0, 0); } function folder_filter_click_handler( event: JQuery.ClickEvent, 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); folder_filter_value = Number.parseInt(filter_id, 10); dropdown.hide(); widget.render(); save_filters(); update_recent_view_folder_filter_button(); hard_redraw_with_scroll_to_top(); } function setup_folder_dropdown_widget(): void { folder_filter_dropdown_widget = folder_dropdown_widget.create_folder_filter_dropdown_widget({ widget_name: "recent-view-folder-filter", widget_selector: ".recent-view-folder-filter-button", item_click_callback: folder_filter_click_handler, $events_container: $("#recent_view_filter_buttons"), default_id: folder_filter_value, }); folder_filter_dropdown_widget.setup(); } export function update_filters_view(): void { const rendered_filters = render_recent_view_filters(get_recent_view_filters_params()); $("#recent_filters_group").html(rendered_filters); show_selected_filters(); filters_dropdown_widget.render(); if (folder_filter_dropdown_widget) { update_recent_view_folder_filter_button(); } assert(topics_widget !== undefined); topics_widget.hard_redraw(); } function sort_comparator(a: string, b: string): number { // compares strings in lowercase and returns -1, 0, 1 if (a.toLowerCase() > b.toLowerCase()) { return 1; } if (a.toLowerCase() === b.toLowerCase()) { return 0; } return -1; } function channel_sort(a: Row, b: Row): number { if (a.type === b.type) { const a_msg = message_store.get(a.last_msg_id); assert(a_msg !== undefined); const b_msg = message_store.get(b.last_msg_id); assert(b_msg !== undefined); if (a_msg.type === "stream") { assert(b_msg.type === "stream"); const channel_cmp = sort_comparator( stream_data.get_stream_name_from_id(a_msg.stream_id), stream_data.get_stream_name_from_id(b_msg.stream_id), ); if (channel_cmp !== 0) { return channel_cmp; } } else { assert(a_msg.type === "private"); assert(b_msg.type === "private"); const dm_cmp = sort_comparator(a_msg.display_reply_to, b_msg.display_reply_to); if (dm_cmp !== 0) { return dm_cmp; } } // Secondary sort: always most recent first, regardless of channel // sort direction. list_widget negates the entire return value when // in reverse mode, so we compensate here so that recency always // sorts most-recent-first within a channel. const is_descend = $(".recent-view-channel-sort-header").hasClass("descend"); const recency = b.last_msg_id - a.last_msg_id; return is_descend ? -recency : recency; } // if type is not same sort between "private" and "stream" return sort_comparator(a.type, b.type); } function conversation_sort(a: Row, b: Row): number { if (a.type === b.type) { const a_msg = message_store.get(a.last_msg_id); assert(a_msg !== undefined); const b_msg = message_store.get(b.last_msg_id); assert(b_msg !== undefined); if (a_msg.type === "stream") { assert(b_msg.type === "stream"); const a_stream_name = stream_data.get_stream_name_from_id(a_msg.stream_id); const b_stream_name = stream_data.get_stream_name_from_id(b_msg.stream_id); if (a_stream_name === b_stream_name) { return sort_comparator(a_msg.topic, b_msg.topic); } return sort_comparator(a_stream_name, b_stream_name); } assert(a_msg.type === "private"); assert(b_msg.type === "private"); return sort_comparator(a_msg.display_reply_to, b_msg.display_reply_to); } // if type is not same sort between "private" and "stream" return sort_comparator(a.type, b.type); } function unread_count(conversation_data: ConversationData): number { const message = message_store.get(conversation_data.last_msg_id); assert(message !== undefined); return message_to_conversation_unread_count(message); } function unread_sort(a: ConversationData, b: ConversationData): number { const a_unread_count = unread_count(a); const b_unread_count = unread_count(b); const a_has_unread = a_unread_count > 0; const b_has_unread = b_unread_count > 0; if (a_has_unread !== b_has_unread) { // Always float conversations with unreads above those without, // regardless of sort direction. list_widget negates the entire // return value in descend mode, so we compensate here. const is_descend = $(".recent-view-unread-sort-header").hasClass("descend"); const unread_first = a_has_unread ? -1 : 1; return is_descend ? -unread_first : unread_first; } if (a_unread_count !== b_unread_count) { return a_unread_count - b_unread_count; } return a.last_msg_id - b.last_msg_id; } function has_visible_unread_conversation(): boolean { // Fast path: no unreads exist anywhere, so none can be visible. if (unread.get_unread_message_count() === 0) { return false; } assert(topics_widget !== undefined); // When the unread filter is active, every row in the filtered // list is unread by construction, so any non-empty list suffices. if (filters.has("unread")) { return topics_widget.get_current_list().length > 0; } // Iterate the widget's already-filtered list so we avoid // re-evaluating `filters_should_hide_row` per topic. for (const topic_data of topics_widget.get_current_list()) { const msg = message_store.get(topic_data.last_msg_id); assert(msg !== undefined); if (message_to_conversation_unread_count(msg) > 0) { return true; } } return false; } let last_has_visible_unread_conversation: boolean | undefined; function update_unread_sort_header_state(): void { if (!topics_widget || !recent_view_util.is_visible()) { return; } const has_visible_unreads = has_visible_unread_conversation(); if (has_visible_unreads === last_has_visible_unread_conversation) { return; } last_has_visible_unread_conversation = has_visible_unreads; const $unread_sort_header = $("#recent-view-table-headers .recent-view-unread-sort-header"); $unread_sort_header.toggleClass("hidden", !has_visible_unreads); if (!has_visible_unreads && $unread_sort_header.hasClass("active")) { // Fall back to time sort, which is the default, when the // unread sort column is no longer meaningful. $unread_sort_header.removeClass("active descend"); $("#recent-view-table-headers .recent-view-last-msg-time-header").addClass( "active descend", ); topics_widget.set_reverse_mode(true); topics_widget.sort("numeric", "last_msg_id"); } } function topic_offset_to_visible_area($topic_row: JQuery): string | undefined { if ($topic_row.length === 0) { // TODO: There is a possibility of topic_row being undefined here // which logically doesn't makes sense. Find out the case and // document it here. // We return undefined here since we don't know anything about the // topic and the callers will take care of undefined being returned. return undefined; } // Rows are only visible below thead bottom and above compose top. const thead_bottom = util.the($("#recent-view-table-headers")).getBoundingClientRect().bottom; const compose_top = window.innerHeight - $("#compose").outerHeight(true)!; const topic_props = util.the($topic_row).getBoundingClientRect(); // Topic is above the visible scroll region. if (topic_props.top < thead_bottom) { return "above"; // Topic is below the visible scroll region. } if (topic_props.bottom > compose_top) { return "below"; } // Topic is visible return "visible"; } function recenter_focus_if_off_screen(): void { if (is_waiting_for_revive_current_focus) { return; } const $topic_rows = $("#recent-view-content-tbody tr"); if (row_focus >= $topic_rows.length) { // User used a filter which reduced // the number of visible rows. return; } const $topic_row = $topic_rows.eq(row_focus); const topic_offset = topic_offset_to_visible_area($topic_row); if (topic_offset === undefined) { // We don't need to return here since technically topic_offset is not visible. blueslip.error("Unable to get topic from row", {row_focus}); } if (topic_offset !== "visible") { // Get the element at the center of the table. const thead_props = util.the($("#recent-view-table-headers")).getBoundingClientRect(); const compose_top = window.innerHeight - $("#compose").outerHeight(true)!; const topic_center_x = (thead_props.left + thead_props.right) / 2; const topic_center_y = (thead_props.bottom + compose_top) / 2; const topic_element = views_util.find_element_at_point( topic_center_x, topic_center_y, "#recent-view-content-tbody", ); if (topic_element === undefined) { // The table is too short for there to be a topic row element // at the center of the table region; in that case, we just // select the last element. row_focus = $topic_rows.length - 1; } else { row_focus = $topic_rows.index($(topic_element).closest("tr")[0]); } set_table_focus(row_focus, col_focus); } } function callback_after_render(): void { // It is important to restore the scroll position as soon // as the rendering is complete to avoid scroll jumping. if (last_scroll_offset !== undefined) { window.scrollTo(0, last_scroll_offset); } update_load_more_banner(); update_unread_sort_header_state(); setTimeout(() => { revive_current_focus(); is_waiting_for_revive_current_focus = false; }, 0); } function dropdown_filter_click_handler( event: JQuery.ClickEvent, dropdown: tippy.Instance, widget: DropdownWidget, ): void { event.preventDefault(); event.stopPropagation(); if (page_params.is_spectator) { // Filter buttons are disabled for spectator. return; } 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. dropdown_filters = new Set([filter_id]); dropdown.hide(); widget.render(); save_filters(); hard_redraw_with_scroll_to_top(); } function get_list_data_for_widget(): ConversationData[] { return recent_view_data.get_conversations().values().toArray(); } function set_time_column_width_css_variable(): void { if (page_params.is_node_test) { return; } // Measure representative localized time strings to find the // longest one. These cover all formats used by // timerender.relative_time_string_from_date(). const candidate_strings = [ $t({defaultMessage: "Just now"}), $t({defaultMessage: "{minutes} min ago"}, {minutes: 59}), $t({defaultMessage: "An hour ago"}), $t({defaultMessage: "{hours} hours ago"}, {hours: 23}), $t({defaultMessage: "Yesterday"}), $t({defaultMessage: "{days_old} days ago"}, {days_old: 89}), // Localized short date with year, using a 2-digit day for // maximum width (e.g., "Sep 28, 2000"). timerender.get_localized_date_or_time_for_format(new Date(2000, 8, 28), "dayofyear_year"), ]; const max_width = util.max_text_content_width(candidate_strings); // The icon and padding space is added via calc() in CSS. $(":root").css("--recent-view-time-text-width", `${Math.ceil(max_width)}px`); } export function update_participants_column_class(): void { if (!page_params.is_node_test) { max_avatars = Number.parseInt($(":root").css("--recent-view-max-avatars"), 10); } $("#recent_view") .removeClass("recent-view-participants-hidden recent-view-participants-3") .addClass( max_avatars === 0 ? "recent-view-participants-hidden" : max_avatars === 2 ? "recent-view-participants-3" : "", ); } export function complete_rerender(coming_from_other_views = false): void { if (!recent_view_util.is_visible()) { return; } update_participants_column_class(); set_time_column_width_css_variable(); // Show topics list const mapped_topic_values = get_list_data_for_widget(); if (topics_widget) { topics_widget.replace_list_data(mapped_topic_values); return; } if (coming_from_other_views) { // This is the first time we are rendering the Recent Conversations view. // So, we always scroll to the top to avoid any scroll jumping in case // user is returning from another view. window.scrollTo(0, 0); } const rendered_body = render_recent_view_body({ search_val: $("#recent_view_search").val() ?? "", ...get_recent_view_filters_params(), }); $("#recent_view_table").html(rendered_body); // `show_selected_filters` needs to be called after the Recent // Conversations view has been added to the DOM, to ensure that filters // have the correct classes (checked or not) if Recent Conversations // was not the first view loaded in the app. show_selected_filters(); const $container = $("#recent-view-content-tbody"); $container.empty(); topics_widget = list_widget.create($container, mapped_topic_values, { name: "recent_view_table", get_item: list_widget.default_get_item, $parent_container: $("#recent_view_table"), modifier_html(item) { return render_recent_view_row(format_conversation(item)); }, filter: { // We use update_filters_view & filters_should_hide_row to do all the // filtering for us, which is called using click_handlers. predicate(topic_data) { return !filters_should_hide_row(topic_data); }, }, sort_fields: { channel_sort, conversation_sort, unread_sort, ...list_widget.generic_sort_functions("numeric", ["last_msg_id"]), }, html_selector: get_topic_row, $simplebar_container: $(":root"), callback_after_render, render_empty_list_widget_for_table: render_recent_view_empty_list_widget_for_table_with_load_more, is_scroll_position_for_render: views_util.is_scroll_position_for_render, post_scroll__pre_render_callback() { // Update the focused element for keyboard navigation if needed. recenter_focus_if_off_screen(); }, get_min_load_count, }); setup_dropdown_filters_widget(); if (channel_folders.user_has_folders()) { setup_folder_dropdown_widget(); update_recent_view_folder_filter_button(); } } export function update_recent_view_rendered_time(): void { if (activity.client_is_active || !recent_view_util.is_visible() || !topics_widget) { return; } // Since we render relative time in recent view, it needs to be // updated otherwise it will show stale time. But, we don't want // to update it every minute due to performance reasons. So, we // only update it when the user comes back from idle which has // maximum chance of user seeing incorrect rendered time. for (const conversation_data of topics_widget.get_rendered_list()) { const last_msg = message_store.get(conversation_data.last_msg_id); assert(last_msg !== undefined); const time = new Date(last_msg.timestamp * 1000); const updated_time = timerender.relative_time_string_from_date(time, true); const $row = get_topic_row(conversation_data); const rendered_time = $row.find(".recent_topic_timestamp").text().trim(); if (updated_time === rendered_time) { continue; } $row.find(".recent_topic_timestamp a").text(updated_time); } } export function show(): void { assert(hide_other_views_callback !== undefined); hide_other_views_callback(); // We remove event handler before hiding, so they need to // be attached again, checking for topics_widget to be defined // is a reliable solution to check if recent view was displayed earlier. const reattach_event_handlers = topics_widget !== undefined; views_util.show({ highlight_view_in_left_sidebar() { views_util.handle_message_view_deactivated( left_sidebar_navigation_area.highlight_recent_view, ); }, $view: $("#recent_view"), // We want to show `new stream message` instead of // `new topic`, which we are already doing in this // function. So, we reuse it here. update_compose: compose_closed_ui.update_buttons, is_recent_view: true, is_visible: recent_view_util.is_visible, set_visible: recent_view_util.set_visible, complete_rerender, update_participants_column_class, }); last_scroll_offset = undefined; if (reattach_event_handlers) { assert(topics_widget !== undefined); topics_widget.set_up_event_handlers(); } if (onboarding_steps.ONE_TIME_NOTICES_TO_DISPLAY.has("intro_recent_view_modal")) { const modal_content_html = render_introduce_zulip_view_modal({ zulip_view: "recent_conversations", current_home_view_and_escape_navigation_enabled: user_settings.web_home_view === "recent" && user_settings.web_escape_navigates_to_home_view, }); dialog_widget.launch({ modal_title_html: $t_html({defaultMessage: "Welcome to recent conversations!"}), modal_content_html, modal_submit_button_text: $t({defaultMessage: "Got it"}), on_click() { /* This widget is purely informational and clicking only closes it. */ }, on_hidden() { revive_current_focus(); }, single_footer_button: true, focus_submit_on_open: true, }); onboarding_steps.post_onboarding_step_as_read("intro_recent_view_modal"); } } function filter_buttons(): JQuery { return $("#recent_filters_group").children(); } export function hide(): void { if (!recent_view_util.is_visible()) { return; } // Since we have events attached to element (window) which are present in // views others than recent view, it is important to clear events here. topics_widget?.clear_event_handlers(); is_waiting_for_revive_current_focus = true; last_scroll_offset = window.scrollY; views_util.hide({ $view: $("#recent_view"), set_visible: recent_view_util.set_visible, }); } function is_focus_at_last_table_row(): boolean { assert(topics_widget !== undefined); return row_focus >= topics_widget.get_current_list().length - 1; } function has_unread(row: number): boolean { assert(topics_widget !== undefined); const current_row = topics_widget.get_current_list()[row]; assert(current_row !== undefined); const last_msg_id = current_row.last_msg_id; const last_msg = message_store.get(last_msg_id); assert(last_msg !== undefined); if (last_msg.type === "stream") { return unread.num_unread_for_topic(last_msg.stream_id, last_msg.topic) > 0; } return unread.num_unread_for_user_ids_string(last_msg.to_user_ids) > 0; } export function focus_clicked_element( topic_row_index: number, col: number, topic_key?: string, ): void { $current_focus_elem = "table"; col_focus = col; row_focus = topic_row_index; if (col === COLUMNS.topic) { last_visited_topic = topic_key ?? undefined; } // Set compose_closed_ui reply button text. The rest of the table // focus logic should be a noop. set_table_focus(row_focus, col_focus); } function left_arrow_navigation(row: number, col: number): void { const type = get_row_type(row); if (type === "stream" && col === MAX_SELECTABLE_TOPIC_COLS - 1 && !has_unread(row)) { col_focus -= 1; } col_focus -= 1; if (col_focus < 0) { col_focus = get_max_selectable_cols(row) - 1; } } function right_arrow_navigation(row: number, col: number): void { if (col === COLUMNS.topic && !has_unread(row)) { col_focus += 1; } col_focus += 1; if (col_focus >= get_max_selectable_cols(row)) { col_focus = 0; } } function up_arrow_navigation(row: number, col: number): void { row_focus -= 1; if (row_focus < 0) { return; } const type = get_row_type(row); if (type === "stream" && col === COLUMNS.read && row >= 1 && !has_unread(row - 1)) { col_focus = COLUMNS.topic; } } function down_arrow_navigation(): void { row_focus += 1; } function get_page_up_down_delta(): number { const thead_bottom = util.the($("#recent-view-table-headers")).getBoundingClientRect().bottom; const compose_box_top = window.innerHeight - $("#compose").outerHeight(true)!; // One usually wants PageDown to move what had been the bottom row // to now be at the top, so one can be confident one will see // every row using it. This offset helps achieve that goal. // // See navigate.amount_to_paginate for similar logic in the message feed. const scrolling_reduction_to_maintain_context = 75; const delta = compose_box_top - thead_bottom - scrolling_reduction_to_maintain_context; return delta; } function page_up_navigation(): void { const delta = get_page_up_down_delta(); const new_scrollTop = window.scrollY - delta; if (new_scrollTop <= 0) { row_focus = 0; // If we are already at the scroll top, a scroll event // is not triggered since the window doesn't actually scroll so // we need to update `row_focus` manually. if (window.scrollY === 0) { set_table_focus(row_focus, col_focus); return; } } window.scroll(0, new_scrollTop); } function page_down_navigation(): void { const delta = get_page_up_down_delta(); const new_scrollTop = window.scrollY + delta; const max_scroll_top = document.body.scrollHeight - window.innerHeight; if (new_scrollTop >= max_scroll_top) { assert(topics_widget !== undefined); row_focus = topics_widget.get_current_list().length - 1; // If we are already at the scroll bottom, a scroll event // is not triggered since the window doesn't actually scroll so // we need to update `row_focus` manually. if (window.scrollY === max_scroll_top) { set_table_focus(row_focus, col_focus); return; } } window.scroll(0, new_scrollTop); } function check_row_type_transition(row: number, col: number): boolean { // This function checks if the row is transitioning // from type "Direct messages" to "Stream" or vice versa. // This helps in setting the col_focus as maximum column // of both the type are different. if (row < 0) { return false; } const max_col = get_max_selectable_cols(row); if (col > max_col - 1) { return true; } return false; } export function change_focused_element($elt: JQuery, input_key: string): boolean { // Called from hotkeys.js; like all logic in that module, // returning true will cause the caller to do // preventDefault/stopPropagation; false will let the browser // handle the key. const is_first_navigation = focus_outline_util.maybe_show_focus_outlines( $("#recent_view"), input_key, ); if (is_first_navigation) { // The focus-driven vdots swap is gated on the same // `no-visible-focus-outlines` flag we just cleared, so // re-evaluate the currently-focused row's swap state now // that keyboard focus has become "visible". const $focused_wrapper = $( "#recent-view-content-table .recent-view-topic-visibility:focus", ); if ($focused_wrapper.length > 0) { update_visibility_icon_swap_state($focused_wrapper); } } if (is_first_navigation && is_table_focused()) { // First navigation keypress after page load / view switch: // just reveal the focus ring on the current row without // moving to a different row. set_table_focus(row_focus, col_focus, true); return true; } if (input_key === "tab" || input_key === "shift_tab") { // Tabbing should be handled by browser but to keep the focus element same // when we update recent view or user uses other hotkeys, we need to track // the current focused element. setTimeout(() => { const post_tab_focus_elem = document.activeElement; if (!(post_tab_focus_elem instanceof HTMLElement)) { return; } if ( post_tab_focus_elem.id === "recent_view_search" || post_tab_focus_elem.classList.contains("button-recent-filters") || post_tab_focus_elem.classList.contains("dropdown-widget-button") || post_tab_focus_elem.classList.contains("recent-view-folder-filter-button") ) { $current_focus_elem = $(post_tab_focus_elem); } if ($(post_tab_focus_elem).parents("#recent-view-content-table").length > 0) { $current_focus_elem = "table"; const topic_row_index = $(post_tab_focus_elem).closest("tr").index(); const col_index = $(post_tab_focus_elem) .closest(".recent_view_focusable") .attr("data-col-index"); if (!col_index) { return; } col_focus = Number.parseInt(col_index, 10); row_focus = topic_row_index; } }, 0); return false; } if ($elt.attr("id") === "recent_view_search") { // Since the search box a text area, we want the browser to handle // Left/Right and selection within the widget; but if the user // arrows off the edges, we should move focus to the adjacent widgets.. const textInput = $<HTMLInputElement>("#recent_view_search").get(0); assert(textInput !== undefined); const start = textInput.selectionStart!; const end = textInput.selectionEnd!; const text_length = textInput.value.length; const is_selected = end > start; switch (input_key) { // Allow browser to handle all // character keypresses. case "vim_left": case "vim_right": case "vim_down": case "vim_up": case "open_recent_view": return false; case "left_arrow": if (start !== 0 || is_selected) { return false; } $current_focus_elem = filter_buttons().last(); break; case "right_arrow": if (end !== text_length || is_selected) { return false; } $current_focus_elem = filter_buttons().first(); break; case "down_arrow": set_table_focus(row_focus, col_focus); return true; case "click": // Note: current_focus_elem can be different here, so we just // set current_focus_elem to the input box, we don't want .trigger("focus") on // it since it is already focused. // We only do this for search because we don't want the focus to // go away from the input box when `revive_current_focus` is called // on rerender when user is typing. $current_focus_elem = $("#recent_view_search"); compose_closed_ui.set_standard_text_for_reply_button(); return true; case "escape": if (is_table_focused()) { return false; } set_table_focus(row_focus, col_focus); return true; } } else if ( $elt.hasClass("button-recent-filters") || $elt.hasClass("dropdown-widget-button") || $elt.hasClass("recent-view-folder-filter-button") ) { switch (input_key) { case "click": $current_focus_elem = $elt; return true; case "vim_left": case "left_arrow": if (filter_buttons().first()[0] === $elt[0]) { $current_focus_elem = $("#recent_view_search"); } else if ($elt.hasClass("recent-view-folder-filter-button")) { // The folder filter button is inside a container div, // so navigate to the container's previous sibling. $current_focus_elem = $elt.parent().prev(); } else { const $prev = $elt.prev(); // If the previous sibling is the folder filter container, // focus the button inside it. if ($prev.attr("id") === "recent_view_folder_filter_container") { $current_focus_elem = $prev.find(".recent-view-folder-filter-button"); } else { $current_focus_elem = $prev; } } break; case "vim_right": case "right_arrow": if (filter_buttons().last()[0] === $elt[0]) { $current_focus_elem = $("#recent_view_search"); } else if ($elt.hasClass("recent-view-folder-filter-button")) { // The folder filter button is inside a container div, // so navigate to the container's next sibling. $current_focus_elem = $elt.parent().next(); } else { const $next = $elt.next(); // If the next sibling is the folder filter container, // focus the button inside it. if ($next.attr("id") === "recent_view_folder_filter_container") { $current_focus_elem = $next.find(".recent-view-folder-filter-button"); } else { $current_focus_elem = $next; } } break; case "vim_down": case "down_arrow": set_table_focus(row_focus, col_focus); return true; case "escape": if (is_table_focused()) { return false; } set_table_focus(row_focus, col_focus); return true; } } else if (is_table_focused()) { // Don't process hotkeys in table if there are no rows. if (!topics_widget || topics_widget.get_current_list().length === 0) { return true; } // For arrowing around the table of topics, we implement left/right // wraparound. Going off the top or the bottom takes one // to the navigation at the top (see set_table_focus). switch (input_key) { case "escape": return false; case "open_recent_view": set_default_focus(); return true; case "vim_left": case "left_arrow": left_arrow_navigation(row_focus, col_focus); break; case "vim_right": case "right_arrow": right_arrow_navigation(row_focus, col_focus); break; case "down_arrow": case "vim_down": // We stop user at last table row // so that user doesn't end up in // input box where it is impossible to // get out of using vim_up / vim_down // keys. This also blocks the user from // having `jjjj` typed in the input box // when continuously pressing `j`. if (is_focus_at_last_table_row()) { return true; } down_arrow_navigation(); break; case "vim_up": // See comment on vim_down. // Similarly, blocks the user from // having `kkkk` typed in the input box // when continuously pressing `k`. if (row_focus === 0) { return true; } up_arrow_navigation(row_focus, col_focus); break; case "up_arrow": up_arrow_navigation(row_focus, col_focus); break; case "page_up": page_up_navigation(); return true; case "page_down": page_down_navigation(); return true; } if (check_row_type_transition(row_focus, col_focus)) { col_focus = get_max_selectable_cols(row_focus) - 1; } set_table_focus(row_focus, col_focus, true); return true; } if ($current_focus_elem !== "table" && input_key !== "escape") { $current_focus_elem.trigger("focus"); if ($current_focus_elem.hasClass("button-recent-filters")) { compose_closed_ui.set_standard_text_for_reply_button(); } return true; } return false; } const filter_schema = z._default(z.array(z.string()), []); function load_filters(): void { // load filters from local storage. if (!page_params.is_spectator) { // A user may have a stored filter and can log out // to see web public view. This ensures no filters are // selected for spectators. const recent_topics = filter_schema.parse(ls.get(ls_key)); filters = new Set(recent_topics); const filter_data = filter_schema.parse(ls.get(ls_dropdown_key)); dropdown_filters = new Set(filter_data); } // Verify that the dropdown_filters are valid. 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 = [...dropdown_filters].every((filter) => valid_filters.has(filter)); if (dropdown_filters.size === 0 || !is_subset) { dropdown_filters = new Set([views_util.FILTERS.UNMUTED_TOPICS]); } // Load and validate folder filter. const folder_filters = folder_dropdown_widget.FOLDER_FILTERS; const saved_folder_filter = ls.get(ls_folder_filter_key); if ( typeof saved_folder_filter === "number" && (saved_folder_filter === folder_filters.ANY_FOLDER_DROPDOWN_OPTION || saved_folder_filter === folder_filters.UNCATEGORIZED_DROPDOWN_OPTION || channel_folders.is_valid_folder_id(saved_folder_filter)) ) { folder_filter_value = saved_folder_filter; } else { folder_filter_value = folder_filters.ANY_FOLDER_DROPDOWN_OPTION; } } export function initialize({ on_click_participant, on_mark_pm_as_read, on_mark_topic_as_read, maybe_load_older_messages, hide_other_views, }: { on_click_participant: (avatar_element: HTMLElement, participant_user_id: number) => void; on_mark_pm_as_read: (user_ids_string: string) => void; on_mark_topic_as_read: (stream_id: number, topic: string) => void; maybe_load_older_messages: (first_unread_unmuted_message_id: number) => void; hide_other_views: () => void; }): void { hide_other_views_callback = hide_other_views; load_filters(); $("body").on( "click", "#recent-view-content-table .recent_view_participant_avatar", function (this: HTMLElement, e) { const user_id_string = $(this).parent().attr("data-user-id"); assert(user_id_string !== undefined); const participant_user_id = Number.parseInt(user_id_string, 10); e.stopPropagation(); assert(this instanceof Element); on_click_participant(this, participant_user_id); }, ); $("body").on("click", "#recent_view_search", (e) => { e.stopPropagation(); assert(e.target instanceof HTMLElement); change_focused_element($(e.target), "click"); }); $(window).on("resize", () => { // The saved viewport offset is no longer meaningful after a // resize, so discard it to avoid incorrect scroll restoration. pre_search_topic_viewport_offset = undefined; }); $("body").on("click", "#recent-view-search-wrapper .input-close-filter-button", () => { set_default_focus(); }); $("body").on("click", "#recent-view-content-table .on_hover_topic_read", (e) => { e.stopPropagation(); assert(e.currentTarget instanceof HTMLElement); const $elt = $(e.currentTarget); const topic_row_index = $elt.closest("tr").index(); focus_clicked_element(topic_row_index, COLUMNS.read); const user_ids_string = $elt.attr("data-user-ids-string"); if (user_ids_string) { // direct message row on_mark_pm_as_read(user_ids_string); } else { // Stream row const stream_id_string = $elt.attr("data-stream-id"); assert(stream_id_string !== undefined); const stream_id = Number.parseInt(stream_id_string, 10); const topic = $elt.attr("data-topic-name"); assert(topic !== undefined); on_mark_topic_as_read(stream_id, topic); } }); $("body").on("keydown", ".recent-view-body-row", (e) => { if (keydown_util.is_enter_event(e)) { e.preventDefault(); e.stopPropagation(); switch (col_focus) { case COLUMNS.topic: $(e.currentTarget).find(".recent-view-conversation-link").trigger("click"); break; case COLUMNS.read: $(e.currentTarget).find(".on_hover_topic_read").trigger("click"); // If `unread` filter is selected, the focused topic row gets removed // and we automatically move one row down. if (!filters.has("unread")) { assert(e.currentTarget instanceof HTMLElement); change_focused_element($(e.currentTarget), "down_arrow"); } break; case COLUMNS.mute: $(e.currentTarget) .find(".recent_view_focusable .visibility-status-icon") .trigger("click"); break; } } }); $("body").on("click", ".button-recent-filters", function (this: HTMLElement, e) { e.stopPropagation(); if (page_params.is_spectator) { // Filter buttons are disabled for spectator. return; } change_focused_element($(this), "click"); const filter = this.getAttribute("data-filter"); assert(filter !== null); set_filter(filter); row_focus = 0; update_filters_view(); window.scrollTo(0, 0); revive_current_focus(); }); $("body").on("click", "#recent-view-filter_widget", (e) => { if (page_params.is_spectator) { // Filter buttons are disabled for spectator. return; } assert(e.currentTarget instanceof HTMLElement); change_focused_element($(e.currentTarget), "click"); }); $("body").on("click", "#recent_view_folder_filter_button", (e) => { assert(e.currentTarget instanceof HTMLElement); change_focused_element($(e.currentTarget), "click"); }); $("body").on("click", "td.recent-view-channel-name", (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) { return; } e.stopPropagation(); const topic_row_index = $(e.target).closest("tr").index(); // Focus topic on channel click since we don't have // separate column for channel and topic. focus_clicked_element(topic_row_index, COLUMNS.topic); window.location.assign($(e.currentTarget).find("a").attr("href")!); }); $("body").on("click", "td.recent_topic_name", (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) { return; } e.stopPropagation(); // The element's parent may re-render while it is being passed to // other functions, so, we get topic_key first. const $topic_row = $(e.target).closest("tr"); const topic_id = $topic_row.attr("id"); assert(topic_id !== undefined); const topic_key = topic_id.slice("recent_conversation:".length); const topic_row_index = $topic_row.index(); focus_clicked_element(topic_row_index, COLUMNS.topic, topic_key); window.location.assign($(e.currentTarget).find("a").attr("href")!); }); $("body").on("click", "#recent-view-content-table .change_visibility_policy", (e) => { const topic_row_index = $(e.target).closest("tr").index(); focus_clicked_element(topic_row_index, COLUMNS.mute); }); $("body").on( "mouseenter mouseleave focusin focusout", "#recent-view-content-table .recent-view-topic-visibility", function (this: HTMLElement) { update_visibility_icon_swap_state($(this)); }, ); // Search for all table rows (this combines stream & topic names) $("body").on( "input", "#recent_view_search", _.debounce(() => { const search_term = $<HTMLInputElement>("#recent_view_search").val() ?? ""; const is_previous_search_term_empty = previous_search_term === ""; previous_search_term = search_term; if (search_term !== "" && is_previous_search_term_empty) { // Store the focused row index and its viewport offset // to restore both focus and scroll position when the // search is cleared. pre_search_row_focus = row_focus; const $topic_row = $("#recent-view-content-tbody tr").eq(row_focus); pre_search_topic_viewport_offset = $topic_row[0]?.getBoundingClientRect().top; } if (search_term === "") { // Restore scroll position to the row that was focused // before search started. row_focus = pre_search_row_focus ?? 0; update_filters_view(); if (topics_widget !== undefined) { // Clamp row_focus if the list shrank while // searching (e.g., messages were read with the // "unread" filter active). const list_length = topics_widget.get_current_list().length; if (list_length > 0) { row_focus = Math.min(row_focus, list_length - 1); } else { row_focus = 0; } // The initial render might not have rendered enough // rows for the target row_focus. Calling render() // will use get_min_load_count to extend rendering // if needed. topics_widget.render(); } // Restore the row to the same viewport position it // was at before search started. const $topic_row = $("#recent-view-content-tbody tr").eq(row_focus); const current_top = $topic_row[0]?.getBoundingClientRect().top; if (current_top !== undefined && pre_search_topic_viewport_offset !== undefined) { window.scrollBy(0, current_top - pre_search_topic_viewport_offset); } pre_search_row_focus = undefined; pre_search_topic_viewport_offset = undefined; } else { // Reset focus to first row on new search. row_focus = 0; update_filters_view(); // Always scroll to top when there is a search term present. window.scrollTo(0, 0); } // Wait for user to go idle before initiating search. }, 300), ); $("body").on( "click", ".recent-view-load-more-container .fetch-messages-button, .recent-view-empty-load-more .fetch-messages-button", function (this: HTMLElement) { const $button = $(this); $button.find(".button-label").toggleClass("invisible", true); $button.prop("disabled", true); loading.make_indicator($button.find(".loading-indicator"), {width: 20}); maybe_load_older_messages(unread.first_unread_unmuted_message_id); }, ); compose_actions.register_compose_cancel_hook(() => { if (recent_view_util.is_visible()) { revive_current_focus(); } }); $(window).on("focus", update_recent_view_rendered_time); }