diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx index 0b7b792..5d1a24a 100644 --- a/apps/web/src/modules/mesh/topic-chat-panel.tsx +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -6,10 +6,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@turbostarter/ui-web/button"; import { + claimTopicKey, decryptMessage, encryptMessage, getTopicKey, registerBrowserPeerPubkey, + sealTopicKeyFor, } from "~/services/crypto/topic-key"; interface TopicMessage { @@ -313,6 +315,56 @@ export function TopicChatPanel({ }; }, [apiKeySecret, topicName]); + // Browser-side re-seal loop. While we hold the topic key, every 30s + // we look for newly-joined topic members who don't yet have a sealed + // copy and seal it for them. Mirrors the CLI re-seal path so a topic + // claimed-by-browser doesn't go dark for CLI joiners. + useEffect(() => { + if (!topicKey || keyState !== "ready") return; + let cancelled = false; + const reseal = async () => { + try { + const res = await fetch( + `/api/v1/topics/${encodeURIComponent(topicName)}/pending-seals`, + { headers, cache: "no-store" }, + ); + if (!res.ok) return; + const json = (await res.json()) as { + pending: Array<{ memberId: string; pubkey: string; displayName: string }>; + }; + for (const target of json.pending) { + if (cancelled) return; + const sealed = await sealTopicKeyFor(topicKey, target.pubkey); + if (!sealed) continue; + try { + await fetch( + `/api/v1/topics/${encodeURIComponent(topicName)}/seal`, + { + method: "POST", + headers, + body: JSON.stringify({ + memberId: target.memberId, + encryptedKey: sealed.encryptedKey, + nonce: sealed.nonce, + }), + }, + ); + } catch { + // Another holder likely sealed first — fine to swallow. + } + } + } catch { + // Soft-fail; next tick retries. + } + }; + void reseal(); + const t = setInterval(reseal, 30_000); + return () => { + cancelled = true; + clearInterval(t); + }; + }, [topicKey, keyState, headers, topicName]); + // Decrypt any v2 messages that we haven't decrypted yet. Runs after // `messages` updates (history backfill, SSE delivery) and after // `topicKey` lands. @@ -849,6 +901,43 @@ export function TopicChatPanel({ > 🔒 end-to-end encrypted (v0.3.0)
+ ) : keyState === "topic_unencrypted" ? ( +