From 97c29bf1fb76516c1adef4313b13c392d9712db1 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 17 Aug 2026 15:14:42 +0200 Subject: [PATCH] fix(app): open channels at the unread boundary when the backlog exceeds one page (#1698) --- .../messaging/state/MessagingMessages.ts | 19 +++++++- .../navigation/state/NavigationSideEffects.ts | 2 + .../features/read_state/state/ReadStates.ts | 20 ++++++++ .../read_states/ReadStateUnreadAnchor.test.ts | 46 +++++++++++++++++++ .../read_states/ReadStateUnreadAnchor.ts | 27 +++++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.test.ts create mode 100644 fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.ts diff --git a/fluxer_app/src/features/messaging/state/MessagingMessages.ts b/fluxer_app/src/features/messaging/state/MessagingMessages.ts index 83b779061..d4f792fa4 100644 --- a/fluxer_app/src/features/messaging/state/MessagingMessages.ts +++ b/fluxer_app/src/features/messaging/state/MessagingMessages.ts @@ -11,6 +11,7 @@ import {ChannelMessages} from '@app/features/messaging/state/ChannelMessages'; import type {ReactionEmoji} from '@app/features/messaging/utils/ReactionUtils'; import SelectedChannel from '@app/features/navigation/state/SelectedChannel'; import SelectedGuild from '@app/features/navigation/state/SelectedGuild'; +import type {UnreadJumpAnchor} from '@app/features/read_state/state/ReadStates'; import Relationships from '@app/features/relationship/state/Relationships'; import Dimension from '@app/features/ui/state/Dimension'; import Users from '@app/features/user/state/Users'; @@ -347,7 +348,12 @@ class Messages { } @action - handleChannelSelect(action: {guildId?: string; channelId?: string | null; messageId?: string}): boolean { + handleChannelSelect(action: { + guildId?: string; + channelId?: string | null; + messageId?: string; + unreadAnchor?: UnreadJumpAnchor | null; + }): boolean { const channelId = action.channelId ?? action.guildId; if (channelId == null || channelId === ME) { return false; @@ -390,6 +396,17 @@ class Messages { } this.commitMessages(messages.mutate({loadingMore: true})); this.notifyChange(); + const unreadAnchor = action.unreadAnchor ?? null; + if (unreadAnchor != null) { + MessageCommands.jumpToMessage({ + channelId, + messageId: unreadAnchor.messageId, + offset: unreadAnchor.offset, + flash: false, + jumpType: JumpTypes.INSTANT, + }); + return false; + } MessageCommands.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL); return false; } diff --git a/fluxer_app/src/features/navigation/state/NavigationSideEffects.ts b/fluxer_app/src/features/navigation/state/NavigationSideEffects.ts index 08ac7ea44..15ae201b3 100644 --- a/fluxer_app/src/features/navigation/state/NavigationSideEffects.ts +++ b/fluxer_app/src/features/navigation/state/NavigationSideEffects.ts @@ -3,6 +3,7 @@ import Messages from '@app/features/messaging/state/MessagingMessages'; import Navigation from '@app/features/navigation/state/Navigation'; import {Logger} from '@app/features/platform/utils/AppLogger'; +import ReadStates from '@app/features/read_state/state/ReadStates'; import Notification from '@app/features/ui/state/Notification'; import {reaction} from 'mobx'; @@ -40,6 +41,7 @@ class NavigationSideEffects { guildId: guildId ?? undefined, channelId, messageId: messageId ?? undefined, + unreadAnchor: ReadStates.getUnreadJumpAnchor(channelId), }); Notification.handleChannelSelect({channelId}); } diff --git a/fluxer_app/src/features/read_state/state/ReadStates.ts b/fluxer_app/src/features/read_state/state/ReadStates.ts index 769e689c8..21d0bbe22 100644 --- a/fluxer_app/src/features/read_state/state/ReadStates.ts +++ b/fluxer_app/src/features/read_state/state/ReadStates.ts @@ -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(); private readonly archivedStates = new Map(); @@ -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 { return Array.from(this.states.keys()); } diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.test.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.test.ts new file mode 100644 index 000000000..2b020c345 --- /dev/null +++ b/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.test.ts @@ -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 { + 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}); + }); +}); diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.ts new file mode 100644 index 000000000..b7c4433b5 --- /dev/null +++ b/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.ts @@ -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}; +}