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
+168 -91

No files matched your search

@@ -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<string> {
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<string, number>();
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<string, number>(), [{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']);
});
});
@@ -1,21 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
function resolveUnreadOrderIndex(order: Map<string, number>, 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<string, number>;
}
export function mergeFrozenUnreadOrder<T extends {id: string}>(
order: Map<string, number>,
current: ReadonlyArray<T>,
export function selectFrozenUnreadChannels<T extends {id: string}>(
order: ReadonlyMap<string, number>,
live: ReadonlyArray<T>,
): Array<T> {
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<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;
}
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-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 {
@@ -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<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 {
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({
</h2>
<InboxMessageHeader
channel={channel}
className={styles.stickyChannelHeader}
onClick={handleHeaderClick}
mentionCount={mentionCount}
leftAdornment={
@@ -648,19 +675,14 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
const {i18n} = useLingui();
const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveInboxScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const readStateVersion = ReadStates.version;
const settingsVersion = UserGuildSettings.version;
const frozenOrderRef = useRef<Map<string, number> | 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<HTMLDivElement>) => {
@@ -780,40 +812,44 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
);
}
return (
<Scroller
key="unread-channels-scroller"
className={styles.scroller}
ref={scrollerRef}
onScroll={handleScroll}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="app.floating.unread-channels-content.scroller"
>
<div className={styles.list} role="list" data-flx="app.floating.unread-channels-content.list">
{unreadRows.map((row, rowIndex) => (
<div
key={row.key}
className={clsx(styles.row, row.endsGroup && styles.rowGroupEnd)}
role="listitem"
aria-posinset={rowIndex + 1}
aria-setsize={unreadRows.length}
data-flx="app.floating.unread-channels-content.row"
>
<section
className={styles.guildGroup}
aria-label={row.groupLabel}
data-flx="app.floating.unread-channels-content.guild-group"
<NearViewportSurfaceContext.Provider value={resolveInboxScrollSurface}>
<Scroller
key="unread-channels-scroller"
className={styles.scroller}
ref={scrollerRef}
onScroll={handleScroll}
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="app.floating.unread-channels-content.scroller"
>
<div className={styles.list} role="list" data-flx="app.floating.unread-channels-content.list">
{unreadRows.map((row, rowIndex) => (
<div
key={row.key}
className={clsx(styles.row, row.endsGroup && styles.rowGroupEnd)}
role="listitem"
aria-posinset={rowIndex + 1}
aria-setsize={unreadRows.length}
data-flx="app.floating.unread-channels-content.row"
>
<UnreadChannelCard
channel={row.channel}
headingId={`inbox-unread-channel-${row.channel.id}`}
previewData={channelPreviews.get(row.channel.id) ?? null}
data-flx="app.floating.unread-channels-content.unread-channel-card"
/>
</section>
</div>
))}
</div>
</Scroller>
<section
className={styles.guildGroup}
aria-label={row.groupLabel}
data-flx="app.floating.unread-channels-content.guild-group"
>
<UnreadChannelCard
channel={row.channel}
headingId={`inbox-unread-channel-${row.channel.id}`}
previewData={channelPreviews.get(row.channel.id) ?? null}
mentionCount={row.mentionCount}
unreadCount={row.unreadCount}
data-flx="app.floating.unread-channels-content.unread-channel-card"
/>
</section>
</div>
))}
</div>
</Scroller>
</NearViewportSurfaceContext.Provider>
);
});