Implement joinRealtimeChannel using ephemeral kind 20932 events

This commit is contained in:
Alex Gleason
2026-02-22 14:56:54 -06:00
parent 44854909bc
commit 8e53b94fc2
+84 -6
View File
@@ -83,6 +83,20 @@ export function useWebxdc(uuid: string): WebxdcAPI<unknown> {
}
}, [updates]);
// Track whether a realtime channel is currently active
const realtimeActiveRef = useRef(false);
const realtimeAbortRef = useRef<AbortController | null>(null);
// Clean up realtime subscription on unmount
useEffect(() => {
return () => {
if (realtimeAbortRef.current) {
realtimeAbortRef.current.abort();
realtimeActiveRef.current = false;
}
};
}, []);
const selfAddr = user ? nip19.npubEncode(user.pubkey) : '';
const selfName = metadata?.display_name || metadata?.name || (user ? nip19.npubEncode(user.pubkey).slice(0, 12) : '');
@@ -139,13 +153,77 @@ export function useWebxdc(uuid: string): WebxdcAPI<unknown> {
}, []);
const joinRealtimeChannel = useCallback((): RealtimeListener => {
// Realtime channels are not supported over Nostr relays
return {
setListener: () => {},
send: () => {},
leave: () => {},
if (realtimeActiveRef.current) {
throw new Error('Already joined a realtime channel. Call leave() first.');
}
realtimeActiveRef.current = true;
const abortController = new AbortController();
realtimeAbortRef.current = abortController;
let realtimeListener: ((data: Uint8Array) => void) | null = null;
// Subscribe to ephemeral kind 20932 events for this UUID
const startSubscription = async () => {
try {
for await (const msg of nostr.req(
[{ kinds: [20932], '#i': [uuid], since: Math.floor(Date.now() / 1000) }],
{ signal: abortController.signal },
)) {
if (msg[0] === 'EVENT') {
const event = msg[2];
// Don't echo back our own events
if (user && event.pubkey === user.pubkey) continue;
if (!realtimeListener) continue;
try {
const binary = Uint8Array.from(atob(event.content), (c) => c.charCodeAt(0));
realtimeListener(binary);
} catch {
// Ignore malformed content
}
} else if (msg[0] === 'CLOSED') {
break;
}
}
} catch {
// Subscription ended (abort or error)
}
};
}, []);
startSubscription();
return {
setListener(listener: (data: Uint8Array) => void) {
realtimeListener = listener;
},
send(data: Uint8Array) {
if (!realtimeActiveRef.current) return;
if (data.length > 128_000) {
throw new Error('Realtime payload exceeds 128,000 byte limit');
}
// Base64-encode the Uint8Array
let binary = '';
for (let i = 0; i < data.length; i++) {
binary += String.fromCharCode(data[i]);
}
const base64 = btoa(binary);
publishEvent({
kind: 20932,
content: base64,
tags: [['i', uuid]],
created_at: Math.floor(Date.now() / 1000),
});
},
leave() {
realtimeActiveRef.current = false;
abortController.abort();
realtimeAbortRef.current = null;
},
};
}, [uuid, nostr, user, publishEvent]);
return useMemo((): WebxdcAPI<unknown> => ({
selfAddr,