+
+
+
+
+
+ #{activeRoomName || "room"}
+
+
+ {communityName || host} · {host}
+
+
+
+
+ {rooms && rooms.length > 1 ? (
+
+ ) : null}
+
+
+ {refusal ? (
+
+ {refusal}{" "}
+
+
+ ) : null}
+
+ {status.state === "closed" || status.state === "offline" ? (
+
+ the relay said: “{status.detail}”
+
+ ) : null}
+
+
+ {events.length === 0 ? (
+
+ {status.state === "live" || status.state === "closed"
+ ? `no messages here yet — ${activeRoomName} is listening`
+ : "connecting to the room…"}
+
+ ) : null}
+ {events.map((event) => (
+
+
+
+ {truncatePubkey(event.pubkey)}
+
+
+ {timeLabel(event.created_at)}
+
+
+
+ {event.content}
+
+
+ ))}
+
+
+ {voiceState !== "idle" ? (
+
+ {voiceState === "recording"
+ ? `recording (${voiceSeconds}s) — tap the mic again to send it to the scribe`
+ : "transcribing your voice note — the scribe is writing it down…"}
+
+ ) : null}
+
+
+ setDraft(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ send();
+ }
+ }}
+ placeholder={`message #${activeRoomName || "room"}`}
+ value={draft}
+ />
+ {voice ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+
+ {voice && tongueSheetOpen ? (
+
{
+ if (event.currentTarget === event.target) setTongueSheetOpen(false);
+ }}
+ >
+
+
+
+ transcribe in which tongue?
+
+
+ The scribe transcribes with the language PINNED to your choice —
+ it never guesses. The community's order:{" "}
+ {voiceTongues.map((t) => TONGUE_LABELS[t] ?? t).join(" · ")}.
+
+
+ {voiceTongues.map((code) => (
+
+ ))}
+
+
+
+
+ ) : null}
+
+ {keySheetOpen ? (
+
{
+ if (event.currentTarget === event.target) setKeySheetOpen(false);
+ }}
+ >
+
+
+
your key
+
+ You are{" "}
+
+ {npub
+ ? truncatePubkey(npub)
+ : "signing with your NIP-07 extension key"}
+
+ . This identity was made on this phone and is stored only in this
+ browser. To keep it beyond this browser, copy the secret and keep
+ it somewhere safe — anyone holding it is you.{" "}
+ Never paste it into a room.
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/features/room/nostr-room.ts b/web/src/features/room/nostr-room.ts
new file mode 100644
index 00000000000..ecb0d2c685b
--- /dev/null
+++ b/web/src/features/room/nostr-room.ts
@@ -0,0 +1,255 @@
+/**
+ * Persistent NIP-42 room connection — the live wire under the room view.
+ *
+ * One WebSocket to the relay the user was given (never a third party):
+ * AUTH on challenge (NIP-42) → REQ history + live subscription for the
+ * channel (NIP-29 shape: kind 9, `#h` = channel id) → send kind 9.
+ *
+ * Every relay verdict surfaces verbatim: CLOSED reasons, NOTICEs and send
+ * rejections are passed to the UI, never swallowed — a members-only relay
+ * refusing an unknown key is a fact the stranger is owed, not an error to
+ * hide. Fail-closed is the relay's law; this client just refuses to pretend.
+ */
+
+import type {
+ SignedNostrEvent,
+ UnsignedNostrEvent,
+} from "@/shared/lib/nostr-signer";
+
+export type RoomEvent = {
+ id: string;
+ pubkey: string;
+ created_at: number;
+ content: string;
+};
+
+export type RoomStatus =
+ | { state: "connecting" }
+ | { state: "authenticating" }
+ | { state: "live" }
+ | { state: "closed"; detail: string }
+ | { state: "offline"; detail: string };
+
+export type RoomSigner = (
+ unsigned: Omit