From b81bfc80fd1a71d7151b423a0b4dc074da27477d Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:41:03 +0200 Subject: [PATCH] fix(app): stop the inbox reshuffling unread channels as you read them (#1894) --- .../floating/UnreadChannelOrder.test.ts | 42 ++---- .../components/floating/UnreadChannelOrder.ts | 69 +++++++-- .../floating/UnreadChannelsContent.module.css | 10 ++ .../floating/UnreadChannelsContent.tsx | 138 +++++++++++------- 4 files changed, 168 insertions(+), 91 deletions(-) diff --git a/fluxer_app/src/features/app/components/floating/UnreadChannelOrder.test.ts b/fluxer_app/src/features/app/components/floating/UnreadChannelOrder.test.ts index bf9578323..bfd808eed 100644 --- a/fluxer_app/src/features/app/components/floating/UnreadChannelOrder.test.ts +++ b/fluxer_app/src/features/app/components/floating/UnreadChannelOrder.test.ts @@ -1,49 +1,34 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {describe, expect, it} from 'vitest'; -import {mergeFrozenUnreadOrder} from './UnreadChannelOrder'; +import {selectFrozenUnreadChannels} from './UnreadChannelOrder'; function ids(channels: ReadonlyArray<{id: string}>): Array { return channels.map((channel) => channel.id); } -describe('mergeFrozenUnreadOrder', () => { +describe('selectFrozenUnreadChannels', () => { it('keeps channels in their frozen position while the list is open', () => { const order = new Map([ ['a', 0], ['b', 1], ['c', 2], ]); - const merged = mergeFrozenUnreadOrder(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]); - expect(ids(merged)).toEqual(['a', 'b', 'c']); + expect(ids(selectFrozenUnreadChannels(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]))).toEqual(['a', 'b', 'c']); }); - it('shows a channel that becomes unread after the order was frozen', () => { + it('hides a channel that becomes unread after the order was frozen', () => { const order = new Map([['a', 0]]); - const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]); - expect(ids(merged)).toEqual(['a', 'late']); + expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}, {id: 'late'}]))).toEqual(['a']); }); - it('appends late arrivals after existing entries rather than reshuffling', () => { - const order = new Map([ - ['a', 0], - ['b', 1], - ]); - const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'b'}, {id: 'a'}]); - expect(ids(merged)).toEqual(['a', 'b', 'late']); - }); - - it('remembers a late arrival position once assigned', () => { + it('never grows the list when every live channel is new to the order', () => { const order = new Map([['a', 0]]); - mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]); - const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'a'}]); - expect(ids(merged)).toEqual(['a', 'late']); + expect(ids(selectFrozenUnreadChannels(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]))).toEqual([]); }); - it('drops nothing when every channel is new', () => { - const order = new Map(); - const merged = mergeFrozenUnreadOrder(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]); - expect(ids(merged)).toEqual(['x', 'y', 'z']); + it('yields nothing for an empty order', () => { + expect(ids(selectFrozenUnreadChannels(new Map(), [{id: 'x'}]))).toEqual([]); }); it('does not resurrect channels that are no longer unread', () => { @@ -51,7 +36,12 @@ describe('mergeFrozenUnreadOrder', () => { ['a', 0], ['gone', 1], ]); - const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}]); - expect(ids(merged)).toEqual(['a']); + expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}]))).toEqual(['a']); + }); + + it('does not mutate the order it was given', () => { + const order = new Map([['a', 0]]); + selectFrozenUnreadChannels(order, [{id: 'a'}, {id: 'late'}]); + expect([...order.keys()]).toEqual(['a']); }); }); diff --git a/fluxer_app/src/features/app/components/floating/UnreadChannelOrder.ts b/fluxer_app/src/features/app/components/floating/UnreadChannelOrder.ts index af7378b71..a6684c4e1 100644 --- a/fluxer_app/src/features/app/components/floating/UnreadChannelOrder.ts +++ b/fluxer_app/src/features/app/components/floating/UnreadChannelOrder.ts @@ -1,21 +1,62 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -function resolveUnreadOrderIndex(order: Map, channelId: string): number { - const index = order.get(channelId); - if (index == null) { - return Number.POSITIVE_INFINITY; - } - return index; +import {useEffect, useMemo, useRef, useState} from 'react'; + +export const UNREAD_REFREEZE_COOLDOWN_MS = 10_000; + +export interface FrozenUnreadOrder { + order: Map; } -export function mergeFrozenUnreadOrder( - order: Map, - current: ReadonlyArray, +export function selectFrozenUnreadChannels( + order: ReadonlyMap, + live: ReadonlyArray, ): Array { - for (const channel of current) { - if (!order.has(channel.id)) { - order.set(channel.id, order.size); - } + const held: Array<{index: number; channel: T}> = []; + for (const channel of live) { + const index = order.get(channel.id); + if (index == null) continue; + held.push({index, channel}); } - return [...current].sort((a, b) => resolveUnreadOrderIndex(order, a.id) - resolveUnreadOrderIndex(order, b.id)); + held.sort((a, b) => a.index - b.index); + return held.map((entry) => entry.channel); +} + +export function useFrozenUnreadOrder( + takeSnapshot: () => S, + live: ReadonlyArray, +): {snapshot: S; channels: Array} { + const [snapshot, setSnapshot] = useState(takeSnapshot); + const [cooldownAttempt, setCooldownAttempt] = useState(0); + const snapshotTakenAtRef = useRef(Date.now()); + const hasShownFrozenListRef = useRef(false); + const channels = useMemo(() => selectFrozenUnreadChannels(snapshot.order, live), [snapshot, live]); + useEffect(() => { + if (channels.length > 0) { + hasShownFrozenListRef.current = true; + return undefined; + } + const next = takeSnapshot(); + if (next.order.size === 0) { + if (snapshot.order.size > 0) { + setSnapshot(next); + } + return undefined; + } + if (hasShownFrozenListRef.current) { + const heldFor = Date.now() - snapshotTakenAtRef.current; + if (heldFor < UNREAD_REFREEZE_COOLDOWN_MS) { + const timer = setTimeout( + () => setCooldownAttempt((attempt) => attempt + 1), + UNREAD_REFREEZE_COOLDOWN_MS - heldFor, + ); + return () => clearTimeout(timer); + } + } + snapshotTakenAtRef.current = Date.now(); + hasShownFrozenListRef.current = false; + setSnapshot(next); + return undefined; + }, [channels, snapshot, cooldownAttempt]); + return {snapshot, channels}; } diff --git a/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.module.css b/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.module.css index 9c1d1f75a..a0867dbe9 100644 --- a/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.module.css +++ b/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.module.css @@ -9,12 +9,20 @@ --inbox-icon-button-radius: 0.375rem; --inbox-collapse-button-size: 1.375rem; --inbox-collapse-button-radius: 0.375rem; + --inbox-sticky-header-height: 3.5rem; flex: 1; min-height: 0; min-width: 0; padding: 0 var(--inbox-scroller-padding-x) var(--inbox-scroller-padding-bottom); } +.stickyChannelHeader { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--form-surface-background); +} + .guildGroup { min-width: 0; } @@ -100,6 +108,8 @@ .messageRow { position: relative; + isolation: isolate; + scroll-margin-top: var(--inbox-sticky-header-height); } .messageActions { diff --git a/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx b/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx index 8ddac9c13..14deffa17 100644 --- a/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx +++ b/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import Accessibility from '@app/features/accessibility/state/Accessibility'; -import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder'; +import {useFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder'; import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css'; import { BULK_PREVIEW_CHANNEL_BATCH_SIZE, @@ -27,6 +27,7 @@ import {ensureMembersForMessages} from '@app/features/messaging/commands/Message import {InboxMessageHeader} from '@app/features/messaging/components/popouts/InboxMessageHeader'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; +import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport'; import {Message} from '@app/features/messaging/models/MessagingMessage'; import {ChannelMessages} from '@app/features/messaging/state/ChannelMessages'; import {createChannelStream} from '@app/features/messaging/utils/MessageGroupingUtils'; @@ -136,6 +137,29 @@ interface ChannelPreviewData { const INITIAL_VISIBLE_CHANNELS = 10; const LOAD_MORE_CHUNK = 10; +interface FrozenUnreadCounts { + mentionCount: number; + unreadCount: number; +} + +interface FrozenUnreadChannels { + order: Map; + counts: Map; +} + +function freezeUnreadChannels(): FrozenUnreadChannels { + const order = new Map(); + const counts = new Map(); + for (const [index, channel] of getUnreadChannels().entries()) { + order.set(channel.id, index); + counts.set(channel.id, { + mentionCount: ReadStates.getMentionCount(channel.id), + unreadCount: ReadStates.getUnreadCount(channel.id), + }); + } + return {order, counts}; +} + interface CacheEntry { cacheKey: string; data: ChannelPreviewData; @@ -350,22 +374,24 @@ const UnreadChannelCard = observer(function UnreadChannelCard({ channel, headingId, previewData, + mentionCount, + unreadCount, }: { channel: Channel; headingId: string; previewData: ChannelPreviewData | null; + mentionCount: number; + unreadCount: number; }) { const {i18n} = useLingui(); const messageDisplayCompact = UserSettings.getMessageDisplayCompact(); const messageGroupSpacing = Accessibility.getMessageGroupSpacingValue(messageDisplayCompact); - const mentionCount = ReadStates.getMentionCount(channel.id); const isCollapsed = UnreadChannels.isCollapsed(channel.id); const isMuted = UserGuildSettings.isMutedAtAnyLevel(channel.guildId ?? null, channel.id); const previewMessages = previewData?.messages ?? []; const oldestUnreadMessageId = previewData?.oldestUnreadMessageId ?? null; - const totalUnreadCount = ReadStates.getUnreadCount(channel.id); const hasMoreUnreadThanShown = - oldestUnreadMessageId != null && totalUnreadCount > 0 && totalUnreadCount > previewMessages.length; + oldestUnreadMessageId != null && unreadCount > 0 && unreadCount > previewMessages.length; const spammerOverrideVersion = LocalUserSpamOverride.version; useEffect(() => { if (previewMessages.length === 0) return; @@ -497,6 +523,7 @@ const UnreadChannelCard = observer(function UnreadChannelCard({ (null); + const resolveInboxScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []); const readStateVersion = ReadStates.version; const settingsVersion = UserGuildSettings.version; - const frozenOrderRef = useRef | null>(null); - if (frozenOrderRef.current === null) { - frozenOrderRef.current = new Map(getUnreadChannels().map((channel, index) => [channel.id, index])); - } - const allUnreadChannels = useMemo(() => { - const order = frozenOrderRef.current; - if (order == null) { - return []; - } - return mergeFrozenUnreadOrder(order, getUnreadChannels()); - }, [readStateVersion, settingsVersion]); + const liveUnreadChannels = useMemo(() => getUnreadChannels(), [readStateVersion, settingsVersion]); + const {snapshot: frozenUnread, channels: allUnreadChannels} = useFrozenUnreadOrder( + freezeUnreadChannels, + liveUnreadChannels, + ); const [loadedCount, setLoadedCount] = useState(INITIAL_VISIBLE_CHANNELS); const visibleChannels = useMemo(() => allUnreadChannels.slice(0, loadedCount), [allUnreadChannels, loadedCount]); const channelPreviews = useBulkChannelPreviews(visibleChannels); @@ -697,19 +719,29 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() { return groups; }, [visibleChannels, channelPreviews, i18n.locale]); const unreadRows = useMemo(() => { - const rows: Array<{channel: Channel; endsGroup: boolean; groupLabel: string; key: string}> = []; + const rows: Array<{ + channel: Channel; + endsGroup: boolean; + groupLabel: string; + key: string; + mentionCount: number; + unreadCount: number; + }> = []; for (const group of groupedChannels) { for (const [index, channel] of group.channels.entries()) { + const counts = frozenUnread.counts.get(channel.id); rows.push({ channel, endsGroup: index === group.channels.length - 1, groupLabel: group.label, key: channel.id, + mentionCount: counts?.mentionCount ?? 0, + unreadCount: counts?.unreadCount ?? 0, }); } } return rows; - }, [groupedChannels]); + }, [groupedChannels, frozenUnread]); const hasMore = loadedCount < allUnreadChannels.length; const handleScroll = useCallback( (event: React.UIEvent) => { @@ -780,40 +812,44 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() { ); } return ( - -
- {unreadRows.map((row, rowIndex) => ( -
-
+ +
+ {unreadRows.map((row, rowIndex) => ( +
- -
-
- ))} -
-
+
+ +
+ + ))} + + + ); });