- commit
- 49af915ea2717f17326f805dc6f10ba64badcaa4
- parent
- d870709cf7d111f864d96e0ccdecfb7554227cc2
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 306 | ++++++++++++++++++++++++++++++- |
| M | package.json | 3 | +- |
| A | scripts/ | 229 | ++++++++++++++++++++++ |
@@ -549,6 +549,299 @@
549549 }550550 }551551 552+ // Rooms: the compose bar, room creation, room administration and the live553+ // stream. Each signed event is built here, signed by the connected signer,554+ // verified unchanged and published once to /events. room-message markup555+ // matches the roomMessage template so streamed messages read the same.556+ const roomKinds = [9, 11, 12, 40002, 44100, 44101];557+ const mentionPattern = /(^|[\s(])@(npub1[02-9ac-hj-np-z]{58}|[0-9a-f]{64})\b/g;558+ const roomLinkPattern = /https?:\/\/[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+/g;559+ const now = () => Math.floor(Date.now() / 1000);560+ // keyHex accepts a hex key or an npub and returns the hex key, or null.561+ const keyHex = value => {562+ const text = String(value || "").trim().replace(/^nostr:/, "");563+ if (isHex64(text)) return text;564+ if (!/^npub1[02-9ac-hj-np-z]{58}$/.test(text)) return null;565+ try { const decoded = window.NostrSigner?.decodeNpub?.(text); return isHex64(decoded) ? decoded : null; } catch { return null; }566+ };567+ // roomMentions lists the keys named as @npub or @hex in a message, once each.568+ const roomMentions = text => {569+ const keys = [];570+ for (const match of String(text).matchAll(mentionPattern)) {571+ const key = keyHex(match[2]);572+ if (key && !keys.includes(key)) keys.push(key);573+ }574+ return keys;575+ };576+ // roomID derives a room id from a name: lowercase letters, digits, hyphen and underscore.577+ const roomID = name => String(name || "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);578+ const tagValue = (event, name) => (event.tags || []).find(tag => tag[0] === name)?.[1] || "";579+ const clock = seconds => {580+ const stamp = new Date(seconds * 1000).toISOString();581+ if (stamp.slice(0, 10) === new Date().toISOString().slice(0, 10)) return stamp.slice(11, 16);582+ return ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][Number(stamp.slice(5, 7)) - 1] + " " + Number(stamp.slice(8, 10)) + " " + stamp.slice(11, 16);583+ };584+ const roomPath = (room, rest = "") => tiny.localPath("/rooms/" + encodeURIComponent(room) + rest);585+ // linkify fills a node with escaped text, turning http(s) URLs and nostr586+ // links into anchors; nostr links open through /open.587+ const linkify = (node, text) => {588+ let last = 0;589+ for (const match of String(text).matchAll(roomLinkPattern)) {590+ node.append(text.slice(last, match.index));591+ const trimmed = match[0].replace(/[.,;:!?)]+$/, "");592+ const link = el("a", trimmed);593+ link.href = trimmed.startsWith("http") ? trimmed : tiny.localPath("/open?target=" + encodeURIComponent(trimmed));594+ if (trimmed.startsWith("http")) link.rel = "noopener";595+ node.append(link, match[0].slice(trimmed.length));596+ last = match.index + match[0].length;597+ }598+ node.append(text.slice(last));599+ return node;600+ };601+ const keyNode = hex => { const node = el("nostr-key", hex.slice(0, 12)); node.setAttribute("hex", hex); node.title = hex; return node; };602+ // panelMembers reads the members list in the panel: role and agent marker by key.603+ const panelMembers = () => {604+ const members = {};605+ document.querySelectorAll("#members li").forEach(item => {606+ const hex = item.querySelector("nostr-key")?.getAttribute("hex");607+ if (hex) members[hex] = {role: (item.querySelector("small")?.textContent || "").split("|")[0].trim(), agent: item.hasAttribute("data-agent")};608+ });609+ return members;610+ };611+ const messageNode = (event, {members = {}, room = "", inThread = false} = {}) => {612+ const node = el("room-message");613+ const notice = event.kind === 44100 ? "joined the room" : event.kind === 44101 ? "left the room" : "";614+ const pubkey = notice ? tagValue(event, "p") : event.pubkey;615+ node.id = "msg-" + event.id;616+ node.dataset.id = event.id;617+ node.dataset.kind = String(event.kind);618+ node.dataset.pubkey = pubkey;619+ const member = members[pubkey];620+ if (member?.agent) node.dataset.agent = "";621+ if (notice) node.dataset.notice = "";622+ const header = el("header"), name = el("b"), time = el("time", clock(event.created_at)), small = el("small");623+ name.append(keyNode(pubkey));624+ time.dateTime = new Date(event.created_at * 1000).toISOString().replace(/\.\d+Z$/, "Z");625+ time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC";626+ small.append(time);627+ header.append(name, member?.role ? " | " + member.role : "", small);628+ const body = notice ? el("p", notice) : linkify(el("p"), event.content || "");629+ node.append(header, body);630+ const footer = el("footer");631+ const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);632+ if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(keyNode(key), " ")); footer.append(span); }633+ if (!inThread && room && event.kind === 11) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); }634+ const root = tagValue(event, "e");635+ if (!inThread && room && event.kind === 12 && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); }636+ if (footer.childNodes.length) node.append(footer);637+ return node;638+ };639+ const roomBox = () => document.getElementById("content");640+ const roomNearBottom = () => {641+ const box = roomBox();642+ if (box && box.scrollHeight > box.clientHeight + 1 && getComputedStyle(box).overflowY !== "visible") return box.scrollHeight - box.scrollTop - box.clientHeight < 120;643+ return document.documentElement.scrollHeight - window.scrollY - window.innerHeight < 120;644+ };645+ const roomScroll = () => {646+ const box = roomBox();647+ if (box) box.scrollTop = box.scrollHeight;648+ window.scrollTo(0, document.documentElement.scrollHeight);649+ };650+ // roomAppend adds one message to the timeline unless it is already there,651+ // keeps the list bounded and follows the newest message when the viewer652+ // is already reading the end of it.653+ const roomAppend = (event, {room = "", inThread = false, own = false} = {}) => {654+ const list = document.getElementById("messages");655+ if (!list || !isHex64(event?.id) || document.getElementById("msg-" + event.id)) return null;656+ const follow = own || roomNearBottom();657+ list.querySelector("#empty")?.remove();658+ const node = messageNode(event, {members: panelMembers(), room, inThread});659+ list.append(node);660+ while (list.children.length > 500) list.firstElementChild.remove();661+ const root = tagValue(event, "e");662+ if (event.kind === 12 && !inThread && isHex64(root)) {663+ const link = document.querySelector("#msg-" + root + " > footer > a");664+ if (link) { const count = Number((link.textContent.match(/(\d+) repl/) || [])[1] || 0) + 1; link.textContent = "thread | " + count + (count === 1 ? " reply" : " replies"); }665+ }666+ if (follow) roomScroll();667+ return node;668+ };669+ // roomReact adds a reaction to its target's summary line.670+ const roomReact = event => {671+ const targets = (event.tags || []).filter(tag => tag[0] === "e");672+ const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);673+ if (!target) return;674+ let footer = target.querySelector(":scope > footer");675+ if (!footer) { footer = el("footer"); target.append(footer); }676+ const content = (event.content || "").trim() === "" || (event.content || "").trim() === "+" ? "+1" : event.content.trim();677+ let span = [...footer.querySelectorAll("span[data-reaction]")].find(node => node.dataset.reaction === content);678+ if (span) span.textContent = content + " " + (Number(span.textContent.slice(content.length)) + 1);679+ else { span = el("span", content + " 1"); span.dataset.reaction = content; footer.append(span); }680+ };681+ // signAndPublish signs one room event, checks it came back unchanged and682+ // valid, and publishes it once.683+ const signAndPublish = async unsigned => {684+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");685+ const expected = JSON.stringify(unsigned);686+ const event = await window.nostr.signEvent(JSON.parse(expected));687+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});688+ if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");689+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});690+ const result = await response.json();691+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");692+ return event;693+ };694+695+ // RoomCompose sends a chat message (kind 9) or a thread reply (kind 12).696+ // Enter sends and Shift+Enter starts a new line.697+ class RoomCompose extends FormElement {698+ connectedCallback() {699+ super.connectedCallback();700+ if (this.keys) return;701+ this.keys = true;702+ this.addEventListener("keydown", event => {703+ if (event.key !== "Enter" || event.shiftKey || event.isComposing || !event.target.matches?.("textarea")) return;704+ event.preventDefault();705+ this.form?.requestSubmit();706+ });707+ }708+709+ event(content) {710+ const room = this.getAttribute("room") || "";711+ if (!/^[a-z0-9_-]{1,64}$/.test(room)) throw Error("The room id is missing.");712+ const kind = Number(this.getAttribute("kind") || 9);713+ if (kind !== 9 && kind !== 12) throw Error("Unsupported message kind.");714+ const tags = [["h", room]];715+ if (kind === 12) {716+ const root = this.getAttribute("root"), author = this.getAttribute("root-pubkey");717+ if (!isHex64(root)) throw Error("The thread root is missing.");718+ tags.push(["e", root]);719+ if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]);720+ }721+ roomMentions(content).forEach(key => { if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p", key]); });722+ return {kind, created_at: now(), tags, content};723+ }724+725+ async submit(form) {726+ const content = (form.elements.content?.value || "").trim();727+ if (!content) return;728+ this.report("Signing…");729+ const event = await signAndPublish(this.event(content));730+ form.reset();731+ this.report("");732+ roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});733+ }734+ }735+736+ // RoomCreate signs a NIP-29 create (kind 9007) with an id derived from the737+ // name, then opens the new room.738+ class RoomCreate extends FormElement {739+ event({name = "", about = "", access = "open"} = {}) {740+ const title = String(name).trim(), id = roomID(title);741+ if (!id) throw Error("Enter a name with letters or digits.");742+ const tags = [["h", id], ["name", title]];743+ if (String(about).trim()) tags.push(["about", String(about).trim()]);744+ tags.push(["visibility", access === "members" ? "members" : "open"]);745+ return {id, event: {kind: 9007, created_at: now(), tags, content: ""}};746+ }747+748+ async submit(form) {749+ const value = name => form.elements[name]?.value || "";750+ const {id, event} = this.event({name: value("name"), about: value("about"), access: value("access")});751+ this.report("Signing…");752+ await signAndPublish(event);753+ this.report("Created #" + id + ".");754+ form.reset();755+ await tiny.navigate?.(roomPath(id), true);756+ }757+ }758+759+ // RoomAction signs one NIP-29 management event for a room: add a member760+ // (9000), remove one (9001), edit the room (9002), join (9021) or leave (9022).761+ class RoomAction extends FormElement {762+ event(values = {}) {763+ const room = this.getAttribute("room") || "";764+ if (!/^[a-z0-9_-]{1,64}$/.test(room)) throw Error("The room id is missing.");765+ const kind = Number(this.getAttribute("kind"));766+ const tags = [["h", room]];767+ switch (kind) {768+ case 9000: case 9001: {769+ const key = keyHex(values.pubkey);770+ if (!key) throw Error("Enter an npub or a 64-character hex key.");771+ const tag = ["p", key];772+ if (kind === 9000) tag.push(["owner", "admin"].includes(values.role) ? values.role : "member");773+ tags.push(tag);774+ break;775+ }776+ case 9002: {777+ const name = String(values.name || "").trim();778+ if (!name) throw Error("Enter a name.");779+ tags.push(["name", name], ["about", String(values.about || "").trim()], ["picture", String(values.picture || "").trim()], ["visibility", values.access === "members" ? "members" : "open"]);780+ break;781+ }782+ case 9021: case 9022: break;783+ default: throw Error("Unsupported room action.");784+ }785+ return {kind, created_at: now(), tags, content: ""};786+ }787+788+ async submit(form) {789+ const values = {};790+ for (const field of form.elements) if (field.name) values[field.name] = field.value;791+ this.report("Signing…");792+ await signAndPublish(this.event(values));793+ this.report("Done.");794+ await tiny.navigate?.(location.href);795+ }796+ }797+798+ // RoomLive follows a room's stream: new messages join the timeline, and799+ // reactions update their targets. It reconnects with backoff.800+ class RoomLive extends HTMLElement {801+ static get observedAttributes() { return ["room", "root"]; }802+ connectedCallback() { this.open(); roomScroll(); }803+ disconnectedCallback() { this.close(); }804+ attributeChangedCallback() { if (this.isConnected) { this.close(); this.open(); } }805+806+ open() {807+ const room = this.getAttribute("room");808+ if (!room || this.source || typeof EventSource !== "function") return;809+ this.delay = this.delay || 1000;810+ const source = new EventSource(roomPath(room, "/stream"), {withCredentials: true});811+ this.source = source;812+ source.addEventListener("open", () => { this.delay = 1000; this.textContent = ""; });813+ source.addEventListener("message", event => {814+ let parsed;815+ try { parsed = JSON.parse(event.data); } catch { return; }816+ this.receive(parsed);817+ });818+ source.addEventListener("error", () => {819+ if (this.source !== source) return;820+ this.close();821+ this.textContent = "reconnecting";822+ this.timer = setTimeout(() => { this.timer = null; this.open(); }, this.delay);823+ this.delay = Math.min(this.delay * 2, 30000);824+ });825+ }826+827+ close() {828+ this.source?.close();829+ this.source = null;830+ clearTimeout(this.timer);831+ this.timer = null;832+ }833+834+ receive(event) {835+ if (!event || typeof event !== "object") return;836+ if (event.kind === 7) { roomReact(event); return; }837+ if (!roomKinds.includes(event.kind)) return;838+ const root = this.getAttribute("root");839+ if (root && (event.kind !== 12 || tagValue(event, "e") !== root)) return;840+ roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});841+ }842+ }843+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, linkify});844+552845 // JsonView renders any JSON value. Arrays of objects become tables, objects553846 // become definition lists, scalar arrays become lists, and deep nesting554847 // falls back to compact JSON so a response never explodes the page.@@ -1174,6 +1467,10 @@
11741467 customElements.define("nostr-react", NostrReact);11751468 customElements.define("approval-item", ApprovalItem);11761469 customElements.define("wiki-compose", WikiCompose);1470+ customElements.define("room-compose", RoomCompose);1471+ customElements.define("room-create", RoomCreate);1472+ customElements.define("room-action", RoomAction);1473+ customElements.define("room-live", RoomLive);11771474 customElements.define("push-toggle", PushToggle);11781475 customElements.define("share-link", ShareLink);11791476 customElements.define("nostr-key", NostrKey);@@ -1204,7 +1501,7 @@
12041501 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;12051502 return root ? url.pathname.slice(root.length) || "/" : url.pathname;12061503 };1207- const allowedRoute = path => /^(?:\/(?:inbox|approvals|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki)\/.+)$/.test(path || "");1504+ const allowedRoute = path => /^(?:\/(?:inbox|approvals|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki)\/.+|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");12081505 let navigationSerial = 0, activeAbort;12091506 const streams = new Set();12101507 const closeStreams = () => {@@ -1219,7 +1516,7 @@
12191516 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));12201517 window.addEventListener("pagehide", closeStreams);12211518 const repairComponents = () => {1222- document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react,wiki-compose").forEach(node => {1519+ document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => {12231520 if (node.form?.isConnected) return;12241521 node.form = null;12251522 node.output = null;@@ -1269,8 +1566,9 @@
12691566 throw error;12701567 } finally { if (serial === navigationSerial) setBusy(false); }12711568 };1272- // navigate re-requests a page in place; components use it after a publish.1273- tiny.navigate = href => load(new URL(href, location.href), false);1569+ // navigate re-requests a page in place; components use it after a1570+ // publish. With push set it opens a new address as a history entry.1571+ tiny.navigate = (href, push = false) => load(new URL(href, location.href), push);12741572 document.addEventListener("fx:config", event => {12751573 const cfg = event.detail.cfg, elt = cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]");12761574 if (!elt?.matches?.("a[data-fixi-nav], form[data-fixi-nav]")) return;@@ -11,7 +11,8 @@
1111 "test:collaboration": "node --test scripts/collaboration-compose.test.mjs",1212 "test:agents": "node --test scripts/agent-grant.test.mjs",1313 "test:approvals": "node --test scripts/approvals.test.mjs",14- "test:wiki": "node --test scripts/wiki-compose.test.mjs"14+ "test:wiki": "node --test scripts/wiki-compose.test.mjs",15+ "test:rooms": "node --test scripts/rooms.test.mjs"1516 },1617 "devDependencies": {1718 "@noble/hashes": "2.4.0",@@ -0,0 +1,229 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";6+import * as nip19 from "nostr-tools/nip19";7+8+const source = fs.readFileSync("internal/webui/components.js", "utf8");9+const roomsSource = source.slice(source.indexOf(" // Rooms: the compose bar"), source.indexOf(" // JsonView renders any JSON value."));10+11+// A small document: enough of the DOM for the room elements to render12+// messages, find their targets and keep the list bounded.13+class FakeNode {14+ constructor(tag) { this.localName = tag; this.childNodes = []; this.attributes = {}; this.dataset = {}; this.parent = null; this.id = ""; }15+ get children() { return this.childNodes.filter(node => node instanceof FakeNode); }16+ get firstElementChild() { return this.children[0] || null; }17+ get isConnected() { return Boolean(this.parent) && (this.parent === document || this.parent.isConnected); }18+ append(...nodes) { for (const node of nodes) { if (node instanceof FakeNode) { node.remove(); node.parent = this; this.childNodes.push(node); } else if (String(node)) this.childNodes.push(String(node)); } }19+ remove() { if (this.parent) { this.parent.childNodes = this.parent.childNodes.filter(node => node !== this); this.parent = null; } }20+ get textContent() { return this.childNodes.map(node => typeof node === "string" ? node : node.textContent).join(""); }21+ set textContent(value) { this.childNodes = value ? [String(value)] : []; }22+ setAttribute(name, value) { if (name === "id") this.id = value; else this.attributes[name] = String(value); }23+ getAttribute(name) { return name === "id" ? this.id || null : this.attributes[name] ?? null; }24+ hasAttribute(name) { return name.startsWith("data-") ? name.slice(5) in this.dataset : name in this.attributes; }25+ matches(selector) { return selector === this.localName; }26+ addEventListener() {}27+ querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }28+ querySelectorAll(selector) {29+ const steps = selector.replace(/^:scope\s*>\s*/, "> ").trim().split(/\s*>\s*|\s+/).filter(Boolean);30+ const child = selector.includes(">");31+ let nodes = [this];32+ for (const step of steps) {33+ const next = [];34+ for (const node of nodes) for (const candidate of child ? node.children : node.descendants()) if (candidate.matchesSimple(step)) next.push(candidate);35+ nodes = next;36+ }37+ return nodes;38+ }39+ descendants() { return this.children.flatMap(node => [node, ...node.descendants()]); }40+ matchesSimple(step) {41+ if (step.startsWith("#")) return this.id === step.slice(1);42+ const [, tag, attr] = step.match(/^([a-z-]+)(?:\[([a-z-]+)\])?$/) || [];43+ return tag === this.localName && (!attr || this.hasAttribute(attr));44+ }45+}46+const document = new FakeNode("#document");47+document.body = new FakeNode("body");48+document.body.parent = document;49+document.documentElement = {scrollHeight: 1000};50+document.createElement = tag => new FakeNode(tag);51+document.getElementById = id => document.body.descendants().find(node => node.id === id) || null;52+document.querySelector = selector => document.body.querySelector(selector);53+document.querySelectorAll = selector => document.body.querySelectorAll(selector);54+55+function setup({attributes = {}, result = {accepted: true}, change = false} = {}) {56+ const sent = [], navigated = [];57+ const secret = generateSecretKey(), pubkey = getPublicKey(secret);58+ document.body.childNodes = [];59+ const content = new FakeNode("div"); content.id = "content"; content.scrollHeight = 500; content.clientHeight = 400; content.scrollTop = 100;60+ const list = new FakeNode("div"); list.id = "messages";61+ const members = new FakeNode("ul"); members.id = "members";62+ const agentRow = new FakeNode("li"); agentRow.dataset.agent = "";63+ const agentKey = new FakeNode("nostr-key"); agentKey.setAttribute("hex", "b".repeat(64));64+ agentRow.append(agentKey, " ", Object.assign(new FakeNode("small"), {childNodes: ["member | agent"]}));65+ members.append(agentRow);66+ document.body.append(content, list, members);67+ class HTMLElement extends FakeNode {68+ constructor() { super("custom"); }69+ getAttribute(name) { return attributes[name] ?? super.getAttribute(name); }70+ }71+ class FormElement extends HTMLElement {72+ connectedCallback() { this.form = {requestSubmit() {}}; }73+ report(message) { this.message = message; }74+ }75+ const sandbox = {76+ document, HTMLElement, FormElement, URL, encodeURIComponent, setTimeout, clearTimeout,77+ getComputedStyle: () => ({overflowY: "auto"}),78+ window: {scrollY: 0, innerHeight: 800, scrollTo() {}, NostrSigner: {verifyEvent, decodeNpub: value => { const decoded = nip19.decode(value); if (decoded.type !== "npub") throw Error("not an npub"); return decoded.data; }}, nostr: {signEvent: async event => { const copy = JSON.parse(JSON.stringify(event)); if (change) copy.content = "changed"; return finalizeEvent(copy, secret); }}},79+ tiny: {localPath: path => "/r/work" + path, navigate: async (href, push) => { navigated.push({href, push}); }, signedFetch: async (path, method, body, options) => { sent.push({path, method, options, event: JSON.parse(body)}); return Response.json(result); }},80+ el: (tag, text) => { const node = new FakeNode(tag); if (text !== undefined) node.textContent = text; return node; },81+ isHex64: value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value)82+ };83+ sandbox.EventSource = class {84+ constructor(url, options) { this.url = url; this.options = options; this.listeners = {}; this.closed = false; sandbox.sources.push(this); }85+ addEventListener(name, handler) { (this.listeners[name] = this.listeners[name] || []).push(handler); }86+ emit(name, detail) { (this.listeners[name] || []).forEach(handler => handler(detail)); }87+ close() { this.closed = true; }88+ };89+ sandbox.sources = [];90+ const classes = vm.runInNewContext(`${roomsSource}\n({RoomCompose, RoomCreate, RoomAction, RoomLive, rooms: tiny.rooms})`, sandbox);91+ return {...classes, sandbox, sent, navigated, pubkey, list, content, form: values => ({elements: Object.fromEntries(Object.entries(values).map(([name, value]) => [name, {name, value}])), reset() { this.resets = (this.resets || 0) + 1; }})};92+}93+const npub = nip19.npubEncode("d".repeat(64));94+// Values built inside the sandbox belong to another realm; compare them as plain data.95+const same = (actual, expected) => assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected);96+97+test("room-compose signs kind 9 with the room tag and mentions from @npub and @hex", async () => {98+ const s = setup({attributes: {room: "build", pubkey: "x"}});99+ const compose = new s.RoomCompose();100+ compose.connectedCallback();101+ const unsigned = compose.event(`hi @${npub} and @${"e".repeat(64)}, again @${"e".repeat(64)} not me@${"f".repeat(64)}`);102+ assert.equal(unsigned.kind, 9);103+ same(unsigned.tags, [["h", "build"], ["p", "d".repeat(64)], ["p", "e".repeat(64)]]);104+ same(s.rooms.roomMentions("@npub1notakey and @abc"), []);105+ const form = s.form({content: " hello room "});106+ await compose.submit(form);107+ assert.equal(s.sent[0].path, "/events");108+ assert.equal(s.sent[0].options.contentType, "application/json");109+ assert.equal(s.sent[0].event.kind, 9);110+ assert.equal(s.sent[0].event.content, "hello room");111+ assert.equal(verifyEvent(s.sent[0].event), true);112+ assert.equal(form.resets, 1);113+ assert.equal(s.list.children.length, 1);114+ assert.equal(s.list.children[0].id, "msg-" + s.sent[0].event.id);115+ assert.equal(s.content.scrollTop, 500);116+ assert.throws(() => new (setup({attributes: {room: "Bad Room"}}).RoomCompose)().event("x"), /room id/);117+});118+119+test("room-compose replies in a thread with the root and its author, and refuses a changed event", async () => {120+ const root = "1".repeat(64), author = "2".repeat(64);121+ const s = setup({attributes: {room: "build", kind: "12", root, "root-pubkey": author, pubkey: "3".repeat(64)}});122+ const compose = new s.RoomCompose();123+ same(compose.event("ok").tags, [["h", "build"], ["e", root], ["p", author]]);124+ const own = setup({attributes: {room: "build", kind: "12", root, "root-pubkey": author, pubkey: author}});125+ same(new own.RoomCompose().event("ok").tags, [["h", "build"], ["e", root]]);126+ const changed = setup({attributes: {room: "build"}, change: true});127+ await assert.rejects(new changed.RoomCompose().submit(changed.form({content: "x"})), /changed event/);128+ assert.equal(changed.sent.length, 0);129+ const refused = setup({attributes: {room: "build"}, result: {accepted: false, message: "restricted: members only"}});130+ await assert.rejects(new refused.RoomCompose().submit(refused.form({content: "x"})), /members only/);131+});132+133+test("room-create derives the id from the name and signs 9007 before opening the room", async () => {134+ const s = setup();135+ assert.equal(s.rooms.roomID("Build & Release 2026!"), "build-release-2026");136+ assert.equal(s.rooms.roomID(" --- "), "");137+ assert.equal(s.rooms.roomID("x".repeat(80)).length, 64);138+ const create = new s.RoomCreate();139+ const {id, event} = create.event({name: " Build & Release ", about: " nightly ", access: "members"});140+ assert.equal(id, "build-release");141+ assert.equal(event.kind, 9007);142+ same(event.tags, [["h", "build-release"], ["name", "Build & Release"], ["about", "nightly"], ["visibility", "members"]]);143+ same(create.event({name: "general"}).event.tags, [["h", "general"], ["name", "general"], ["visibility", "open"]]);144+ assert.throws(() => create.event({name: "!!!"}), /letters or digits/);145+ await create.submit(s.form({name: "Build", about: "", access: "open"}));146+ assert.equal(s.sent[0].event.kind, 9007);147+ assert.equal(verifyEvent(s.sent[0].event), true);148+ same(s.navigated, [{href: "/r/work/rooms/build", push: true}]);149+});150+151+test("room-action signs membership, settings, join and leave events", () => {152+ const s = setup({attributes: {room: "build", kind: "9000"}});153+ const add = new s.RoomAction();154+ same(add.event({pubkey: npub, role: "admin"}).tags, [["h", "build"], ["p", "d".repeat(64), "admin"]]);155+ same(add.event({pubkey: "e".repeat(64), role: "nonsense"}).tags, [["h", "build"], ["p", "e".repeat(64), "member"]]);156+ assert.throws(() => add.event({pubkey: "who"}), /npub or a 64/);157+ const edit = new (setup({attributes: {room: "build", kind: "9002"}}).RoomAction)();158+ same(edit.event({name: " Build ", about: "x", picture: "", access: "members"}).tags, [["h", "build"], ["name", "Build"], ["about", "x"], ["picture", ""], ["visibility", "members"]]);159+ assert.throws(() => edit.event({name: " "}), /Enter a name/);160+ same(new (setup({attributes: {room: "build", kind: "9022"}}).RoomAction)().event().tags, [["h", "build"]]);161+ assert.equal(new (setup({attributes: {room: "build", kind: "9021"}}).RoomAction)().event().kind, 9021);162+ assert.throws(() => new (setup({attributes: {room: "build", kind: "9008"}}).RoomAction)().event(), /Unsupported/);163+});164+165+test("room-live appends streamed messages once, summarizes reactions and reconnects with backoff", async () => {166+ const s = setup({attributes: {room: "build"}});167+ const live = new s.RoomLive();168+ live.connectedCallback();169+ assert.equal(s.sandbox.sources.length, 1);170+ const source = s.sandbox.sources[0];171+ assert.equal(source.url, "/r/work/rooms/build/stream");172+ assert.equal(source.options.withCredentials, true);173+ const secret = generateSecretKey();174+ const message = finalizeEvent({kind: 9, created_at: 1757203600, tags: [["h", "build"], ["p", "c".repeat(64)]], content: "see https://example.com/x, ok"}, secret);175+ source.emit("message", {data: JSON.stringify(message)});176+ source.emit("message", {data: JSON.stringify(message)});177+ assert.equal(s.list.children.length, 1);178+ const node = s.list.children[0];179+ assert.equal(node.id, "msg-" + message.id);180+ assert.equal(node.dataset.kind, "9");181+ assert.equal(node.querySelector("header b nostr-key").getAttribute("hex"), message.pubkey);182+ const link = node.querySelector("p a");183+ assert.equal(link.href, "https://example.com/x");184+ assert.equal(node.querySelector("p").textContent, "see https://example.com/x, ok");185+ assert.equal(node.querySelector("footer span nostr-key").getAttribute("hex"), "c".repeat(64));186+ source.emit("message", {data: "not json"});187+ source.emit("message", {data: JSON.stringify({...message, id: "9".repeat(64), kind: 20001})});188+ assert.equal(s.list.children.length, 1);189+ const reaction = finalizeEvent({kind: 7, created_at: 1757203700, tags: [["h", "build"], ["e", message.id]], content: "+"}, secret);190+ source.emit("message", {data: JSON.stringify(reaction)});191+ source.emit("message", {data: JSON.stringify({...reaction, id: "8".repeat(64)})});192+ const summary = node.querySelector("footer span[data-reaction]");193+ assert.equal(summary.dataset.reaction, "+1");194+ assert.equal(summary.textContent, "+1 2");195+ // An agent's message carries the marker from the members list.196+ const agent = finalizeEvent({kind: 11, created_at: 1757203800, tags: [["h", "build"]], content: "draft"}, secret);197+ s.rooms.roomAppend({...agent, pubkey: "b".repeat(64)}, {room: "build"});198+ const agentNode = s.list.children[1];199+ assert.equal("agent" in agentNode.dataset, true);200+ assert.equal(agentNode.querySelector("footer a").href, "/r/work/rooms/build/thread/" + agent.id);201+ assert.equal(agentNode.querySelector("header").textContent.includes("| member"), true);202+ // The list stays bounded.203+ for (let i = 0; i < 520; i++) s.rooms.roomAppend({...message, id: i.toString(16).padStart(64, "0")}, {room: "build"});204+ assert.equal(s.list.children.length, 500);205+ source.emit("error", {});206+ assert.equal(source.closed, true);207+ assert.equal(live.textContent, "reconnecting");208+ assert.equal(live.delay, 2000);209+ live.close();210+ assert.equal(live.timer, null);211+});212+213+test("room-live on a thread page keeps only replies to its root", () => {214+ const root = "a".repeat(64);215+ const s = setup({attributes: {room: "build", root}});216+ const live = new s.RoomLive();217+ live.connectedCallback();218+ const source = s.sandbox.sources[0];219+ const secret = generateSecretKey();220+ source.emit("message", {data: JSON.stringify(finalizeEvent({kind: 9, created_at: 1, tags: [["h", "build"]], content: "chat"}, secret))});221+ source.emit("message", {data: JSON.stringify(finalizeEvent({kind: 12, created_at: 2, tags: [["h", "build"], ["e", "b".repeat(64)]], content: "elsewhere"}, secret))});222+ const reply = finalizeEvent({kind: 12, created_at: 3, tags: [["h", "build"], ["e", root]], content: "here"}, secret);223+ source.emit("message", {data: JSON.stringify(reply)});224+ assert.equal(s.list.children.length, 1);225+ assert.equal(s.list.children[0].id, "msg-" + reply.id);226+ assert.equal(s.list.children[0].querySelector("footer"), null);227+ live.disconnectedCallback();228+ assert.equal(source.closed, true);229+});