Skip to content
Open
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
167 changes: 69 additions & 98 deletions desktop/src/features/huddle/components/HuddleAttachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -32,11 +33,6 @@ type HuddleAttachmentProps = {
onOpenThread?: (message: TimelineMessage) => void;
};

type HuddleLifecycleState = {
ended: boolean;
participants: Set<string>;
};

function parseEphemeralChannelId(content: string): string | null {
try {
const parsed = JSON.parse(content) as { ephemeral_channel_id?: unknown };
Expand All @@ -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<RelayEvent>,
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<string>();
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,
Expand All @@ -124,20 +71,35 @@ 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<HuddleLifecycleState>(() => ({
ended: false,
participants: new Set(message.pubkey ? [message.pubkey] : []),
}));
React.useState<HuddleLifecycleState>(() =>
ephemeralChannelId
? reconstructHuddleState(
[messageLifecycleEvent(message)],
ephemeralChannelId,
{ isCurrentHuddle },
)
: {
ended: true,
participants: new Set(),
startCreatedAt: null,
staleDeadlineMs: null,
},
);

React.useEffect(() => {
if (!channelId || !ephemeralChannelId) return;

const huddleChannelId = ephemeralChannelId;
let disposed = false;
let cleanup: (() => void) | null = null;
let staleTimeout: ReturnType<typeof setTimeout> | null = null;
const seenChannelEventIds = new Set<string>();
const seenEvents = new Map<string, RelayEvent>([
[
message.id,
Expand All @@ -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) => {
Expand All @@ -185,11 +163,13 @@ export function HuddleAttachment({

return () => {
disposed = true;
if (staleTimeout) clearTimeout(staleTimeout);
cleanup?.();
};
}, [
channelId,
ephemeralChannelId,
isCurrentHuddle,
message.body,
message.createdAt,
message.id,
Expand All @@ -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;
Expand Down
Loading
Loading