| 1 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 2 | index 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; |
| 346 | diff --git a/package.json b/package.json |
| 347 | index 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", |
| 360 | diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs |
| 361 | new file mode 100644 |
| 362 | index 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 | +}); |