From e63ee026fe374a677ca8c69892ee803a64d4080e Mon Sep 17 00:00:00 2001 From: Evy Kassirer Date: Tue, 1 Jul 2025 12:05:00 -0700 Subject: [PATCH] left_sidebar: Hide inactive channels in channel folders. --- web/src/left_sidebar_navigation_area.ts | 25 +++++++++ web/src/stream_list.ts | 57 ++++++++++++++++++-- web/src/stream_list_sort.ts | 17 ++++-- web/styles/left_sidebar.css | 69 +++++++++++++++++++++++- web/templates/show_inactive_channels.hbs | 20 +++++++ web/tests/stream_list_sort.test.cjs | 3 ++ 6 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 web/templates/show_inactive_channels.hbs diff --git a/web/src/left_sidebar_navigation_area.ts b/web/src/left_sidebar_navigation_area.ts index 075da59d8e..a76509c14f 100644 --- a/web/src/left_sidebar_navigation_area.ts +++ b/web/src/left_sidebar_navigation_area.ts @@ -88,8 +88,12 @@ function should_mask_header_unread_count( } type SectionUnreadCount = { + // These both include inactive unreads as well. unmuted: number; muted: number; + // These are used for the "+ n inactive channels" button. + inactive_unmuted: number; + inactive_muted: number; }; export function update_dom_with_unread_counts( @@ -110,15 +114,25 @@ export function update_dom_with_unread_counts( const pinned_unread_counts: SectionUnreadCount = { unmuted: 0, muted: 0, + // Not used for the pinned section, but included here to make typing easier + inactive_unmuted: 0, + inactive_muted: 0, }; const folder_unread_counts = new Map(); + // TODO: In an upcoming commit, the normal and inactive sections will be + // merged. For this commit, the normal section has no inactive channels + // and the inactive section has no active channels. const normal_section_unread_counts: SectionUnreadCount = { unmuted: 0, muted: 0, + inactive_unmuted: 0, + inactive_muted: 0, }; const inactive_section_unread_counts: SectionUnreadCount = { unmuted: 0, muted: 0, + inactive_unmuted: 0, + inactive_muted: 0, }; for (const [stream_id, stream_count_info] of counts.stream_count.entries()) { @@ -131,12 +145,18 @@ export function update_dom_with_unread_counts( const unread_counts = folder_unread_counts.get(sub.folder_id) ?? { unmuted: 0, muted: 0, + inactive_unmuted: 0, + inactive_muted: 0, }; if (!folder_unread_counts.has(sub.folder_id)) { folder_unread_counts.set(sub.folder_id, unread_counts); } unread_counts.unmuted += stream_count_info.unmuted_count; unread_counts.muted += stream_count_info.muted_count; + if (!stream_list_sort.has_recent_activity(sub)) { + unread_counts.inactive_unmuted += stream_count_info.unmuted_count; + unread_counts.inactive_muted += stream_count_info.muted_count; + } } else if (stream_list_sort.has_recent_activity(sub)) { normal_section_unread_counts.unmuted += stream_count_info.unmuted_count; normal_section_unread_counts.muted += stream_count_info.muted_count; @@ -186,6 +206,11 @@ export function update_dom_with_unread_counts( unread_counts?.unmuted ?? 0, unread_counts?.muted ?? 0, ); + update_section_unread_count( + $(`#stream-list-${folder_id}-container .show-inactive-channels`), + unread_counts?.inactive_unmuted ?? 0, + unread_counts?.inactive_muted ?? 0, + ); } if (!skip_animations) { diff --git a/web/src/stream_list.ts b/web/src/stream_list.ts index 09e3e9b5dc..36f6047c34 100644 --- a/web/src/stream_list.ts +++ b/web/src/stream_list.ts @@ -6,6 +6,7 @@ import * as tippy from "tippy.js"; import render_filter_topics from "../templates/filter_topics.hbs"; import render_go_to_channel_feed_tooltip from "../templates/go_to_channel_feed_tooltip.hbs"; import render_go_to_channel_list_of_topics_tooltip from "../templates/go_to_channel_list_of_topics_tooltip.hbs"; +import render_show_inactive_channels from "../templates/show_inactive_channels.hbs"; import render_stream_list_section_container from "../templates/stream_list_section_container.hbs"; import render_stream_privacy from "../templates/stream_privacy.hbs"; import render_stream_sidebar_row from "../templates/stream_sidebar_row.hbs"; @@ -60,6 +61,7 @@ export function rewire_stream_cursor(value: typeof stream_cursor): void { let has_scrolled = false; const collapsed_sections = new Set(); +const sections_showing_inactive = new Set(); export function is_zoomed_in(): boolean { return zoomed_in; @@ -305,11 +307,19 @@ export function build_stream_list(force_rerender: boolean): void { return; } - function add_sidebar_li(stream_id: number, $list: JQuery): void { + function add_sidebar_li( + stream_id: number, + $list: JQuery, + inactive_in_channel_folder = false, + ): void { const sidebar_row = stream_sidebar.get_row(stream_id); assert(sidebar_row !== undefined); sidebar_row.update_whether_active(); - $list.append($(sidebar_row.get_li())); + const $li = sidebar_row.get_li(); + if (inactive_in_channel_folder) { + $li.addClass("inactive-in-channel-folder"); + } + $list.append($li); } clear_topics(); @@ -322,17 +332,34 @@ export function build_stream_list(force_rerender: boolean): void { $("#stream_filters").append( $(stream_list_section_container_html(section, can_create_streams)), ); - const is_empty = section.streams.length === 0 && section.muted_streams.length === 0; + const is_empty = + section.streams.length === 0 && + section.muted_streams.length === 0 && + section.inactive_streams.length === 0; $(`#stream-list-${section.id}-container`).toggleClass("no-display", is_empty); for (const stream_id of [...section.streams, ...section.muted_streams]) { add_sidebar_li(stream_id, $(`#stream-list-${section.id}`)); } + // This should only be relevant for folders + for (const stream_id of section.inactive_streams) { + add_sidebar_li(stream_id, $(`#stream-list-${section.id}`), true); + } + if (section.inactive_streams.length > 0) { + $(`#stream-list-${section.id}`).append( + $( + render_show_inactive_channels({ + inactive_count: section.inactive_streams.length, + }), + ), + ); + } } // Rerendering can moving channels between folders and change heading unread counts. left_sidebar_navigation_area.update_dom_with_unread_counts(unread.get_counts(), false); sidebar_ui.update_unread_counts_visibility(); - collapse_collapsed_sections(); + set_sections_states(); + $("#streams_list").toggleClass("is_searching", get_search_term() !== ""); } /* When viewing a channel in a collapsed folder, we show that active @@ -365,7 +392,7 @@ function toggle_section_collapse($container: JQuery): void { maybe_hide_topic_bracket(section_id); } -function collapse_collapsed_sections(): void { +function set_sections_states(): void { for (const section_id of collapsed_sections) { const $container = $(`#stream-list-${section_id}-container`); $container.toggleClass("collapsed", true); @@ -374,6 +401,9 @@ function collapse_collapsed_sections(): void { .toggleClass("rotate-icon-down", false) .toggleClass("rotate-icon-right", true); } + for (const section_id of sections_showing_inactive) { + $(`#stream-list-${section_id}-container`).toggleClass("showing-inactive", true); + } } export function get_stream_li(stream_id: number): JQuery | undefined { @@ -1141,6 +1171,23 @@ export function set_event_handlers({ e.stopPropagation(); }, ); + + $("#streams_list").on( + "click", + ".stream-list-toggle-inactive-channels", + function (this: HTMLElement, e: JQuery.ClickEvent) { + e.stopPropagation(); + const $section_container = $(this).closest(".stream-list-section-container"); + $section_container.toggleClass("showing-inactive"); + const showing_inactive = $section_container.hasClass("showing-inactive"); + const section_id = $section_container.attr("data-section-id")!; + if (showing_inactive) { + sections_showing_inactive.add(section_id); + } else { + sections_showing_inactive.delete(section_id); + } + }, + ); } export function searching(): boolean { diff --git a/web/src/stream_list_sort.ts b/web/src/stream_list_sort.ts index 598d570c5a..ba5f2f2402 100644 --- a/web/src/stream_list_sort.ts +++ b/web/src/stream_list_sort.ts @@ -30,6 +30,7 @@ function current_section_ids_for_streams(): Map { for (const stream_id of [ ...section.streams, ...section.muted_streams, + ...section.inactive_streams, ]) { map.set(stream_id, section); } @@ -94,6 +95,7 @@ export type StreamListSection = { section_title: string; streams: number[]; muted_streams: number[]; // Not used for the inactive section + inactive_streams: number[]; // Only used for folder sections }; type StreamListSortResult = { @@ -121,18 +123,21 @@ export function sort_groups(stream_ids: number[], search_term: string): StreamLi section_title: $t({defaultMessage: "PINNED CHANNELS"}), streams: [], muted_streams: [], + inactive_streams: [], }; const normal_section: StreamListSection = { id: "normal-streams", section_title: $t({defaultMessage: "OTHER CHANNELS"}), streams: [], muted_streams: [], + inactive_streams: [], }; const dormant_section: StreamListSection = { id: "dormant-streams", section_title: $t({defaultMessage: "INACTIVE CHANNELS"}), streams: [], muted_streams: [], // Not used for the dormant section + inactive_streams: [], }; const folder_sections = new Map(); @@ -158,10 +163,13 @@ export function sort_groups(stream_ids: number[], search_term: string): StreamLi section_title: folder.name.toUpperCase(), streams: [], muted_streams: [], + inactive_streams: [], }; folder_sections.set(sub.folder_id, section); } - if (sub.is_muted) { + if (!has_recent_activity(sub)) { + section.inactive_streams.push(stream_id); + } else if (sub.is_muted) { section.muted_streams.push(stream_id); } else { section.streams.push(stream_id); @@ -196,6 +204,7 @@ export function sort_groups(stream_ids: number[], search_term: string): StreamLi for (const section of new_sections) { section.streams.sort(compare_function); section.muted_streams.sort(compare_function); + section.inactive_streams.sort(compare_function); } const same_as_before = @@ -207,16 +216,18 @@ export function sort_groups(stream_ids: number[], search_term: string): StreamLi new_section.id === current_section.id && new_section.section_title === current_section.section_title && util.array_compare(new_section.streams, current_section.streams) && - util.array_compare(new_section.muted_streams, current_section.muted_streams) + util.array_compare(new_section.muted_streams, current_section.muted_streams) && + util.array_compare(new_section.inactive_streams, current_section.inactive_streams) ); }); if (!same_as_before) { first_render_completed = true; current_sections = new_sections; - all_streams = new_sections.flatMap((section) => [ + all_streams = current_sections.flatMap((section) => [ ...section.streams, ...section.muted_streams, + ...section.inactive_streams, ]); } diff --git a/web/styles/left_sidebar.css b/web/styles/left_sidebar.css index ae1407e482..e5235e6b7d 100644 --- a/web/styles/left_sidebar.css +++ b/web/styles/left_sidebar.css @@ -88,7 +88,8 @@ } } -#left-sidebar-navigation-list .selected-home-view { +#left-sidebar-navigation-list .selected-home-view, +.show-inactive-channels { &.hide-unread-messages-count { .masked_unread_count { display: flex; @@ -108,6 +109,7 @@ } #left-sidebar-navigation-list .selected-home-view:hover, +.stream-list-toggle-inactive-channels:hover .show-inactive-channels, .selected-home-view.top-left-active-filter { &.hide-unread-messages-count { .masked_unread_count { @@ -403,6 +405,67 @@ } } +.stream-list-toggle-inactive-channels { + padding-left: var(--left-sidebar-toggle-width-offset); + + .show-inactive-channels, + .hide-inactive-channels { + /* Override the action heading font size, so that em measurements + on child elements are sized properly */ + font-size: var(--base-font-size-px); + /* We also want the count text to be the normal font style. */ + font-variant: inherit; + padding: 0; + + .stream-list-toggle-inactive-channels-text { + font-size: var(--font-size-sidebar-action-heading); + font-variant: var(--font-variant-sidebar-action-heading); + overflow-x: hidden; + text-overflow: ellipsis; + } + } +} + +.stream-list-section-container:not(.showing-inactive) { + .inactive-in-channel-folder { + display: none; + } + + .stream-list-toggle-inactive-channels { + .hide-inactive-channels { + display: none; + } + } +} + +#streams_list.is_searching { + .show-inactive-channels, + .hide-inactive-channels { + display: none; + } + + .inactive-in-channel-folder { + display: block; + } +} + +.stream-list-section-container.showing-inactive { + .stream-list-toggle-inactive-channels { + .show-inactive-channels { + display: none; + } + } +} + +.show-inactive-channels, +.hide-inactive-channels { + display: grid; + grid-template: + "content markers-and-unreads three-dot-placeholder" auto + / minmax(0, 1fr) minmax(0, max-content) var(--left-sidebar-vdots-width); + align-items: center; +} + .stream-list-section { margin: 0; } @@ -413,6 +476,7 @@ .stream-list-section-container.collapsed { .narrow-filter:not(.stream-expanded), + .stream-list-toggle-inactive-channels, .topic-list-item:not(.active-sub-filter), &.hide-topic-bracket ul.topic-list.topic-list-has-topics::before, &.hide-topic-bracket ul.topic-list.topic-list-has-topics::after { @@ -1545,7 +1609,8 @@ li.top_left_scheduled_messages { .dm-markers-and-unreads, .stream-markers-and-unreads, -.topic-markers-and-unreads { +.topic-markers-and-unreads, +.show-inactive-channels .markers-and-unreads { grid-area: markers-and-unreads; display: flex; /* Present a uniform space between icons */ diff --git a/web/templates/show_inactive_channels.hbs b/web/templates/show_inactive_channels.hbs new file mode 100644 index 0000000000..5ad3a6d829 --- /dev/null +++ b/web/templates/show_inactive_channels.hbs @@ -0,0 +1,20 @@ +
+ + +
diff --git a/web/tests/stream_list_sort.test.cjs b/web/tests/stream_list_sort.test.cjs index 045754bc5c..c41e9045a7 100644 --- a/web/tests/stream_list_sort.test.cjs +++ b/web/tests/stream_list_sort.test.cjs @@ -103,18 +103,21 @@ test("no_subscribed_streams", () => { sections: [ { id: "pinned-streams", + inactive_streams: [], muted_streams: [], section_title: "translated: PINNED CHANNELS", streams: [], }, { id: "normal-streams", + inactive_streams: [], muted_streams: [], section_title: "translated: CHANNELS", streams: [], }, { id: "dormant-streams", + inactive_streams: [], muted_streams: [], section_title: "translated: INACTIVE CHANNELS", streams: [],