From 4df4f072f41ebed1e53e00157861ca030d4f3ebb Mon Sep 17 00:00:00 2001 From: Evy Kassirer Date: Wed, 6 Aug 2025 14:01:56 -0700 Subject: [PATCH] stream_list: Show mention indicator in section headings. Fixes #35104. --- web/src/stream_list.ts | 25 +++++++++++ web/src/stream_list_sort.ts | 4 ++ web/src/unread.ts | 41 +++++++++++++++++++ web/styles/left_sidebar.css | 9 ++++ .../stream_list_section_container.hbs | 1 + web/tests/stream_list.test.cjs | 11 +++-- 6 files changed, 88 insertions(+), 3 deletions(-) diff --git a/web/src/stream_list.ts b/web/src/stream_list.ts index d49c2cc8eb..7113f65937 100644 --- a/web/src/stream_list.ts +++ b/web/src/stream_list.ts @@ -357,11 +357,35 @@ export function build_stream_list(force_rerender: boolean): void { const counts = unread.get_counts(); left_sidebar_navigation_area.update_dom_with_unread_counts(counts, false); update_dom_with_unread_counts(counts); + update_stream_section_mention_indicators(); sidebar_ui.update_unread_counts_visibility(); set_sections_states(); $("#streams_list").toggleClass("is_searching", ui_util.get_left_sidebar_search_term() !== ""); } +export let update_stream_section_mention_indicators = function (): void { + const mentions = unread.mention_counts_by_section(); + for (const section of stream_list_sort.section_ids()) { + const $header = $(`#stream-list-${section}-container .stream-list-subsection-header`); + const mentions_for_section = mentions.get(section) ?? { + has_mentions: false, + has_unmuted_mentions: false, + }; + ui_util.update_unread_mention_info_in_dom($header, mentions_for_section.has_mentions); + + $header.toggleClass( + "has-only-muted-mentions", + mentions_for_section.has_mentions && !mentions_for_section.has_unmuted_mentions, + ); + } +}; + +export function rewire_update_stream_section_mention_indicators( + value: typeof update_stream_section_mention_indicators, +): void { + update_stream_section_mention_indicators = value; +} + /* When viewing a channel in a collapsed folder, we show that active highlighted channel in the left sidebar even though the folder is collapsed. If there's an active highlighted topic within the @@ -689,6 +713,7 @@ export let update_dom_with_unread_counts = function (counts: FullUnreadCountsDat stream_has_only_muted_unread_mentions, ); } + update_stream_section_mention_indicators(); // (2) Unread counts in stream headers and collapse/uncollapse // toggles for muted and inactive channels. diff --git a/web/src/stream_list_sort.ts b/web/src/stream_list_sort.ts index 6c1d47cc54..16b3cf49ee 100644 --- a/web/src/stream_list_sort.ts +++ b/web/src/stream_list_sort.ts @@ -35,6 +35,10 @@ export function get_stream_ids(): number[] { return all_rows.flatMap((row) => (row.type === "stream" ? row.stream_id : [])); } +export function section_ids(): string[] { + return current_sections.map((section) => section.id); +} + function current_section_ids_for_streams(): Map { const map = new Map(); for (const section of current_sections) { diff --git a/web/src/unread.ts b/web/src/unread.ts index 47604323b2..535e3343ec 100644 --- a/web/src/unread.ts +++ b/web/src/unread.ts @@ -14,6 +14,7 @@ import type { unread_direct_message_info_schema, } from "./state_data.ts"; import * as stream_data from "./stream_data.ts"; +import * as stream_list_sort from "./stream_list_sort.ts"; import type {TopicHistoryEntry} from "./stream_topic_history.ts"; import * as sub_store from "./sub_store.ts"; import {user_settings} from "./user_settings.ts"; @@ -1026,6 +1027,46 @@ export function stream_has_any_unmuted_mentions(stream_id: number): boolean { return streams_with_mentions.has(stream_id); } +export function mention_counts_by_section(): Map< + string, + { + has_mentions: boolean; + has_unmuted_mentions: boolean; + } +> { + const mentions_map = new Map< + string, + { + has_mentions: boolean; + has_unmuted_mentions: boolean; + } + >(); + const streams_with_mentions = unread_topic_counter.get_streams_with_unread_mentions(); + const streams_with_unmuted_mentions = unread_topic_counter.get_streams_with_unmuted_mentions(); + for (const stream_id of streams_with_mentions) { + const section_id = stream_list_sort.current_section_id_for_stream(stream_id); + if (section_id === undefined) { + continue; + } + if (!mentions_map.has(section_id)) { + mentions_map.set(section_id, { + has_mentions: false, + has_unmuted_mentions: false, + }); + } + mentions_map.get(section_id)!.has_mentions = true; + } + for (const stream_id of streams_with_unmuted_mentions) { + const section_id = stream_list_sort.current_section_id_for_stream(stream_id); + if (section_id === undefined) { + continue; + } + mentions_map.get(section_id)!.has_unmuted_mentions = true; + } + + return mentions_map; +} + export function topic_has_any_unread_mentions(stream_id: number, topic: string): boolean { // Because this function is called in a loop for every displayed // Recent Conversations row, it's important for it to run in O(1) time. diff --git a/web/styles/left_sidebar.css b/web/styles/left_sidebar.css index 894663d866..4e04b7b86a 100644 --- a/web/styles/left_sidebar.css +++ b/web/styles/left_sidebar.css @@ -367,6 +367,11 @@ below. This extra margin prevents that overlap. */ margin-bottom: 1px; + .markers-and-unreads { + display: flex; + align-items: center; + } + .unread_count, .masked_unread_count { margin-right: var(--left-sidebar-unread-offset); @@ -536,6 +541,10 @@ visibility: hidden; } } + + .stream-list-subsection-header .unread_mention_info { + display: none; + } } .direct-messages-container { diff --git a/web/templates/stream_list_section_container.hbs b/web/templates/stream_list_section_container.hbs index 782411f94e..62326b176d 100644 --- a/web/templates/stream_list_section_container.hbs +++ b/web/templates/stream_list_section_container.hbs @@ -10,6 +10,7 @@ {{/if}}
+ diff --git a/web/tests/stream_list.test.cjs b/web/tests/stream_list.test.cjs index 777daa16d1..093f7d3dd4 100644 --- a/web/tests/stream_list.test.cjs +++ b/web/tests/stream_list.test.cjs @@ -176,6 +176,7 @@ test_ui("create_sidebar_row", ({override, override_rewire, mock_template}) => { return ``; }); override_rewire(stream_list, "update_dom_with_unread_counts", noop); + override_rewire(stream_list, "update_stream_section_mention_indicators", noop); const pinned_streams = []; $("#stream-list-pinned-streams").append = (stream) => { @@ -251,6 +252,7 @@ test_ui("create_sidebar_row", ({override, override_rewire, mock_template}) => { }); test_ui("pinned_streams_never_inactive", ({mock_template, override_rewire}) => { + override_rewire(stream_list, "update_stream_section_mention_indicators", noop); override_rewire(stream_list, "update_dom_with_unread_counts", noop); stream_data.add_sub(devel); @@ -442,15 +444,15 @@ test_ui("zoom_in_and_zoom_out", ({mock_template}) => { }); test_ui("narrowing", ({override_rewire}) => { - override_rewire(stream_list, "update_dom_with_unread_counts", noop); - initialize_stream_data(); - topic_list.close = noop; topic_list.rebuild_left_sidebar = noop; topic_list.active_stream_id = noop; topic_list.get_stream_li = noop; override_rewire(stream_list, "scroll_stream_into_view", noop); + override_rewire(stream_list, "update_stream_section_mention_indicators", noop); + override_rewire(stream_list, "update_dom_with_unread_counts", noop); + initialize_stream_data(); assert.ok(!$("").hasClass("active-filter")); let filter; @@ -570,6 +572,8 @@ test_ui("sort_streams", ({override_rewire}) => { test_ui("separators_only_pinned_and_dormant", ({override_rewire}) => { override_rewire(stream_list, "update_dom_with_unread_counts", noop); + override_rewire(stream_list, "update_stream_section_mention_indicators", noop); + // Get coverage on early-exit. stream_list.build_stream_list(); @@ -623,6 +627,7 @@ test_ui("separators_only_pinned_and_dormant", ({override_rewire}) => { test_ui("rename_stream", ({mock_template, override, override_rewire}) => { override_rewire(stream_list, "update_dom_with_unread_counts", noop); + override_rewire(stream_list, "update_stream_section_mention_indicators", noop); override(user_settings, "web_stream_unreads_count_display_policy", 3); override(current_user, "user_id", me.user_id); initialize_stream_data();