fix(app): open channels at the unread boundary when the backlog exceeds one page (#1698)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-17 15:14:42 +02:00
1 parent 3ff7189566
commit 97c29bf1fb
5 files changed
+113 -1

No files matched your search

@@ -11,6 +11,10 @@ import {resolveReadStateAckDecision} from '@app/features/read_state/state/read_s
import {ReadStateEntry} from '@app/features/read_state/state/read_states/ReadStateEntry';
import {resolveReadStateIncomingMessageDecision} from '@app/features/read_state/state/read_states/ReadStateIncomingMessageMachine';
import {resolveReadStateServerAckDecision} from '@app/features/read_state/state/read_states/ReadStateServerAckMachine';
import {
resolveUnreadJumpAnchor,
type UnreadJumpAnchor,
} from '@app/features/read_state/state/read_states/ReadStateUnreadAnchor';
import {
ACK_BATCH_DELAY_MS,
ACK_BATCH_SIZE,
@@ -48,6 +52,8 @@ export type {GatewayReadState};
const logger = new Logger('ReadStates');
export type {UnreadJumpAnchor};
class ReadStates {
private readonly states = new Map<ChannelId, ReadStateEntry>();
private readonly archivedStates = new Map<ChannelId, ArchivedReadState>();
@@ -345,6 +351,20 @@ class ReadStates {
return state?.canTrackUnreads() ? state.visualUnreadMessageId : null;
}
getUnreadJumpAnchor(channelId: string): UnreadJumpAnchor | null {
const state = this.getIfExists(channelId);
if (state == null) {
return null;
}
return resolveUnreadJumpAnchor({
canBeUnread: state.canBeUnread(),
canTrackUnreads: state.canTrackUnreads(),
hasUnread: state.hasUnread(),
oldestUnreadMessageId: state.oldestUnreadMessageId,
ackMessageId: state.ackMessageId,
});
}
getChannelIds(): Array<ChannelId> {
return Array.from(this.states.keys());
}
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {resolveUnreadJumpAnchor, type UnreadJumpAnchorInput} from './ReadStateUnreadAnchor';
function input(overrides: Partial<UnreadJumpAnchorInput> = {}): UnreadJumpAnchorInput {
return {
canBeUnread: true,
canTrackUnreads: true,
hasUnread: true,
oldestUnreadMessageId: '900',
ackMessageId: '100',
...overrides,
};
}
describe('resolveUnreadJumpAnchor', () => {
it('anchors on the oldest unread message when it is known', () => {
expect(resolveUnreadJumpAnchor(input())).toEqual({messageId: '900', offset: 0});
});
it('anchors just after the ack when the oldest unread message is unknown', () => {
expect(resolveUnreadJumpAnchor(input({oldestUnreadMessageId: null}))).toEqual({messageId: '100', offset: 1});
});
it('returns no anchor when the channel has nothing unread', () => {
expect(resolveUnreadJumpAnchor(input({hasUnread: false}))).toBeNull();
});
it('returns no anchor when the channel cannot be unread', () => {
expect(resolveUnreadJumpAnchor(input({canBeUnread: false}))).toBeNull();
});
it('returns no anchor when unreads are not trackable for the channel', () => {
expect(resolveUnreadJumpAnchor(input({canTrackUnreads: false}))).toBeNull();
});
it('returns no anchor when neither an unread boundary nor an ack exists', () => {
expect(resolveUnreadJumpAnchor(input({oldestUnreadMessageId: null, ackMessageId: null}))).toBeNull();
});
it('anchors on the unread boundary regardless of how far behind the ack is', () => {
const anchor = resolveUnreadJumpAnchor(input({ackMessageId: '1', oldestUnreadMessageId: '2'}));
expect(anchor).toEqual({messageId: '2', offset: 0});
});
});
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface UnreadJumpAnchorInput {
canBeUnread: boolean;
canTrackUnreads: boolean;
hasUnread: boolean;
oldestUnreadMessageId: string | null;
ackMessageId: string | null;
}
export interface UnreadJumpAnchor {
messageId: string;
offset: number;
}
export function resolveUnreadJumpAnchor(input: UnreadJumpAnchorInput): UnreadJumpAnchor | null {
if (!input.canBeUnread || !input.canTrackUnreads || !input.hasUnread) {
return null;
}
if (input.oldestUnreadMessageId != null) {
return {messageId: input.oldestUnreadMessageId, offset: 0};
}
if (input.ackMessageId == null) {
return null;
}
return {messageId: input.ackMessageId, offset: 1};
}