tinyrelaycommit

Commit 49af915ea271

browse files at this commit | history

1diff --git a/internal/webui/components.js b/internal/webui/components.js
2index 71f7915..eadc778 100644
3--- a/internal/webui/components.js
4+++ b/internal/webui/components.js
5@@ -549,6 +549,299 @@
6 }
7 }
8
9+ // Rooms: the compose bar, room creation, room administration and the live
10+ // stream. Each signed event is built here, signed by the connected signer,
11+ // verified unchanged and published once to /events. room-message markup
12+ // matches the roomMessage template so streamed messages read the same.
13+ const roomKinds = [9, 11, 12, 40002, 44100, 44101];
14+ const mentionPattern = /(^|[\s(])@(npub1[02-9ac-hj-np-z]{58}|[0-9a-f]{64})\b/g;
15+ const roomLinkPattern = /https?:\/\/[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+/g;
16+ const now = () => Math.floor(Date.now() / 1000);
17+ // keyHex accepts a hex key or an npub and returns the hex key, or null.
18+ const keyHex = value => {
19+ const text = String(value || "").trim().replace(/^nostr:/, "");
20+ if (isHex64(text)) return text;
21+ if (!/^npub1[02-9ac-hj-np-z]{58}$/.test(text)) return null;
22+ try { const decoded = window.NostrSigner?.decodeNpub?.(text); return isHex64(decoded) ? decoded : null; } catch { return null; }
23+ };
24+ // roomMentions lists the keys named as @npub or @hex in a message, once each.
25+ const roomMentions = text => {
26+ const keys = [];
27+ for (const match of String(text).matchAll(mentionPattern)) {
28+ const key = keyHex(match[2]);
29+ if (key && !keys.includes(key)) keys.push(key);
30+ }
31+ return keys;
32+ };
33+ // roomID derives a room id from a name: lowercase letters, digits, hyphen and underscore.
34+ const roomID = name => String(name || "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
35+ const tagValue = (event, name) => (event.tags || []).find(tag => tag[0] === name)?.[1] || "";
36+ const clock = seconds => {
37+ const stamp = new Date(seconds * 1000).toISOString();
38+ if (stamp.slice(0, 10) === new Date().toISOString().slice(0, 10)) return stamp.slice(11, 16);
39+ 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);
40+ };
41+ const roomPath = (room, rest = "") => tiny.localPath("/rooms/" + encodeURIComponent(room) + rest);
42+ // linkify fills a node with escaped text, turning http(s) URLs and nostr
43+ // links into anchors; nostr links open through /open.
44+ const linkify = (node, text) => {
45+ let last = 0;
46+ for (const match of String(text).matchAll(roomLinkPattern)) {
47+ node.append(text.slice(last, match.index));
48+ const trimmed = match[0].replace(/[.,;:!?)]+$/, "");
49+ const link = el("a", trimmed);
50+ link.href = trimmed.startsWith("http") ? trimmed : tiny.localPath("/open?target=" + encodeURIComponent(trimmed));
51+ if (trimmed.startsWith("http")) link.rel = "noopener";
52+ node.append(link, match[0].slice(trimmed.length));
53+ last = match.index + match[0].length;
54+ }
55+ node.append(text.slice(last));
56+ return node;
57+ };
58+ const keyNode = hex => { const node = el("nostr-key", hex.slice(0, 12)); node.setAttribute("hex", hex); node.title = hex; return node; };
59+ // panelMembers reads the members list in the panel: role and agent marker by key.
60+ const panelMembers = () => {
61+ const members = {};
62+ document.querySelectorAll("#members li").forEach(item => {
63+ const hex = item.querySelector("nostr-key")?.getAttribute("hex");
64+ if (hex) members[hex] = {role: (item.querySelector("small")?.textContent || "").split("|")[0].trim(), agent: item.hasAttribute("data-agent")};
65+ });
66+ return members;
67+ };
68+ const messageNode = (event, {members = {}, room = "", inThread = false} = {}) => {
69+ const node = el("room-message");
70+ const notice = event.kind === 44100 ? "joined the room" : event.kind === 44101 ? "left the room" : "";
71+ const pubkey = notice ? tagValue(event, "p") : event.pubkey;
72+ node.id = "msg-" + event.id;
73+ node.dataset.id = event.id;
74+ node.dataset.kind = String(event.kind);
75+ node.dataset.pubkey = pubkey;
76+ const member = members[pubkey];
77+ if (member?.agent) node.dataset.agent = "";
78+ if (notice) node.dataset.notice = "";
79+ const header = el("header"), name = el("b"), time = el("time", clock(event.created_at)), small = el("small");
80+ name.append(keyNode(pubkey));
81+ time.dateTime = new Date(event.created_at * 1000).toISOString().replace(/\.\d+Z$/, "Z");
82+ time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC";
83+ small.append(time);
84+ header.append(name, member?.role ? " | " + member.role : "", small);
85+ const body = notice ? el("p", notice) : linkify(el("p"), event.content || "");
86+ node.append(header, body);
87+ const footer = el("footer");
88+ const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);
89+ if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(keyNode(key), " ")); footer.append(span); }
90+ if (!inThread && room && event.kind === 11) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); }
91+ const root = tagValue(event, "e");
92+ if (!inThread && room && event.kind === 12 && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); }
93+ if (footer.childNodes.length) node.append(footer);
94+ return node;
95+ };
96+ const roomBox = () => document.getElementById("content");
97+ const roomNearBottom = () => {
98+ const box = roomBox();
99+ if (box && box.scrollHeight > box.clientHeight + 1 && getComputedStyle(box).overflowY !== "visible") return box.scrollHeight - box.scrollTop - box.clientHeight < 120;
100+ return document.documentElement.scrollHeight - window.scrollY - window.innerHeight < 120;
101+ };
102+ const roomScroll = () => {
103+ const box = roomBox();
104+ if (box) box.scrollTop = box.scrollHeight;
105+ window.scrollTo(0, document.documentElement.scrollHeight);
106+ };
107+ // roomAppend adds one message to the timeline unless it is already there,
108+ // keeps the list bounded and follows the newest message when the viewer
109+ // is already reading the end of it.
110+ const roomAppend = (event, {room = "", inThread = false, own = false} = {}) => {
111+ const list = document.getElementById("messages");
112+ if (!list || !isHex64(event?.id) || document.getElementById("msg-" + event.id)) return null;
113+ const follow = own || roomNearBottom();
114+ list.querySelector("#empty")?.remove();
115+ const node = messageNode(event, {members: panelMembers(), room, inThread});
116+ list.append(node);
117+ while (list.children.length > 500) list.firstElementChild.remove();
118+ const root = tagValue(event, "e");
119+ if (event.kind === 12 && !inThread && isHex64(root)) {
120+ const link = document.querySelector("#msg-" + root + " > footer > a");
121+ if (link) { const count = Number((link.textContent.match(/(\d+) repl/) || [])[1] || 0) + 1; link.textContent = "thread | " + count + (count === 1 ? " reply" : " replies"); }
122+ }
123+ if (follow) roomScroll();
124+ return node;
125+ };
126+ // roomReact adds a reaction to its target's summary line.
127+ const roomReact = event => {
128+ const targets = (event.tags || []).filter(tag => tag[0] === "e");
129+ const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);
130+ if (!target) return;
131+ let footer = target.querySelector(":scope > footer");
132+ if (!footer) { footer = el("footer"); target.append(footer); }
133+ const content = (event.content || "").trim() === "" || (event.content || "").trim() === "+" ? "+1" : event.content.trim();
134+ let span = [...footer.querySelectorAll("span[data-reaction]")].find(node => node.dataset.reaction === content);
135+ if (span) span.textContent = content + " " + (Number(span.textContent.slice(content.length)) + 1);
136+ else { span = el("span", content + " 1"); span.dataset.reaction = content; footer.append(span); }
137+ };
138+ // signAndPublish signs one room event, checks it came back unchanged and
139+ // valid, and publishes it once.
140+ const signAndPublish = async unsigned => {
141+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
142+ const expected = JSON.stringify(unsigned);
143+ const event = await window.nostr.signEvent(JSON.parse(expected));
144+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});
145+ if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");
146+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
147+ const result = await response.json();
148+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");
149+ return event;
150+ };
151+
152+ // RoomCompose sends a chat message (kind 9) or a thread reply (kind 12).
153+ // Enter sends and Shift+Enter starts a new line.
154+ class RoomCompose extends FormElement {
155+ connectedCallback() {
156+ super.connectedCallback();
157+ if (this.keys) return;
158+ this.keys = true;
159+ this.addEventListener("keydown", event => {
160+ if (event.key !== "Enter" || event.shiftKey || event.isComposing || !event.target.matches?.("textarea")) return;
161+ event.preventDefault();
162+ this.form?.requestSubmit();
163+ });
164+ }
165+
166+ event(content) {
167+ const room = this.getAttribute("room") || "";
168+ if (!/^[a-z0-9_-]{1,64}$/.test(room)) throw Error("The room id is missing.");
169+ const kind = Number(this.getAttribute("kind") || 9);
170+ if (kind !== 9 && kind !== 12) throw Error("Unsupported message kind.");
171+ const tags = [["h", room]];
172+ if (kind === 12) {
173+ const root = this.getAttribute("root"), author = this.getAttribute("root-pubkey");
174+ if (!isHex64(root)) throw Error("The thread root is missing.");
175+ tags.push(["e", root]);
176+ if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]);
177+ }
178+ roomMentions(content).forEach(key => { if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p", key]); });
179+ return {kind, created_at: now(), tags, content};
180+ }
181+
182+ async submit(form) {
183+ const content = (form.elements.content?.value || "").trim();
184+ if (!content) return;
185+ this.report("Signing…");
186+ const event = await signAndPublish(this.event(content));
187+ form.reset();
188+ this.report("");
189+ roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});
190+ }
191+ }
192+
193+ // RoomCreate signs a NIP-29 create (kind 9007) with an id derived from the
194+ // name, then opens the new room.
195+ class RoomCreate extends FormElement {
196+ event({name = "", about = "", access = "open"} = {}) {
197+ const title = String(name).trim(), id = roomID(title);
198+ if (!id) throw Error("Enter a name with letters or digits.");
199+ const tags = [["h", id], ["name", title]];
200+ if (String(about).trim()) tags.push(["about", String(about).trim()]);
201+ tags.push(["visibility", access === "members" ? "members" : "open"]);
202+ return {id, event: {kind: 9007, created_at: now(), tags, content: ""}};
203+ }
204+
205+ async submit(form) {
206+ const value = name => form.elements[name]?.value || "";
207+ const {id, event} = this.event({name: value("name"), about: value("about"), access: value("access")});
208+ this.report("Signing…");
209+ await signAndPublish(event);
210+ this.report("Created #" + id + ".");
211+ form.reset();
212+ await tiny.navigate?.(roomPath(id), true);
213+ }
214+ }
215+
216+ // RoomAction signs one NIP-29 management event for a room: add a member
217+ // (9000), remove one (9001), edit the room (9002), join (9021) or leave (9022).
218+ class RoomAction extends FormElement {
219+ event(values = {}) {
220+ const room = this.getAttribute("room") || "";
221+ if (!/^[a-z0-9_-]{1,64}$/.test(room)) throw Error("The room id is missing.");
222+ const kind = Number(this.getAttribute("kind"));
223+ const tags = [["h", room]];
224+ switch (kind) {
225+ case 9000: case 9001: {
226+ const key = keyHex(values.pubkey);
227+ if (!key) throw Error("Enter an npub or a 64-character hex key.");
228+ const tag = ["p", key];
229+ if (kind === 9000) tag.push(["owner", "admin"].includes(values.role) ? values.role : "member");
230+ tags.push(tag);
231+ break;
232+ }
233+ case 9002: {
234+ const name = String(values.name || "").trim();
235+ if (!name) throw Error("Enter a name.");
236+ tags.push(["name", name], ["about", String(values.about || "").trim()], ["picture", String(values.picture || "").trim()], ["visibility", values.access === "members" ? "members" : "open"]);
237+ break;
238+ }
239+ case 9021: case 9022: break;
240+ default: throw Error("Unsupported room action.");
241+ }
242+ return {kind, created_at: now(), tags, content: ""};
243+ }
244+
245+ async submit(form) {
246+ const values = {};
247+ for (const field of form.elements) if (field.name) values[field.name] = field.value;
248+ this.report("Signing…");
249+ await signAndPublish(this.event(values));
250+ this.report("Done.");
251+ await tiny.navigate?.(location.href);
252+ }
253+ }
254+
255+ // RoomLive follows a room's stream: new messages join the timeline, and
256+ // reactions update their targets. It reconnects with backoff.
257+ class RoomLive extends HTMLElement {
258+ static get observedAttributes() { return ["room", "root"]; }
259+ connectedCallback() { this.open(); roomScroll(); }
260+ disconnectedCallback() { this.close(); }
261+ attributeChangedCallback() { if (this.isConnected) { this.close(); this.open(); } }
262+
263+ open() {
264+ const room = this.getAttribute("room");
265+ if (!room || this.source || typeof EventSource !== "function") return;
266+ this.delay = this.delay || 1000;
267+ const source = new EventSource(roomPath(room, "/stream"), {withCredentials: true});
268+ this.source = source;
269+ source.addEventListener("open", () => { this.delay = 1000; this.textContent = ""; });
270+ source.addEventListener("message", event => {
271+ let parsed;
272+ try { parsed = JSON.parse(event.data); } catch { return; }
273+ this.receive(parsed);
274+ });
275+ source.addEventListener("error", () => {
276+ if (this.source !== source) return;
277+ this.close();
278+ this.textContent = "reconnecting";
279+ this.timer = setTimeout(() => { this.timer = null; this.open(); }, this.delay);
280+ this.delay = Math.min(this.delay * 2, 30000);
281+ });
282+ }
283+
284+ close() {
285+ this.source?.close();
286+ this.source = null;
287+ clearTimeout(this.timer);
288+ this.timer = null;
289+ }
290+
291+ receive(event) {
292+ if (!event || typeof event !== "object") return;
293+ if (event.kind === 7) { roomReact(event); return; }
294+ if (!roomKinds.includes(event.kind)) return;
295+ const root = this.getAttribute("root");
296+ if (root && (event.kind !== 12 || tagValue(event, "e") !== root)) return;
297+ roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});
298+ }
299+ }
300+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, linkify});
301+
302 // JsonView renders any JSON value. Arrays of objects become tables, objects
303 // become definition lists, scalar arrays become lists, and deep nesting
304 // falls back to compact JSON so a response never explodes the page.
305@@ -1174,6 +1467,10 @@
306 customElements.define("nostr-react", NostrReact);
307 customElements.define("approval-item", ApprovalItem);
308 customElements.define("wiki-compose", WikiCompose);
309+ customElements.define("room-compose", RoomCompose);
310+ customElements.define("room-create", RoomCreate);
311+ customElements.define("room-action", RoomAction);
312+ customElements.define("room-live", RoomLive);
313 customElements.define("push-toggle", PushToggle);
314 customElements.define("share-link", ShareLink);
315 customElements.define("nostr-key", NostrKey);
316@@ -1204,7 +1501,7 @@
317 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;
318 return root ? url.pathname.slice(root.length) || "/" : url.pathname;
319 };
320- 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 || "");
321+ 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 || "");
322 let navigationSerial = 0, activeAbort;
323 const streams = new Set();
324 const closeStreams = () => {
325@@ -1219,7 +1516,7 @@
326 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));
327 window.addEventListener("pagehide", closeStreams);
328 const repairComponents = () => {
329- document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react,wiki-compose").forEach(node => {
330+ document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => {
331 if (node.form?.isConnected) return;
332 node.form = null;
333 node.output = null;
334@@ -1269,8 +1566,9 @@
335 throw error;
336 } finally { if (serial === navigationSerial) setBusy(false); }
337 };
338- // navigate re-requests a page in place; components use it after a publish.
339- tiny.navigate = href => load(new URL(href, location.href), false);
340+ // navigate re-requests a page in place; components use it after a
341+ // publish. With push set it opens a new address as a history entry.
342+ tiny.navigate = (href, push = false) => load(new URL(href, location.href), push);
343 document.addEventListener("fx:config", event => {
344 const cfg = event.detail.cfg, elt = cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]");
345 if (!elt?.matches?.("a[data-fixi-nav], form[data-fixi-nav]")) return;
346diff --git a/package.json b/package.json
347index 2bbbdd9..cdcbfbd 100644
348--- a/package.json
349+++ b/package.json
350@@ -11,7 +11,8 @@
351 "test:collaboration": "node --test scripts/collaboration-compose.test.mjs",
352 "test:agents": "node --test scripts/agent-grant.test.mjs",
353 "test:approvals": "node --test scripts/approvals.test.mjs",
354- "test:wiki": "node --test scripts/wiki-compose.test.mjs"
355+ "test:wiki": "node --test scripts/wiki-compose.test.mjs",
356+ "test:rooms": "node --test scripts/rooms.test.mjs"
357 },
358 "devDependencies": {
359 "@noble/hashes": "2.4.0",
360diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs
361new file mode 100644
362index 0000000..86eccb3
363--- /dev/null
364+++ b/scripts/rooms.test.mjs
365@@ -0,0 +1,229 @@
366+import test from "node:test";
367+import assert from "node:assert/strict";
368+import fs from "node:fs";
369+import vm from "node:vm";
370+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";
371+import * as nip19 from "nostr-tools/nip19";
372+
373+const source = fs.readFileSync("internal/webui/components.js", "utf8");
374+const roomsSource = source.slice(source.indexOf(" // Rooms: the compose bar"), source.indexOf(" // JsonView renders any JSON value."));
375+
376+// A small document: enough of the DOM for the room elements to render
377+// messages, find their targets and keep the list bounded.
378+class FakeNode {
379+ constructor(tag) { this.localName = tag; this.childNodes = []; this.attributes = {}; this.dataset = {}; this.parent = null; this.id = ""; }
380+ get children() { return this.childNodes.filter(node => node instanceof FakeNode); }
381+ get firstElementChild() { return this.children[0] || null; }
382+ get isConnected() { return Boolean(this.parent) && (this.parent === document || this.parent.isConnected); }
383+ 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)); } }
384+ remove() { if (this.parent) { this.parent.childNodes = this.parent.childNodes.filter(node => node !== this); this.parent = null; } }
385+ get textContent() { return this.childNodes.map(node => typeof node === "string" ? node : node.textContent).join(""); }
386+ set textContent(value) { this.childNodes = value ? [String(value)] : []; }
387+ setAttribute(name, value) { if (name === "id") this.id = value; else this.attributes[name] = String(value); }
388+ getAttribute(name) { return name === "id" ? this.id || null : this.attributes[name] ?? null; }
389+ hasAttribute(name) { return name.startsWith("data-") ? name.slice(5) in this.dataset : name in this.attributes; }
390+ matches(selector) { return selector === this.localName; }
391+ addEventListener() {}
392+ querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
393+ querySelectorAll(selector) {
394+ const steps = selector.replace(/^:scope\s*>\s*/, "> ").trim().split(/\s*>\s*|\s+/).filter(Boolean);
395+ const child = selector.includes(">");
396+ let nodes = [this];
397+ for (const step of steps) {
398+ const next = [];
399+ for (const node of nodes) for (const candidate of child ? node.children : node.descendants()) if (candidate.matchesSimple(step)) next.push(candidate);
400+ nodes = next;
401+ }
402+ return nodes;
403+ }
404+ descendants() { return this.children.flatMap(node => [node, ...node.descendants()]); }
405+ matchesSimple(step) {
406+ if (step.startsWith("#")) return this.id === step.slice(1);
407+ const [, tag, attr] = step.match(/^([a-z-]+)(?:\[([a-z-]+)\])?$/) || [];
408+ return tag === this.localName && (!attr || this.hasAttribute(attr));
409+ }
410+}
411+const document = new FakeNode("#document");
412+document.body = new FakeNode("body");
413+document.body.parent = document;
414+document.documentElement = {scrollHeight: 1000};
415+document.createElement = tag => new FakeNode(tag);
416+document.getElementById = id => document.body.descendants().find(node => node.id === id) || null;
417+document.querySelector = selector => document.body.querySelector(selector);
418+document.querySelectorAll = selector => document.body.querySelectorAll(selector);
419+
420+function setup({attributes = {}, result = {accepted: true}, change = false} = {}) {
421+ const sent = [], navigated = [];
422+ const secret = generateSecretKey(), pubkey = getPublicKey(secret);
423+ document.body.childNodes = [];
424+ const content = new FakeNode("div"); content.id = "content"; content.scrollHeight = 500; content.clientHeight = 400; content.scrollTop = 100;
425+ const list = new FakeNode("div"); list.id = "messages";
426+ const members = new FakeNode("ul"); members.id = "members";
427+ const agentRow = new FakeNode("li"); agentRow.dataset.agent = "";
428+ const agentKey = new FakeNode("nostr-key"); agentKey.setAttribute("hex", "b".repeat(64));
429+ agentRow.append(agentKey, " ", Object.assign(new FakeNode("small"), {childNodes: ["member | agent"]}));
430+ members.append(agentRow);
431+ document.body.append(content, list, members);
432+ class HTMLElement extends FakeNode {
433+ constructor() { super("custom"); }
434+ getAttribute(name) { return attributes[name] ?? super.getAttribute(name); }
435+ }
436+ class FormElement extends HTMLElement {
437+ connectedCallback() { this.form = {requestSubmit() {}}; }
438+ report(message) { this.message = message; }
439+ }
440+ const sandbox = {
441+ document, HTMLElement, FormElement, URL, encodeURIComponent, setTimeout, clearTimeout,
442+ getComputedStyle: () => ({overflowY: "auto"}),
443+ 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); }}},
444+ 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); }},
445+ el: (tag, text) => { const node = new FakeNode(tag); if (text !== undefined) node.textContent = text; return node; },
446+ isHex64: value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value)
447+ };
448+ sandbox.EventSource = class {
449+ constructor(url, options) { this.url = url; this.options = options; this.listeners = {}; this.closed = false; sandbox.sources.push(this); }
450+ addEventListener(name, handler) { (this.listeners[name] = this.listeners[name] || []).push(handler); }
451+ emit(name, detail) { (this.listeners[name] || []).forEach(handler => handler(detail)); }
452+ close() { this.closed = true; }
453+ };
454+ sandbox.sources = [];
455+ const classes = vm.runInNewContext(`${roomsSource}\n({RoomCompose, RoomCreate, RoomAction, RoomLive, rooms: tiny.rooms})`, sandbox);
456+ 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; }})};
457+}
458+const npub = nip19.npubEncode("d".repeat(64));
459+// Values built inside the sandbox belong to another realm; compare them as plain data.
460+const same = (actual, expected) => assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected);
461+
462+test("room-compose signs kind 9 with the room tag and mentions from @npub and @hex", async () => {
463+ const s = setup({attributes: {room: "build", pubkey: "x"}});
464+ const compose = new s.RoomCompose();
465+ compose.connectedCallback();
466+ const unsigned = compose.event(`hi @${npub} and @${"e".repeat(64)}, again @${"e".repeat(64)} not me@${"f".repeat(64)}`);
467+ assert.equal(unsigned.kind, 9);
468+ same(unsigned.tags, [["h", "build"], ["p", "d".repeat(64)], ["p", "e".repeat(64)]]);
469+ same(s.rooms.roomMentions("@npub1notakey and @abc"), []);
470+ const form = s.form({content: " hello room "});
471+ await compose.submit(form);
472+ assert.equal(s.sent[0].path, "/events");
473+ assert.equal(s.sent[0].options.contentType, "application/json");
474+ assert.equal(s.sent[0].event.kind, 9);
475+ assert.equal(s.sent[0].event.content, "hello room");
476+ assert.equal(verifyEvent(s.sent[0].event), true);
477+ assert.equal(form.resets, 1);
478+ assert.equal(s.list.children.length, 1);
479+ assert.equal(s.list.children[0].id, "msg-" + s.sent[0].event.id);
480+ assert.equal(s.content.scrollTop, 500);
481+ assert.throws(() => new (setup({attributes: {room: "Bad Room"}}).RoomCompose)().event("x"), /room id/);
482+});
483+
484+test("room-compose replies in a thread with the root and its author, and refuses a changed event", async () => {
485+ const root = "1".repeat(64), author = "2".repeat(64);
486+ const s = setup({attributes: {room: "build", kind: "12", root, "root-pubkey": author, pubkey: "3".repeat(64)}});
487+ const compose = new s.RoomCompose();
488+ same(compose.event("ok").tags, [["h", "build"], ["e", root], ["p", author]]);
489+ const own = setup({attributes: {room: "build", kind: "12", root, "root-pubkey": author, pubkey: author}});
490+ same(new own.RoomCompose().event("ok").tags, [["h", "build"], ["e", root]]);
491+ const changed = setup({attributes: {room: "build"}, change: true});
492+ await assert.rejects(new changed.RoomCompose().submit(changed.form({content: "x"})), /changed event/);
493+ assert.equal(changed.sent.length, 0);
494+ const refused = setup({attributes: {room: "build"}, result: {accepted: false, message: "restricted: members only"}});
495+ await assert.rejects(new refused.RoomCompose().submit(refused.form({content: "x"})), /members only/);
496+});
497+
498+test("room-create derives the id from the name and signs 9007 before opening the room", async () => {
499+ const s = setup();
500+ assert.equal(s.rooms.roomID("Build & Release 2026!"), "build-release-2026");
501+ assert.equal(s.rooms.roomID(" --- "), "");
502+ assert.equal(s.rooms.roomID("x".repeat(80)).length, 64);
503+ const create = new s.RoomCreate();
504+ const {id, event} = create.event({name: " Build & Release ", about: " nightly ", access: "members"});
505+ assert.equal(id, "build-release");
506+ assert.equal(event.kind, 9007);
507+ same(event.tags, [["h", "build-release"], ["name", "Build & Release"], ["about", "nightly"], ["visibility", "members"]]);
508+ same(create.event({name: "general"}).event.tags, [["h", "general"], ["name", "general"], ["visibility", "open"]]);
509+ assert.throws(() => create.event({name: "!!!"}), /letters or digits/);
510+ await create.submit(s.form({name: "Build", about: "", access: "open"}));
511+ assert.equal(s.sent[0].event.kind, 9007);
512+ assert.equal(verifyEvent(s.sent[0].event), true);
513+ same(s.navigated, [{href: "/r/work/rooms/build", push: true}]);
514+});
515+
516+test("room-action signs membership, settings, join and leave events", () => {
517+ const s = setup({attributes: {room: "build", kind: "9000"}});
518+ const add = new s.RoomAction();
519+ same(add.event({pubkey: npub, role: "admin"}).tags, [["h", "build"], ["p", "d".repeat(64), "admin"]]);
520+ same(add.event({pubkey: "e".repeat(64), role: "nonsense"}).tags, [["h", "build"], ["p", "e".repeat(64), "member"]]);
521+ assert.throws(() => add.event({pubkey: "who"}), /npub or a 64/);
522+ const edit = new (setup({attributes: {room: "build", kind: "9002"}}).RoomAction)();
523+ same(edit.event({name: " Build ", about: "x", picture: "", access: "members"}).tags, [["h", "build"], ["name", "Build"], ["about", "x"], ["picture", ""], ["visibility", "members"]]);
524+ assert.throws(() => edit.event({name: " "}), /Enter a name/);
525+ same(new (setup({attributes: {room: "build", kind: "9022"}}).RoomAction)().event().tags, [["h", "build"]]);
526+ assert.equal(new (setup({attributes: {room: "build", kind: "9021"}}).RoomAction)().event().kind, 9021);
527+ assert.throws(() => new (setup({attributes: {room: "build", kind: "9008"}}).RoomAction)().event(), /Unsupported/);
528+});
529+
530+test("room-live appends streamed messages once, summarizes reactions and reconnects with backoff", async () => {
531+ const s = setup({attributes: {room: "build"}});
532+ const live = new s.RoomLive();
533+ live.connectedCallback();
534+ assert.equal(s.sandbox.sources.length, 1);
535+ const source = s.sandbox.sources[0];
536+ assert.equal(source.url, "/r/work/rooms/build/stream");
537+ assert.equal(source.options.withCredentials, true);
538+ const secret = generateSecretKey();
539+ const message = finalizeEvent({kind: 9, created_at: 1757203600, tags: [["h", "build"], ["p", "c".repeat(64)]], content: "see https://example.com/x, ok"}, secret);
540+ source.emit("message", {data: JSON.stringify(message)});
541+ source.emit("message", {data: JSON.stringify(message)});
542+ assert.equal(s.list.children.length, 1);
543+ const node = s.list.children[0];
544+ assert.equal(node.id, "msg-" + message.id);
545+ assert.equal(node.dataset.kind, "9");
546+ assert.equal(node.querySelector("header b nostr-key").getAttribute("hex"), message.pubkey);
547+ const link = node.querySelector("p a");
548+ assert.equal(link.href, "https://example.com/x");
549+ assert.equal(node.querySelector("p").textContent, "see https://example.com/x, ok");
550+ assert.equal(node.querySelector("footer span nostr-key").getAttribute("hex"), "c".repeat(64));
551+ source.emit("message", {data: "not json"});
552+ source.emit("message", {data: JSON.stringify({...message, id: "9".repeat(64), kind: 20001})});
553+ assert.equal(s.list.children.length, 1);
554+ const reaction = finalizeEvent({kind: 7, created_at: 1757203700, tags: [["h", "build"], ["e", message.id]], content: "+"}, secret);
555+ source.emit("message", {data: JSON.stringify(reaction)});
556+ source.emit("message", {data: JSON.stringify({...reaction, id: "8".repeat(64)})});
557+ const summary = node.querySelector("footer span[data-reaction]");
558+ assert.equal(summary.dataset.reaction, "+1");
559+ assert.equal(summary.textContent, "+1 2");
560+ // An agent's message carries the marker from the members list.
561+ const agent = finalizeEvent({kind: 11, created_at: 1757203800, tags: [["h", "build"]], content: "draft"}, secret);
562+ s.rooms.roomAppend({...agent, pubkey: "b".repeat(64)}, {room: "build"});
563+ const agentNode = s.list.children[1];
564+ assert.equal("agent" in agentNode.dataset, true);
565+ assert.equal(agentNode.querySelector("footer a").href, "/r/work/rooms/build/thread/" + agent.id);
566+ assert.equal(agentNode.querySelector("header").textContent.includes("| member"), true);
567+ // The list stays bounded.
568+ for (let i = 0; i < 520; i++) s.rooms.roomAppend({...message, id: i.toString(16).padStart(64, "0")}, {room: "build"});
569+ assert.equal(s.list.children.length, 500);
570+ source.emit("error", {});
571+ assert.equal(source.closed, true);
572+ assert.equal(live.textContent, "reconnecting");
573+ assert.equal(live.delay, 2000);
574+ live.close();
575+ assert.equal(live.timer, null);
576+});
577+
578+test("room-live on a thread page keeps only replies to its root", () => {
579+ const root = "a".repeat(64);
580+ const s = setup({attributes: {room: "build", root}});
581+ const live = new s.RoomLive();
582+ live.connectedCallback();
583+ const source = s.sandbox.sources[0];
584+ const secret = generateSecretKey();
585+ source.emit("message", {data: JSON.stringify(finalizeEvent({kind: 9, created_at: 1, tags: [["h", "build"]], content: "chat"}, secret))});
586+ source.emit("message", {data: JSON.stringify(finalizeEvent({kind: 12, created_at: 2, tags: [["h", "build"], ["e", "b".repeat(64)]], content: "elsewhere"}, secret))});
587+ const reply = finalizeEvent({kind: 12, created_at: 3, tags: [["h", "build"], ["e", root]], content: "here"}, secret);
588+ source.emit("message", {data: JSON.stringify(reply)});
589+ assert.equal(s.list.children.length, 1);
590+ assert.equal(s.list.children[0].id, "msg-" + reply.id);
591+ assert.equal(s.list.children[0].querySelector("footer"), null);
592+ live.disconnectedCallback();
593+ assert.equal(source.closed, true);
594+});

About

ownerc01503e8035f
visibilitypublic
head49af915ea271

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs