mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): stop the inbox reshuffling unread channels as you read them (#1894)
This commit is contained in:
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>
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user