mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(app): derive unread from an ack timestamp floor (#2717)
This commit is contained in:
1 parent
e1bc6c2f7e
commit
6d600990fe
19 files changed
+307
-460
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<HTMLDivElement | null>(null);
|
||||
const lastStateSnapshotRef = useRef<MessagesStateSnapshot | null>(null);
|
||||
const recoveryFetchChannelIdRef = useRef<string | null>(null);
|
||||
const tailProbeKeyRef = useRef<string | null>(null);
|
||||
const tailProbeAttemptsRef = useRef<{key: string; attempts: number} | null>(null);
|
||||
const [settledTailProbeKey, setSettledTailProbeKey] = useState<string | null>(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);
|
||||
|
||||
@@ -70,6 +70,7 @@ class Guilds {
|
||||
}
|
||||
this.guilds[guild.id] = new Guild({
|
||||
...guild,
|
||||
joined_at: guild.joined_at ?? existingGuild.joinedAt ?? undefined,
|
||||
roles: existingGuild.roles,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Array<WireMessage>>;
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ChannelMessagesWindowInput> = {},
|
||||
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<Parameters<typeof selectChannelMessagesTailProbeOutcome>[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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, number>();
|
||||
|
||||
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<WireMessage>;
|
||||
}): 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);
|
||||
|
||||
@@ -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<string, Channel>();
|
||||
const guilds = new Map<string, {joinedAt: string | null}>();
|
||||
const blockedUserIds = new Set<string>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, Channel>();
|
||||
const guilds = new Map<string, {joinedAt: string | null}>();
|
||||
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: {
|
||||
|
||||
@@ -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<string, Channel>();
|
||||
const guilds = new Map<string, {joinedAt: string | null}>();
|
||||
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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+15
-7
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+7
-3
@@ -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> = {}): 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');
|
||||
});
|
||||
|
||||
+8
-12
@@ -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'}},
|
||||
},
|
||||
|
||||
+7
-9
@@ -22,10 +22,9 @@ function input(overrides: Partial<ReadStateIncomingMessageInput> = {}): 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, {
|
||||
|
||||
+7
-18
@@ -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',
|
||||
|
||||
@@ -65,7 +65,6 @@ export interface PendingAck {
|
||||
export interface ArchivedReadState {
|
||||
ackMessageId: string | null;
|
||||
acknowledgedPinTimestamp: number;
|
||||
readStateKnown: boolean;
|
||||
}
|
||||
|
||||
export function parseTimestamp(timestamp?: string | null): number {
|
||||
|
||||
Reference in new issue
Block a user