diff --git a/desktop/src/features/huddle/components/HuddleAttachment.tsx b/desktop/src/features/huddle/components/HuddleAttachment.tsx index db64e20bd7..758c0b80f7 100644 --- a/desktop/src/features/huddle/components/HuddleAttachment.tsx +++ b/desktop/src/features/huddle/components/HuddleAttachment.tsx @@ -6,12 +6,7 @@ import { toast } from "sonner"; import type { TimelineMessage } from "@/features/messages/types"; import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; -import { - KIND_HUDDLE_ENDED, - KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, - KIND_HUDDLE_STARTED, -} from "@/shared/constants/kinds"; +import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { Attachment, @@ -23,8 +18,15 @@ import { AttachmentTitle, } from "@/shared/ui/attachment"; import { useHuddle } from "../HuddleContext"; -import { isHuddleStartStale } from "../lib/huddleCardState"; import { formatHuddleActionError } from "../lib/huddleError"; +import { + createHuddleReplayTracker, + type HuddleLifecycleState, + huddleParticipantDisplayCount, + huddleStalenessDelayMs, + recordHuddleSubscriptionEvent, + reconstructHuddleState, +} from "../lib/huddleLifecycleState"; type HuddleAttachmentProps = { channelId: string | null; @@ -33,11 +35,6 @@ type HuddleAttachmentProps = { onOpenThread?: (message: TimelineMessage) => void; }; -type HuddleLifecycleState = { - ended: boolean; - participants: Set; -}; - function parseEphemeralChannelId(content: string): string | null { try { const parsed = JSON.parse(content) as { ephemeral_channel_id?: unknown }; @@ -49,71 +46,22 @@ function parseEphemeralChannelId(content: string): string | null { } } -function lifecycleEventChannelId(event: RelayEvent): string | null { - return parseEphemeralChannelId(event.content); -} - -function lifecycleParticipant(event: RelayEvent): string | null { - return ( - event.tags.find( - (tag) => tag[0] === "p" && typeof tag[1] === "string", - )?.[1] ?? - event.pubkey ?? - null - ); -} - -function reconstructHuddleLifecycle( - events: Iterable, - fallbackCreatorPubkey: string | undefined, - ephemeralChannelId: string, -): HuddleLifecycleState { - const sorted = [...events] - .filter((event) => lifecycleEventChannelId(event) === ephemeralChannelId) - .sort( - (left, right) => - left.created_at - right.created_at || - left.kind - right.kind || - left.id.localeCompare(right.id), - ); - const participants = new Set(); - let ended = false; - - if (fallbackCreatorPubkey) { - participants.add(fallbackCreatorPubkey); - } - - for (const event of sorted) { - switch (event.kind) { - case KIND_HUDDLE_STARTED: - ended = false; - if (event.pubkey) participants.add(event.pubkey); - break; - case KIND_HUDDLE_PARTICIPANT_JOINED: { - if (ended) break; - const pubkey = lifecycleParticipant(event); - if (pubkey) participants.add(pubkey); - break; - } - case KIND_HUDDLE_PARTICIPANT_LEFT: { - if (ended) break; - const pubkey = lifecycleParticipant(event); - if (pubkey) participants.delete(pubkey); - break; - } - case KIND_HUDDLE_ENDED: - ended = true; - break; - } - } - - return { ended, participants }; -} - function participantLabel(count: number) { return `${count} participant${count === 1 ? "" : "s"}`; } +function messageLifecycleEvent(message: TimelineMessage): RelayEvent { + return { + id: message.id, + pubkey: message.pubkey ?? "", + kind: message.kind ?? KIND_HUDDLE_STARTED, + created_at: message.createdAt, + content: message.body, + tags: message.tags ?? [], + sig: "", + }; +} + export function HuddleAttachment({ channelId, className, @@ -125,13 +73,26 @@ export function HuddleAttachment({ [message.body], ); const { activeEphemeralChannelId, isStarting, joinHuddle } = useHuddle(); + const isCurrentHuddle = + Boolean(ephemeralChannelId) && + activeEphemeralChannelId === ephemeralChannelId; const queryClient = useQueryClient(); const [isJoining, setIsJoining] = React.useState(false); const [lifecycleState, setLifecycleState] = - React.useState(() => ({ - ended: false, - participants: new Set(message.pubkey ? [message.pubkey] : []), - })); + React.useState(() => + ephemeralChannelId + ? reconstructHuddleState( + [messageLifecycleEvent(message)], + ephemeralChannelId, + { isCurrentHuddle }, + ) + : { + ended: true, + participants: new Set(), + startCreatedAt: null, + staleDeadlineMs: null, + }, + ); React.useEffect(() => { if (!channelId || !ephemeralChannelId) return; @@ -139,6 +100,9 @@ export function HuddleAttachment({ const huddleChannelId = ephemeralChannelId; let disposed = false; let cleanup: (() => void) | null = null; + let staleTimeout: ReturnType | null = null; + const replayTracker = createHuddleReplayTracker(); + const seenChannelEventIds = new Set(); const seenEvents = new Map([ [ message.id, @@ -153,26 +117,67 @@ export function HuddleAttachment({ }, ], ]); + replayTracker.markReplayStarted(seenEvents.values()); function updateState() { if (disposed) return; - setLifecycleState( - reconstructHuddleLifecycle( - seenEvents.values(), - message.pubkey, - huddleChannelId, - ), + if (staleTimeout) clearTimeout(staleTimeout); + const state = reconstructHuddleState( + seenEvents.values(), + huddleChannelId, + { + historyMayBeTruncated: replayTracker.historyMayBeTruncatedForEvents( + seenEvents.values(), + ), + isCurrentHuddle, + replayComplete: replayTracker.replayComplete(), + replayInProgress: replayTracker.replayInProgress(), + }, ); + setLifecycleState(state); + const staleDelay = state.ended + ? null + : huddleStalenessDelayMs(state.staleDeadlineMs); + if (staleDelay !== null) + staleTimeout = setTimeout(updateState, staleDelay); } updateState(); relayClient - .subscribeToHuddleEvents(channelId, (event) => { - if (disposed || seenEvents.has(event.id)) return; - if (lifecycleEventChannelId(event) !== huddleChannelId) return; - seenEvents.set(event.id, event); - updateState(); - }) + .subscribeToHuddleEvents( + channelId, + (event) => { + if (disposed) return; + replayTracker.recordReplayEvent(event); + if ( + !recordHuddleSubscriptionEvent( + seenChannelEventIds, + seenEvents, + huddleChannelId, + event, + ) + ) + return; + updateState(); + }, + { + onEose: () => { + if (disposed) return; + replayTracker.markReplayComplete(); + updateState(); + }, + onTerminalClose: () => { + if (disposed) return; + replayTracker.markReplayFailed(); + updateState(); + }, + onReplayStart: () => { + if (disposed) return; + replayTracker.markReplayStarted(seenEvents.values()); + updateState(); + }, + }, + ) .then((dispose) => { if (disposed) { void dispose(); @@ -181,16 +186,21 @@ export function HuddleAttachment({ cleanup = () => void dispose(); }) .catch((error) => { + if (disposed) return; console.error("[HuddleAttachment] subscription failed:", error); + replayTracker.markReplayFailed(); + updateState(); }); return () => { disposed = true; + if (staleTimeout) clearTimeout(staleTimeout); cleanup?.(); }; }, [ channelId, ephemeralChannelId, + isCurrentHuddle, message.body, message.createdAt, message.id, @@ -199,21 +209,15 @@ export function HuddleAttachment({ message.tags, ]); - const participantCount = Math.max(1, lifecycleState.participants.size); + const participantCount = huddleParticipantDisplayCount( + lifecycleState.participants, + { isCurrentHuddle }, + ); const isEnded = lifecycleState.ended; - const isCurrentHuddle = - Boolean(ephemeralChannelId) && - activeEphemeralChannelId === ephemeralChannelId; - const isStaleUnconfirmedHuddle = - !isCurrentHuddle && isHuddleStartStale(message.createdAt); const canJoin = Boolean( - channelId && - ephemeralChannelId && - !isEnded && - !isCurrentHuddle && - !isStaleUnconfirmedHuddle, + channelId && ephemeralChannelId && !isEnded && !isCurrentHuddle, ); - const displayEnded = isEnded || isStaleUnconfirmedHuddle; + const displayEnded = isEnded; async function handleJoin() { if (!channelId || !ephemeralChannelId || isJoining || isStarting) return; diff --git a/desktop/src/features/huddle/components/HuddleIndicator.tsx b/desktop/src/features/huddle/components/HuddleIndicator.tsx index 19b504cf28..85ede1aead 100644 --- a/desktop/src/features/huddle/components/HuddleIndicator.tsx +++ b/desktop/src/features/huddle/components/HuddleIndicator.tsx @@ -6,22 +6,28 @@ import { useQueryClient } from "@tanstack/react-query"; import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; +import { KIND_HUDDLE_PARTICIPANT_LEFT } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle } from "../HuddleContext"; import { formatHuddleActionError } from "../lib/huddleError"; - -/** Huddle lifecycle event kinds */ -const KIND_HUDDLE_STARTED = 48100; -const KIND_HUDDLE_PARTICIPANT_JOINED = 48101; -const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; -const KIND_HUDDLE_ENDED = 48103; +import { + createHuddleReplayTracker, + huddleEventClearsSuppressionForState, + huddleEventChannelId, + huddleParticipantDisplayCount, + huddleStalenessDelayMs, + reconstructHuddleState, + resolveIdleHuddleTransition, + selectActiveHuddleState, +} from "../lib/huddleLifecycleState"; type ActiveHuddle = { ephemeralChannelId: string; participants: Set; + staleDeadlineMs: number | null; }; type HuddleIndicatorProps = { @@ -46,127 +52,149 @@ export function HuddleIndicator({ onStart, startDisabled, }: HuddleIndicatorProps) { - const { joinHuddle, isStarting } = useHuddle(); + const { activeEphemeralChannelId, joinHuddle, isStarting } = useHuddle(); const queryClient = useQueryClient(); const [activeHuddle, setActiveHuddle] = React.useState( null, ); const [isJoining, setIsJoining] = React.useState(false); + const activeEphemeralChannelIdRef = React.useRef(activeEphemeralChannelId); + const activeHuddleRef = React.useRef(null); + const lastActiveEphemeralChannelIdRef = React.useRef( + activeEphemeralChannelId, + ); + const suppressedEphemeralChannelIdRef = React.useRef(null); + const reconstructActiveHuddleRef = React.useRef<(() => void) | null>(null); + + activeEphemeralChannelIdRef.current = activeEphemeralChannelId; + + if (activeEphemeralChannelId) { + lastActiveEphemeralChannelIdRef.current = activeEphemeralChannelId; + suppressedEphemeralChannelIdRef.current = null; + } + + const setDisplayedHuddle = React.useCallback( + (huddle: ActiveHuddle | null) => { + activeHuddleRef.current = huddle; + setActiveHuddle(huddle); + }, + [], + ); React.useEffect(() => { if (!channelId) return; let disposed = false; let cleanup: (() => void) | null = null; + let staleTimeout: ReturnType | null = null; + const replayTracker = createHuddleReplayTracker(); // Track all seen events for reconstruction. Keyed by event.id for dedup. const seenEvents = new Map(); - /** Reconstruct huddle state from the full set of seen events. - * Sort by created_at, then kind (causal: start < join < left < end), - * then event id for final tiebreak. This handles out-of-order delivery, - * reconnect replay, late mounts, and same-second event batches. - * - * Resilient to missing start event: if we see join/left events for an - * ephemeral channel without a prior start, we infer the huddle exists. - * This covers the edge case where >100 lifecycle events push the start - * event out of the subscription window. */ function reconstruct() { - const sorted = [...seenEvents.values()].sort( - (a, b) => - a.created_at - b.created_at || - a.kind - b.kind || - a.id.localeCompare(b.id), - ); - - let huddle: ActiveHuddle | null = null; - // Track ended ephemeral channels so late-arriving join/left events - // (e.g. relay-emitted 48102 that lands 1s after a client-emitted 48103) - // don't resurrect a phantom huddle via the "infer huddle exists" fallback. - const endedChannels = new Set(); - - for (const ev of sorted) { - let ephId: string | null = null; - try { - const content = JSON.parse(ev.content); - ephId = content.ephemeral_channel_id ?? null; - } catch { - continue; // Malformed — skip - } - - switch (ev.kind) { - case KIND_HUDDLE_STARTED: { - if (!ephId) break; - // A new start supersedes any previous ended state for this channel. - endedChannels.delete(ephId); - huddle = { - ephemeralChannelId: ephId, - participants: new Set([ev.pubkey]), - }; - break; - } - case KIND_HUDDLE_PARTICIPANT_JOINED: { - if (!ephId) break; - // Skip if this ephemeral channel has already ended — don't - // resurrect a phantom huddle from a late-arriving relay event. - if (endedChannels.has(ephId)) break; - // 48101 events are relay-signed — the actual participant is in the "p" tag. - const joinedPk = - ev.tags.find((t) => t[0] === "p")?.[1] ?? ev.pubkey; - if (!huddle || ephId !== huddle.ephemeralChannelId) { - huddle = { - ephemeralChannelId: ephId, - participants: new Set(), - }; - } - huddle.participants.add(joinedPk); - break; - } - case KIND_HUDDLE_PARTICIPANT_LEFT: { - if (!ephId) break; - // Skip if this ephemeral channel has already ended. - if (endedChannels.has(ephId)) break; - // 48102 events are relay-signed — the actual participant is in the "p" tag. - const leftPk = ev.tags.find((t) => t[0] === "p")?.[1] ?? ev.pubkey; - if (!huddle || ephId !== huddle.ephemeralChannelId) { - huddle = { - ephemeralChannelId: ephId, - participants: new Set(), - }; - } - huddle.participants.delete(leftPk); - break; - } - case KIND_HUDDLE_ENDED: { - if (!ephId) break; - endedChannels.add(ephId); - if (huddle && ephId === huddle.ephemeralChannelId) { - huddle = null; - } - break; + if (staleTimeout) clearTimeout(staleTimeout); + const selected = selectActiveHuddleState(seenEvents.values(), { + activeEphemeralChannelId: activeEphemeralChannelIdRef.current, + historyMayBeTruncatedForEvents: + replayTracker.historyMayBeTruncatedForEvents, + replayComplete: replayTracker.replayComplete(), + replayInProgress: replayTracker.replayInProgress(), + suppressedEphemeralChannelId: suppressedEphemeralChannelIdRef.current, + }); + const huddle: ActiveHuddle | null = selected + ? { + ephemeralChannelId: selected.ephemeralChannelId, + participants: selected.state.participants, + staleDeadlineMs: selected.state.staleDeadlineMs, } - } - } + : null; if (!disposed) { - setActiveHuddle(huddle); + setDisplayedHuddle(huddle); + const staleDelay = huddle + ? huddleStalenessDelayMs(huddle.staleDeadlineMs) + : null; + if (staleDelay !== null) { + staleTimeout = setTimeout(reconstruct, staleDelay); + } } } + reconstructActiveHuddleRef.current = reconstruct; // Subscribe to huddle lifecycle events only (kinds 48100–48103). // limit: 100 covers long-lived huddles with many join/leave cycles. relayClient - .subscribeToHuddleEvents(channelId, (event: RelayEvent) => { - if (disposed) return; + .subscribeToHuddleEvents( + channelId, + (event: RelayEvent) => { + if (disposed) return; + replayTracker.recordReplayEvent(event); - // Dedup by event ID — ignore replayed events from reconnect. - if (seenEvents.has(event.id)) return; - seenEvents.set(event.id, event); + // Dedup by event ID — ignore replayed events from reconnect. + if (seenEvents.has(event.id)) return; + seenEvents.set(event.id, event); + const suppressedEphemeralChannelId = + suppressedEphemeralChannelIdRef.current; + const eventEphemeralChannelId = huddleEventChannelId(event); + const suppressedHuddleState = + suppressedEphemeralChannelId !== null && + eventEphemeralChannelId === suppressedEphemeralChannelId && + event.kind === KIND_HUDDLE_PARTICIPANT_LEFT + ? reconstructHuddleState( + seenEvents.values(), + suppressedEphemeralChannelId, + { + historyMayBeTruncated: + replayTracker.historyMayBeTruncatedForEvents( + seenEvents.values(), + ), + isCurrentHuddle: + activeEphemeralChannelIdRef.current === + suppressedEphemeralChannelId, + replayComplete: replayTracker.replayComplete(), + replayInProgress: replayTracker.replayInProgress(), + }, + ) + : null; + if ( + suppressedEphemeralChannelId !== null && + eventEphemeralChannelId === suppressedEphemeralChannelId && + huddleEventClearsSuppressionForState( + event, + suppressedHuddleState ?? { + ended: false, + participants: new Set(), + staleDeadlineMs: null, + startCreatedAt: null, + }, + ) + ) { + suppressedEphemeralChannelIdRef.current = null; + } - // Reconstruct from full history on every new event. - // This is cheap — huddle lifecycle events are rare (typically <20). - reconstruct(); - }) + // Reconstruct from full history on every new event. + // This is cheap — huddle lifecycle events are rare (typically <20). + reconstruct(); + }, + { + onEose: () => { + if (disposed) return; + replayTracker.markReplayComplete(); + reconstruct(); + }, + onTerminalClose: () => { + if (disposed) return; + replayTracker.markReplayFailed(); + reconstruct(); + }, + onReplayStart: () => { + if (disposed) return; + replayTracker.markReplayStarted(seenEvents.values()); + reconstruct(); + }, + }, + ) .then((dispose) => { if (disposed) { void dispose(); @@ -175,15 +203,22 @@ export function HuddleIndicator({ cleanup = () => void dispose(); }) .catch((err) => { + if (disposed) return; console.error("[HuddleIndicator] subscription failed:", err); + replayTracker.markReplayFailed(); + reconstruct(); }); return () => { disposed = true; + if (staleTimeout) clearTimeout(staleTimeout); cleanup?.(); - setActiveHuddle(null); + if (reconstructActiveHuddleRef.current === reconstruct) { + reconstructActiveHuddleRef.current = null; + } + setDisplayedHuddle(null); }; - }, [channelId]); + }, [channelId, setDisplayedHuddle]); // When the local user ends/leaves a huddle, the backend transitions to idle // and emits huddle-state-changed. Clear the indicator immediately rather than @@ -193,11 +228,32 @@ export function HuddleIndicator({ let unlisten: (() => void) | null = null; let cancelled = false; - listen<{ phase: string }>("huddle-state-changed", (event) => { - if (!cancelled && event.payload.phase === "idle") { - setActiveHuddle(null); - } - }).then((fn) => { + listen<{ ephemeral_channel_id: string | null; phase: string }>( + "huddle-state-changed", + (event) => { + if (!cancelled && event.payload.phase === "idle") { + const transition = resolveIdleHuddleTransition({ + activeEphemeralChannelId: activeEphemeralChannelIdRef.current, + displayedEphemeralChannelId: + activeHuddleRef.current?.ephemeralChannelId, + eventEphemeralChannelId: event.payload.ephemeral_channel_id, + lastActiveEphemeralChannelId: + lastActiveEphemeralChannelIdRef.current, + }); + if (transition.suppressedEphemeralChannelId) { + suppressedEphemeralChannelIdRef.current = + transition.suppressedEphemeralChannelId; + if ( + transition.suppressedEphemeralChannelId === + lastActiveEphemeralChannelIdRef.current + ) { + lastActiveEphemeralChannelIdRef.current = null; + } + reconstructActiveHuddleRef.current?.(); + } + } + }, + ).then((fn) => { if (cancelled) fn(); else unlisten = fn; }); @@ -251,10 +307,13 @@ export function HuddleIndicator({ ); } - // At least 1 participant must exist for the huddle to be active. - // When START fell out of the event window, the creator isn't in the - // reconstructed set — floor at 1 to avoid showing "0 participants". - const participantCount = Math.max(1, activeHuddle.participants.size); + const participantCount = huddleParticipantDisplayCount( + activeHuddle.participants, + { + isCurrentHuddle: + activeHuddle.ephemeralChannelId === activeEphemeralChannelId, + }, + ); async function doJoin() { if (!activeHuddle || isJoining) return; diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs new file mode 100644 index 0000000000..0ef90ca8e4 --- /dev/null +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs @@ -0,0 +1,1547 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createHuddleReplayTracker, + huddleEventClearsSuppression, + huddleEventClearsSuppressionForState, + huddleParticipantDisplayCount, + huddleStalenessDelayMs, + recordHuddleSubscriptionEvent, + reconstructHuddleState, + resolveIdleHuddleTransition, + selectActiveHuddleState, +} from "./huddleLifecycleState.ts"; + +const HUDDLE_ID = "huddle-id"; +const CREATOR = "a".repeat(64); +const PARTICIPANT = "b".repeat(64); +const NOW_SECONDS = 2_000_000; + +function lifecycleEvent(kind, overrides = {}) { + return { + id: `${kind}-${overrides.created_at ?? NOW_SECONDS}`, + pubkey: CREATOR, + created_at: NOW_SECONDS, + kind, + tags: [], + content: JSON.stringify({ ephemeral_channel_id: HUDDLE_ID }), + sig: "", + ...overrides, + }; +} + +function eventForHuddle(kind, ephemeralChannelId, overrides = {}) { + return lifecycleEvent(kind, { + content: JSON.stringify({ ephemeral_channel_id: ephemeralChannelId }), + ...overrides, + }); +} + +test("reconstructHuddleState ends an explicitly ended huddle", () => { + const state = reconstructHuddleState( + [lifecycleEvent(48100), lifecycleEvent(48103)], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, true); + assert.equal(state.startCreatedAt, NOW_SECONDS); +}); + +test("reconstructHuddleState folds the participant roster for an ended huddle", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 4 }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 3, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 2, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }), + lifecycleEvent(48103), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, true); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState keeps an in-flight empty roster joinable without END", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 2 }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000, replayComplete: false }, + ); + + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); + + const completeState = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 2 }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000, replayComplete: true }, + ); + + assert.equal(completeState.ended, true); + assert.equal(completeState.participants.size, 0); +}); + +test("reconstructHuddleState keeps truncated empty rosters inconclusive after EOSE", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 2 }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { + historyMayBeTruncated: true, + nowMs: NOW_SECONDS * 1000, + replayComplete: true, + }, + ); + + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState keeps a fully empty relay roster joinable", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100), + lifecycleEvent(48101, { tags: [["p", PARTICIPANT]] }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS + 1, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS + 1, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { nowMs: (NOW_SECONDS + 1) * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState does not double-count the creator START seed", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 3 }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 2, + id: "creator-join", + tags: [["p", CREATOR]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + id: "creator-left", + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState preserves same-pubkey peer multiplicity", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 5 }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 4, + id: "creator-left", + tags: [["p", CREATOR]], + }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 3, + id: "participant-join-one", + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 2, + id: "participant-join-two", + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + id: "participant-left-one", + tags: [["p", PARTICIPANT]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.deepEqual([...state.participants], [PARTICIPANT]); +}); + +test("reconstructHuddleState ends a stale huddle and retains its start time", () => { + const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; + const state = reconstructHuddleState( + [lifecycleEvent(48100, { created_at: startCreatedAt })], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, true); + assert.equal(state.startCreatedAt, startCreatedAt); + assert.deepEqual([...state.participants], [CREATOR]); +}); + +test("reconstructHuddleState ends stale lifecycle evidence after participant events", () => { + const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: startCreatedAt }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 1, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + tags: [["p", PARTICIPANT]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000, replayComplete: true }, + ); + + assert.equal(state.ended, true); + assert.equal(state.staleDeadlineMs, (startCreatedAt + 60 * 60) * 1000 + 1); + assert.deepEqual([...state.participants], [CREATOR]); +}); + +test("reconstructHuddleState documents bounded staleness extension under maximum future skew", () => { + const maxClientClockSkewSeconds = 15 * 60; + const startCreatedAt = NOW_SECONDS + maxClientClockSkewSeconds; + const state = reconstructHuddleState( + [lifecycleEvent(48100, { created_at: startCreatedAt })], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.staleDeadlineMs, (startCreatedAt + 60 * 60) * 1000 + 1); +}); + +test("reconstructHuddleState keeps a live relay participant after START staleness", () => { + const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: startCreatedAt }), + lifecycleEvent(48101, { tags: [["p", PARTICIPANT]] }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.staleDeadlineMs, null); + assert.deepEqual([...state.participants], [CREATOR, PARTICIPANT]); +}); + +test("reconstructHuddleState preserves a JOIN timestamped before START", () => { + const startCreatedAt = NOW_SECONDS - 10; + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: startCreatedAt }), + lifecycleEvent(48101, { + created_at: startCreatedAt - 5, + tags: [["p", PARTICIPANT]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.staleDeadlineMs, null); + assert.deepEqual([...state.participants], [CREATOR, PARTICIPANT]); +}); + +test("reconstructHuddleState applies a LEFT timestamped before START", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 5, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState keeps an empty relay roster joinable under START clock skew", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 4, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 3, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState keeps the current huddle active past START age", () => { + const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; + const state = reconstructHuddleState( + [lifecycleEvent(48100, { created_at: startCreatedAt })], + HUDDLE_ID, + { isCurrentHuddle: true, nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.staleDeadlineMs, null); + assert.deepEqual([...state.participants], [CREATOR]); +}); + +test("reconstructHuddleState keeps the current huddle active after a local leave", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 2 }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { + isCurrentHuddle: true, + nowMs: NOW_SECONDS * 1000, + replayComplete: true, + }, + ); + + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState keeps real joins active when START aged out", () => { + const state = reconstructHuddleState( + [lifecycleEvent(48101, { tags: [["p", PARTICIPANT]] })], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.startCreatedAt, null); + assert.deepEqual([...state.participants], [PARTICIPANT]); +}); + +test("reconstructHuddleState treats empty truncated history as inconclusive", () => { + const events = [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 100 }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 99, + tags: [["p", CREATOR]], + }), + ]; + for (let index = 0; index < 50; index += 1) { + const participant = `participant-${index}`; + events.push( + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 50 + index, + tags: [["p", participant]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 50 + index, + tags: [["p", participant]], + }), + ); + } + + const state = reconstructHuddleState(events.slice(-100), HUDDLE_ID, { + nowMs: NOW_SECONDS * 1000, + }); + + assert.equal(state.ended, false); + assert.equal(state.startCreatedAt, null); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState keeps a skew-retained START inconclusive when history is truncated", () => { + const events = [lifecycleEvent(48100)]; + for (let index = 0; index < 49; index += 1) { + const participant = `participant-${index}`; + events.push( + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 100 + index * 2, + tags: [["p", participant]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 99 + index * 2, + tags: [["p", participant]], + }), + ); + } + events.push( + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }), + ); + + const state = reconstructHuddleState(events, HUDDLE_ID, { + historyMayBeTruncated: true, + nowMs: NOW_SECONDS * 1000, + }); + + assert.equal(events.length, 100); + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + +test("recordHuddleSubscriptionEvent preserves channel-wide truncation before huddle filtering", () => { + const seenChannelEventIds = new Set(); + const seenHuddleEvents = new Map(); + const start = lifecycleEvent(48100); + seenHuddleEvents.set(start.id, start); + + for (let index = 0; index < 99; index += 1) { + const event = eventForHuddle(48101, `unrelated-huddle-${index}`, { + id: `unrelated-event-${index}`, + created_at: NOW_SECONDS - 100 + index, + tags: [["p", `participant-${index}`]], + }); + assert.equal( + recordHuddleSubscriptionEvent( + seenChannelEventIds, + seenHuddleEvents, + HUDDLE_ID, + event, + ), + true, + ); + } + + const retainedLeft = lifecycleEvent(48102, { + id: "retained-left", + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }); + recordHuddleSubscriptionEvent( + seenChannelEventIds, + seenHuddleEvents, + HUDDLE_ID, + retainedLeft, + ); + + const state = reconstructHuddleState(seenHuddleEvents.values(), HUDDLE_ID, { + historyMayBeTruncated: seenChannelEventIds.size >= 100, + nowMs: NOW_SECONDS * 1000, + }); + + assert.equal(seenChannelEventIds.size, 100); + assert.equal(seenHuddleEvents.size, 2); + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + +test("createHuddleReplayTracker freezes truncation at the initial replay boundary", () => { + const tracker = createHuddleReplayTracker(); + + tracker.recordReplayEvent(lifecycleEvent(48100, { id: "initial-event" })); + tracker.markReplayComplete(); + + for (let index = 0; index < 99; index += 1) { + tracker.recordReplayEvent( + lifecycleEvent(48100, { id: `live-event-${index}` }), + ); + } + + assert.equal( + tracker.historyMayBeTruncated(), + false, + "live accumulation after initial replay must not mark history truncated", + ); + + const fullTracker = createHuddleReplayTracker(); + for (let index = 0; index < 100; index += 1) { + fullTracker.recordReplayEvent( + lifecycleEvent(48100, { id: `initial-event-${index}` }), + ); + } + + assert.equal( + fullTracker.historyMayBeTruncated(), + true, + "initial replay at the relay limit must still mark history truncated", + ); +}); + +test("createHuddleReplayTracker detects truncation during reconnect replay", () => { + const tracker = createHuddleReplayTracker(); + const initialEvent = lifecycleEvent(48100, { id: "initial-event" }); + + tracker.recordReplayEvent(initialEvent); + tracker.markReplayComplete(); + assert.equal(tracker.replayComplete(), true); + assert.equal(tracker.historyMayBeTruncated(), false); + + tracker.markReplayStarted([initialEvent]); + assert.equal(tracker.replayComplete(), false); + for (let index = 0; index < 100; index += 1) { + tracker.recordReplayEvent( + lifecycleEvent(48100, { id: `replay-event-${index}` }), + ); + } + tracker.markReplayComplete(); + + assert.equal(tracker.replayComplete(), true); + assert.equal(tracker.historyMayBeTruncated(), true); +}); + +test("createHuddleReplayTracker marks terminal replay failure as not in progress", () => { + const tracker = createHuddleReplayTracker(2); + tracker.markReplayStarted([lifecycleEvent(48100, { id: "retained-start" })]); + tracker.recordReplayEvent( + eventForHuddle(48100, "unrelated-huddle-1", { id: "unrelated-1" }), + ); + tracker.recordReplayEvent( + eventForHuddle(48100, "unrelated-huddle-2", { id: "unrelated-2" }), + ); + + tracker.markReplayFailed(); + + assert.equal(tracker.replayComplete(), false); + assert.equal(tracker.replayInProgress(), false); + assert.equal(tracker.historyMayBeTruncated(), true); +}); + +test("createHuddleReplayTracker deduplicates deliveries within a replay window", () => { + const tracker = createHuddleReplayTracker(); + const duplicateEvent = lifecycleEvent(48100, { id: "duplicate-event" }); + + for (let index = 0; index < 100; index += 1) { + tracker.recordReplayEvent(duplicateEvent); + } + + assert.equal(tracker.historyMayBeTruncated(), false); + + for (let index = 0; index < 99; index += 1) { + tracker.recordReplayEvent( + lifecycleEvent(48100, { id: `unique-event-${index}` }), + ); + } + + assert.equal(tracker.historyMayBeTruncated(), true); +}); + +test("createHuddleReplayTracker scopes truncation to replayed event ids", () => { + const tracker = createHuddleReplayTracker(2); + const replayedStart = lifecycleEvent(48100, { id: "replayed-start" }); + const replayedLeft = lifecycleEvent(48102, { + id: "replayed-left", + tags: [["p", CREATOR]], + }); + + tracker.recordReplayEvent(replayedStart); + tracker.recordReplayEvent(replayedLeft); + tracker.markReplayComplete(); + + assert.equal(tracker.historyMayBeTruncated(), true); + assert.equal( + tracker.historyMayBeTruncatedForEvents([replayedStart, replayedLeft]), + true, + ); + + const liveStart = lifecycleEvent(48100, { + id: "live-start", + created_at: NOW_SECONDS + 1, + }); + const liveLeft = lifecycleEvent(48102, { + id: "live-left", + created_at: NOW_SECONDS + 2, + tags: [["p", CREATOR]], + }); + + assert.equal( + tracker.historyMayBeTruncatedForEvents([liveStart, liveLeft]), + false, + ); + assert.equal( + reconstructHuddleState([liveStart, liveLeft], HUDDLE_ID, { + historyMayBeTruncated: tracker.historyMayBeTruncatedForEvents([ + liveStart, + liveLeft, + ]), + nowMs: (NOW_SECONDS + 2) * 1000, + }).ended, + false, + ); +}); + +test("createHuddleReplayTracker preserves replay gaps for pre-existing huddles", () => { + const tracker = createHuddleReplayTracker(2); + const retainedStart = lifecycleEvent(48100, { + id: "retained-start", + created_at: NOW_SECONDS - 10, + }); + + tracker.recordReplayEvent(retainedStart); + tracker.markReplayComplete(); + + tracker.markReplayStarted([retainedStart]); + tracker.recordReplayEvent( + eventForHuddle(48100, "unrelated-huddle-1", { id: "unrelated-1" }), + ); + tracker.recordReplayEvent( + eventForHuddle(48100, "unrelated-huddle-2", { id: "unrelated-2" }), + ); + tracker.markReplayComplete(); + + const retainedLeft = lifecycleEvent(48102, { + id: "retained-left", + created_at: NOW_SECONDS + 1, + tags: [["p", CREATOR]], + }); + + assert.equal( + tracker.historyMayBeTruncatedForEvents([retainedStart, retainedLeft]), + true, + ); + assert.equal( + reconstructHuddleState([retainedStart, retainedLeft], HUDDLE_ID, { + historyMayBeTruncated: tracker.historyMayBeTruncatedForEvents([ + retainedStart, + retainedLeft, + ]), + nowMs: (NOW_SECONDS + 1) * 1000, + }).ended, + false, + ); +}); + +test("createHuddleReplayTracker seeds initial replay gaps with a retained START", () => { + const tracker = createHuddleReplayTracker(2); + const retainedStart = lifecycleEvent(48100, { + id: "retained-start", + created_at: NOW_SECONDS - 10, + }); + + tracker.markReplayStarted([retainedStart]); + tracker.recordReplayEvent( + eventForHuddle(48100, "unrelated-huddle-1", { id: "unrelated-1" }), + ); + tracker.recordReplayEvent( + eventForHuddle(48100, "unrelated-huddle-2", { id: "unrelated-2" }), + ); + + const retainedLeft = lifecycleEvent(48102, { + id: "retained-left", + created_at: NOW_SECONDS + 1, + tags: [["p", CREATOR]], + }); + + assert.equal( + tracker.historyMayBeTruncatedForEvents([retainedStart, retainedLeft]), + true, + ); + assert.equal( + reconstructHuddleState([retainedStart, retainedLeft], HUDDLE_ID, { + historyMayBeTruncated: tracker.historyMayBeTruncatedForEvents([ + retainedStart, + retainedLeft, + ]), + nowMs: (NOW_SECONDS + 1) * 1000, + }).ended, + false, + ); +}); + +test("createHuddleReplayTracker preserves truncation when replay restarts before EOSE", () => { + const tracker = createHuddleReplayTracker(2); + const retainedStart = lifecycleEvent(48100, { + id: "retained-start", + created_at: NOW_SECONDS - 10, + }); + + tracker.markReplayStarted([retainedStart]); + tracker.recordReplayEvent( + eventForHuddle(48100, "unrelated-huddle-1", { id: "unrelated-1" }), + ); + tracker.recordReplayEvent( + eventForHuddle(48100, "unrelated-huddle-2", { id: "unrelated-2" }), + ); + + tracker.markReplayStarted([retainedStart]); + + const retainedLeft = lifecycleEvent(48102, { + id: "retained-left", + created_at: NOW_SECONDS + 1, + tags: [["p", CREATOR]], + }); + + assert.equal( + tracker.historyMayBeTruncatedForEvents([retainedStart, retainedLeft]), + true, + ); + assert.equal( + reconstructHuddleState([retainedStart, retainedLeft], HUDDLE_ID, { + historyMayBeTruncated: tracker.historyMayBeTruncatedForEvents([ + retainedStart, + retainedLeft, + ]), + nowMs: (NOW_SECONDS + 1) * 1000, + }).ended, + false, + ); +}); + +test("createHuddleReplayTracker keeps empty rosters joinable during reconnect replay", () => { + const tracker = createHuddleReplayTracker(); + const events = [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 60 * 60 - 1 }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 60 * 60, + tags: [["p", CREATOR]], + }), + ]; + + tracker.markReplayComplete(); + assert.equal( + reconstructHuddleState(events, HUDDLE_ID, { + nowMs: NOW_SECONDS * 1000, + replayComplete: tracker.replayComplete(), + }).ended, + true, + ); + + tracker.markReplayStarted(); + assert.equal( + reconstructHuddleState(events, HUDDLE_ID, { + nowMs: NOW_SECONDS * 1000, + replayComplete: tracker.replayComplete(), + replayInProgress: tracker.replayInProgress(), + }).ended, + false, + ); + + tracker.markReplayFailed(); + assert.equal( + reconstructHuddleState(events, HUDDLE_ID, { + nowMs: NOW_SECONDS * 1000, + replayComplete: tracker.replayComplete(), + replayInProgress: tracker.replayInProgress(), + }).ended, + true, + ); +}); + +test("selectActiveHuddleState does not resurrect an older incomplete huddle", () => { + const olderHuddleId = "older-huddle"; + const newerHuddleId = "newer-huddle"; + + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 19, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48100, newerHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, newerHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, newerHuddleId, { + created_at: NOW_SECONDS - 8, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected, null); +}); + +test("selectActiveHuddleState does not fall back to an older start-only room after a newer relay room ends", () => { + const startOnlyHuddleId = "start-only-huddle"; + const endedHuddleId = "ended-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48100, endedHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, endedHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS - 8, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected, null); +}); + +test("selectActiveHuddleState does not fall back to an older start-only room after a newer start-only room ends", () => { + const olderHuddleId = "older-huddle"; + const newerHuddleId = "newer-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48100, newerHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48103, newerHuddleId, { + created_at: NOW_SECONDS - 9, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected, null); +}); + +test("selectActiveHuddleState does not reshow a locally suppressed relay huddle", () => { + const startOnlyHuddleId = "start-only-huddle"; + const suppressedHuddleId = "suppressed-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48100, suppressedHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, suppressedHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + ], + { + nowMs: NOW_SECONDS * 1000, + suppressedEphemeralChannelId: suppressedHuddleId, + }, + ); + + assert.equal(selected, null); +}); + +test("selectActiveHuddleState lets the local current huddle override suppression", () => { + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, HUDDLE_ID, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, HUDDLE_ID, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + ], + { + activeEphemeralChannelId: HUDDLE_ID, + nowMs: NOW_SECONDS * 1000, + suppressedEphemeralChannelId: HUDDLE_ID, + }, + ); + + assert.equal(selected?.ephemeralChannelId, HUDDLE_ID); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState preserves a truncated huddle when the creator JOIN is missing", () => { + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, HUDDLE_ID, { + created_at: NOW_SECONDS - 10, + pubkey: CREATOR, + }), + eventForHuddle(48101, HUDDLE_ID, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, HUDDLE_ID, { + created_at: NOW_SECONDS - 8, + tags: [["p", PARTICIPANT]], + }), + ], + { historyMayBeTruncated: true, nowMs: NOW_SECONDS * 1000 }, + ); + + assert.ok(selected); + assert.equal(selected?.ephemeralChannelId, HUDDLE_ID); + assert.equal(selected?.state.ended, false); + assert.deepEqual([...selected.state.participants], [CREATOR]); +}); + +test("selectActiveHuddleState keeps a newer ended-room barrier after an older room LEFT", () => { + const olderHuddleId = "older-huddle"; + const newerHuddleId = "newer-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 19, + tags: [["p", CREATOR]], + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 18, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48100, newerHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, newerHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, newerHuddleId, { + created_at: NOW_SECONDS - 8, + }), + eventForHuddle(48102, olderHuddleId, { + created_at: NOW_SECONDS - 1, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected, null); +}); + +test("selectActiveHuddleState keeps a newer ended-room barrier before an older live relay participant", () => { + const olderHuddleId = "older-huddle"; + const newerHuddleId = "newer-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 19, + tags: [["p", CREATOR]], + }), + eventForHuddle(48100, newerHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48103, newerHuddleId, { + created_at: NOW_SECONDS - 9, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected, null); +}); + +test("selectActiveHuddleState orders relay lifecycle evidence across skewed START clocks", () => { + const olderHuddleId = "older-huddle"; + const newerHuddleId = "newer-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS + 10, + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 20, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48100, newerHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, newerHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, newerHuddleId, { + created_at: NOW_SECONDS - 3, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected, null); +}); + +test("selectActiveHuddleState ignores a future-skewed END when ordering rooms", () => { + const endedHuddleId = "ended-huddle"; + const liveHuddleId = "live-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, endedHuddleId, { + created_at: NOW_SECONDS - 30, + }), + eventForHuddle(48101, endedHuddleId, { + created_at: NOW_SECONDS - 20, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS + 15 * 60, + }), + eventForHuddle(48100, liveHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, liveHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, liveHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState ignores a delayed LEFT from an ended room", () => { + const endedHuddleId = "ended-huddle"; + const liveHuddleId = "live-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, endedHuddleId, { + created_at: NOW_SECONDS - 6, + }), + eventForHuddle(48101, endedHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS - 4, + }), + eventForHuddle(48100, liveHuddleId, { + created_at: NOW_SECONDS - 3, + }), + eventForHuddle(48101, liveHuddleId, { + created_at: NOW_SECONDS - 2, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, endedHuddleId, { + created_at: NOW_SECONDS - 1, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, liveHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState prefers live relay evidence over a future-skewed START-only session", () => { + const startOnlyHuddleId = "start-only-huddle"; + const relayActiveHuddleId = "relay-active-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS + 15 * 60, + }), + eventForHuddle(48100, relayActiveHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, relayActiveHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, relayActiveHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState prefers live relay evidence over a future-skewed ended session", () => { + const endedHuddleId = "ended-huddle"; + const relayActiveHuddleId = "relay-active-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, endedHuddleId, { + created_at: NOW_SECONDS + 15 * 60, + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS + 15 * 60 + 1, + }), + eventForHuddle(48100, relayActiveHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, relayActiveHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, relayActiveHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState prefers a present relay huddle when JOIN timestamps tie", () => { + const endedHuddleId = "ended-huddle"; + const liveHuddleId = "live-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, endedHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, endedHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS - 4, + }), + eventForHuddle(48100, liveHuddleId, { + created_at: NOW_SECONDS - 9, + }), + eventForHuddle(48101, liveHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, liveHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState preserves live relay evidence when its START aged out", () => { + const startOnlyHuddleId = "start-only-huddle"; + const relayActiveHuddleId = "relay-active-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS + 15 * 60, + }), + eventForHuddle(48101, relayActiveHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, relayActiveHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState keeps a truncated LEFT-only relay huddle selectable", () => { + const selected = selectActiveHuddleState( + [ + eventForHuddle(48102, HUDDLE_ID, { + created_at: NOW_SECONDS - 1, + id: "retained-left", + tags: [["p", CREATOR]], + }), + ], + { historyMayBeTruncated: true, nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, HUDDLE_ID); + assert.equal(selected?.state.ended, false); + assert.equal(selected?.state.participants.size, 0); +}); + +test("selectActiveHuddleState preserves a newer truncated LEFT-only huddle over older JOIN evidence", () => { + const olderHuddleId = "older-huddle"; + const newerHuddleId = "newer-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS - 30, + pubkey: CREATOR, + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 29, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, olderHuddleId, { + created_at: NOW_SECONDS - 28, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, olderHuddleId, { + created_at: NOW_SECONDS - 27, + tags: [["p", CREATOR]], + }), + eventForHuddle(48100, newerHuddleId, { + created_at: NOW_SECONDS - 10, + pubkey: CREATOR, + }), + eventForHuddle(48102, newerHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", CREATOR]], + }), + ], + { historyMayBeTruncated: true, nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, newerHuddleId); + assert.equal(selected?.state.ended, false); + assert.equal(selected?.state.participants.size, 0); +}); + +test("selectActiveHuddleState keeps an empty relay huddle selectable", () => { + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, HUDDLE_ID, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48102, HUDDLE_ID, { + created_at: NOW_SECONDS - 9, + tags: [["p", CREATOR]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, HUDDLE_ID); + assert.equal(selected?.state.ended, false); + assert.equal(selected?.state.participants.size, 0); +}); + +test("selectActiveHuddleState keeps an empty relay huddle selectable until replay completes", () => { + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, HUDDLE_ID, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, HUDDLE_ID, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, HUDDLE_ID, { + created_at: NOW_SECONDS - 8, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, HUDDLE_ID, { + created_at: NOW_SECONDS - 7, + tags: [["p", CREATOR]], + }), + ], + { nowMs: NOW_SECONDS * 1000, replayComplete: false }, + ); + + assert.equal(selected?.ephemeralChannelId, HUDDLE_ID); + assert.equal(selected?.state.ended, false); + assert.equal(selected?.state.participants.size, 0); +}); + +test("selectActiveHuddleState defers START-only stale expiry during replay", () => { + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, HUDDLE_ID, { + created_at: NOW_SECONDS - 60 * 60 - 1, + }), + ], + { + nowMs: NOW_SECONDS * 1000, + replayComplete: false, + replayInProgress: true, + }, + ); + + assert.equal(selected?.ephemeralChannelId, HUDDLE_ID); + assert.equal(selected?.state.ended, false); + assert.equal(selected?.state.staleDeadlineMs, null); +}); + +test("selectActiveHuddleState prefers a fresh START-only session over older empty relay history", () => { + const endedHuddleId = "ended-huddle"; + const emptyRosterHuddleId = "empty-roster-huddle"; + const startOnlyHuddleId = "start-only-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, endedHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48101, endedHuddleId, { + created_at: NOW_SECONDS - 19, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS - 18, + }), + eventForHuddle(48100, emptyRosterHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48102, emptyRosterHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", CREATOR]], + }), + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS - 1, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, startOnlyHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState keeps a suppressed START-only huddle as a newer barrier", () => { + const olderHuddleId = "older-huddle"; + const suppressedHuddleId = "suppressed-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48100, suppressedHuddleId, { + created_at: NOW_SECONDS - 10, + }), + ], + { + nowMs: NOW_SECONDS * 1000, + suppressedEphemeralChannelId: suppressedHuddleId, + }, + ); + + assert.equal(selected, null); +}); + +test("selectActiveHuddleState does not compare START-only clocks to relay JOIN clocks", () => { + const endedHuddleId = "ended-huddle"; + const startOnlyHuddleId = "start-only-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48101, endedHuddleId, { + created_at: NOW_SECONDS, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS + 1, + }), + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS - 60, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, startOnlyHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState does not tier a departed JOIN participant as present", () => { + const relayHistoryHuddleId = "relay-history-huddle"; + const startOnlyHuddleId = "start-only-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, relayHistoryHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48101, relayHistoryHuddleId, { + created_at: NOW_SECONDS - 19, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, relayHistoryHuddleId, { + created_at: NOW_SECONDS - 18, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS - 1, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, startOnlyHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState preserves an inconclusive truncated relay huddle", () => { + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, HUDDLE_ID, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48102, HUDDLE_ID, { + created_at: NOW_SECONDS - 9, + id: "creator-left", + tags: [["p", CREATOR]], + }), + eventForHuddle(48101, HUDDLE_ID, { + created_at: NOW_SECONDS - 8, + id: "balanced-join", + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, HUDDLE_ID, { + created_at: NOW_SECONDS - 7, + id: "balanced-left", + tags: [["p", PARTICIPANT]], + }), + ], + { historyMayBeTruncated: true, nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, HUDDLE_ID); + assert.equal(selected?.state.ended, false); + assert.equal(selected?.state.participants.size, 0); +}); + +test("reconstructHuddleState does not resurrect after an end event", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100), + lifecycleEvent(48103, { created_at: NOW_SECONDS + 1 }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS + 2, + tags: [["p", PARTICIPANT]], + }), + ], + HUDDLE_ID, + { nowMs: (NOW_SECONDS + 2) * 1000 }, + ); + + assert.equal(state.ended, true); + assert.deepEqual([...state.participants], [CREATOR, PARTICIPANT]); +}); + +test("huddleStalenessDelayMs schedules just past the stale boundary", () => { + assert.equal( + huddleStalenessDelayMs((NOW_SECONDS + 10) * 1000 + 1, NOW_SECONDS * 1000), + 10_001, + ); + assert.equal(huddleStalenessDelayMs(null, NOW_SECONDS * 1000), null); +}); + +test("huddleStalenessDelayMs caps oversized timer delays", () => { + assert.equal( + huddleStalenessDelayMs( + NOW_SECONDS * 1000 + 3_000_000_000, + NOW_SECONDS * 1000, + ), + 2_147_483_647, + ); +}); + +test("huddleParticipantDisplayCount floors the locally current huddle", () => { + assert.equal(huddleParticipantDisplayCount(new Set(), {}), 0); + assert.equal( + huddleParticipantDisplayCount(new Set(), { isCurrentHuddle: true }), + 1, + ); + assert.equal( + huddleParticipantDisplayCount(new Set([CREATOR, PARTICIPANT]), { + isCurrentHuddle: true, + }), + 2, + ); +}); + +test("resolveIdleHuddleTransition does not clear an unrelated displayed huddle", () => { + const transition = resolveIdleHuddleTransition({ + activeEphemeralChannelId: null, + displayedEphemeralChannelId: "visible-huddle", + eventEphemeralChannelId: null, + lastActiveEphemeralChannelId: "departing-huddle", + }); + + assert.equal(transition.shouldClearDisplayedHuddle, false); + assert.equal(transition.suppressedEphemeralChannelId, "departing-huddle"); +}); + +test("resolveIdleHuddleTransition clears the displayed huddle that went idle", () => { + const transition = resolveIdleHuddleTransition({ + activeEphemeralChannelId: null, + displayedEphemeralChannelId: "departing-huddle", + eventEphemeralChannelId: null, + lastActiveEphemeralChannelId: "departing-huddle", + }); + + assert.equal(transition.shouldClearDisplayedHuddle, true); + assert.equal(transition.suppressedEphemeralChannelId, "departing-huddle"); +}); + +test("huddleEventClearsSuppression ignores inconclusive participant LEFT", () => { + assert.equal(huddleEventClearsSuppression(lifecycleEvent(48102)), false); + assert.equal(huddleEventClearsSuppression(lifecycleEvent(48101)), true); + assert.equal(huddleEventClearsSuppression(lifecycleEvent(48103)), true); +}); + +test("huddleEventClearsSuppressionForState clears participant LEFT only for active remaining rosters", () => { + const leftEvent = lifecycleEvent(48102); + + assert.equal( + huddleEventClearsSuppressionForState(leftEvent, { + ended: false, + participants: new Set([PARTICIPANT]), + staleDeadlineMs: null, + startCreatedAt: NOW_SECONDS, + }), + true, + ); + assert.equal( + huddleEventClearsSuppressionForState(leftEvent, { + ended: false, + participants: new Set(), + staleDeadlineMs: null, + startCreatedAt: NOW_SECONDS, + }), + false, + ); + assert.equal( + huddleEventClearsSuppressionForState(leftEvent, { + ended: true, + participants: new Set([PARTICIPANT]), + staleDeadlineMs: null, + startCreatedAt: NOW_SECONDS, + }), + false, + ); +}); diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.ts b/desktop/src/features/huddle/lib/huddleLifecycleState.ts new file mode 100644 index 0000000000..58c7a498e7 --- /dev/null +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.ts @@ -0,0 +1,645 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_HUDDLE_ENDED, + KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_STARTED, +} from "@/shared/constants/kinds"; +import { HUDDLE_JOINABLE_WINDOW_SECONDS } from "./huddleCardState"; + +export type HuddleLifecycleState = { + ended: boolean; + participants: Set; + startCreatedAt: number | null; + staleDeadlineMs: number | null; +}; + +type ReconstructHuddleOptions = { + historyMayBeTruncated?: boolean; + isCurrentHuddle?: boolean; + nowMs?: number; + replayComplete?: boolean; + replayInProgress?: boolean; +}; + +type SelectActiveHuddleOptions = { + activeEphemeralChannelId?: string | null; + historyMayBeTruncated?: boolean; + historyMayBeTruncatedForEvents?: (events: readonly RelayEvent[]) => boolean; + nowMs?: number; + replayComplete?: boolean; + replayInProgress?: boolean; + suppressedEphemeralChannelId?: string | null; +}; + +type IdleHuddleTransitionInput = { + activeEphemeralChannelId?: string | null; + displayedEphemeralChannelId?: string | null; + eventEphemeralChannelId?: string | null; + lastActiveEphemeralChannelId?: string | null; +}; + +export type IdleHuddleTransition = { + shouldClearDisplayedHuddle: boolean; + suppressedEphemeralChannelId: string | null; +}; + +export function huddleEventClearsSuppression(event: RelayEvent): boolean { + return ( + event.kind === KIND_HUDDLE_STARTED || + event.kind === KIND_HUDDLE_PARTICIPANT_JOINED || + event.kind === KIND_HUDDLE_ENDED + ); +} + +export function huddleEventClearsSuppressionForState( + event: RelayEvent, + state: HuddleLifecycleState, +): boolean { + return ( + huddleEventClearsSuppression(event) || + (event.kind === KIND_HUDDLE_PARTICIPANT_LEFT && + !state.ended && + state.participants.size > 0) + ); +} + +export const HUDDLE_EVENT_HISTORY_LIMIT = 100; + +export type ActiveHuddleLifecycleState = { + ephemeralChannelId: string; + state: HuddleLifecycleState; +}; + +export type HuddleReplayTracker = { + historyMayBeTruncated: () => boolean; + historyMayBeTruncatedForEvents: (events: Iterable) => boolean; + markReplayComplete: () => void; + markReplayFailed: () => void; + markReplayStarted: (retainedEvents?: Iterable) => void; + replayComplete: () => boolean; + replayInProgress: () => boolean; + recordReplayEvent: (event: RelayEvent) => void; +}; + +const MAX_SET_TIMEOUT_DELAY_MS = 2_147_483_647; + +export function createHuddleReplayTracker( + limit = HUDDLE_EVENT_HISTORY_LIMIT, +): HuddleReplayTracker { + let historyWasTruncated = false; + let replayComplete = false; + let replayInProgress = true; + let countingReplay = true; + let replayEventCount = 0; + let seenReplayEventIds = new Set(); + let preReplayHuddleIds = new Set(); + const truncatedReplayEventIds = new Set(); + const truncatedPreReplayHuddleIds = new Set(); + + function currentReplayWasTruncated(): boolean { + return countingReplay && replayEventCount >= limit; + } + + function preserveCurrentReplayTruncation() { + if (!currentReplayWasTruncated()) return; + for (const eventId of seenReplayEventIds) { + truncatedReplayEventIds.add(eventId); + } + for (const huddleId of preReplayHuddleIds) { + truncatedPreReplayHuddleIds.add(huddleId); + } + } + + return { + historyMayBeTruncated: () => + historyWasTruncated || currentReplayWasTruncated(), + historyMayBeTruncatedForEvents: (events) => { + const retainedEvents = [...events]; + return retainedEvents.some((event) => { + const huddleId = huddleEventChannelId(event); + const huddleMayBeTruncated = + huddleId !== null && + (truncatedPreReplayHuddleIds.has(huddleId) || + (currentReplayWasTruncated() && preReplayHuddleIds.has(huddleId))); + return ( + truncatedReplayEventIds.has(event.id) || + huddleMayBeTruncated || + (currentReplayWasTruncated() && seenReplayEventIds.has(event.id)) + ); + }); + }, + markReplayComplete: () => { + preserveCurrentReplayTruncation(); + replayComplete = true; + replayInProgress = false; + countingReplay = false; + seenReplayEventIds = new Set(); + preReplayHuddleIds = new Set(); + }, + markReplayFailed: () => { + preserveCurrentReplayTruncation(); + replayComplete = false; + replayInProgress = false; + countingReplay = false; + seenReplayEventIds = new Set(); + preReplayHuddleIds = new Set(); + }, + markReplayStarted: (retainedEvents = []) => { + preserveCurrentReplayTruncation(); + replayComplete = false; + replayInProgress = true; + countingReplay = true; + replayEventCount = 0; + seenReplayEventIds = new Set(); + preReplayHuddleIds = new Set( + [...retainedEvents] + .map((event) => huddleEventChannelId(event)) + .filter((huddleId): huddleId is string => huddleId !== null), + ); + }, + replayComplete: () => replayComplete, + replayInProgress: () => replayInProgress, + recordReplayEvent: (event: RelayEvent) => { + if (countingReplay && !seenReplayEventIds.has(event.id)) { + seenReplayEventIds.add(event.id); + replayEventCount += 1; + historyWasTruncated ||= currentReplayWasTruncated(); + } + }, + }; +} + +export function huddleEventChannelId(event: RelayEvent): string | null { + try { + const parsed = JSON.parse(event.content) as { + ephemeral_channel_id?: unknown; + }; + return typeof parsed.ephemeral_channel_id === "string" + ? parsed.ephemeral_channel_id + : null; + } catch { + return null; + } +} + +/** + * Record one channel-wide subscription event while retaining only the target + * huddle's events for reconstruction. The channel-wide IDs preserve whether + * the relay history query reached its limit before per-huddle filtering. + */ +export function recordHuddleSubscriptionEvent( + seenChannelEventIds: Set, + seenHuddleEvents: Map, + ephemeralChannelId: string, + event: RelayEvent, +): boolean { + if (seenChannelEventIds.has(event.id)) return false; + seenChannelEventIds.add(event.id); + if (huddleEventChannelId(event) === ephemeralChannelId) { + seenHuddleEvents.set(event.id, event); + } + return true; +} + +export function resolveIdleHuddleTransition({ + activeEphemeralChannelId, + displayedEphemeralChannelId, + eventEphemeralChannelId, + lastActiveEphemeralChannelId, +}: IdleHuddleTransitionInput): IdleHuddleTransition { + const departingEphemeralChannelId = + eventEphemeralChannelId ?? + activeEphemeralChannelId ?? + lastActiveEphemeralChannelId ?? + null; + if ( + departingEphemeralChannelId === null || + (departingEphemeralChannelId !== activeEphemeralChannelId && + departingEphemeralChannelId !== displayedEphemeralChannelId && + departingEphemeralChannelId !== lastActiveEphemeralChannelId) + ) { + return { + shouldClearDisplayedHuddle: false, + suppressedEphemeralChannelId: null, + }; + } + + return { + shouldClearDisplayedHuddle: + departingEphemeralChannelId === displayedEphemeralChannelId, + suppressedEphemeralChannelId: departingEphemeralChannelId, + }; +} + +export function huddleParticipantDisplayCount( + participants: ReadonlySet, + options: { isCurrentHuddle?: boolean } = {}, +): number { + if (options.isCurrentHuddle) { + return Math.max(participants.size, 1); + } + return participants.size; +} + +function lifecycleParticipant(event: RelayEvent): string | null { + return ( + event.tags.find( + (tag) => tag[0] === "p" && typeof tag[1] === "string", + )?.[1] ?? + event.pubkey ?? + null + ); +} + +/** + * Reconstruct one huddle from its lifecycle events. + * + * An inferred huddle with no START event stays non-terminal because the + * subscription window may have truncated an older participant JOIN. + */ +export function reconstructHuddleState( + events: Iterable, + ephemeralChannelId: string, + options: ReconstructHuddleOptions = {}, +): HuddleLifecycleState { + const matchingEvents = [...events].filter( + (event) => huddleEventChannelId(event) === ephemeralChannelId, + ); + const startEvent = matchingEvents + .filter((event) => event.kind === KIND_HUDDLE_STARTED) + .sort( + (left, right) => + left.created_at - right.created_at || left.id.localeCompare(right.id), + ) + .at(-1); + const participantEvents = matchingEvents + .filter( + (event) => + event.kind === KIND_HUDDLE_PARTICIPANT_JOINED || + event.kind === KIND_HUDDLE_PARTICIPANT_LEFT, + ) + .sort( + (left, right) => + left.created_at - right.created_at || + left.kind - right.kind || + left.id.localeCompare(right.id), + ); + const participantCounts = new Map(); + const startPubkey = startEvent?.pubkey ?? null; + if (startPubkey) { + participantCounts.set(startPubkey, 1); + } + const explicitlyEnded = matchingEvents.some( + (event) => event.kind === KIND_HUDDLE_ENDED, + ); + const startCreatedAt = startEvent?.created_at ?? null; + let startSeedPendingPubkey = startPubkey; + + function participantCount(pubkey: string): number { + return participantCounts.get(pubkey) ?? 0; + } + + function incrementParticipant(pubkey: string) { + participantCounts.set(pubkey, participantCount(pubkey) + 1); + } + + function decrementParticipant(pubkey: string) { + const count = participantCount(pubkey); + if (count <= 1) { + participantCounts.delete(pubkey); + } else { + participantCounts.set(pubkey, count - 1); + } + } + + // START is client-signed while participant transitions are relay-signed, so + // their created_at values are not one causal clock. Seed the creator from + // START, then let the first matching JOIN or LEFT consume that seed so the + // retained START does not double-count the creator's first relay peer. + for (const event of participantEvents) { + switch (event.kind) { + case KIND_HUDDLE_PARTICIPANT_JOINED: { + const pubkey = lifecycleParticipant(event); + if (!pubkey) break; + if (pubkey === startSeedPendingPubkey) { + startSeedPendingPubkey = null; + } else { + incrementParticipant(pubkey); + } + break; + } + case KIND_HUDDLE_PARTICIPANT_LEFT: { + const pubkey = lifecycleParticipant(event); + if (!pubkey) break; + if (pubkey === startSeedPendingPubkey) { + startSeedPendingPubkey = null; + } + decrementParticipant(pubkey); + break; + } + } + } + const participants = new Set(participantCounts.keys()); + const hasLiveRelayParticipant = participantEvents.some((event) => { + if (event.kind !== KIND_HUDDLE_PARTICIPANT_JOINED) return false; + const pubkey = lifecycleParticipant(event); + return pubkey !== null && participants.has(pubkey); + }); + + // Retained lifecycle evidence can outlive the ephemeral huddle channel if the + // relay archives the room without a parent END event. Bound stale non-local + // sessions by the room's START-based TTL window, while replay is still + // inconclusive until EOSE. A retained live relay JOIN is stronger evidence + // than the START fallback because relay activity may reflect a still-live + // room whose TTL was refreshed by ephemeral-channel traffic. + const staleDeadlineMs = + startCreatedAt !== null && + !hasLiveRelayParticipant && + !options.isCurrentHuddle && + !explicitlyEnded && + options.replayInProgress !== true + ? (startCreatedAt + HUDDLE_JOINABLE_WINDOW_SECONDS) * 1000 + 1 + : null; + const nowMs = options.nowMs ?? Date.now(); + const drainedAfterCompleteReplay = + options.replayComplete === true && + options.historyMayBeTruncated !== true && + !options.isCurrentHuddle && + startCreatedAt !== null && + participantEvents.length > 0 && + participants.size === 0; + + return { + ended: + explicitlyEnded || + drainedAfterCompleteReplay || + (staleDeadlineMs !== null && nowMs >= staleDeadlineMs), + participants, + startCreatedAt, + staleDeadlineMs, + }; +} + +/** + * Select the channel header's huddle without falling back past a newer ended + * session. Retained START events are the session boundaries; participant and + * END timestamps never compete with a different client's START timestamp. + */ +export function selectActiveHuddleState( + events: Iterable, + options: SelectActiveHuddleOptions = {}, +): ActiveHuddleLifecycleState | null { + const allEvents = [...events]; + const nowMs = options.nowMs ?? Date.now(); + const nowSeconds = nowMs / 1000; + const historyMayBeTruncated = + options.historyMayBeTruncated ?? + allEvents.length >= HUDDLE_EVENT_HISTORY_LIMIT; + const eventsByHuddle = new Map(); + for (const event of allEvents) { + const ephemeralChannelId = huddleEventChannelId(event); + if (!ephemeralChannelId) continue; + const huddleEvents = eventsByHuddle.get(ephemeralChannelId) ?? []; + huddleEvents.push(event); + eventsByHuddle.set(ephemeralChannelId, huddleEvents); + } + + const candidates = [...eventsByHuddle.entries()].map( + ([ephemeralChannelId, huddleEvents]) => { + const relayParticipantEvents = huddleEvents.filter( + (event) => + event.kind === KIND_HUDDLE_PARTICIPANT_JOINED || + event.kind === KIND_HUDDLE_PARTICIPANT_LEFT, + ); + const relayJoinEvents = relayParticipantEvents.filter( + (event) => event.kind === KIND_HUDDLE_PARTICIPANT_JOINED, + ); + const latestStartEvent = huddleEvents + .filter((event) => event.kind === KIND_HUDDLE_STARTED) + .sort( + (left, right) => + left.created_at - right.created_at || + left.id.localeCompare(right.id), + ) + .at(-1); + const huddleHistoryMayBeTruncated = + options.historyMayBeTruncatedForEvents?.(huddleEvents) ?? + historyMayBeTruncated; + const state = reconstructHuddleState(huddleEvents, ephemeralChannelId, { + historyMayBeTruncated: huddleHistoryMayBeTruncated, + isCurrentHuddle: + options.activeEphemeralChannelId === ephemeralChannelId, + nowMs, + replayComplete: options.replayComplete, + replayInProgress: options.replayInProgress, + }); + const hasPresentRetainedCreator = + huddleHistoryMayBeTruncated && + latestStartEvent?.pubkey !== undefined && + state.participants.has(latestStartEvent.pubkey); + return { + ephemeralChannelId, + state, + hasRelayParticipantEvent: relayParticipantEvents.length > 0, + hasPresentRelayParticipant: + !state.ended && + (hasPresentRetainedCreator || + relayJoinEvents.some((event) => + state.participants.has(lifecycleParticipant(event) ?? ""), + )), + historyMayBeTruncated: huddleHistoryMayBeTruncated, + latestRelayJoinCreatedAt: + relayJoinEvents.length > 0 + ? Math.max(...relayJoinEvents.map((event) => event.created_at)) + : null, + }; + }, + ); + + const current = candidates.find( + ({ ephemeralChannelId, state }) => + ephemeralChannelId === options.activeEphemeralChannelId && !state.ended, + ); + if (current) { + return { + ephemeralChannelId: current.ephemeralChannelId, + state: current.state, + }; + } + + // Relay-signed JOIN events share one clock across huddles, so only the newest + // relay-backed session may be shown. LEFT is a departure transition within a + // session, while END is client-emitted room-local evidence; neither may make + // an older room outrank a newer session. If the newest relay-backed session + // is terminal, do not resurrect an older relay-backed session. A currently + // present participant gives that newest relay-backed session priority over + // every START-only candidate without comparing relay and client clocks. + const replayInProgress = + options.replayInProgress ?? options.replayComplete === false; + const newestRelayCandidate = candidates + .filter(({ hasRelayParticipantEvent }) => hasRelayParticipantEvent) + .sort( + (left, right) => + (right.latestRelayJoinCreatedAt ?? 0) - + (left.latestRelayJoinCreatedAt ?? 0) || + Number(right.hasPresentRelayParticipant) - + Number(left.hasPresentRelayParticipant) || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + )[0]; + const newestRelayStartCreatedAt = + newestRelayCandidate?.state.startCreatedAt ?? null; + // Future-skewed client STARTs may keep their own room joinable, but they + // cannot be used as a terminal barrier over relay-ordered live evidence. + const newestSuppressedStartCreatedAt = + candidates + .filter( + ({ ephemeralChannelId, state }) => + ephemeralChannelId === options.suppressedEphemeralChannelId && + state.startCreatedAt !== null && + state.startCreatedAt <= nowSeconds, + ) + .sort( + (left, right) => + (right.state.startCreatedAt ?? 0) - + (left.state.startCreatedAt ?? 0) || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + )[0]?.state.startCreatedAt ?? null; + const newestEndedStartCreatedAt = + candidates + .filter( + ({ state }) => + state.ended && + state.startCreatedAt !== null && + state.startCreatedAt <= nowSeconds, + ) + .sort( + (left, right) => + (right.state.startCreatedAt ?? 0) - + (left.state.startCreatedAt ?? 0) || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + )[0]?.state.startCreatedAt ?? null; + const newestTerminalStartBarrierCreatedAt = Math.max( + newestSuppressedStartCreatedAt ?? Number.NEGATIVE_INFINITY, + newestEndedStartCreatedAt ?? Number.NEGATIVE_INFINITY, + ); + if (newestRelayCandidate?.hasPresentRelayParticipant) { + if ( + newestRelayCandidate.ephemeralChannelId === + options.suppressedEphemeralChannelId || + (newestRelayStartCreatedAt !== null && + newestTerminalStartBarrierCreatedAt > newestRelayStartCreatedAt) + ) { + return null; + } + return { + ephemeralChannelId: newestRelayCandidate.ephemeralChannelId, + state: newestRelayCandidate.state, + }; + } + // LEFT-only inconclusive candidates have no JOIN timestamp to sort on. If + // their retained START boundary is newer than the newest JOIN-backed relay + // candidate, keep that session instead of falling through to the start action + // or an older empty relay huddle. + const newestInconclusiveRelayCandidate = candidates + .filter( + ({ + hasPresentRelayParticipant, + hasRelayParticipantEvent, + historyMayBeTruncated, + state, + }) => + hasRelayParticipantEvent && + (historyMayBeTruncated || replayInProgress) && + !hasPresentRelayParticipant && + !state.ended && + state.startCreatedAt !== null && + (newestRelayStartCreatedAt === null || + state.startCreatedAt >= newestRelayStartCreatedAt), + ) + .sort( + (left, right) => + (right.state.startCreatedAt ?? 0) - (left.state.startCreatedAt ?? 0) || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + )[0]; + if (newestInconclusiveRelayCandidate) { + if ( + newestInconclusiveRelayCandidate.ephemeralChannelId === + options.suppressedEphemeralChannelId + ) { + return null; + } + return { + ephemeralChannelId: newestInconclusiveRelayCandidate.ephemeralChannelId, + state: newestInconclusiveRelayCandidate.state, + }; + } + // A truncated or in-flight empty roster is inconclusive: the surviving + // participant's JOIN can be outside the retained window or not delivered yet. + // Keep the newest non-terminal relay-backed candidate instead of exposing the + // start action for a room that may still be live. + if ( + newestRelayCandidate !== undefined && + (newestRelayCandidate.historyMayBeTruncated || replayInProgress) && + !newestRelayCandidate.state.ended + ) { + if ( + newestRelayCandidate.ephemeralChannelId === + options.suppressedEphemeralChannelId + ) { + return null; + } + return { + ephemeralChannelId: newestRelayCandidate.ephemeralChannelId, + state: newestRelayCandidate.state, + }; + } + const newestRelayJoinCreatedAt = + newestRelayCandidate?.latestRelayJoinCreatedAt ?? null; + const newestStartBarrierCreatedAt = Math.max( + newestRelayStartCreatedAt ?? Number.NEGATIVE_INFINITY, + newestTerminalStartBarrierCreatedAt, + ); + + // A START-only session has no relay-clock evidence, so compare it only to a + // retained START boundary from the newest relay-backed session. A terminal + // relay session with no retained START cannot prove its relative order + // against a START-only candidate authored on a different client clock. + const selected = candidates + .filter( + ({ ephemeralChannelId, latestRelayJoinCreatedAt, state }) => + ephemeralChannelId !== options.suppressedEphemeralChannelId && + latestRelayJoinCreatedAt === null && + state.startCreatedAt !== null && + (newestRelayJoinCreatedAt === null || + newestRelayStartCreatedAt === null || + state.startCreatedAt > newestRelayStartCreatedAt) && + (newestStartBarrierCreatedAt === Number.NEGATIVE_INFINITY || + ephemeralChannelId === newestRelayCandidate?.ephemeralChannelId || + state.startCreatedAt > newestStartBarrierCreatedAt) && + !state.ended, + ) + .sort( + (left, right) => + (right.state.startCreatedAt ?? 0) - (left.state.startCreatedAt ?? 0) || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + )[0]; + + if (!selected) return null; + return { + ephemeralChannelId: selected.ephemeralChannelId, + state: selected.state, + }; +} + +/** Delay until an unconfirmed START crosses the shared stale boundary. */ +export function huddleStalenessDelayMs( + staleDeadlineMs: number | null, + nowMs = Date.now(), +): number | null { + if (staleDeadlineMs === null) return null; + return Math.min( + Math.max(0, staleDeadlineMs - nowMs), + MAX_SET_TIMEOUT_DELAY_MS, + ); +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 53d541ff0f..58e9c4884b 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -16,6 +16,7 @@ import { getTextPayload, type ConnectionState, type PendingEvent, + type RelayLiveSubscriptionOptions, type RelaySubscription, type RelaySubscriptionFilter, } from "@/shared/api/relayClientShared"; @@ -66,7 +67,6 @@ import { } from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; - export class RelayClient { private wsId: number | null = null; private relayUrl: string | null = null; @@ -92,7 +92,6 @@ export class RelayClient { private connectionGeneration = 0; private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; - private terminal = false; private connectionStateEmitter = new RelayConnectionStateEmitter("idle"); @@ -339,15 +338,10 @@ export class RelayClient { ); } - /** - * Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel. - * Used by HuddleIndicator to detect active huddles without being drowned - * out by regular channel messages in the generic subscription window. - * Includes both historical (last 10) and live events. - */ async subscribeToHuddleEvents( channelId: string, onEvent: (event: RelayEvent) => void, + options?: RelayLiveSubscriptionOptions, ) { return this.subscribe( { @@ -356,6 +350,7 @@ export class RelayClient { limit: 100, }, onEvent, + options, ); } @@ -584,6 +579,7 @@ export class RelayClient { private async subscribe( filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, + options: RelayLiveSubscriptionOptions = {}, ) { await this.ensureConnected(); @@ -605,6 +601,7 @@ export class RelayClient { mode: "live", filter, onEvent, + ...options, resolveReady, }); @@ -879,6 +876,8 @@ export class RelayClient { } private handleEose(subId: string) { + if (this.flushTimeout !== null) window.clearTimeout(this.flushTimeout); + this.flushEventBuffer(); handleSubscriptionEose({ subscriptions: this.subscriptions, subId, diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 9108f7b6d7..176ca0581c 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -58,12 +58,20 @@ type LiveSubscription = { mode: "live"; filter: RelaySubscriptionFilter; onEvent: (event: RelayEvent) => void; + onEose?: () => void; + onReplayStart?: () => void; + onTerminalClose?: () => void; resolveReady?: () => void; lastSeenCreatedAt?: number; closedRetryAttempt?: number; closedRetryTimeout?: number; }; +export type RelayLiveSubscriptionOptions = Pick< + LiveSubscription, + "onEose" | "onReplayStart" | "onTerminalClose" +>; + export type PendingEvent = { event: RelayEvent; resolve: (event: RelayEvent) => void; diff --git a/desktop/src/shared/api/relayClosedRecovery.test.mjs b/desktop/src/shared/api/relayClosedRecovery.test.mjs index c1f7bd98c4..500cd40dca 100644 --- a/desktop/src/shared/api/relayClosedRecovery.test.mjs +++ b/desktop/src/shared/api/relayClosedRecovery.test.mjs @@ -5,6 +5,7 @@ import { handleRelayClosed, handleSubscriptionEose, } from "./relayClosedRecovery.ts"; +import { RelayClient } from "./relayClientSession.ts"; import { requestFirstEventGated, requestHistoryGated, @@ -260,8 +261,74 @@ test("first-event request resolves null when EOSE arrives without an event", asy assert.equal(subscriptions.has(requestedSubId), false); }); +test("live subscription EOSE calls readiness and replay boundary callbacks", () => { + let readyCalls = 0; + let eoseCalls = 0; + const subscriptions = new Map([ + [ + "live-1", + { + mode: "live", + filter: { kinds: [9], limit: 50 }, + onEvent: () => {}, + onEose: () => { + eoseCalls += 1; + }, + resolveReady: () => { + readyCalls += 1; + }, + }, + ], + ]); + + handleSubscriptionEose({ + subscriptions, + subId: "live-1", + closeSubscription: async () => {}, + }); + + assert.equal(eoseCalls, 1); + assert.equal(readyCalls, 1); + assert.equal(subscriptions.has("live-1"), true); +}); + +test("live subscription EOSE clears pending event batch timer before forced flush", () => { + resetAll(0); + const client = new RelayClient(); + const deliveredEvents = []; + const subscription = { + mode: "live", + filter: { kinds: [9], limit: 50 }, + onEvent: (event) => deliveredEvents.push(event), + resolveReady: () => {}, + }; + const firstEvent = { + id: "event-1", + pubkey: "a".repeat(64), + created_at: 1, + kind: 9, + tags: [], + content: "", + sig: "", + }; + + client.subscriptions.set("live-1", subscription); + client.handleEvent("live-1", firstEvent); + assert.equal(pendingTimers.size, 1); + const [batchTimerId] = pendingTimers.keys(); + + client.handleEose("live-1"); + + assert.equal(pendingTimers.has(batchTimerId), false); + assert.deepEqual( + deliveredEvents.map((event) => event.id), + ["event-1"], + ); +}); + test("production CLOSED handler removes terminal live subscriptions", () => { let readyCalls = 0; + let terminalCloseCalls = 0; const subscriptions = new Map([ [ "live-1", @@ -272,6 +339,9 @@ test("production CLOSED handler removes terminal live subscriptions", () => { resolveReady: () => { readyCalls += 1; }, + onTerminalClose: () => { + terminalCloseCalls += 1; + }, }, ], ]); @@ -283,6 +353,7 @@ test("production CLOSED handler removes terminal live subscriptions", () => { }); assert.equal(subscriptions.has("live-1"), false); assert.equal(readyCalls, 1); + assert.equal(terminalCloseCalls, 1); }); // ── Rate-limited CLOSED core behaviour (F5) ─────────────────────────────────── @@ -416,6 +487,38 @@ test("non-rate-limited retryable CLOSED still schedules a retry", () => { ); }); +test("retryable CLOSED marks live subscription replay before retry REQ", () => { + resetAll(0); + const calls = []; + const subscriptions = new Map([ + [ + "live-1", + { + mode: "live", + filter: { kinds: [9], "#h": ["ch-1"], limit: 50 }, + onEvent: () => {}, + onReplayStart: () => { + calls.push("replay-start"); + }, + resolveReady: () => {}, + }, + ], + ]); + handleRelayClosed({ + subscriptions, + subId: "live-1", + message: "error: database error", + sendReq: () => { + calls.push("req"); + return Promise.resolve(); + }, + }); + + tickTo(1_001); + + assert.deepEqual(calls, ["replay-start", "req"]); +}); + test("terminal CLOSED deletes subscription and does not retry", () => { resetAll(0); const firedAt = []; diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index fd366671fd..d1dfe88c63 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -81,6 +81,7 @@ function recoverLiveSubscriptionFromClosed({ if (closedClass === "terminal") { // Auth/access/filter failure — permanently remove the subscription so it // doesn't silently loop. + subscription.onTerminalClose?.(); subscriptions.delete(subId); return; } @@ -111,6 +112,7 @@ function recoverLiveSubscriptionFromClosed({ subscription.closedRetryTimeout = window.setTimeout(() => { subscription.closedRetryTimeout = undefined; if (subscriptions.get(subId) !== subscription) return; + subscription.onReplayStart?.(); void sendReq(subId, subscription.filter).catch((error) => { if (subscriptions.get(subId) !== subscription) return; console.error("Failed to restore closed relay subscription", error); @@ -157,6 +159,7 @@ export function handleSubscriptionEose({ const subscription = subscriptions.get(subId); if (!subscription) return; if (subscription.mode === "live") { + subscription.onEose?.(); subscription.resolveReady?.(); subscription.resolveReady = undefined; subscription.closedRetryAttempt = 0; diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index 59253a459f..7d53bbfcb6 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -218,6 +218,37 @@ test("replay sends all subs in one batch when count equals REPLAY_BATCH_SIZE", a assert.equal(delayCount, 0, "no inter-batch delay for exactly one batch"); }); +test("replay marks non-paged live subscriptions before reconnect replay", async () => { + resetGate(); + const calls = []; + const subscriptions = new Map([ + [ + "sub-1", + { + mode: "live", + filter: { + kinds: [48100, 48101, 48102, 48103], + "#h": ["ch-1"], + limit: 100, + }, + onEvent: () => {}, + onReplayStart: () => calls.push("replay-start"), + lastSeenCreatedAt: 100, + }, + ], + ]); + + await replayLiveSubscriptions({ + subscriptions, + sendRaw: async () => { + calls.push("req"); + }, + requestHistory: async () => [], + }); + + assert.deepEqual(calls, ["replay-start", "req"]); +}); + test("replay splits subscriptions into batches of REPLAY_BATCH_SIZE", async () => { resetGate(); let delayCount = 0; diff --git a/desktop/src/shared/api/relayReconnectReplay.ts b/desktop/src/shared/api/relayReconnectReplay.ts index 74b752f666..3ba41e85e3 100644 --- a/desktop/src/shared/api/relayReconnectReplay.ts +++ b/desktop/src/shared/api/relayReconnectReplay.ts @@ -209,15 +209,16 @@ export async function replayLiveSubscriptions({ if (!isActive()) return; const batch = replayRequests.slice(i, i + replayBatchSize); await Promise.all( - batch.map(({ subId, subscription, replaySince, shouldPageReplay }) => - sendRaw([ + batch.map(({ subId, subscription, replaySince, shouldPageReplay }) => { + if (!shouldPageReplay) subscription.onReplayStart?.(); + return sendRaw([ "REQ", subId, shouldPageReplay ? subscription.filter : buildReconnectReplayFilter(subscription.filter, replaySince), - ]), - ), + ]); + }), ); if (i + replayBatchSize < replayRequests.length) { await new Promise((resolve) =>