diff --git a/desktop/src/features/huddle/components/HuddleAttachment.tsx b/desktop/src/features/huddle/components/HuddleAttachment.tsx index 80e446f798..40f2c213ba 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,7 +18,13 @@ import { AttachmentTitle, } from "@/shared/ui/attachment"; import { useHuddle } from "../HuddleContext"; -import { isHuddleStartStale } from "../lib/huddleCardState"; +import { + HUDDLE_EVENT_HISTORY_LIMIT, + type HuddleLifecycleState, + huddleStalenessDelayMs, + recordHuddleSubscriptionEvent, + reconstructHuddleState, +} from "../lib/huddleLifecycleState"; type HuddleAttachmentProps = { channelId: string | null; @@ -32,11 +33,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 }; @@ -48,71 +44,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, @@ -124,13 +71,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; @@ -138,6 +98,8 @@ export function HuddleAttachment({ const huddleChannelId = ephemeralChannelId; let disposed = false; let cleanup: (() => void) | null = null; + let staleTimeout: ReturnType | null = null; + const seenChannelEventIds = new Set(); const seenEvents = new Map([ [ message.id, @@ -155,21 +117,37 @@ export function HuddleAttachment({ function updateState() { if (disposed) return; - setLifecycleState( - reconstructHuddleLifecycle( - seenEvents.values(), - message.pubkey, - huddleChannelId, - ), + if (staleTimeout) clearTimeout(staleTimeout); + const state = reconstructHuddleState( + seenEvents.values(), + huddleChannelId, + { + historyMayBeTruncated: + seenChannelEventIds.size >= HUDDLE_EVENT_HISTORY_LIMIT, + isCurrentHuddle, + }, ); + 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); + if ( + disposed || + !recordHuddleSubscriptionEvent( + seenChannelEventIds, + seenEvents, + huddleChannelId, + event, + ) + ) + return; updateState(); }) .then((dispose) => { @@ -185,11 +163,13 @@ export function HuddleAttachment({ return () => { disposed = true; + if (staleTimeout) clearTimeout(staleTimeout); cleanup?.(); }; }, [ channelId, ephemeralChannelId, + isCurrentHuddle, message.body, message.createdAt, message.id, @@ -198,21 +178,12 @@ export function HuddleAttachment({ message.tags, ]); - const participantCount = Math.max(1, lifecycleState.participants.size); + const participantCount = lifecycleState.participants.size; 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 ad5baea581..646584abd0 100644 --- a/desktop/src/features/huddle/components/HuddleIndicator.tsx +++ b/desktop/src/features/huddle/components/HuddleIndicator.tsx @@ -10,16 +10,16 @@ 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"; - -/** 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 { + HUDDLE_EVENT_HISTORY_LIMIT, + huddleStalenessDelayMs, + selectActiveHuddleState, +} from "../lib/huddleLifecycleState"; type ActiveHuddle = { ephemeralChannelId: string; participants: Set; + staleDeadlineMs: number | null; }; type HuddleIndicatorProps = { @@ -44,7 +44,7 @@ export function HuddleIndicator({ onStart, startDisabled, }: HuddleIndicatorProps) { - const { joinHuddle, isStarting } = useHuddle(); + const { activeEphemeralChannelId, joinHuddle, isStarting } = useHuddle(); const queryClient = useQueryClient(); const [activeHuddle, setActiveHuddle] = React.useState( null, @@ -56,98 +56,33 @@ export function HuddleIndicator({ let disposed = false; let cleanup: (() => void) | null = null; + let staleTimeout: ReturnType | null = null; // 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, + historyMayBeTruncated: seenEvents.size >= HUDDLE_EVENT_HISTORY_LIMIT, + }); + const huddle: ActiveHuddle | null = selected + ? { + ephemeralChannelId: selected.ephemeralChannelId, + participants: selected.state.participants, + staleDeadlineMs: selected.state.staleDeadlineMs, } - } - } + : null; if (!disposed) { setActiveHuddle(huddle); + const staleDelay = huddle + ? huddleStalenessDelayMs(huddle.staleDeadlineMs) + : null; + if (staleDelay !== null) { + staleTimeout = setTimeout(reconstruct, staleDelay); + } } } @@ -178,10 +113,11 @@ export function HuddleIndicator({ return () => { disposed = true; + if (staleTimeout) clearTimeout(staleTimeout); cleanup?.(); setActiveHuddle(null); }; - }, [channelId]); + }, [activeEphemeralChannelId, channelId]); // When the local user ends/leaves a huddle, the backend transitions to idle // and emits huddle-state-changed. Clear the indicator immediately rather than @@ -249,10 +185,7 @@ 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 = activeHuddle.participants.size; 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..76b59ad5ee --- /dev/null +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs @@ -0,0 +1,620 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + huddleStalenessDelayMs, + recordHuddleSubscriptionEvent, + reconstructHuddleState, + 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 ends a fully drained huddle", () => { + 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, true); + assert.equal(state.participants.size, 0); +}); + +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 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 an old START active after a recent JOIN", () => { + 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 - 60 * 60 - 1; + 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, true); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState drains participants 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, true); + 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 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("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 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 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 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 prefers a fresh START-only session over terminal relay history", () => { + const endedHuddleId = "ended-huddle"; + const drainedHuddleId = "drained-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, drainedHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48102, drainedHuddleId, { + 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 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("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); +}); diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.ts b/desktop/src/features/huddle/lib/huddleLifecycleState.ts new file mode 100644 index 0000000000..7b5919575c --- /dev/null +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.ts @@ -0,0 +1,286 @@ +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; +}; + +type SelectActiveHuddleOptions = { + activeEphemeralChannelId?: string | null; + historyMayBeTruncated?: boolean; + nowMs?: number; +}; + +export const HUDDLE_EVENT_HISTORY_LIMIT = 100; + +export type ActiveHuddleLifecycleState = { + ephemeralChannelId: string; + state: HuddleLifecycleState; +}; + +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; +} + +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. A retained + * START makes an empty reconstructed participant set conclusive. + */ +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 participants = new Set( + startEvent?.pubkey ? [startEvent.pubkey] : [], + ); + const explicitlyEnded = matchingEvents.some( + (event) => event.kind === KIND_HUDDLE_ENDED, + ); + const startCreatedAt = startEvent?.created_at ?? null; + + // 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 fold only relay participant transitions in their own order. + for (const event of participantEvents) { + switch (event.kind) { + case KIND_HUDDLE_PARTICIPANT_JOINED: { + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.add(pubkey); + break; + } + case KIND_HUDDLE_PARTICIPANT_LEFT: { + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.delete(pubkey); + break; + } + } + } + + // An empty set is conclusive only when START is retained and the replay did + // not hit its limit. Under clock skew, START can survive in a truncated + // window even when an earlier relay-signed JOIN did not. + const drained = + startCreatedAt !== null && + !options.historyMayBeTruncated && + participants.size === 0; + // START age is only a fallback for a huddle that never produced newer + // lifecycle evidence. The relay TTL is renewable, so a later JOIN/LEFT or + // the locally current huddle must not be expired from START time alone. + const staleDeadlineMs = + startCreatedAt !== null && + participantEvents.length === 0 && + !options.isCurrentHuddle && + !explicitlyEnded && + !drained + ? (startCreatedAt + HUDDLE_JOINABLE_WINDOW_SECONDS) * 1000 + 1 + : null; + const nowMs = options.nowMs ?? Date.now(); + + return { + ended: + explicitlyEnded || + drained || + (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 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 state = reconstructHuddleState(huddleEvents, ephemeralChannelId, { + historyMayBeTruncated, + isCurrentHuddle: + options.activeEphemeralChannelId === ephemeralChannelId, + nowMs: options.nowMs, + }); + return { + ephemeralChannelId, + state, + hasPresentRelayParticipant: + !state.ended && + relayJoinEvents.some((event) => + state.participants.has(lifecycleParticipant(event) ?? ""), + ), + 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 newestRelayCandidate = candidates + .filter(({ latestRelayJoinCreatedAt }) => latestRelayJoinCreatedAt !== null) + .sort( + (left, right) => + (right.latestRelayJoinCreatedAt ?? 0) - + (left.latestRelayJoinCreatedAt ?? 0) || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + )[0]; + if (newestRelayCandidate?.hasPresentRelayParticipant) { + return { + ephemeralChannelId: newestRelayCandidate.ephemeralChannelId, + state: newestRelayCandidate.state, + }; + } + + // A START-only session has no relay-clock evidence to compare with a + // terminal relay-backed session. Prefer the newest non-terminal START-only + // candidate instead of letting END/LEFT-only history displace a fresh room. + const selected = candidates + .filter( + ({ latestRelayJoinCreatedAt, state }) => + latestRelayJoinCreatedAt === null && + state.startCreatedAt !== null && + !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.max(0, staleDeadlineMs - nowMs); +}