From 6d600990fe386b890cabf0cbaec190a351a445ab Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 12 Sep 2026 01:40:36 +0200 Subject: [PATCH] fix(app): derive unread from an ack timestamp floor (#2717) --- .../floating/UnreadChannelsContent.tsx | 11 +- .../channel/components/ChannelMessages.tsx | 68 +-------- fluxer_app/src/features/guild/state/Guilds.ts | 1 + .../messaging/commands/MessageCommands.tsx | 76 ++-------- .../messaging/state/ChannelMessages.ts | 45 ++---- .../ChannelMessagesLoadStateMachine.test.ts | 129 ---------------- .../state/ChannelMessagesLoadStateMachine.ts | 42 ------ .../messaging/state/MessagingMessages.ts | 35 +++-- .../read_state/state/ReadStates.test.ts | 141 ++++++++++++++++-- .../features/read_state/state/ReadStates.ts | 45 ++---- .../state/ReadStatesAckBatching.test.ts | 2 + .../ReadStatesDeletedTailMessage.test.ts | 2 + .../state/read_states/ReadStateEntry.ts | 76 ++++++++-- .../ReadStateEntryStatusMachine.bench.ts | 22 ++- .../ReadStateEntryStatusMachine.test.ts | 10 +- .../ReadStateEntryStatusMachine.ts | 20 +-- .../ReadStateIncomingMessageMachine.test.ts | 16 +- .../ReadStateIncomingMessageMachine.ts | 25 +--- .../read_state/state/read_states/shared.ts | 1 - 19 files changed, 307 insertions(+), 460 deletions(-) diff --git a/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx b/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx index 14deffa17..b907af3b2 100644 --- a/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx +++ b/fluxer_app/src/features/app/components/floating/UnreadChannelsContent.tsx @@ -221,8 +221,15 @@ function buildPreviewFetchPlan(channel: Channel, cacheKey: string): PreviewFetch } const ackMessageId = ReadStates.ackMessageId(channel.id); if (!ackMessageId) { - previewCache.set(channel.id, {cacheKey, data: buildEmptyPreview(channel)}); - return null; + return { + channel, + cacheKey, + oldestUnreadMessageId: null, + request: { + channel_id: channel.id, + limit: UNREAD_PREVIEW_MESSAGE_LIMIT, + }, + }; } return { channel, diff --git a/fluxer_app/src/features/channel/components/ChannelMessages.tsx b/fluxer_app/src/features/channel/components/ChannelMessages.tsx index a853225cc..466ecffe8 100644 --- a/fluxer_app/src/features/channel/components/ChannelMessages.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMessages.tsx @@ -29,8 +29,6 @@ import { resolveChannelMessagesWindowStatus, selectChannelMessagesFillerVisible, selectChannelMessagesSpacerHeight, - selectChannelMessagesTailGapId, - selectChannelMessagesTailProbeId, selectChannelMessagesWindowBar, } from '@app/features/messaging/state/ChannelMessagesLoadStateMachine'; import MessageEdit from '@app/features/messaging/state/MessageEdit'; @@ -68,11 +66,7 @@ import {clsx} from 'clsx'; import {runInAction} from 'mobx'; import {observer, useLocalObservable} from 'mobx-react-lite'; import type React from 'react'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; - -const TAIL_PROBE_MAX_ATTEMPTS = 2; -const TAIL_PROBE_MIN_INTERVAL_MS = 10_000; -const tailProbeAttemptedAt = new Map(); +import {useCallback, useEffect, useMemo, useRef} from 'react'; const MESSAGE_LIST_FOR_DESCRIPTOR = msg({ message: 'Message list for {channelName}', @@ -173,9 +167,6 @@ export const Messages = observer(function Messages({ const scrollerContainerRef = useRef(null); const lastStateSnapshotRef = useRef(null); const recoveryFetchChannelIdRef = useRef(null); - const tailProbeKeyRef = useRef(null); - const tailProbeAttemptsRef = useRef<{key: string; attempts: number} | null>(null); - const [settledTailProbeKey, setSettledTailProbeKey] = useState(null); interface MessageState extends MessagesStateSnapshot { highlightedMessageId: string | null; isAtBottom: boolean; @@ -211,17 +202,6 @@ export const Messages = observer(function Messages({ }); const windowBar = selectChannelMessagesWindowBar(windowStatus); const windowNeedsPage = windowStatus.needsPage; - const tailInput = { - status: windowStatus, - loading: safeMessages.loadingMore || safeMessages.probeLoading, - newestLoadedMessageId: safeMessages.last()?.id ?? null, - knownLatestMessageId: state.lastReadStateMessageId, - }; - const tailWatermarkMessageId = state.lastReadStateMessageId; - const tailGapMessageId = selectChannelMessagesTailGapId(tailInput); - const tailProbeMessageId = selectChannelMessagesTailProbeId(tailInput); - const tailGapKey = tailGapMessageId == null ? null : `${tailGapMessageId}:${tailWatermarkMessageId}`; - const tailProbeKey = tailProbeMessageId == null ? null : `${tailProbeMessageId}:${tailWatermarkMessageId}`; const canAutoAck = shouldAutoAck({ channelActive: allowAutoAck, windowFocused: isWindowFocused, @@ -474,47 +454,6 @@ export const Messages = observer(function Messages({ } }); }, [channel.id, isGatewayConnected, selectedChannelId, windowNeedsPage, state.messageVersion]); - useEffect(() => { - if (!isGatewayConnected) { - tailProbeKeyRef.current = null; - return; - } - if (tailProbeMessageId == null || tailProbeKey == null || tailWatermarkMessageId == null) { - return; - } - if (selectedChannelId !== channel.id || tailProbeKeyRef.current === tailProbeKey) { - return; - } - const lastAttemptAt = tailProbeAttemptedAt.get(tailProbeKey); - if (lastAttemptAt != null && Date.now() - lastAttemptAt < TAIL_PROBE_MIN_INTERVAL_MS) { - return; - } - tailProbeKeyRef.current = tailProbeKey; - tailProbeAttemptedAt.set(tailProbeKey, Date.now()); - void MessageCommands.fetchMessages(channel.id, null, tailProbeMessageId, MAX_MESSAGES_PER_CHANNEL, undefined, { - tailProbe: { - watermarkMessageId: tailWatermarkMessageId, - onSettled: (settlement) => { - if (settlement === 'applied') { - setSettledTailProbeKey(tailProbeKey); - return; - } - if (settlement === 'failed') { - const attempts = - tailProbeAttemptsRef.current?.key === tailProbeKey ? tailProbeAttemptsRef.current.attempts : 0; - if (attempts >= TAIL_PROBE_MAX_ATTEMPTS) { - setSettledTailProbeKey(tailProbeKey); - return; - } - tailProbeAttemptsRef.current = {key: tailProbeKey, attempts: attempts + 1}; - } - if (tailProbeKeyRef.current === tailProbeKey) { - tailProbeKeyRef.current = null; - } - }, - }, - }); - }, [channel.id, isGatewayConnected, selectedChannelId, tailProbeKey, tailProbeMessageId, tailWatermarkMessageId]); useMessageListKeyboardNavigation({ containerRef: scrollManager.ref, channelId: channel.id, @@ -548,12 +487,11 @@ export const Messages = observer(function Messages({ }; }, []); useEffect(() => { - if (!canAutoAck || !state.isAtBottom || !state.messages?.ready) return; - if (tailGapKey != null && settledTailProbeKey !== tailGapKey) return; + if (!canAutoAck || !state.isAtBottom || !state.messages?.ready || state.messages.loadingMore) return; if (ReadStates.hasUnread(channel.id)) { ReadStateCommands.ackWithStickyUnread(channel.id); } - }, [canAutoAck, state.isAtBottom, state.messages?.ready, tailGapKey, settledTailProbeKey, channel.id]); + }, [canAutoAck, state.isAtBottom, state.messages?.ready, state.messages?.loadingMore, channel.id]); useEffect(() => { return () => { const readState = ReadStates.getIfExists(channel.id); diff --git a/fluxer_app/src/features/guild/state/Guilds.ts b/fluxer_app/src/features/guild/state/Guilds.ts index 19d6bbb28..16a5a6d17 100644 --- a/fluxer_app/src/features/guild/state/Guilds.ts +++ b/fluxer_app/src/features/guild/state/Guilds.ts @@ -70,6 +70,7 @@ class Guilds { } this.guilds[guild.id] = new Guild({ ...guild, + joined_at: guild.joined_at ?? existingGuild.joinedAt ?? undefined, roles: existingGuild.roles, }); } diff --git a/fluxer_app/src/features/messaging/commands/MessageCommands.tsx b/fluxer_app/src/features/messaging/commands/MessageCommands.tsx index 7cbe87236..ec11192f7 100644 --- a/fluxer_app/src/features/messaging/commands/MessageCommands.tsx +++ b/fluxer_app/src/features/messaging/commands/MessageCommands.tsx @@ -25,7 +25,6 @@ import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/ import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal'; import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage'; import type {JumpOptions} from '@app/features/messaging/state/ChannelMessages'; -import {selectChannelMessagesTailProbeOutcome} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine'; import MessageEdit from '@app/features/messaging/state/MessageEdit'; import MessageEditMobile from '@app/features/messaging/state/MessageEditMobile'; import MessageQueue from '@app/features/messaging/state/MessageQueue'; @@ -118,19 +117,7 @@ export interface JumpToMessageOptions { interface FetchMessagesOptions { throwOnError?: boolean; - tailProbe?: TailProbeContext; -} - -export type TailProbeSettlement = 'applied' | 'retry' | 'failed'; - -export interface TailProbeContext { - watermarkMessageId: string; - onSettled: (settlement: TailProbeSettlement) => void; -} - -interface TailProbeEpoch { - loadGeneration: number; - jumpTicket: number; + staleRefetch?: boolean; } interface MessagePageState { @@ -158,12 +145,12 @@ function makeFetchKey( ): string { const SEP = '\x1f'; const throwOnError = options?.throwOnError ? '1' : '0'; - const tailProbe = options?.tailProbe ? `1.${options.tailProbe.watermarkMessageId}` : '0'; + const staleRefetch = options?.staleRefetch ? '1' : '0'; if (!jump) { - return `${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${tailProbe}`; + return `${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${staleRefetch}`; } return ( - `${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${tailProbe}${SEP}` + + `${channelId}${SEP}${before ?? ''}${SEP}${after ?? ''}${SEP}${limit}${SEP}${throwOnError}${SEP}${staleRefetch}${SEP}` + `${jump.present ? '1' : '0'}${SEP}${jump.messageId ?? ''}${SEP}${jump.offset ?? 0}${SEP}` + `${jump.flash ? '1' : '0'}${SEP}${jump.returnToMessageId ?? ''}${SEP}` + `${jump.returnChannelId ?? ''}${SEP}${jump.returnGuildId ?? ''}${SEP}${jump.jumpType ?? ''}` @@ -262,7 +249,6 @@ function handleMessageFetchSuccess( pageState: MessagePageState, cached: boolean, jump?: JumpOptions, - tailProbe?: TailProbeContext, ): void { Messages.handleLoadMessagesSuccess({ channelId, @@ -273,7 +259,6 @@ function handleMessageFetchSuccess( hasMoreAfter: pageState.hasMoreAfter, cached, jump, - tailProbe: tailProbe != null, }); ReadStates.handleLoadMessages({ channelId, @@ -361,6 +346,9 @@ function getMessageFetchCacheHit( jump?: JumpOptions, ): MessageFetchCacheHit | null { const messages = Messages.getMessages(channelId); + if (!messages.ready || messages.cached) { + return null; + } if (jump?.messageId && messages.has(jump.messageId, false)) { return 'jump'; } @@ -394,30 +382,6 @@ function applyMessageFetchCacheHit( } } -function isTailProbeApplicable(channelId: string, probe: TailProbeEpoch, after: string | null): boolean { - const current = Messages.getMessages(channelId); - const outcome = selectChannelMessagesTailProbeOutcome({ - probeGeneration: probe.loadGeneration, - currentGeneration: current.loadGeneration, - probeJumpTicket: probe.jumpTicket, - currentJumpTicket: current.jumpTicket, - ready: current.ready, - hasMoreAfter: current.hasMoreAfter, - anchorMessageId: after, - newestLoadedMessageId: current.last()?.id ?? null, - }); - if (outcome === 'apply') { - return true; - } - logger.debug(`Discarding tail probe for channel ${channelId} (${outcome})`); - Messages.handleTailProbeSettled({channelId}); - return false; -} - -function settleTailProbe(options: FetchMessagesOptions | undefined, settlement: TailProbeSettlement): void { - options?.tailProbe?.onSettled(settlement); -} - export async function fetchMessages( channelId: string, before: string | null, @@ -436,16 +400,13 @@ export async function fetchMessages( switch (preflightDecision.type) { case 'useInFlightRequest': logger.debug(`Using in-flight fetchMessages for channel ${channelId} (deduped)`); - settleTailProbe(options, 'retry'); return inFlight as Promise>; case 'blockForGate': logger.debug(`Skipping message fetch for gated channel ${channelId}`); Messages.handleLoadMessagesBlocked({channelId}); - settleTailProbe(options, 'retry'); return []; case 'useCache': applyMessageFetchCacheHit(channelId, preflightDecision.cacheHit, before, after, limit, jump); - settleTailProbe(options, 'retry'); return []; case 'startFetch': break; @@ -456,18 +417,9 @@ export async function fetchMessages( forceFailure: DeveloperOptions.forceFailMessageLoads, }); if (executionDecision.type === 'simulateFailure') { - if (options?.tailProbe != null) { - settleTailProbe(options, 'failed'); - return []; - } return handleForcedMessageLoadFailure(channelId, jump); } - Messages.handleLoadMessages({channelId, jump, tailProbe: options?.tailProbe != null}); - let probeEpoch: TailProbeEpoch | null = null; - if (options?.tailProbe) { - const started = Messages.getMessages(channelId); - probeEpoch = {loadGeneration: started.loadGeneration, jumpTicket: started.jumpTicket}; - } + Messages.handleLoadMessages({channelId, jump}); const connectedAtRequest = GatewayConnection.isConnected; const epochAtRequest = GatewayConnection.connectionEpoch; try { @@ -480,22 +432,12 @@ export async function fetchMessages( epochAtRequest, epochAtResponse: GatewayConnection.connectionEpoch, }); - if (probeEpoch != null && !isTailProbeApplicable(channelId, probeEpoch, after)) { - settleTailProbe(options, 'retry'); - return []; - } const pageState = calculateMessagePageState(channelId, before, after, limit, messages, jump); logger.info(`Fetched ${messages.length} messages for channel ${channelId}, took ${Date.now() - timeStart}ms`); - handleMessageFetchSuccess(channelId, messages, pageState, cached, jump, options?.tailProbe); - settleTailProbe(options, 'applied'); + handleMessageFetchSuccess(channelId, messages, pageState, cached, jump); return messages; } catch (error) { logger.error(`Failed to fetch messages for channel ${channelId}:`, error); - if (probeEpoch != null) { - Messages.handleTailProbeSettled({channelId}); - settleTailProbe(options, 'failed'); - return []; - } Messages.handleLoadMessagesFailure({channelId}); if (options?.throwOnError) { throw error; diff --git a/fluxer_app/src/features/messaging/state/ChannelMessages.ts b/fluxer_app/src/features/messaging/state/ChannelMessages.ts index 3af29d1d6..bfe41164e 100644 --- a/fluxer_app/src/features/messaging/state/ChannelMessages.ts +++ b/fluxer_app/src/features/messaging/state/ChannelMessages.ts @@ -52,7 +52,6 @@ interface LoadCompleteOptions { hasMoreBefore?: boolean; hasMoreAfter?: boolean; cached?: boolean; - tailProbe?: boolean; } type MessageInput = Message | WireMessage; @@ -300,7 +299,6 @@ export class ChannelMessages { jumpDestinationOffset = 0; jumpTicket = 1; loadGeneration = 0; - probeLoading = false; hasJumped = false; landedAtLiveEdge = false; jumpHighlight = true; @@ -835,15 +833,6 @@ export class ChannelMessages { return this.cloneAnd({ready: true, cached: true}).merge([hydrateMessage(this, wire, 'preserve')]); } - beginProbeLoad(): ChannelMessages { - return this.cloneAnd({loadGeneration: ++nextLoadGeneration, probeLoading: true}); - } - - endProbeLoad(): ChannelMessages { - if (!this.probeLoading) return this; - return this.cloneAnd({probeLoading: false}); - } - beginLoad(jump?: JumpOptions): ChannelMessages { return this.cloneAnd({ loadGeneration: ++nextLoadGeneration, @@ -868,7 +857,6 @@ export class ChannelMessages { hasMoreBefore = false, hasMoreAfter = false, cached = false, - tailProbe = false, } = options; const records = [...windowMessages].reverse().map((m) => hydrateMessage(this, m, 'empty')); const loadDecision = resolveChannelMessagesLoadDecision({ @@ -887,26 +875,24 @@ export class ChannelMessages { } } else { next = this.merge(records, loadDecision.prepend, true, true); - if (!tailProbe && loadDecision.trimBottom) { + if (loadDecision.trimBottom) { next = next.trimToWindow(true, false); - } else if (!tailProbe && loadDecision.trimTop) { + } else if (loadDecision.trimTop) { next = next.trimToWindow(false, true); } } - const jumpPatch = tailProbe - ? {} - : { - jumpType: jump?.jumpType ?? JumpTypes.ANIMATED, - jumpHighlight: jump?.flash ?? false, - hasJumped: jump != null, - landedAtLiveEdge: jump?.present ?? false, - jumpDestinationId: jump?.messageId ?? null, - jumpDestinationOffset: jump && jump.messageId != null && jump.offset != null ? jump.offset : 0, - jumpTicket: jump ? next.jumpTicket + 1 : next.jumpTicket, - jumpReturnMessageId: jump?.returnToMessageId ?? null, - jumpReturnChannelId: jump?.returnToMessageId ? (jump.returnChannelId ?? this.channelId) : null, - jumpReturnGuildId: jump?.returnToMessageId ? (jump.returnGuildId ?? null) : null, - }; + const jumpPatch = { + jumpType: jump?.jumpType ?? JumpTypes.ANIMATED, + jumpHighlight: jump?.flash ?? false, + hasJumped: jump != null, + landedAtLiveEdge: jump?.present ?? false, + jumpDestinationId: jump?.messageId ?? null, + jumpDestinationOffset: jump && jump.messageId != null && jump.offset != null ? jump.offset : 0, + jumpTicket: jump ? next.jumpTicket + 1 : next.jumpTicket, + jumpReturnMessageId: jump?.returnToMessageId ?? null, + jumpReturnChannelId: jump?.returnToMessageId ? (jump.returnChannelId ?? this.channelId) : null, + jumpReturnGuildId: jump?.returnToMessageId ? (jump.returnGuildId ?? null) : null, + }; const reachesLiveEdge = selectChannelMessagesLoadRestoresTrust({ mode: loadDecision.mode, isAfter, @@ -915,7 +901,6 @@ export class ChannelMessages { next = next.cloneAnd({ ready: true, loadingMore: false, - probeLoading: false, ...jumpPatch, hasMoreBefore: loadDecision.preserveHasMoreBefore ? next.hasMoreBefore : hasMoreBefore, hasMoreAfter: loadDecision.preserveHasMoreAfter ? next.hasMoreAfter : hasMoreAfter, @@ -996,7 +981,6 @@ export class ChannelMessages { clone.jumpDestinationOffset = this.jumpDestinationOffset; clone.jumpTicket = this.jumpTicket; clone.loadGeneration = this.loadGeneration; - clone.probeLoading = this.probeLoading; clone.hasJumped = this.hasJumped; clone.landedAtLiveEdge = this.landedAtLiveEdge; clone.jumpHighlight = this.jumpHighlight; @@ -1020,7 +1004,6 @@ export class ChannelMessages { patch.jumpDestinationOffset !== undefined ? patch.jumpDestinationOffset : this.jumpDestinationOffset; clone.jumpTicket = patch.jumpTicket !== undefined ? patch.jumpTicket : this.jumpTicket; clone.loadGeneration = patch.loadGeneration !== undefined ? patch.loadGeneration : this.loadGeneration; - clone.probeLoading = 'probeLoading' in patch ? !!patch.probeLoading : this.probeLoading; clone.hasJumped = 'hasJumped' in patch ? !!patch.hasJumped : this.hasJumped; clone.landedAtLiveEdge = 'landedAtLiveEdge' in patch ? !!patch.landedAtLiveEdge : this.landedAtLiveEdge; clone.jumpHighlight = 'jumpHighlight' in patch ? !!patch.jumpHighlight : this.jumpHighlight; diff --git a/fluxer_app/src/features/messaging/state/ChannelMessagesLoadStateMachine.test.ts b/fluxer_app/src/features/messaging/state/ChannelMessagesLoadStateMachine.test.ts index 84c2a713c..a5ce15f9c 100644 --- a/fluxer_app/src/features/messaging/state/ChannelMessagesLoadStateMachine.test.ts +++ b/fluxer_app/src/features/messaging/state/ChannelMessagesLoadStateMachine.test.ts @@ -13,9 +13,6 @@ import { selectChannelMessagesLoadDecision, selectChannelMessagesLoadRestoresTrust, selectChannelMessagesSpacerHeight, - selectChannelMessagesTailGapId, - selectChannelMessagesTailProbeId, - selectChannelMessagesTailProbeOutcome, selectChannelMessagesWindowBar, selectChannelMessagesWindowStatus, transitionChannelMessagesLoadSnapshot, @@ -394,132 +391,6 @@ describe('selectChannelMessagesFillerVisible', () => { }); }); -describe('selectChannelMessagesTailProbeId', () => { - const ID = {older: '1519773906704011264', newer: '1519773906708205568'}; - - function tailInput( - overrides: Partial = {}, - tail: { - loading?: boolean; - newestLoadedMessageId?: string | null; - knownLatestMessageId?: string | null; - } = {}, - ) { - const windowInput: ChannelMessagesWindowInput = { - ready: true, - loading: false, - failed: false, - messageCount: 12, - hasMoreBefore: true, - hasMoreAfter: false, - ...overrides, - }; - return { - status: resolveChannelMessagesWindowStatus(windowInput), - loading: tail.loading ?? windowInput.loading, - newestLoadedMessageId: tail.newestLoadedMessageId === undefined ? ID.older : tail.newestLoadedMessageId, - knownLatestMessageId: tail.knownLatestMessageId === undefined ? ID.newer : tail.knownLatestMessageId, - }; - } - - it('probes from the newest loaded message when the live edge is known to be ahead', () => { - expect(selectChannelMessagesTailProbeId(tailInput())).toBe(ID.older); - }); - - it('stays silent for a window that already advertises a newer page', () => { - expect(selectChannelMessagesTailProbeId(tailInput({hasMoreAfter: true}))).toBeNull(); - }); - - it('stays silent for a failed window so a failed probe cannot re-arm itself', () => { - expect(selectChannelMessagesTailProbeId(tailInput({failed: true}))).toBeNull(); - }); - - it('stays silent while a load is in flight', () => { - expect(selectChannelMessagesTailProbeId(tailInput({loading: true}))).toBeNull(); - }); - - it('stays silent for a window that has not loaded a page yet', () => { - expect(selectChannelMessagesTailProbeId(tailInput({ready: false}))).toBeNull(); - }); - - it('never probes an empty window, so a channel with no readable history cannot arm it', () => { - expect( - selectChannelMessagesTailProbeId( - tailInput({messageCount: 0, hasMoreBefore: false}, {newestLoadedMessageId: null}), - ), - ).toBeNull(); - }); - - it('stays silent when the watermark matches or trails the newest loaded message', () => { - expect(selectChannelMessagesTailProbeId(tailInput({}, {knownLatestMessageId: ID.older}))).toBeNull(); - expect( - selectChannelMessagesTailProbeId( - tailInput({}, {newestLoadedMessageId: ID.newer, knownLatestMessageId: ID.older}), - ), - ).toBeNull(); - expect(selectChannelMessagesTailProbeId(tailInput({}, {knownLatestMessageId: null}))).toBeNull(); - }); - - it('keeps reporting the tail gap while the probe it armed is in flight', () => { - const inFlight = tailInput({loading: true}); - expect(selectChannelMessagesTailProbeId(inFlight)).toBeNull(); - expect(selectChannelMessagesTailGapId(inFlight)).toBe(ID.older); - }); - - it('reports no tail gap once the newest loaded message reaches the watermark', () => { - expect(selectChannelMessagesTailGapId(tailInput({}, {newestLoadedMessageId: ID.newer}))).toBeNull(); - }); - - it('still reports the gap on a deep window so auto-ack cannot run past it', () => { - expect(selectChannelMessagesTailGapId(tailInput({messageCount: 200}))).toBe(ID.older); - expect(selectChannelMessagesTailProbeId(tailInput({messageCount: 200}))).toBe(ID.older); - }); -}); - -describe('selectChannelMessagesTailProbeOutcome', () => { - const ID = {older: '1519773906704011264', newer: '1519773906708205568'}; - - function outcomeInput(overrides: Partial[0]> = {}) { - return { - probeGeneration: 4, - currentGeneration: 4, - probeJumpTicket: 2, - currentJumpTicket: 2, - ready: true, - hasMoreAfter: false, - anchorMessageId: ID.older, - newestLoadedMessageId: ID.older, - ...overrides, - }; - } - - it('applies a probe onto the window it was issued against', () => { - expect(selectChannelMessagesTailProbeOutcome(outcomeInput())).toBe('apply'); - }); - - it('discards without touching shared state once another load owns the window', () => { - expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentGeneration: 5}))).toBe('discard'); - }); - - it('releases a probe whose window a jump has taken out of the ready state', () => { - expect(selectChannelMessagesTailProbeOutcome(outcomeInput({ready: false}))).toBe('release'); - expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentGeneration: 9, ready: false}))).toBe('discard'); - }); - - it('releases a probe whose tail moved under it', () => { - expect(selectChannelMessagesTailProbeOutcome(outcomeInput({newestLoadedMessageId: ID.newer}))).toBe('release'); - expect(selectChannelMessagesTailProbeOutcome(outcomeInput({newestLoadedMessageId: null}))).toBe('release'); - }); - - it('releases a probe whose window started advertising a newer page', () => { - expect(selectChannelMessagesTailProbeOutcome(outcomeInput({hasMoreAfter: true}))).toBe('release'); - }); - - it('discards a probe whose window a cached jump took over without starting a load', () => { - expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentJumpTicket: 3}))).toBe('discard'); - }); -}); - describe('selectChannelMessagesLoadRestoresTrust', () => { it('trusts a window a replacement load rebuilt from scratch', () => { expect(selectChannelMessagesLoadRestoresTrust({mode: 'replace', isAfter: false, hasMoreAfter: false})).toBe(true); diff --git a/fluxer_app/src/features/messaging/state/ChannelMessagesLoadStateMachine.ts b/fluxer_app/src/features/messaging/state/ChannelMessagesLoadStateMachine.ts index ebcefea2e..e48143981 100644 --- a/fluxer_app/src/features/messaging/state/ChannelMessagesLoadStateMachine.ts +++ b/fluxer_app/src/features/messaging/state/ChannelMessagesLoadStateMachine.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {compare as compareSnowflakes} from '@fluxer/snowflake/src/SnowflakeUtils'; import {assign, initialTransition, type SnapshotFrom, setup, transition} from 'xstate'; export interface ChannelMessagesLoadInput { @@ -282,25 +281,6 @@ export function selectChannelMessagesSpacerHeight(status: ChannelMessagesWindowS return status.olderPageAvailable || status.newerPageAvailable ? fillerHeight : 0; } -export interface ChannelMessagesTailInput { - status: ChannelMessagesWindowStatus; - loading: boolean; - newestLoadedMessageId: string | null; - knownLatestMessageId: string | null; -} - -export function selectChannelMessagesTailGapId(input: ChannelMessagesTailInput): string | null { - if (input.status.phase !== 'stream' || input.status.retryVisible || input.status.newerPageAvailable) return null; - if (input.newestLoadedMessageId == null || input.knownLatestMessageId == null) return null; - if (compareSnowflakes(input.knownLatestMessageId, input.newestLoadedMessageId) <= 0) return null; - return input.newestLoadedMessageId; -} - -export function selectChannelMessagesTailProbeId(input: ChannelMessagesTailInput): string | null { - if (input.loading) return null; - return selectChannelMessagesTailGapId(input); -} - export function selectChannelMessagesLoadRestoresTrust(input: { mode: ChannelMessagesLoadMode; isAfter: boolean; @@ -310,28 +290,6 @@ export function selectChannelMessagesLoadRestoresTrust(input: { return input.isAfter && !input.hasMoreAfter; } -export interface ChannelMessagesTailProbeResultInput { - probeGeneration: number; - currentGeneration: number; - probeJumpTicket: number; - currentJumpTicket: number; - ready: boolean; - hasMoreAfter: boolean; - anchorMessageId: string | null; - newestLoadedMessageId: string | null; -} - -export type ChannelMessagesTailProbeOutcome = 'apply' | 'release' | 'discard'; - -export function selectChannelMessagesTailProbeOutcome( - input: ChannelMessagesTailProbeResultInput, -): ChannelMessagesTailProbeOutcome { - if (input.currentGeneration !== input.probeGeneration) return 'discard'; - if (input.currentJumpTicket !== input.probeJumpTicket) return 'discard'; - if (!input.ready || input.hasMoreAfter || input.newestLoadedMessageId !== input.anchorMessageId) return 'release'; - return 'apply'; -} - export function selectChannelMessagesWindowBar(status: ChannelMessagesWindowStatus): ChannelMessagesWindowBar { if (status.phase === 'placeholder') return 'none'; if (status.phase === 'retry' || status.retryVisible) return 'retry'; diff --git a/fluxer_app/src/features/messaging/state/MessagingMessages.ts b/fluxer_app/src/features/messaging/state/MessagingMessages.ts index a22775976..ca669329b 100644 --- a/fluxer_app/src/features/messaging/state/MessagingMessages.ts +++ b/fluxer_app/src/features/messaging/state/MessagingMessages.ts @@ -23,6 +23,9 @@ import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMember import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import {action, makeAutoObservable, reaction} from 'mobx'; +const STALE_WINDOW_REFETCH_INTERVAL_MS = 10_000; +const staleWindowRefetchedAt = new Map(); + interface GuildMemberUpdateAction { type: 'GUILD_MEMBER_UPDATE'; guildId: string; @@ -379,10 +382,9 @@ class Messages { if (!isNonGuildChannel && !guildExists) { return false; } - const distrustedTailId = messages.ready && messages.cached ? (messages.last()?.id ?? null) : null; this.commitMessages(messages.withPatch({loadingMore: true})); this.notifyChange(); - MessageCommands.fetchMessages(channelId, null, distrustedTailId, MAX_MESSAGES_PER_CHANNEL); + MessageCommands.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL); return false; } @@ -443,9 +445,9 @@ class Messages { } @action - handleLoadMessages(action: {channelId: string; jump?: JumpOptions; tailProbe?: boolean}): boolean { + handleLoadMessages(action: {channelId: string; jump?: JumpOptions}): boolean { const messages = ChannelMessages.getOrCreate(action.channelId); - this.commitMessages(action.tailProbe ? messages.beginProbeLoad() : messages.beginLoad(action.jump)); + this.commitMessages(messages.beginLoad(action.jump)); this.notifyChange(); return false; } @@ -499,7 +501,6 @@ class Messages { hasMoreBefore?: boolean; hasMoreAfter?: boolean; cached?: boolean; - tailProbe?: boolean; messages: Array; }): boolean { const messages = ChannelMessages.getOrCreate(action.channelId).applyLoadedWindow({ @@ -510,13 +511,26 @@ class Messages { hasMoreBefore: action.hasMoreBefore, hasMoreAfter: action.hasMoreAfter, cached: action.cached, - tailProbe: action.tailProbe, }); this.commitMessages(messages); this.notifyChange(); + this.refetchStaleWindow(action.channelId, action.cached === true, action.jump); return false; } + private refetchStaleWindow(channelId: string, stale: boolean, jump?: JumpOptions): void { + if (!stale || !GatewayConnection.isConnected || SelectedChannel.currentChannelId !== channelId) { + return; + } + const lastAttemptAt = staleWindowRefetchedAt.get(channelId) ?? 0; + const now = Date.now(); + if (now - lastAttemptAt < STALE_WINDOW_REFETCH_INTERVAL_MS) { + return; + } + staleWindowRefetchedAt.set(channelId, now); + MessageCommands.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL, jump, {staleRefetch: true}); + } + @action handleLoadMessagesFailure(action: {channelId: string}): boolean { const messages = ChannelMessages.getOrCreate(action.channelId); @@ -525,15 +539,6 @@ class Messages { return false; } - @action - handleTailProbeSettled(action: {channelId: string}): boolean { - const messages = ChannelMessages.get(action.channelId); - if (!messages?.probeLoading) return false; - this.commitMessages(messages.endProbeLoad()); - this.notifyChange(); - return true; - } - @action handleLoadMessagesBlocked(action: {channelId: string}): boolean { const messages = ChannelMessages.getOrCreate(action.channelId); diff --git a/fluxer_app/src/features/read_state/state/ReadStates.test.ts b/fluxer_app/src/features/read_state/state/ReadStates.test.ts index b2ff3662a..af4a1bc17 100644 --- a/fluxer_app/src/features/read_state/state/ReadStates.test.ts +++ b/fluxer_app/src/features/read_state/state/ReadStates.test.ts @@ -10,6 +10,7 @@ import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSche import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; const channels = new Map(); +const guilds = new Map(); const blockedUserIds = new Set(); let pinnedToEnd = false; let automaticAck = false; @@ -46,6 +47,7 @@ vi.mock('@app/features/user/state/Users', () => ({ vi.mock('@app/features/relationship/state/Relationships', () => ({ default: {isBlocked: (id: string) => blockedUserIds.has(id)}, })); +vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: (id: string) => guilds.get(id)}})); vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}})); vi.mock('@app/features/user/state/UserGuildSettings', () => ({ default: { @@ -76,7 +78,6 @@ function seedReadChannel() { const channelId = `channel-${++nextChannelId}`; channels.set(channelId, new Channel({id: channelId, type: ChannelTypes.GUILD_TEXT, guild_id: 'guild-1'})); const state = ReadStates.get(channelId); - state.readStateKnown = true; state.ackMessageId = ID.ack; state.lastMessageId = ID.ack; state.unreadCount = 0; @@ -491,19 +492,22 @@ describe('ReadStates private channel open, close and reopen', () => { messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.newDm, TUNA)); expect(unreadState(CHANNEL.newDm)).toEqual({unread: true, unreadCount: 1, mentionCount: 1, ackMessageId: null}); expect(ReadStates.getOldestUnreadMessageId(CHANNEL.newDm)).toBe(MESSAGE.incoming); - expect(ReadStates.getIfExists(CHANNEL.newDm)?.readStateKnown).toBe(true); messageCreate(wireMessage(MESSAGE.next, CHANNEL.newDm, TUNA)); expect(unreadState(CHANNEL.newDm)).toEqual({unread: true, unreadCount: 2, mentionCount: 2, ackMessageId: null}); }); - it('keeps a message already loaded into a DM without read state covered', () => { + it('guesses the ack at the newest message for a mention-free DM without read state', () => { ready([], []); channelCreate(dm(CHANNEL.dm, MESSAGE.last)); loadedMessages.push({id: MESSAGE.last, author: {id: TUNA}}, {id: MESSAGE.incoming, author: {id: TUNA}}); ReadStates.handleLoadMessages({channelId: CHANNEL.dm, messages: []}); messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA)); - expect(unreadState(CHANNEL.dm)).toEqual({unread: false, unreadCount: 0, mentionCount: 0, ackMessageId: null}); - expect(ReadStates.getIfExists(CHANNEL.dm)?.readStateKnown).toBe(false); + expect(unreadState(CHANNEL.dm)).toEqual({ + unread: false, + unreadCount: 0, + mentionCount: 0, + ackMessageId: MESSAGE.incoming, + }); }); it('characterisation: keeps a self-opened DM read when its read state covers the last message', () => { @@ -528,11 +532,10 @@ describe('ReadStates private channel open, close and reopen', () => { }); }); - it('leaves a self-opened DM without read state unknown', () => { + it('shows a self-opened DM with history and no read state as unread', () => { ready([], []); channelCreate(dm(CHANNEL.dm, MESSAGE.last)); - expect(unreadState(CHANNEL.dm)).toEqual({unread: false, unreadCount: 0, mentionCount: 0, ackMessageId: null}); - expect(ReadStates.getIfExists(CHANNEL.dm)?.readStateKnown).toBe(false); + expect(unreadState(CHANNEL.dm)).toEqual({unread: true, unreadCount: 0, mentionCount: 0, ackMessageId: null}); }); it('keeps an open unread DM unread when a duplicate CHANNEL_CREATE arrives', () => { @@ -594,13 +597,13 @@ describe('ReadStates private channel open, close and reopen', () => { it('characterisation: counts only the recipient add message when the user joins a group DM with history', () => { ready([], []); channelCreate(groupDm(MESSAGE.last)); - expect(ReadStates.hasUnread(CHANNEL.groupDm)).toBe(false); + expect(ReadStates.hasUnread(CHANNEL.groupDm)).toBe(true); messageCreate(recipientAddMessage()); expect(unreadState(CHANNEL.groupDm)).toEqual({ unread: true, unreadCount: 1, mentionCount: 1, - ackMessageId: MESSAGE.last, + ackMessageId: null, }); }); @@ -734,20 +737,19 @@ describe('ReadStates private channel open, close and reopen', () => { unread: true, unreadCount: 1, mentionCount: 1, - ackMessageId: MESSAGE.last, + ackMessageId: null, }); }); - it('characterisation: keeps a guild message covered when read state is unknown and the watermark already equals it', () => { + it('shows a guild message unread when there is no read state to cover it', () => { ready([], [guildText(MESSAGE.incoming)]); messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.guildText, TUNA, {guild_id: 'guild-1'})); expect(unreadState(CHANNEL.guildText)).toEqual({ - unread: false, + unread: true, unreadCount: 0, mentionCount: 0, ackMessageId: null, }); - expect(ReadStates.getIfExists(CHANNEL.guildText)?.readStateKnown).toBe(false); }); it('characterisation: still forgets a guild channel that a visibility change hides and shows again', () => { @@ -761,10 +763,119 @@ describe('ReadStates private channel open, close and reopen', () => { channelDelete({id: CHANNEL.guildText, guild_id: 'guild-1'}); channelCreate(guildText(MESSAGE.last)); expect(unreadState(CHANNEL.guildText)).toEqual({ - unread: false, + unread: true, unreadCount: 0, mentionCount: 0, ackMessageId: null, }); }); }); + +describe('ReadStates ack floor for channels with no read state', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + ReadStates.clearAll(); + guilds.clear(); + vi.useRealTimers(); + }); + + it('shows a guild channel unread when its newest message postdates the join', () => { + guilds.set('guild-1', {joinedAt: '2026-09-01T00:00:00.000Z'}); + ready([], [guildText(MESSAGE.last)]); + expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true); + }); + + it('leaves a guild channel read when its newest message predates the join', () => { + guilds.set('guild-1', {joinedAt: '2026-09-11T00:00:00.000Z'}); + ready([], [guildText(MESSAGE.last)]); + expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(false); + }); + + it('falls back to the channel snowflake while its guild record is missing', () => { + ready([], [guildText(MESSAGE.last)]); + expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true); + }); + + it('recomputes the floor once the guild record arrives instead of freezing it', () => { + ready([], [guildText(MESSAGE.last)]); + expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true); + guilds.set('guild-1', {joinedAt: '2026-09-11T00:00:00.000Z'}); + expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(false); + }); + + it('never lets the floor drift forward with the wall clock', () => { + guilds.set('guild-1', {joinedAt: null}); + ready([], [guildText(MESSAGE.last)]); + messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.guildText, TUNA, {guild_id: 'guild-1'})); + expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true); + vi.advanceTimersByTime(60 * 60 * 1000); + expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true); + expect(ReadStates.getUnreadCount(CHANNEL.guildText)).toBe(1); + }); + + it('counts only the messages that postdate the join', () => { + guilds.set('guild-1', {joinedAt: '2026-09-10T21:00:00.000Z'}); + ready([], [guildText(MESSAGE.incoming)]); + loadedMessages.push( + {id: MESSAGE.older, author: {id: TUNA}}, + {id: MESSAGE.last, author: {id: TUNA}}, + {id: MESSAGE.incoming, author: {id: TUNA}}, + ); + ReadStates.handleLoadMessages({channelId: CHANNEL.guildText, messages: []}); + expect(ReadStates.getUnreadCount(CHANNEL.guildText)).toBe(2); + expect(ReadStates.getOldestUnreadMessageId(CHANNEL.guildText)).toBe(MESSAGE.last); + }); + + it("never writes an ack for someone else's message in a channel with no read state", () => { + guilds.set('guild-1', {joinedAt: '2026-09-01T00:00:00.000Z'}); + ready([], [guildText(MESSAGE.last)]); + messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.guildText, TUNA, {guild_id: 'guild-1'})); + expect(ReadStates.ackMessageId(CHANNEL.guildText)).toBeNull(); + }); +}); + +describe('ReadStates guessed ack for private channels', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + ReadStates.clearAll(); + vi.useRealTimers(); + }); + + it('takes the newest message as the ack when a DM has no mentions', () => { + ready([], []); + channelCreate(dm(CHANNEL.dm, MESSAGE.last)); + loadedMessages.push({id: MESSAGE.older, author: {id: TUNA}}, {id: MESSAGE.last, author: {id: TUNA}}); + ReadStates.handleLoadMessages({channelId: CHANNEL.dm, messages: []}); + expect(ReadStates.ackMessageId(CHANNEL.dm)).toBe(MESSAGE.last); + expect(ReadStates.hasUnread(CHANNEL.dm)).toBe(false); + }); + + it('walks back one message per mention, skipping the current user', () => { + ready([], []); + channelCreate(dm(CHANNEL.dm, MESSAGE.incoming)); + loadedMessages.push( + {id: MESSAGE.older, author: {id: TUNA}}, + {id: MESSAGE.last, author: {id: ME}}, + {id: MESSAGE.incoming, author: {id: TUNA}}, + ); + const state = ReadStates.get(CHANNEL.dm); + state.mentionCount = 1; + state.rebuild(); + expect(ReadStates.ackMessageId(CHANNEL.dm)).toBe(MESSAGE.last); + }); + + it('leaves the ack null when the window does not reach the newest message', () => { + ready([], []); + channelCreate(dm(CHANNEL.dm, MESSAGE.last)); + hasNewestMessages = false; + loadedMessages.push({id: MESSAGE.older, author: {id: TUNA}}); + ReadStates.handleLoadMessages({channelId: CHANNEL.dm, messages: []}); + expect(ReadStates.ackMessageId(CHANNEL.dm)).toBeNull(); + }); +}); diff --git a/fluxer_app/src/features/read_state/state/ReadStates.ts b/fluxer_app/src/features/read_state/state/ReadStates.ts index e4e2eb45b..4ff435136 100644 --- a/fluxer_app/src/features/read_state/state/ReadStates.ts +++ b/fluxer_app/src/features/read_state/state/ReadStates.ts @@ -22,7 +22,6 @@ import { type ChannelPayload, chunkEntries, compareMessageIds, - compareReadStateVersions, type GatewayReadState, isNewerMessageId, type PendingAck, @@ -233,7 +232,6 @@ class ReadStates { this.archivedStates.set(channelId as ChannelId, { ackMessageId: entry.ackMessageId, acknowledgedPinTimestamp: entry.acknowledgedPinTimestamp, - readStateKnown: entry.readStateKnown, }); } @@ -396,7 +394,6 @@ class ReadStates { for (const readState of readStates) { channelsWithReadState.add(readState.id as ChannelId); const state = this.get(readState.id); - state.readStateKnown = true; this.setMentionCount(state, readState.mention_count ?? 0); state.ackMessageId = readState.last_message_id ?? null; state.acknowledgedPinTimestamp = parseTimestamp(readState.last_pin_timestamp); @@ -466,10 +463,12 @@ class ReadStates { if (action.message.guild_id != null) { state.storedGuildId = action.message.guild_id; } - const previousLastMessageId = - state.isPrivate && !state.messagesLoaded && state.lastMessageId === action.message.id - ? null - : state.lastMessageId; + const reopenedPrivateChannel = + state.isPrivate && !state.messagesLoaded && state.lastMessageId === action.message.id; + const coveredByLastMessage = + !reopenedPrivateChannel && + state.lastMessageId != null && + compareMessageIds(state.lastMessageId, action.message.id) >= 0; const currentUser = Users.getCurrentUser(); const authorBlocked = Relationships.isBlocked(action.message.author.id); const hadUnreadOrMentions = state.isUnreadOrMentioned(); @@ -482,10 +481,9 @@ class ReadStates { isAtBottom: Dimension.channelPinnedToEnd(action.channelId), authorBlocked, hadUnreadOrMentions, - readStateKnown: state.readStateKnown, messageId: action.message.id, ackMessageId: state.ackMessageId, - previousLastMessageId, + coveredByLastMessage, }); switch (decision.type) { case 'ackCurrentUserMessage': @@ -519,14 +517,12 @@ class ReadStates { this.notifyChange(action.channelId); return; case 'recordUnread': - if (decision.initializeUnknownReadState) { - state.ackMessageId = previousLastMessageId; - state.readStateKnown = true; - } if (state.oldestUnreadMessageId == null || state.oldestUnreadNeedsRecompute) { state.oldestUnreadMessageId = action.message.id; } - state.unreadCount++; + if (!decision.coveredByLastMessage) { + state.unreadCount++; + } if (currentUser != null && state.shouldMentionFor(action.message, currentUser.id, state.isPrivate)) { this.setMentionCount(state, state.mentionCount + 1); } @@ -552,7 +548,6 @@ class ReadStates { if (archivedState != null) { state.ackMessageId = archivedState.ackMessageId; state.acknowledgedPinTimestamp = archivedState.acknowledgedPinTimestamp; - state.readStateKnown = archivedState.readStateKnown; this.archivedStates.delete(action.channel.id as ChannelId); } this.notifyChange(action.channel.id); @@ -721,7 +716,7 @@ class ReadStates { version?: string; }): void { const state = this.get(action.channelId); - const readStateWasKnown = state.readStateKnown; + const readStateWasKnown = state.ackMessageId != null; const mentionCount = action.mentionCount; const decision = resolveReadStateServerAckDecision({ messageId: action.messageId, @@ -736,7 +731,6 @@ class ReadStates { case 'ignoreStaleVersion': return; case 'applyManualAck': - state.readStateKnown = true; state.clearStickyUnread(); state.ackedManually = true; state.rebuild(action.messageId, {recomputeMentions: true}); @@ -749,11 +743,9 @@ class ReadStates { this.notifyChange(action.channelId); return; case 'ignoreOlderMessage': - state.readStateKnown = true; state.serverVersion = action.version ?? state.serverVersion; return; case 'refreshCurrentAck': - state.readStateKnown = true; state.serverVersion = action.version ?? state.serverVersion; if (decision.shouldUpdateMentionCount && mentionCount != null) { this.setMentionCount(state, mentionCount); @@ -767,7 +759,6 @@ class ReadStates { this.cancelPendingAckIfCovered(action.channelId, action.messageId); return; case 'advanceAck': { - state.readStateKnown = true; const result = this.applyAck(state, {messageId: action.messageId, local: true}); if (!result.acked) { return; @@ -793,7 +784,7 @@ class ReadStates { handleRelationshipUpdate(): void { for (const state of this.states.values()) { - if (state.isUnreadOrMentioned()) { + if (state.messagesLoaded && state.isUnreadOrMentioned()) { state.rebuild(undefined, {recomputeMentions: true}); } } @@ -844,7 +835,6 @@ class ReadStates { state.estimated = false; state.unreadCount = 0; this.setMentionCount(state, 0); - state.readStateKnown = true; state.ackMessageId = decision.messageId; state.oldestUnreadMessageId = null; if (decision.shouldClearManualAck) { @@ -1044,17 +1034,6 @@ class ReadStates { } for (const readState of this.decodeReadStateBundle(response.read_state_proto, response.read_states)) { if (readState.last_message_id == null) { - const state = this.get(readState.id); - if (readState.version != null && compareReadStateVersions(readState.version, state.serverVersion) < 0) { - continue; - } - state.readStateKnown = true; - state.ackMessageId = null; - state.serverVersion = readState.version ?? state.serverVersion; - this.setMentionCount(state, readState.mention_count ?? 0); - state.rebuild(null, {recomputeMentions: manual}); - this.clearUnreadStateIfRead(state); - this.notifyChange(readState.id); continue; } this.handleMessageAck({ diff --git a/fluxer_app/src/features/read_state/state/ReadStatesAckBatching.test.ts b/fluxer_app/src/features/read_state/state/ReadStatesAckBatching.test.ts index 8b5e90370..edf503990 100644 --- a/fluxer_app/src/features/read_state/state/ReadStatesAckBatching.test.ts +++ b/fluxer_app/src/features/read_state/state/ReadStatesAckBatching.test.ts @@ -10,6 +10,7 @@ import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSche import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; const channels = new Map(); +const guilds = new Map(); let pinnedToEnd = false; let automaticAck = false; const loadedMessages: Array<{id: string; author: {id: string}}> = []; @@ -41,6 +42,7 @@ vi.mock('@app/features/user/state/Users', () => ({ default: {getCurrentUser: () => ({id: 'me'}), cacheUsers: () => {}}, })); vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}})); +vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: (id: string) => guilds.get(id)}})); vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}})); vi.mock('@app/features/user/state/UserGuildSettings', () => ({ default: { diff --git a/fluxer_app/src/features/read_state/state/ReadStatesDeletedTailMessage.test.ts b/fluxer_app/src/features/read_state/state/ReadStatesDeletedTailMessage.test.ts index efedb71c7..906b09789 100644 --- a/fluxer_app/src/features/read_state/state/ReadStatesDeletedTailMessage.test.ts +++ b/fluxer_app/src/features/read_state/state/ReadStatesDeletedTailMessage.test.ts @@ -8,6 +8,7 @@ import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/Ch import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; const channels = new Map(); +const guilds = new Map(); let pinnedToEnd = false; let automaticAck = false; let hasNewest = true; @@ -38,6 +39,7 @@ vi.mock('@app/features/user/state/Users', () => ({ default: {getCurrentUser: () => ({id: 'me'}), cacheUsers: () => {}}, })); vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}})); +vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: (id: string) => guilds.get(id)}})); vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}})); vi.mock('@app/features/user/state/UserGuildSettings', () => ({ default: { diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateEntry.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateEntry.ts index a97c04d2f..c216129b0 100644 --- a/fluxer_app/src/features/read_state/state/read_states/ReadStateEntry.ts +++ b/fluxer_app/src/features/read_state/state/read_states/ReadStateEntry.ts @@ -1,12 +1,18 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import Channels from '@app/features/channel/state/Channels'; +import Guilds from '@app/features/guild/state/Guilds'; import GuildMembers from '@app/features/member/state/GuildMembers'; import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage'; import Messages from '@app/features/messaging/state/MessagingMessages'; import {resolveReadStateEntryStatus} from '@app/features/read_state/state/read_states/ReadStateEntryStatusMachine'; import {resolveReadStateMention} from '@app/features/read_state/state/read_states/ReadStateMentionMachine'; -import {compareMessageIds, normalizeCount, snowflakeTimestamp} from '@app/features/read_state/state/read_states/shared'; +import { + compareMessageIds, + normalizeCount, + parseTimestamp, + snowflakeTimestamp, +} from '@app/features/read_state/state/read_states/shared'; import Relationships from '@app/features/relationship/state/Relationships'; import UserGuildSettings from '@app/features/user/state/UserGuildSettings'; import Users from '@app/features/user/state/Users'; @@ -17,7 +23,6 @@ export class ReadStateEntry { readonly channelId: string; storedGuildId: string | null = null; messagesLoaded = false; - readStateKnown = false; private storedLastMessageId: string | null = null; private storedLastMessageTimestamp = 0; private storedAckMessageId: string | null = null; @@ -118,10 +123,28 @@ export class ReadStateEntry { } get ackTimestamp(): number { - if (Number.isNaN(this.storedAckMessageTimestamp)) { - return 0; + if (this.storedAckMessageTimestamp !== 0 && !Number.isNaN(this.storedAckMessageTimestamp)) { + return this.storedAckMessageTimestamp; } - return this.storedAckMessageTimestamp; + return this.ackFloorTimestamp; + } + + private get ackFloorTimestamp(): number { + const guildId = this.guildId; + if (guildId != null) { + const joinedAt = parseTimestamp(Guilds.getGuild(guildId)?.joinedAt); + if (joinedAt !== 0) { + return joinedAt; + } + } + return snowflakeTimestamp(this.channelId); + } + + isNewerThanAck(messageId: string): boolean { + if (this.storedAckMessageId != null) { + return compareMessageIds(messageId, this.storedAckMessageId) > 0; + } + return snowflakeTimestamp(messageId) > this.ackTimestamp; } get isPrivate(): boolean { @@ -137,9 +160,10 @@ export class ReadStateEntry { return resolveReadStateEntryStatus({ supportsUnreadTracking: this.supportsUnreadTracking(), hasBlockedDirectMessageRecipient: this.hasBlockedDirectMessageRecipient(), - readStateKnown: this.readStateKnown, lastMessageId: this.storedLastMessageId, ackMessageId: this.storedAckMessageId, + ackTimestamp: this.ackTimestamp, + lastMessageTimestamp: this.storedLastMessageTimestamp, mentionCount: this.mentionCount, }); } @@ -207,11 +231,11 @@ export class ReadStateEntry { } = {}, ): void { const previousUnreadCount = this.storedUnreadCount; - if (ackMessageId !== undefined) { + const previousOldestUnreadMessageId = this.storedOldestUnreadMessageId; + if (ackMessageId != null) { this.ackMessageId = ackMessageId; - this.readStateKnown = true; } else { - this.ackMessageId = this.storedAckMessageId; + this.ackMessageId = this.storedAckMessageId ?? this.guessAckMessageId(); } this.oldestUnreadMessageId = null; this.estimated = false; @@ -246,7 +270,7 @@ export class ReadStateEntry { } else if (this.storedOldestUnreadMessageId == null) { this.storedOldestUnreadMessageId = message.id; } - if (compareMessageIds(message.id, this.storedAckMessageId) > 0) { + if (this.isNewerThanAck(message.id)) { loadedUnreadCount++; if (recomputeMentions && !Relationships.isBlocked(message.author.id)) { const mentions = message.mentions; @@ -280,7 +304,37 @@ export class ReadStateEntry { } else { this.unreadCount = loadedUnreadCount; } - this.oldestUnreadMessageId = this.storedOldestUnreadMessageId ?? oldestUnread; + const resolvedOldestUnread = this.storedOldestUnreadMessageId ?? oldestUnread; + this.oldestUnreadMessageId = + resolvedOldestUnread ?? (this.estimated && this.unreadCount > 0 ? previousOldestUnreadMessageId : null); + } + + private guessAckMessageId(): string | null { + if (!this.isPrivate) { + return null; + } + const messages = Messages.getMessages(this.channelId); + if (!messages.hasNewestMessages()) { + return null; + } + if (!this.hasMentions()) { + return this.storedLastMessageId; + } + const currentUserId = Users.getCurrentUser()?.id; + const candidates: Array<{id: string; authorId: string}> = []; + messages.forEachBuffered((message) => { + candidates.push({id: message.id, authorId: message.author.id}); + }); + let remaining = this.mentionCount; + for (let index = candidates.length - 1; index >= 0; index--) { + const candidate = candidates[index]; + if (remaining > 0 && candidate.authorId !== currentUserId) { + remaining--; + } else if (remaining === 0) { + return candidate.id; + } + } + return null; } shouldMentionFor(message: MessageModel | WireMessage, userId: string, isPrivate: boolean): boolean { diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.bench.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.bench.ts index 0182870d5..c0ebfddf2 100644 --- a/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.bench.ts +++ b/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.bench.ts @@ -7,6 +7,8 @@ import {type ReadStateEntryStatusInput, resolveReadStateEntryStatus} from './Rea const BASE_TIMESTAMP = Date.UTC(2024, 0, 1); const ACK_ID = fromTimestamp(BASE_TIMESTAMP + 1_000); const LAST_ID = fromTimestamp(BASE_TIMESTAMP + 2_000); +const ACK_TS = BASE_TIMESTAMP + 1_000; +const LAST_TS = BASE_TIMESTAMP + 2_000; const INPUTS = Object.freeze( Array.from({length: 100_000}, (_, index): ReadStateEntryStatusInput => { switch (index % 6) { @@ -14,54 +16,60 @@ const INPUTS = Object.freeze( return { supportsUnreadTracking: false, hasBlockedDirectMessageRecipient: false, - readStateKnown: true, lastMessageId: LAST_ID, ackMessageId: ACK_ID, + ackTimestamp: ACK_TS, + lastMessageTimestamp: LAST_TS, mentionCount: 1, }; case 1: return { supportsUnreadTracking: true, hasBlockedDirectMessageRecipient: true, - readStateKnown: true, lastMessageId: LAST_ID, ackMessageId: ACK_ID, + ackTimestamp: ACK_TS, + lastMessageTimestamp: LAST_TS, mentionCount: 1, }; case 2: return { supportsUnreadTracking: true, hasBlockedDirectMessageRecipient: false, - readStateKnown: false, lastMessageId: LAST_ID, - ackMessageId: ACK_ID, + ackMessageId: null, + ackTimestamp: ACK_TS, + lastMessageTimestamp: LAST_TS, mentionCount: 0, }; case 3: return { supportsUnreadTracking: true, hasBlockedDirectMessageRecipient: false, - readStateKnown: true, lastMessageId: null, ackMessageId: ACK_ID, + ackTimestamp: ACK_TS, + lastMessageTimestamp: LAST_TS, mentionCount: 0, }; case 4: return { supportsUnreadTracking: true, hasBlockedDirectMessageRecipient: false, - readStateKnown: true, lastMessageId: LAST_ID, ackMessageId: ACK_ID, + ackTimestamp: ACK_TS, + lastMessageTimestamp: LAST_TS, mentionCount: 2, }; default: return { supportsUnreadTracking: true, hasBlockedDirectMessageRecipient: false, - readStateKnown: true, lastMessageId: LAST_ID, ackMessageId: LAST_ID, + ackTimestamp: ACK_TS, + lastMessageTimestamp: LAST_TS, mentionCount: 0, }; } diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.test.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.test.ts index 5577484b3..ed3afa3e5 100644 --- a/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.test.ts +++ b/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.test.ts @@ -14,14 +14,17 @@ import { const BASE_TIMESTAMP = Date.UTC(2024, 0, 1); const ACK_ID = fromTimestamp(BASE_TIMESTAMP + 1000); const LAST_ID = fromTimestamp(BASE_TIMESTAMP + 2000); +const ACK_TS = BASE_TIMESTAMP + 1000; +const LAST_TS = BASE_TIMESTAMP + 2000; function input(overrides: Partial = {}): ReadStateEntryStatusInput { return { supportsUnreadTracking: true, hasBlockedDirectMessageRecipient: false, - readStateKnown: true, lastMessageId: LAST_ID, ackMessageId: LAST_ID, + ackTimestamp: LAST_TS, + lastMessageTimestamp: LAST_TS, mentionCount: 0, ...overrides, }; @@ -35,8 +38,9 @@ describe('readStateEntryStatusMachine', () => { it('routes the read-state status by priority', () => { expectResolvedState({supportsUnreadTracking: false, mentionCount: 1, ackMessageId: ACK_ID}, 'untracked'); expectResolvedState({hasBlockedDirectMessageRecipient: true, mentionCount: 1, ackMessageId: ACK_ID}, 'blocked'); - expectResolvedState({readStateKnown: false, ackMessageId: ACK_ID}, 'unknown'); - expectResolvedState({lastMessageId: null, ackMessageId: ACK_ID}, 'unknown'); + expectResolvedState({lastMessageId: null, ackMessageId: ACK_ID}, 'read'); + expectResolvedState({ackMessageId: null, ackTimestamp: ACK_TS}, 'unread'); + expectResolvedState({ackMessageId: null, ackTimestamp: LAST_TS}, 'read'); expectResolvedState({ackMessageId: ACK_ID}, 'unread'); expectResolvedState({ackMessageId: LAST_ID}, 'read'); }); diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.ts index ee72ba7d6..de2fc7dda 100644 --- a/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.ts +++ b/fluxer_app/src/features/read_state/state/read_states/ReadStateEntryStatusMachine.ts @@ -6,9 +6,10 @@ import {compareMessageIds} from './shared'; export interface ReadStateEntryStatusInput { supportsUnreadTracking: boolean; hasBlockedDirectMessageRecipient: boolean; - readStateKnown: boolean; lastMessageId: string | null; ackMessageId: string | null; + ackTimestamp: number; + lastMessageTimestamp: number; mentionCount: number; } @@ -17,7 +18,7 @@ export type ReadStateEntryStatusEvent = { input: ReadStateEntryStatusInput; }; -export type ReadStateEntryStatusValue = 'untracked' | 'blocked' | 'unknown' | 'read' | 'unread'; +export type ReadStateEntryStatusValue = 'untracked' | 'blocked' | 'read' | 'unread'; export interface ReadStateEntryStatusModel { state: ReadStateEntryStatusValue; @@ -34,8 +35,6 @@ function getStatusValue(snapshot: ReadStateEntryStatusSnapshot): ReadStateEntryS return 'untracked'; case 'blocked': return 'blocked'; - case 'unknown': - return 'unknown'; case 'unread': return 'unread'; default: @@ -44,14 +43,16 @@ function getStatusValue(snapshot: ReadStateEntryStatusSnapshot): ReadStateEntryS } function isUnread(context: ReadStateEntryStatusInput): boolean { - if (!context.readStateKnown || context.lastMessageId == null) return false; - return compareMessageIds(context.ackMessageId, context.lastMessageId) < 0; + if (context.lastMessageId == null) return false; + if (context.ackMessageId != null) { + return compareMessageIds(context.ackMessageId, context.lastMessageId) < 0; + } + return context.ackTimestamp < context.lastMessageTimestamp; } function getStatusValueFromInput(input: ReadStateEntryStatusInput): ReadStateEntryStatusValue { if (!input.supportsUnreadTracking) return 'untracked'; if (input.hasBlockedDirectMessageRecipient) return 'blocked'; - if (!input.readStateKnown || input.lastMessageId == null) return 'unknown'; if (isUnread(input)) return 'unread'; return 'read'; } @@ -89,7 +90,6 @@ export const readStateEntryStatusMachine = setup({ guards: { isUntracked: ({context}) => !context.supportsUnreadTracking, isBlocked: ({context}) => context.hasBlockedDirectMessageRecipient, - isUnknown: ({context}) => !context.readStateKnown || context.lastMessageId == null, isUnread: ({context}) => isUnread(context), }, }).createMachine({ @@ -101,7 +101,6 @@ export const readStateEntryStatusMachine = setup({ always: [ {guard: 'isUntracked', target: 'untracked'}, {guard: 'isBlocked', target: 'blocked'}, - {guard: 'isUnknown', target: 'unknown'}, {guard: 'isUnread', target: 'unread'}, {target: 'read'}, ], @@ -112,9 +111,6 @@ export const readStateEntryStatusMachine = setup({ blocked: { on: {'readStateEntry.updated': {target: 'routing', actions: 'applyInput'}}, }, - unknown: { - on: {'readStateEntry.updated': {target: 'routing', actions: 'applyInput'}}, - }, read: { on: {'readStateEntry.updated': {target: 'routing', actions: 'applyInput'}}, }, diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateIncomingMessageMachine.test.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateIncomingMessageMachine.test.ts index 5c267b364..d778e70cb 100644 --- a/fluxer_app/src/features/read_state/state/read_states/ReadStateIncomingMessageMachine.test.ts +++ b/fluxer_app/src/features/read_state/state/read_states/ReadStateIncomingMessageMachine.test.ts @@ -22,10 +22,9 @@ function input(overrides: Partial = {}): ReadStat isAtBottom: false, authorBlocked: false, hadUnreadOrMentions: false, - readStateKnown: true, messageId: MESSAGE_ID, ackMessageId: ACK_ID, - previousLastMessageId: PREVIOUS_ID, + coveredByLastMessage: false, ...overrides, }; } @@ -63,22 +62,21 @@ describe('readStateIncomingMessageMachine', () => { expect( resolveReadStateIncomingMessageDecision( input({ - readStateKnown: false, messageId: PREVIOUS_ID, ackMessageId: null, }), ), - ).toEqual({type: 'coveredByAck'}); + ).toEqual({type: 'recordUnread', coveredByLastMessage: false}); }); - it('records unread and initializes unknown read state only when needed', () => { + it('records unread and reports whether the message is already covered by the watermark', () => { expect(resolveReadStateIncomingMessageDecision(input())).toEqual({ type: 'recordUnread', - initializeUnknownReadState: false, + coveredByLastMessage: false, }); - expect(resolveReadStateIncomingMessageDecision(input({readStateKnown: false, ackMessageId: null}))).toEqual({ + expect(resolveReadStateIncomingMessageDecision(input({coveredByLastMessage: true}))).toEqual({ type: 'recordUnread', - initializeUnknownReadState: true, + coveredByLastMessage: true, }); }); @@ -86,7 +84,7 @@ describe('readStateIncomingMessageMachine', () => { const unreadSnapshot = createReadStateIncomingMessageSnapshot(input()); expect(selectReadStateIncomingMessageDecision(unreadSnapshot)).toEqual({ type: 'recordUnread', - initializeUnknownReadState: false, + coveredByLastMessage: false, }); const autoAckSnapshot = transitionReadStateIncomingMessageSnapshot(unreadSnapshot, { diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateIncomingMessageMachine.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateIncomingMessageMachine.ts index b3df86633..b61b6eb93 100644 --- a/fluxer_app/src/features/read_state/state/read_states/ReadStateIncomingMessageMachine.ts +++ b/fluxer_app/src/features/read_state/state/read_states/ReadStateIncomingMessageMachine.ts @@ -9,10 +9,9 @@ export interface ReadStateIncomingMessageInput { isAtBottom: boolean; authorBlocked: boolean; hadUnreadOrMentions: boolean; - readStateKnown: boolean; messageId: string; ackMessageId: string | null; - previousLastMessageId: string | null; + coveredByLastMessage: boolean; } export type ReadStateIncomingMessageDecision = @@ -33,7 +32,7 @@ export type ReadStateIncomingMessageDecision = } | { type: 'recordUnread'; - initializeUnknownReadState: boolean; + coveredByLastMessage: boolean; }; export type ReadStateIncomingMessageEvent = { @@ -41,19 +40,9 @@ export type ReadStateIncomingMessageEvent = { input: ReadStateIncomingMessageInput; }; -function getEffectiveAckMessageId(context: ReadStateIncomingMessageInput): string | null { - switch (context.readStateKnown) { - case true: - return context.ackMessageId; - case false: - return context.previousLastMessageId; - } -} - -function isCoveredByEffectiveAck(context: ReadStateIncomingMessageInput): boolean { - const effectiveAckMessageId = getEffectiveAckMessageId(context); - if (effectiveAckMessageId == null) return false; - return compareMessageIds(context.messageId, effectiveAckMessageId) <= 0; +function isCoveredByAck(context: ReadStateIncomingMessageInput): boolean { + if (context.ackMessageId == null) return false; + return compareMessageIds(context.messageId, context.ackMessageId) <= 0; } function getDecision(snapshot: ReadStateIncomingMessageSnapshot): ReadStateIncomingMessageDecision { @@ -71,7 +60,7 @@ function getDecision(snapshot: ReadStateIncomingMessageSnapshot): ReadStateIncom default: return { type: 'recordUnread', - initializeUnknownReadState: !snapshot.context.readStateKnown, + coveredByLastMessage: snapshot.context.coveredByLastMessage, }; } } @@ -93,7 +82,7 @@ export const readStateIncomingMessageMachine = setup({ shouldAutomaticallyAck: ({context}) => context.automaticAckEnabled && context.isAtBottom, shouldAckBlockedMessage: ({context}) => context.authorBlocked && !context.hadUnreadOrMentions, shouldIgnoreBlockedMessage: ({context}) => context.authorBlocked, - isCoveredByAck: ({context}) => isCoveredByEffectiveAck(context), + isCoveredByAck: ({context}) => isCoveredByAck(context), }, }).createMachine({ id: 'readStateIncomingMessage', diff --git a/fluxer_app/src/features/read_state/state/read_states/shared.ts b/fluxer_app/src/features/read_state/state/read_states/shared.ts index 4b09a5ab4..5c4cc0db5 100644 --- a/fluxer_app/src/features/read_state/state/read_states/shared.ts +++ b/fluxer_app/src/features/read_state/state/read_states/shared.ts @@ -65,7 +65,6 @@ export interface PendingAck { export interface ArchivedReadState { ackMessageId: string | null; acknowledgedPinTimestamp: number; - readStateKnown: boolean; } export function parseTimestamp(timestamp?: string | null): number {