Skip to content
This repository was archived by the owner on Aug 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 13 additions & 104 deletions desktop/src/features/huddle/components/HuddleIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,13 @@ 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 {
type ActiveHuddleSummary,
getHuddleParticipantCount,
reconstructActiveHuddlesByParentChannel,
} from "../lib/activeHuddleState";
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;

type ActiveHuddle = {
ephemeralChannelId: string;
participants: Set<string>;
};

type HuddleIndicatorProps = {
channelId: string;
className?: string;
Expand All @@ -48,9 +42,8 @@ export function HuddleIndicator({
}: HuddleIndicatorProps) {
const { joinHuddle, isStarting } = useHuddle();
const queryClient = useQueryClient();
const [activeHuddle, setActiveHuddle] = React.useState<ActiveHuddle | null>(
null,
);
const [activeHuddle, setActiveHuddle] =
React.useState<ActiveHuddleSummary | null>(null);
const [isJoining, setIsJoining] = React.useState(false);

React.useEffect(() => {
Expand All @@ -62,94 +55,13 @@ export function HuddleIndicator({
// Track all seen events for reconstruction. Keyed by event.id for dedup.
const seenEvents = new Map<string, RelayEvent>();

/** 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<string>();

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 (!disposed) {
setActiveHuddle(huddle);
setActiveHuddle(
reconstructActiveHuddlesByParentChannel(seenEvents.values()).get(
channelId,
) ?? null,
);
}
}

Expand Down Expand Up @@ -251,10 +163,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 = getHuddleParticipantCount(activeHuddle);

async function doJoin() {
if (!activeHuddle || isJoining) return;
Expand Down
247 changes: 247 additions & 0 deletions desktop/src/features/huddle/lib/activeHuddleState.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
KIND_HUDDLE_ENDED,
KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT,
KIND_HUDDLE_STARTED,
} from "@/shared/constants/kinds.ts";
import { HUDDLE_JOINABLE_WINDOW_SECONDS } from "./huddleCardState.ts";
import { reconstructActiveHuddlesByParentChannel } from "./activeHuddleState.ts";

const CREATOR = "a".repeat(64);
const BENJI = "b".repeat(64);
const ANDREW = "c".repeat(64);

function huddleEvent({
id,
kind,
parentChannelId = "general",
ephemeralChannelId = "huddle-1",
pubkey = CREATOR,
createdAt,
participant,
}) {
const tags = [["h", parentChannelId]];
if (participant) tags.push(["p", participant]);

return {
id,
pubkey,
kind,
created_at: createdAt,
content: JSON.stringify({ ephemeral_channel_id: ephemeralChannelId }),
tags,
sig: "",
};
}

test("reconstructActiveHuddlesByParentChannel replays lifecycle for a late mounted sidebar", () => {
const events = [
huddleEvent({
id: "start",
kind: KIND_HUDDLE_STARTED,
createdAt: 100,
}),
huddleEvent({
id: "join-benji",
kind: KIND_HUDDLE_PARTICIPANT_JOINED,
createdAt: 101,
participant: BENJI,
pubkey: "relay",
}),
];

const active = reconstructActiveHuddlesByParentChannel(events, 110_000);
const huddle = active.get("general");

assert.equal(active.size, 1);
assert.equal(huddle?.ephemeralChannelId, "huddle-1");
assert.equal(huddle?.participantPubkeys.has(CREATOR), true);
assert.equal(huddle?.participantPubkeys.has(BENJI), true);
});

test("reconstructActiveHuddlesByParentChannel handles out-of-order replay and suppresses ended huddles", () => {
const events = [
huddleEvent({
id: "late-left",
kind: KIND_HUDDLE_PARTICIPANT_LEFT,
createdAt: 102,
participant: BENJI,
pubkey: "relay",
}),
huddleEvent({
id: "ended",
kind: KIND_HUDDLE_ENDED,
createdAt: 103,
}),
huddleEvent({
id: "start",
kind: KIND_HUDDLE_STARTED,
createdAt: 100,
}),
huddleEvent({
id: "join-benji",
kind: KIND_HUDDLE_PARTICIPANT_JOINED,
createdAt: 101,
participant: BENJI,
pubkey: "relay",
}),
];

const active = reconstructActiveHuddlesByParentChannel(events, 110_000);

assert.equal(active.size, 0);
});

test("reconstructActiveHuddlesByParentChannel does not resurrect phantom huddles after an end event", () => {
const events = [
huddleEvent({
id: "start",
kind: KIND_HUDDLE_STARTED,
createdAt: 100,
}),
huddleEvent({
id: "ended",
kind: KIND_HUDDLE_ENDED,
createdAt: 101,
}),
huddleEvent({
id: "late-join",
kind: KIND_HUDDLE_PARTICIPANT_JOINED,
createdAt: 102,
participant: ANDREW,
pubkey: "relay",
}),
];

const active = reconstructActiveHuddlesByParentChannel(events, 110_000);

assert.equal(active.size, 0);
});

test("reconstructActiveHuddlesByParentChannel filters stale huddles by joinable window", () => {
const startAt = 1_000;
const nowSeconds = startAt + HUDDLE_JOINABLE_WINDOW_SECONDS + 1;
const events = [
huddleEvent({
id: "start",
kind: KIND_HUDDLE_STARTED,
createdAt: startAt,
}),
];

const active = reconstructActiveHuddlesByParentChannel(
events,
nowSeconds * 1000,
);

assert.equal(active.size, 0);
});

test("reconstructActiveHuddlesByParentChannel ignores lifecycle rows without a start event", () => {
const events = [
huddleEvent({
id: "join-only",
kind: KIND_HUDDLE_PARTICIPANT_JOINED,
createdAt: 3_500,
participant: BENJI,
pubkey: "relay",
}),
];

const active = reconstructActiveHuddlesByParentChannel(events, 3_600_000);

assert.equal(active.size, 0);
});

test("reconstructActiveHuddlesByParentChannel preserves same-second leave and rejoin transitions", () => {
const events = [
huddleEvent({
id: "start",
kind: KIND_HUDDLE_STARTED,
createdAt: 100,
}),
huddleEvent({
id: "join-benji",
kind: KIND_HUDDLE_PARTICIPANT_JOINED,
createdAt: 101,
participant: BENJI,
pubkey: "relay",
}),
huddleEvent({
id: "z-left-benji",
kind: KIND_HUDDLE_PARTICIPANT_LEFT,
createdAt: 102,
participant: BENJI,
pubkey: "relay",
}),
huddleEvent({
id: "a-rejoin-benji",
kind: KIND_HUDDLE_PARTICIPANT_JOINED,
createdAt: 102,
participant: BENJI,
pubkey: "relay",
}),
];

const active = reconstructActiveHuddlesByParentChannel(events, 110_000);

assert.equal(active.get("general")?.participantPubkeys.has(BENJI), true);
});

test("reconstructActiveHuddlesByParentChannel prefers the newest active huddle for a channel", () => {
const events = [
huddleEvent({
id: "old-start",
kind: KIND_HUDDLE_STARTED,
createdAt: 100,
ephemeralChannelId: "old-huddle",
}),
huddleEvent({
id: "new-start",
kind: KIND_HUDDLE_STARTED,
createdAt: 110,
ephemeralChannelId: "new-huddle",
pubkey: BENJI,
}),
huddleEvent({
id: "old-late-join",
kind: KIND_HUDDLE_PARTICIPANT_JOINED,
createdAt: 120,
ephemeralChannelId: "old-huddle",
participant: ANDREW,
pubkey: "relay",
}),
];

const active = reconstructActiveHuddlesByParentChannel(events, 125_000);

assert.equal(active.get("general")?.ephemeralChannelId, "new-huddle");
});

test("reconstructActiveHuddlesByParentChannel keeps parent channels isolated", () => {
const events = [
huddleEvent({
id: "general-start",
kind: KIND_HUDDLE_STARTED,
createdAt: 100,
parentChannelId: "general",
ephemeralChannelId: "huddle-general",
}),
huddleEvent({
id: "private-start",
kind: KIND_HUDDLE_STARTED,
createdAt: 101,
parentChannelId: "private",
ephemeralChannelId: "huddle-private",
pubkey: BENJI,
}),
];

const active = reconstructActiveHuddlesByParentChannel(events, 110_000);

assert.equal(active.get("general")?.ephemeralChannelId, "huddle-general");
assert.equal(active.get("private")?.ephemeralChannelId, "huddle-private");
});
Loading
Loading