clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

compose, create, administer and follow rooms in the browser

Dami committed 49af915
commit
49af915ea2717f17326f805dc6f10ba64badcaa4
parent
d870709cf7d111f864d96e0ccdecfb7554227cc2
author
Dami <dami.etoile@gmail.com>
3 files changed, +533 -5
Minternal/webui/components.js306++++++++++++++++++++++++++++++-
Mpackage.json3+-
Ascripts/rooms.test.mjs229++++++++++++++++++++++
M internal/webui/components.js +302 -4
@@ -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;
M package.json +2 -1
A scripts/rooms.test.mjs +229 -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+});