fix(app): stop the inbox reshuffling unread channels as you read them (#1894)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:41:03 +02:00
1 parent d8bad50eec
commit b81bfc80fd
4 files changed
+133 -56

No files matched your search

@@ -1,49 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {mergeFrozenUnreadOrder} from './UnreadChannelOrder'; import {selectFrozenUnreadChannels} from './UnreadChannelOrder';
function ids(channels: ReadonlyArray<{id: string}>): Array<string> { function ids(channels: ReadonlyArray<{id: string}>): Array<string> {
return channels.map((channel) => channel.id); return channels.map((channel) => channel.id);
} }
describe('mergeFrozenUnreadOrder', () => { describe('selectFrozenUnreadChannels', () => {
it('keeps channels in their frozen position while the list is open', () => { it('keeps channels in their frozen position while the list is open', () => {
const order = new Map([ const order = new Map([
['a', 0], ['a', 0],
['b', 1], ['b', 1],
['c', 2], ['c', 2],
]); ]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]); expect(ids(selectFrozenUnreadChannels(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]))).toEqual(['a', 'b', 'c']);
expect(ids(merged)).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 order = new Map([['a', 0]]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]); expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}, {id: 'late'}]))).toEqual(['a']);
expect(ids(merged)).toEqual(['a', 'late']);
}); });
it('appends late arrivals after existing entries rather than reshuffling', () => { it('never grows the list when every live channel is new to the order', () => {
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', () => {
const order = new Map([['a', 0]]); const order = new Map([['a', 0]]);
mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]); expect(ids(selectFrozenUnreadChannels(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]))).toEqual([]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'a'}]);
expect(ids(merged)).toEqual(['a', 'late']);
}); });
it('drops nothing when every channel is new', () => { it('yields nothing for an empty order', () => {
const order = new Map<string, number>(); expect(ids(selectFrozenUnreadChannels(new Map<string, number>(), [{id: 'x'}]))).toEqual([]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]);
expect(ids(merged)).toEqual(['x', 'y', 'z']);
}); });
it('does not resurrect channels that are no longer unread', () => { it('does not resurrect channels that are no longer unread', () => {
@@ -51,7 +36,12 @@ describe('mergeFrozenUnreadOrder', () => {
['a', 0], ['a', 0],
['gone', 1], ['gone', 1],
]); ]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}]); expect(ids(selectFrozenUnreadChannels(order, [{id: 'a'}]))).toEqual(['a']);
expect(ids(merged)).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']);
}); });
}); });
@@ -1,21 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
function resolveUnreadOrderIndex(order: Map<string, number>, channelId: string): number { import {useEffect, useMemo, useRef, useState} from 'react';
const index = order.get(channelId);
if (index == null) { export const UNREAD_REFREEZE_COOLDOWN_MS = 10_000;
return Number.POSITIVE_INFINITY;
} export interface FrozenUnreadOrder {
return index; order: Map<string, number>;
} }
export function mergeFrozenUnreadOrder<T extends {id: string}>( export function selectFrozenUnreadChannels<T extends {id: string}>(
order: Map<string, number>, order: ReadonlyMap<string, number>,
current: ReadonlyArray<T>, live: ReadonlyArray<T>,
): Array<T> { ): Array<T> {
for (const channel of current) { const held: Array<{index: number; channel: T}> = [];
if (!order.has(channel.id)) { for (const channel of live) {
order.set(channel.id, order.size); const index = order.get(channel.id);
if (index == null) continue;
held.push({index, channel});
} }
held.sort((a, b) => a.index - b.index);
return held.map((entry) => entry.channel);
}
export function useFrozenUnreadOrder<S extends FrozenUnreadOrder, T extends {id: string}>(
takeSnapshot: () => S,
live: ReadonlyArray<T>,
): {snapshot: S; channels: Array<T>} {
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;
} }
return [...current].sort((a, b) => resolveUnreadOrderIndex(order, a.id) - resolveUnreadOrderIndex(order, b.id)); 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};
} }
@@ -9,12 +9,20 @@
--inbox-icon-button-radius: 0.375rem; --inbox-icon-button-radius: 0.375rem;
--inbox-collapse-button-size: 1.375rem; --inbox-collapse-button-size: 1.375rem;
--inbox-collapse-button-radius: 0.375rem; --inbox-collapse-button-radius: 0.375rem;
--inbox-sticky-header-height: 3.5rem;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
padding: 0 var(--inbox-scroller-padding-x) var(--inbox-scroller-padding-bottom); 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 { .guildGroup {
min-width: 0; min-width: 0;
} }
@@ -100,6 +108,8 @@
.messageRow { .messageRow {
position: relative; position: relative;
isolation: isolate;
scroll-margin-top: var(--inbox-sticky-header-height);
} }
.messageActions { .messageActions {
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility'; 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 styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
import { import {
BULK_PREVIEW_CHANNEL_BATCH_SIZE, 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 {InboxMessageHeader} from '@app/features/messaging/components/popouts/InboxMessageHeader';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; 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 {Message} from '@app/features/messaging/models/MessagingMessage';
import {ChannelMessages} from '@app/features/messaging/state/ChannelMessages'; import {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
import {createChannelStream} from '@app/features/messaging/utils/MessageGroupingUtils'; import {createChannelStream} from '@app/features/messaging/utils/MessageGroupingUtils';
@@ -136,6 +137,29 @@ interface ChannelPreviewData {
const INITIAL_VISIBLE_CHANNELS = 10; const INITIAL_VISIBLE_CHANNELS = 10;
const LOAD_MORE_CHUNK = 10; const LOAD_MORE_CHUNK = 10;
interface FrozenUnreadCounts {
mentionCount: number;
unreadCount: number;
}
interface FrozenUnreadChannels {
order: Map<string, number>;
counts: Map<string, FrozenUnreadCounts>;
}
function freezeUnreadChannels(): FrozenUnreadChannels {
const order = new Map<string, number>();
const counts = new Map<string, FrozenUnreadCounts>();
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 { interface CacheEntry {
cacheKey: string; cacheKey: string;
data: ChannelPreviewData; data: ChannelPreviewData;
@@ -350,22 +374,24 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
channel, channel,
headingId, headingId,
previewData, previewData,
mentionCount,
unreadCount,
}: { }: {
channel: Channel; channel: Channel;
headingId: string; headingId: string;
previewData: ChannelPreviewData | null; previewData: ChannelPreviewData | null;
mentionCount: number;
unreadCount: number;
}) { }) {
const {i18n} = useLingui(); const {i18n} = useLingui();
const messageDisplayCompact = UserSettings.getMessageDisplayCompact(); const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
const messageGroupSpacing = Accessibility.getMessageGroupSpacingValue(messageDisplayCompact); const messageGroupSpacing = Accessibility.getMessageGroupSpacingValue(messageDisplayCompact);
const mentionCount = ReadStates.getMentionCount(channel.id);
const isCollapsed = UnreadChannels.isCollapsed(channel.id); const isCollapsed = UnreadChannels.isCollapsed(channel.id);
const isMuted = UserGuildSettings.isMutedAtAnyLevel(channel.guildId ?? null, channel.id); const isMuted = UserGuildSettings.isMutedAtAnyLevel(channel.guildId ?? null, channel.id);
const previewMessages = previewData?.messages ?? []; const previewMessages = previewData?.messages ?? [];
const oldestUnreadMessageId = previewData?.oldestUnreadMessageId ?? null; const oldestUnreadMessageId = previewData?.oldestUnreadMessageId ?? null;
const totalUnreadCount = ReadStates.getUnreadCount(channel.id);
const hasMoreUnreadThanShown = const hasMoreUnreadThanShown =
oldestUnreadMessageId != null && totalUnreadCount > 0 && totalUnreadCount > previewMessages.length; oldestUnreadMessageId != null && unreadCount > 0 && unreadCount > previewMessages.length;
const spammerOverrideVersion = LocalUserSpamOverride.version; const spammerOverrideVersion = LocalUserSpamOverride.version;
useEffect(() => { useEffect(() => {
if (previewMessages.length === 0) return; if (previewMessages.length === 0) return;
@@ -497,6 +523,7 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
</h2> </h2>
<InboxMessageHeader <InboxMessageHeader
channel={channel} channel={channel}
className={styles.stickyChannelHeader}
onClick={handleHeaderClick} onClick={handleHeaderClick}
mentionCount={mentionCount} mentionCount={mentionCount}
leftAdornment={ leftAdornment={
@@ -648,19 +675,14 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
export const UnreadChannelsContent = observer(function UnreadChannelsContent() { export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
const {i18n} = useLingui(); const {i18n} = useLingui();
const scrollerRef = useRef<ScrollerHandle | null>(null); const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveInboxScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const readStateVersion = ReadStates.version; const readStateVersion = ReadStates.version;
const settingsVersion = UserGuildSettings.version; const settingsVersion = UserGuildSettings.version;
const frozenOrderRef = useRef<Map<string, number> | null>(null); const liveUnreadChannels = useMemo(() => getUnreadChannels(), [readStateVersion, settingsVersion]);
if (frozenOrderRef.current === null) { const {snapshot: frozenUnread, channels: allUnreadChannels} = useFrozenUnreadOrder(
frozenOrderRef.current = new Map(getUnreadChannels().map((channel, index) => [channel.id, index])); freezeUnreadChannels,
} liveUnreadChannels,
const allUnreadChannels = useMemo(() => { );
const order = frozenOrderRef.current;
if (order == null) {
return [];
}
return mergeFrozenUnreadOrder(order, getUnreadChannels());
}, [readStateVersion, settingsVersion]);
const [loadedCount, setLoadedCount] = useState(INITIAL_VISIBLE_CHANNELS); const [loadedCount, setLoadedCount] = useState(INITIAL_VISIBLE_CHANNELS);
const visibleChannels = useMemo(() => allUnreadChannels.slice(0, loadedCount), [allUnreadChannels, loadedCount]); const visibleChannels = useMemo(() => allUnreadChannels.slice(0, loadedCount), [allUnreadChannels, loadedCount]);
const channelPreviews = useBulkChannelPreviews(visibleChannels); const channelPreviews = useBulkChannelPreviews(visibleChannels);
@@ -697,19 +719,29 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
return groups; return groups;
}, [visibleChannels, channelPreviews, i18n.locale]); }, [visibleChannels, channelPreviews, i18n.locale]);
const unreadRows = useMemo(() => { 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 group of groupedChannels) {
for (const [index, channel] of group.channels.entries()) { for (const [index, channel] of group.channels.entries()) {
const counts = frozenUnread.counts.get(channel.id);
rows.push({ rows.push({
channel, channel,
endsGroup: index === group.channels.length - 1, endsGroup: index === group.channels.length - 1,
groupLabel: group.label, groupLabel: group.label,
key: channel.id, key: channel.id,
mentionCount: counts?.mentionCount ?? 0,
unreadCount: counts?.unreadCount ?? 0,
}); });
} }
} }
return rows; return rows;
}, [groupedChannels]); }, [groupedChannels, frozenUnread]);
const hasMore = loadedCount < allUnreadChannels.length; const hasMore = loadedCount < allUnreadChannels.length;
const handleScroll = useCallback( const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => { (event: React.UIEvent<HTMLDivElement>) => {
@@ -780,6 +812,7 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
); );
} }
return ( return (
<NearViewportSurfaceContext.Provider value={resolveInboxScrollSurface}>
<Scroller <Scroller
key="unread-channels-scroller" key="unread-channels-scroller"
className={styles.scroller} className={styles.scroller}
@@ -808,6 +841,8 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
channel={row.channel} channel={row.channel}
headingId={`inbox-unread-channel-${row.channel.id}`} headingId={`inbox-unread-channel-${row.channel.id}`}
previewData={channelPreviews.get(row.channel.id) ?? null} previewData={channelPreviews.get(row.channel.id) ?? null}
mentionCount={row.mentionCount}
unreadCount={row.unreadCount}
data-flx="app.floating.unread-channels-content.unread-channel-card" data-flx="app.floating.unread-channels-content.unread-channel-card"
/> />
</section> </section>
@@ -815,5 +850,6 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
))} ))}
</div> </div>
</Scroller> </Scroller>
</NearViewportSurfaceContext.Provider>
); );
}); });