1// Room custom elements and rendering/live-update helpers. This feature module2// runs after components.js has published the shared UI helper contract.3(() => {4 "use strict";5 // Room elements own composition, room management actions and live updates.6 // room-compose signs kind 9 and 12 events, room-create signs room metadata,7 // room-action handles membership controls, and room-live consumes the room8 // event stream.9 const tiny = window.tiny;10 const {FormElement, el, isHex64} = tiny.ui;11 // Rooms: the compose bar, room creation, room administration and the live12 // stream. Each signed event is built here, signed by the connected signer,13 // verified unchanged and published once to /events. room-message markup14 // matches the roomMessage template so streamed messages read the same.15 const roomKinds = [9, 11, 12, 40002, 44100, 44101];16 const mentionPattern = /(^|[\s(])@(npub1[02-9ac-hj-np-z]{58}|[0-9a-f]{64})\b/g;17 const roomLinkPattern = /https?:\/\/[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+/g;18 const now = () => Math.floor(Date.now() / 1000);19 // keyHex accepts a hex key or an npub and returns the hex key, or null.20 const keyHex = value => {21 const text = String(value || "").trim().replace(/^nostr:/, "");22 if (isHex64(text)) return text;23 if (!/^npub1[02-9ac-hj-np-z]{58}$/.test(text)) return null;24 try { const decoded = window.NostrSigner?.decodeNpub?.(text); return isHex64(decoded) ? decoded : null; } catch { return null; }25 };26 // roomMentions lists the keys named as @npub or @hex in a message, once each.27 const roomMentions = text => {28 const keys = [];29 for (const match of String(text).matchAll(mentionPattern)) {30 const key = keyHex(match[2]);31 if (key && !keys.includes(key)) keys.push(key);32 }33 return keys;34 };35 // roomID derives a room id from a name: lowercase letters, digits, hyphen and underscore.36 const roomID = name => String(name || "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);37 const tagValue = (event, name) => (event.tags || []).find(tag => tag[0] === name)?.[1] || "";38 const quoteID = event => { const id = tagValue(event, "q"); return isHex64(id) ? id : ""; };39 const clock = seconds => {40 const stamp = new Date(seconds * 1000).toISOString();41 if (stamp.slice(0, 10) === new Date().toISOString().slice(0, 10)) return stamp.slice(11, 16);42 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);43 };44 const roomPath = (room, rest = "") => tiny.localPath("/rooms/" + encodeURIComponent(room) + rest);45 // linkify fills a node with escaped text, turning http(s) URLs and nostr46 // links into anchors; nostr links open through /open.47 const linkify = (node, text) => {48 let last = 0;49 for (const match of String(text).matchAll(roomLinkPattern)) {50 node.append(text.slice(last, match.index));51 const trimmed = match[0].replace(/[.,;:!?)]+$/, "");52 const link = el("a", trimmed);53 link.href = trimmed.startsWith("http") ? trimmed : tiny.localPath("/open?target=" + encodeURIComponent(trimmed));54 if (trimmed.startsWith("http")) link.rel = "noopener";55 node.append(link, match[0].slice(trimmed.length));56 last = match.index + match[0].length;57 }58 node.append(text.slice(last));59 return node;60 };61 // chatMarkdown renders the same Markdown subset the page renders for chat62 // messages: paragraphs with line breaks, fenced code, headings, lists,63 // inline code, emphasis, links, and bare http(s) or nostr: references. It64 // builds nodes rather than markup, so message text never becomes HTML.65 const inlinePattern = /(`[^`]+`)|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|(^|[^*\w])\*([^*]+)\*|(https?:\/\/[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+)/g;66 const safeLink = target => /^(https?:\/\/|\/(?!\/)|\.\.?\/|#|mailto:)/.test(target) || (!target.includes(":") && !target.startsWith("//"));67 const chatInline = (node, text) => {68 let last = 0;69 for (const match of String(text).matchAll(inlinePattern)) {70 node.append(text.slice(last, match.index));71 last = match.index + match[0].length;72 if (match[1]) { node.append(el("code", match[1].slice(1, -1))); continue; }73 if (match[2] !== undefined) {74 if (!safeLink(match[3])) { node.append(match[0]); continue; }75 const link = el("a", match[2]);76 link.href = match[3];77 if (match[3].startsWith("http")) link.rel = "noopener";78 node.append(link);79 continue;80 }81 if (match[4] !== undefined) { node.append(chatInline(el("strong"), match[4])); continue; }82 if (match[6] !== undefined) { node.append(match[5], chatInline(el("em"), match[6])); continue; }83 const trimmed = match[7].replace(/[.,;:!?)]+$/, "");84 const link = el("a", trimmed);85 link.href = trimmed.startsWith("http") ? trimmed : tiny.localPath("/open?target=" + encodeURIComponent(trimmed));86 if (trimmed.startsWith("http")) link.rel = "noopener";87 node.append(link, match[7].slice(trimmed.length));88 }89 node.append(text.slice(last));90 return node;91 };92 const pipeEscaped = (value, index) => {93 let slashes = 0;94 for (let i = index - 1; i >= 0 && value[i] === "\\"; i--) slashes++;95 return slashes % 2 === 1;96 };97 const pipeRow = line => {98 let value = String(line).trim();99 if (!value.includes("|")) return null;100 const leadingPipe = value.startsWith("|");101 const trailingPipe = value.endsWith("|") && !pipeEscaped(value, value.length - 1);102 if (value.startsWith("|")) value = value.slice(1);103 const last = value.length - 1;104 if (value.endsWith("|") && !pipeEscaped(value, last)) value = value.slice(0, -1);105 const cells = [];106 let hasPipe = false;107 let cell = "", code = false;108 for (let i = 0; i < value.length; i++) {109 const character = value[i];110 if (character === "`") { code = !code; cell += character; continue; }111 if (character === "|" && pipeEscaped(value, i)) { cell = cell.slice(0, -1) + "|"; continue; }112 if (character === "|" && !code) {113 hasPipe = true; cells.push(cell.trim()); cell = ""; continue;114 }115 cell += character;116 }117 cells.push(cell.trim());118 return (hasPipe || leadingPipe || trailingPipe) ? cells : null;119 };120 const tableDelimiter = cell => {121 const value = String(cell).trim();122 if (!/^:?-{3,}:?$/.test(value)) return null;123 return value.startsWith(":") ? (value.endsWith(":") ? "center" : "left") : (value.endsWith(":") ? "right" : "");124 };125 const tableBodyLine = line => {126 const value = String(line).trim();127 return Boolean(value) && !value.startsWith("#") && !value.startsWith("```") && !value.startsWith("~~~");128 };129 const markdownTable = (lines, start) => {130 const header = pipeRow(lines[start]), separator = pipeRow(lines[start + 1]);131 if (!header || !separator || header.length !== separator.length) return null;132 const align = separator.map(tableDelimiter);133 if (align.some(value => value === null)) return null;134 const rows = [];135 let index = start + 2;136 for (; index < lines.length; index++) {137 if (!tableBodyLine(lines[index])) break;138 const row = pipeRow(lines[index]);139 if (!row) break;140 rows.push(row);141 }142 const wrapper = el("div");143 wrapper.dataset.markdownTable = "";144 wrapper.setAttribute("data-markdown-table", "");145 wrapper.setAttribute("role", "region");146 wrapper.setAttribute("aria-label", "Table");147 wrapper.setAttribute("tabindex", "0");148 const table = el("table"), thead = el("thead"), heading = el("tr");149 header.forEach((cell, column) => {150 const th = el("th");151 th.setAttribute("scope", "col");152 if (align[column]) { th.dataset.align = align[column]; th.setAttribute("data-align", align[column]); }153 chatInline(th, cell);154 heading.append(th);155 });156 thead.append(heading);157 const tbody = el("tbody");158 rows.forEach(row => {159 const tr = el("tr");160 for (let column = 0; column < header.length; column++) {161 const td = el("td");162 if (align[column]) { td.dataset.align = align[column]; td.setAttribute("data-align", align[column]); }163 chatInline(td, row[column] || "");164 tr.append(td);165 }166 tbody.append(tr);167 });168 table.append(thead, tbody);169 wrapper.append(table);170 return {node: wrapper, end: index};171 };172 const listItem = line => /^\s*(?:[-*+]|\d+[.)])\s+/.test(line);173 const chatMarkdown = (node, text) => {174 const lines = String(text).replace(/\r\n/g, "\n").split("\n");175 let paragraph = [];176 const flush = () => {177 if (!paragraph.length) return;178 const p = el("p");179 paragraph.forEach((line, index) => { if (index) p.append(el("br")); chatInline(p, line); });180 node.append(p);181 paragraph = [];182 };183 for (let i = 0; i < lines.length; i++) {184 const line = lines[i];185 const fence = line.trim().match(/^(`{3,}|~{3,})/);186 const table = !fence && markdownTable(lines, i);187 if (fence) {188 flush();189 const code = [], marker = fence[1];190 const closesFence = value => value.length >= marker.length && [...value].every(character => character === marker[0]);191 for (i++; i < lines.length && !closesFence(lines[i].trim()); i++) code.push(lines[i]);192 const pre = el("pre"); pre.append(el("code", code.join("\n"))); node.append(pre);193 } else if (table) {194 flush();195 node.append(table.node);196 i = table.end - 1;197 } else if (line.startsWith("#")) {198 flush();199 const level = Math.min(6, line.length - line.replace(/^#+/, "").length);200 node.append(chatInline(el("h" + level), line.slice(level).trim()));201 } else if (listItem(line)) {202 flush();203 const list = el(/^\s*\d/.test(line) ? "ol" : "ul");204 for (; i < lines.length && listItem(lines[i]); i++) list.append(chatInline(el("li"), lines[i].replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "")));205 i--;206 node.append(list);207 } else if (!line.trim()) {208 flush();209 } else {210 paragraph.push(line.trim());211 }212 }213 flush();214 return node;215 };216 // NIP-92 references are matched outside code. Only standalone attachment217 // lines are removed; prose links and examples keep their original text.218 const attachmentURLPattern = /^https?:\/\/[^\s\x00-\x1f\x7f\\<>"']+$/;219 const attachmentReference = /!?\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>"']+)/g;220 const attachmentLines = (text, visit) => {221 let marker = "";222 return String(text || "").replace(/\r\n/g, "\n").split("\n").map(line => {223 const trimmed = line.trim();224 if (marker) {225 if (trimmed.length >= marker.length && [...trimmed].every(c => c === marker[0])) marker = "";226 return line;227 }228 const fence = trimmed.match(/^(`{3,}|~{3,})/);229 if (fence) { marker = fence[1]; return line; }230 return visit(line);231 }).join("\n");232 };233 const attachmentOutsideCode = line => {234 let out = "";235 while (line.length) {236 const start = line.indexOf("`");237 if (start < 0) return out + line;238 out += line.slice(0, start);239 line = line.slice(start);240 const delimiter = line.match(/^`+/)[0], end = line.indexOf(delimiter, delimiter.length);241 if (end < 0) return out + line;242 out += " ";243 line = line.slice(end + delimiter.length);244 }245 return out;246 };247 const attachmentReferenceURL = match => match[2] || match[3].replace(/[.,;:!?)]+$/, "");248 const attachmentURLVisible = (text, target) => {249 let visible = false;250 attachmentLines(text, line => {251 for (const match of attachmentOutsideCode(line).matchAll(attachmentReference)) if (attachmentReferenceURL(match) === target) visible = true;252 return line;253 });254 return visible;255 };256 const attachmentText = (text, limit) => [...String(text || "")].slice(0, limit).join("");257 const roomAttachments = event => {258 const out = [], seen = new Set(), content = String(event?.content || "");259 for (const tag of (event?.tags || [])) {260 if (!Array.isArray(tag) || tag[0] !== "imeta" || out.length >= 16) continue;261 const item = {};262 for (const field of tag.slice(1)) {263 if (typeof field !== "string") continue;264 const split = field.indexOf(" ");265 if (split < 1) continue;266 const key = field.slice(0, split), value = field.slice(split + 1);267 if (!value) continue;268 if (key === "url") item.url = value;269 else if (key === "m") item.mime = value.toLowerCase();270 else if (key === "x") item.hash = value;271 else if (key === "size") item.size = /^-?\d+$/.test(value) ? Number(value) : 0;272 else if (key === "filename" || key === "name") item.name = value;273 else if (key === "alt") item.alt = value;274 }275 if (!attachmentURLPattern.test(item.url || "") || seen.has(item.url) || !attachmentURLVisible(content, item.url)) continue;276 let parsed;277 try { parsed = new URL(item.url); } catch { continue; }278 if (!parsed.hostname || parsed.username || parsed.password) continue;279 item.size = Number.isSafeInteger(item.size) && item.size > 0 && item.size <= 1099511627776 ? item.size : 0;280 item.name = attachmentText(item.name, 255);281 item.alt = attachmentText(item.alt, 500);282 if (!item.name) item.name = item.alt;283 if (!item.name) { try { item.name = decodeURIComponent(parsed.pathname.split("/").pop() || "Attachment"); } catch { item.name = "Attachment"; } }284 seen.add(item.url);285 out.push(item);286 }287 return out;288 };289 const attachmentContent = (text, items) => {290 const urls = new Set(items.map(item => item.url));291 return attachmentLines(text, line => {292 const trimmed = line.trim(), match = [...trimmed.matchAll(attachmentReference)][0];293 return match && match[0] === trimmed && urls.has(attachmentReferenceURL(match)) ? "" : line;294 }).trim();295 };296 const attachmentSize = size => size < 1024 ? size + " B" : size < 1048576 ? Math.floor(size / 1024) + " KB" : Math.floor(size / 1048576) + " MB";297 const attachmentNodes = items => {298 if (!items.length) return null;299 const box = el("div"); box.dataset.roomAttachments = "";300 for (const item of items) {301 const figure = el("figure"), name = item.name || "Attachment";302 if (item.mime?.startsWith("image/") && item.mime !== "image/svg+xml") {303 const link = el("a"), image = el("img");304 link.href = item.url; link.rel = "noopener noreferrer"; link.setAttribute("fx-ignore", "");305 image.src = item.url; image.alt = item.alt || name; image.loading = "lazy"; image.referrerPolicy = "no-referrer";306 link.append(image); figure.append(link);307 } else if (item.mime?.startsWith("video/") || item.mime?.startsWith("audio/")) {308 const media = el(item.mime.startsWith("video/") ? "video" : "audio");309 media.controls = true; media.preload = "none"; media.src = item.url; figure.append(media);310 }311 const caption = el("figcaption"), link = el("a", name);312 link.href = item.url; link.download = name; link.dataset.download = ""; link.rel = "noopener noreferrer"; link.setAttribute("fx-ignore", "");313 caption.append(link);314 if (item.size > 0) caption.append(" ", el("small", attachmentSize(item.size)));315 figure.append(caption); box.append(figure);316 }317 return box;318 };319 const keyNode = hex => { const node = el("nostr-key", hex.slice(0, 12)); node.setAttribute("hex", hex); node.title = hex; return node; };320 // nameNode shows a person: the vendored nostr-name element replaces the short321 // id with the profile name published on this relay.322 const nameNode = hex => { const node = el("nostr-name", hex.slice(0, 12)); node.setAttribute("pubkey", hex); node.title = hex; return node; };323 // panelMembers reads the members list in the panel: role and agent marker by key.324 const panelMembers = () => {325 const members = {};326 document.querySelectorAll("#members li").forEach(item => {327 const hex = item.querySelector("nostr-name")?.getAttribute("pubkey") || item.querySelector("nostr-key")?.getAttribute("hex");328 if (hex) members[hex] = {role: (item.querySelector("small")?.textContent || "").split("|")[0].trim(), agent: item.hasAttribute("data-agent"), operator: item.getAttribute("data-operator") || ""};329 });330 return members;331 };332 const replyRoot = event => {333 if (![9, 12].includes(event?.kind)) return "";334 let root = "", reply = "", first = "", marked = false;335 for (const tag of event.tags || []) {336 if (tag[0] !== "e" || !isHex64(tag[1])) continue;337 const marker = tag[3] || "";338 marked ||= Boolean(marker);339 if (marker === "root") root = tag[1];340 else if (marker === "reply") reply = tag[1];341 else if (!marker && !first) first = tag[1];342 }343 return root || reply || (!marked ? first : "");344 };345 const messageNode = (event, {members = {}, room = "", inThread = false} = {}) => {346 const node = el("room-message");347 const notice = event.kind === 44100 ? "joined the room" : event.kind === 44101 ? "left the room" : "";348 const pubkey = notice ? tagValue(event, "p") : event.pubkey;349 const viewer = (document.getElementById("room") || document.getElementById("thread"))?.dataset.viewer;350 node.id = "msg-" + event.id;351 node.dataset.id = event.id;352 node.dataset.kind = String(event.kind);353 node.dataset.updatedAt = String(event.created_at);354 node.dataset.pubkey = pubkey;355 const member = members[pubkey];356 const own = !notice && Boolean(viewer) && pubkey === viewer;357 if (own) node.dataset.own = "";358 if (member?.agent) node.dataset.agent = "";359 if (notice) node.dataset.notice = "";360 const avatar = el("nostr-avatar");361 avatar.setAttribute("pubkey", pubkey);362 avatar.setAttribute("aria-hidden", "true");363 const header = el("header"), name = el("b"), time = el("time", clock(event.created_at)), small = el("small");364 name.append(nameNode(pubkey));365 time.dateTime = new Date(event.created_at * 1000).toISOString().replace(/\.\d+Z$/, "Z");366 time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC";367 small.append(time);368 header.append(name);369 if (own) { const label = el("span", "you"); label.dataset.ownLabel = ""; header.append(label); }370 if (member?.agent) {371 header.append(" | ");372 if (member.operator) header.append(nameNode(member.operator), "'s agent");373 else header.append("agent");374 } else if (member?.role) header.append(" | " + member.role);375 header.append(small);376 const attachments = notice ? [] : roomAttachments(event);377 const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments));378 node.append(avatar, header);379 const quote = notice ? "" : quoteID(event);380 if (quote) { const block = el("blockquote"), link = el("a", "quoted event " + quote.slice(0, 12)); block.dataset.quote = ""; link.href = tiny.localPath("/open?target=" + encodeURIComponent("nostr:" + quote)); block.title = quote; block.append(link); node.append(block); }381 node.append(body);382 const media = attachmentNodes(attachments); if (media) node.append(media);383 const footer = el("footer");384 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);385 if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(nameNode(key), " ")); footer.append(span); }386 const root = replyRoot(event);387 if (!inThread && room && !root && [9, 11, 40002].includes(event.kind)) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); }388 if (!inThread && room && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); }389 if (footer.childNodes.length) node.append(footer);390 return node;391 };392 // The timeline scrolls inside the content column on desktop and with the393 // page body on phones; roomBox finds whichever holds the overflow.394 const roomBox = () => [document.getElementById("content"), document.body, document.documentElement].find(node => node && node.scrollHeight > node.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(node).overflowY)) || null;395 const roomNearBottom = () => {396 const box = roomBox();397 return !box || box.scrollHeight - box.scrollTop - box.clientHeight < 120;398 };399 const roomScroll = () => {400 const box = roomBox();401 if (box) box.scrollTop = box.scrollHeight;402 };403 // roomAppend adds one message to the timeline unless it is already there,404 // keeps the list bounded and follows the newest message when the viewer405 // is already reading the end of it.406 const roomAppend = (event, {room = "", inThread = false, own = false, canonicalRoot = ""} = {}) => {407 const list = document.getElementById("messages");408 if (!list || !isHex64(event?.id) || document.getElementById("msg-" + event.id)) return null;409 list.roomSeen ||= new Set();410 if (list.roomSeen.has(event.id)) return null;411 list.roomSeen.add(event.id);412 while (list.roomSeen.size > 2000) list.roomSeen.delete(list.roomSeen.values().next().value);413 const reference = canonicalRoot || replyRoot(event);414 if (reference && !inThread) {415 const link = document.querySelector("#msg-" + reference + " > footer > a");416 if (link) { const count = Number((link.textContent.match(/(\d+) repl/) || [])[1] || 0) + 1; link.textContent = "thread | " + count + (count === 1 ? " reply" : " replies"); }417 return null;418 }419 const follow = own || roomNearBottom();420 list.querySelector("#empty")?.remove();421 const node = messageNode(event, {members: panelMembers(), room, inThread});422 list.append(node);423 while (list.children.length > 500) list.firstElementChild.remove();424 const root = tagValue(event, "e");425 if (event.kind === 12 && !inThread && isHex64(root)) {426 const link = document.querySelector("#msg-" + root + " > footer > a");427 if (link) { const count = Number((link.textContent.match(/(\d+) repl/) || [])[1] || 0) + 1; link.textContent = "thread | " + count + (count === 1 ? " reply" : " replies"); }428 }429 if (follow) roomScroll();430 return node;431 };432 // roomReact adds a reaction to its target's summary line.433 const roomReact = event => {434 const targets = (event.tags || []).filter(tag => tag[0] === "e");435 const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);436 if (!target) return;437 let footer = target.querySelector(":scope > footer");438 if (!footer) { footer = el("footer"); target.append(footer); }439 const content = (event.content || "").trim() === "" || (event.content || "").trim() === "+" ? "+1" : event.content.trim();440 let span = [...footer.querySelectorAll("span[data-reaction]")].find(node => node.dataset.reaction === content);441 if (span) span.textContent = content + " " + (Number(span.textContent.slice(content.length)) + 1);442 else { span = el("span", content + " 1"); span.dataset.reaction = content; footer.append(span); }443 };444 // roomEdit applies a kind 40003 edit to the message it names when the445 // editor is that message's author, replacing the text and marking it.446 const roomEdit = event => {447 const targets = (event.tags || []).filter(tag => tag[0] === "e");448 const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);449 if (!target || target.dataset.pubkey !== event.pubkey || target.dataset.notice !== undefined) return;450 const updatedAt = Number(event.created_at);451 if (!Number.isFinite(updatedAt) || updatedAt < Number(target.dataset.updatedAt || 0)) return;452 const body = target.querySelector(":scope > div");453 if (!body) return;454 target.dataset.updatedAt = String(updatedAt);455 const attachments = roomAttachments(event);456 body.replaceWith(chatMarkdown(el("div"), attachmentContent(event.content || "", attachments)));457 target.querySelector(":scope > [data-room-attachments]")?.remove();458 const media = attachmentNodes(attachments); if (media) target.insertBefore(media, target.querySelector(":scope > footer"));459 if (target.dataset.edited !== undefined) return;460 target.dataset.edited = "";461 let footer = target.querySelector(":scope > footer");462 if (!footer) { footer = el("footer"); target.append(footer); }463 const mark = el("span", "edited");464 mark.dataset.edited = "";465 footer.append(mark);466 };467 // signAndPublish signs one room event, checks it came back unchanged and468 // valid, and publishes it once.469 const signAndPublish = async (unsigned, signal) => {470 if (typeof tiny.signing?.publish !== "function") throw Error("The event signing API is still loading. Try again.");471 return tiny.signing.publish(unsigned, {signal});472 };473474 // RoomCompose sends a chat message (kind 9) or a thread reply (kind 12).475 // Enter sends and Shift+Enter starts a new line.476 class RoomCompose extends FormElement {477 connectedCallback() {478 super.connectedCallback();479 this.renderFiles();480 this.resizeContent();481 if (this.keys) return;482 this.keys = true;483 this.querySelector("[data-attach]")?.addEventListener("click", () => {484 this.form.elements.attachments.click();485 });486 this.addEventListener("input", event => {487 if (event.target.name === "content") this.resizeContent();488 });489 this.addEventListener("keydown", event => {490 if (event.key !== "Enter" || event.shiftKey || event.isComposing || !event.target.matches?.("textarea")) return;491 event.preventDefault();492 this.form?.requestSubmit();493 });494 this.addEventListener("change", event => {495 if (event.target.name !== "attachments") return;496 this.chooseFiles(event.target.files);497 event.target.value = "";498 });499 this.addEventListener("paste", event => {500 if (!this.querySelector("room-files")) return;501 const files = [...(event.clipboardData?.files || [])];502 if (!files.length) return;503 event.preventDefault();504 this.chooseFiles(files);505 });506 this.addEventListener("dragover", event => {507 if (!this.querySelector("room-files") || ![...(event.dataTransfer?.types || [])].includes("Files")) return;508 event.preventDefault();509 if (!this.submitting) this.dataset.over = "";510 });511 this.addEventListener("dragleave", () => { delete this.dataset.over; });512 this.addEventListener("drop", event => {513 delete this.dataset.over;514 if (!this.querySelector("room-files") || !event.dataTransfer?.files?.length) return;515 event.preventDefault();516 this.chooseFiles(event.dataTransfer.files);517 });518 }519520 disconnectedCallback() {521 this.uploadController?.abort();522 this.releasePreviews();523 }524525 resizeContent() {526 const content = this.form?.elements?.content;527 if (!content) return;528 content.style.height = "auto";529 content.style.height = content.scrollHeight + "px";530 }531532 releasePreviews() {533 for (const entry of this.pendingFiles || []) {534 if (entry.preview) URL.revokeObjectURL(entry.preview);535 delete entry.preview;536 }537 }538539 busy(on) {540 super.busy(on);541 this.form?.querySelectorAll("input, textarea").forEach(input => { input.disabled = on; });542 }543544 chooseFiles(files) {545 try { this.addFiles(files); this.report(""); }546 catch (error) { this.report(error.message, true); }547 }548549 addFiles(files) {550 if (this.submitting) return;551 const chosen = [...files];552 const pending = this.pendingFiles || [];553 if (pending.length + chosen.length > 8) throw Error("Attach up to 8 files per message.");554 if (chosen.some(file => file.size === 0 || file.size > 32 * 1024 * 1024)) throw Error("Each attachment must be between 1 byte and 32 MiB.");555 this.pendingFiles = [...pending, ...chosen.map(file => ({file}))];556 this.renderFiles();557 }558559 renderFiles() {560 const pending = this.pendingFiles || [];561 const content = this.form?.elements?.content;562 if (content) content.required = pending.length === 0;563 const target = this.querySelector("room-files");564 if (!target) return;565 const list = el("ul");566 pending.forEach((entry, index) => {567 const row = el("li"), remove = el("button", "×");568 remove.type = "button";569 remove.disabled = Boolean(this.submitting);570 remove.setAttribute("aria-label", "Remove " + entry.file.name);571 remove.title = "Remove " + entry.file.name;572 remove.addEventListener("click", () => {573 if (this.submitting) return;574 if (entry.preview) URL.revokeObjectURL(entry.preview);575 this.pendingFiles.splice(index, 1);576 this.renderFiles();577 const next = target.querySelectorAll("button")[Math.min(index, this.pendingFiles.length - 1)];578 (next || this.querySelector("[data-attach]"))?.focus();579 });580 let preview;581 if (entry.file.type.startsWith("image/") && entry.file.type !== "image/svg+xml") {582 entry.preview ||= URL.createObjectURL(entry.file);583 preview = el("img");584 preview.src = entry.preview;585 preview.alt = "";586 } else {587 preview = el("span", entry.file.type.startsWith("video/") ? "Video" : entry.file.type.startsWith("audio/") ? "Audio" : "File");588 preview.dataset.preview = "";589 preview.setAttribute("aria-hidden", "true");590 }591 const name = el("span", entry.file.name);592 name.title = entry.file.name;593 const size = entry.file.size < 1024 * 1024 ? Math.ceil(entry.file.size / 1024) + " KB" : (entry.file.size / (1024 * 1024)).toFixed(1) + " MB";594 row.append(preview, name, el("small", entry.descriptor ? "Uploaded" : size), remove);595 list.append(row);596 });597 target.replaceChildren(...(pending.length ? [list] : []));598 }599600 event(content, attachments = []) {601 const room = this.getAttribute("room") || "";602 if (!/^[a-z0-9_-]{1,64}$/.test(room)) throw Error("The room id is missing.");603 const kind = Number(this.getAttribute("kind") || 9);604 if (kind !== 9 && kind !== 12) throw Error("Unsupported message kind.");605 const tags = [["h", room]];606 if (kind === 12 || this.getAttribute("root")) {607 const root = this.getAttribute("root"), author = this.getAttribute("root-pubkey");608 if (!isHex64(root)) throw Error("The thread root is missing.");609 tags.push(kind === 12 ? ["e", root] : ["e", root, "", "root"]);610 if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]);611 }612 const quote = this.getAttribute("quote");613 if (quote) {614 if (!isHex64(quote)) throw Error("The quoted event is invalid.");615 const relay = this.getAttribute("quote-relay");616 const author = this.getAttribute("quote-pubkey");617 // NIP-10 keeps relay and author positional. Preserve the empty relay618 // slot when only the quoted author's key is known.619 const tag = ["q", quote, relay || ""];620 if (isHex64(author)) {621 tag.push(author);622 if (author !== this.getAttribute("pubkey") && !tags.some(item => item[0] === "p" && item[1] === author)) tags.push(["p", author]);623 }624 tags.push(tag);625 }626 roomMentions(content).forEach(key => { if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p", key]); });627 const lines = [];628 attachments.forEach(file => {629 tags.push(["imeta", "url " + file.url, "m " + file.type, "x " + file.sha256, "size " + file.size, "filename " + file.filename]);630 const label = file.filename.replace(/[\\[\]]/g, "\\$&");631 lines.push(file.type.startsWith("image/") ? `` : file.type.startsWith("video/") ? `` : `[${label}](${file.url})`);632 });633 content = [content, lines.join("\n")].filter(Boolean).join("\n\n");634 return {kind, created_at: now(), tags, content};635 }636637 async uploadFiles(signal) {638 const pending = this.pendingFiles || [];639 for (const [index, entry] of pending.entries()) {640 if (signal.aborted) throw Error("Upload canceled.");641 if (entry.descriptor) continue;642 const file = entry.file;643 this.report("Uploading " + file.name + " (" + (index + 1) + " of " + pending.length + ")…");644 const bytes = new Uint8Array(await file.arrayBuffer());645 const hash = await tiny.sha256hex(bytes);646 const filename = Array.from(file.name.replace(/[\u0000-\u001f\u007f]/g, " ").trim()).slice(0, 255).join("") || "file";647 const path = "/rooms/" + encodeURIComponent(this.getAttribute("room")) + "/attachments?filename=" + encodeURIComponent(filename);648 const response = await tiny.signedFetch(path, "PUT", bytes, {contentType: file.type || "application/octet-stream", signal});649 if (signal.aborted) throw Error("Upload canceled.");650 const descriptor = await response.json();651 if (descriptor.sha256 !== hash || descriptor.size !== bytes.byteLength) throw Error("The upload descriptor has an unexpected hash or size.");652 let url;653 try { url = new URL(descriptor.url); } catch { throw Error("The upload descriptor has an invalid URL."); }654 const prefix = tiny.localPath("/media/" + hash);655 if (url.origin !== new URL(location.href).origin || url.username || url.password || url.search || url.hash || !(url.pathname === prefix || new RegExp("^" + prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\.[a-z0-9]{1,16}$").test(url.pathname))) throw Error("The upload descriptor has an unexpected URL.");656 if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(descriptor.type || "")) throw Error("The upload descriptor has an invalid media type.");657 entry.descriptor = {...descriptor, filename};658 this.renderFiles();659 }660 return pending.map(entry => entry.descriptor);661 }662663 async submit(form) {664 const content = (form.elements.content?.value || "").trim();665 if (!content && !this.pendingFiles?.length) return;666 this.event(content);667 if (!window.nostr?.signEvent) throw Error("Connect a signer first.");668 const controller = new AbortController();669 this.uploadController = controller;670 try {671 const files = await this.uploadFiles(controller.signal);672 if (controller.signal.aborted) throw Error("Upload canceled.");673 this.report("Signing…");674 const event = await signAndPublish(this.event(content, files), controller.signal);675 form.reset();676 this.releasePreviews();677 this.pendingFiles = [];678 this.renderFiles();679 this.resizeContent();680 this.report("");681 roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(this.getAttribute("root")), own: true});682 } finally { this.uploadController = null; }683 }684 }685686 // RoomCreate signs a NIP-29 create (kind 9007) with an id derived from the687 // name, then opens the new room.688 class RoomCreate extends FormElement {689 // event builds the kind 9007 room creation. The id comes from the name690 // unless one is given, so a client that wants a fixed id, such as a Buzz691 // gateway that expects a UUID, can ask for it.692 event({name = "", id = "", about = "", access = "open"} = {}) {693 const title = String(name).trim(), given = String(id).trim().toLowerCase();694 if (given && !/^[a-z0-9_-]{1,64}$/.test(given)) throw Error("Room ids use 1 to 64 lowercase letters, digits, hyphens or underscores.");695 id = given || roomID(title);696 if (!id) throw Error("Enter a name with letters or digits.");697 const tags = [["h", id], ["name", title]];698 if (String(about).trim()) tags.push(["about", String(about).trim()]);699 tags.push(["visibility", access === "members" ? "members" : "open"]);700 return {id, event: {kind: 9007, created_at: now(), tags, content: ""}};701 }702703 async submit(form) {704 const value = name => form.elements[name]?.value || "";705 const {id, event} = this.event({name: value("name"), id: value("id"), about: value("about"), access: value("access")});706 this.report("Signing…");707 await signAndPublish(event);708 this.report("Created #" + id + ".");709 form.reset();710 await tiny.navigate?.(roomPath(id), true);711 }712 }713714 // RoomAction signs one NIP-29 management event for a room: add a member715 // (9000), remove one (9001), edit the room (9002), join (9021) or leave (9022).716 class RoomAction extends FormElement {717 event(values = {}) {718 const room = this.getAttribute("room") || "";719 if (!/^[a-z0-9_-]{1,64}$/.test(room)) throw Error("The room id is missing.");720 const kind = Number(this.getAttribute("kind"));721 const tags = [["h", room]];722 switch (kind) {723 case 9000: case 9001: {724 const key = keyHex(values.pubkey);725 if (!key) throw Error("Enter an npub or a 64-character hex key.");726 const tag = ["p", key];727 if (kind === 9000) tag.push(["owner", "admin"].includes(values.role) ? values.role : "member");728 tags.push(tag);729 break;730 }731 case 9002: {732 const name = String(values.name || "").trim();733 if (!name) throw Error("Enter a name.");734 tags.push(["name", name], ["about", String(values.about || "").trim()], ["picture", String(values.picture || "").trim()], ["visibility", values.access === "members" ? "members" : "open"]);735 break;736 }737 case 9021: case 9022: break;738 default: throw Error("Unsupported room action.");739 }740 return {kind, created_at: now(), tags, content: ""};741 }742743 async submit(form) {744 const values = {};745 for (const field of form.elements) if (field.name) values[field.name] = field.value;746 this.report("Signing…");747 await signAndPublish(this.event(values));748 this.report("Done.");749 await tiny.navigate?.(location.href);750 }751 }752753 // RoomLive follows a room's stream: new messages join the timeline, and754 // reactions update their targets. It reconnects with backoff.755 class RoomLive extends HTMLElement {756 static get observedAttributes() { return ["room", "root"]; }757 connectedCallback() { this.open(); roomScroll(); }758 disconnectedCallback() { this.close(); }759 attributeChangedCallback() { if (this.isConnected) { this.close(); this.open(); } }760761 open() {762 const room = this.getAttribute("room");763 if (!room || this.source || typeof EventSource !== "function") return;764 this.delay = this.delay || 1000;765 const generation = (this.generation || 0) + 1;766 this.generation = generation;767 const source = new EventSource(roomPath(room, "/stream"), {withCredentials: true});768 this.source = source;769 source.addEventListener("open", () => { this.delay = 1000; this.textContent = ""; });770 source.addEventListener("message", event => {771 let parsed;772 try { parsed = JSON.parse(event.data); } catch { return; }773 this.receive(parsed, source, generation);774 });775 source.addEventListener("error", () => {776 if (this.source !== source) return;777 this.close();778 this.textContent = "reconnecting";779 this.timer = setTimeout(() => { this.timer = null; this.open(); }, this.delay);780 this.delay = Math.min(this.delay * 2, 30000);781 });782 }783784 close() {785 this.generation = (this.generation || 0) + 1;786 this.lookupController?.abort();787 this.lookupController = null;788 this.roots?.clear();789 this.rootQueries?.clear();790 this.source?.close();791 this.source = null;792 clearTimeout(this.timer);793 this.timer = null;794 }795796 async canonicalRoot(room, reference, source, generation, eventID = "") {797 this.roots ||= new Map();798 if (this.roots.has(reference)) { const root = this.roots.get(reference); this.rememberRoot(eventID, root); return root; }799 this.rootQueries ||= new Map();800 if (!this.rootQueries.has(reference)) {801 const path = tiny.localPath("/webmcp/query");802 const params = encodeURIComponent(JSON.stringify([{id: room, event: reference, limit: 1}]));803 this.lookupController ||= new AbortController();804 const query = this.rootQueries.get(reference) || (async () => {805 if (typeof fetch !== "function") return "";806 const response = await fetch(path + "?method=browsethread¶ms=" + params, {credentials: "same-origin", cache: "no-store", signal: this.lookupController?.signal});807 if (!response.ok) throw Error("thread lookup failed");808 const value = await response.json();809 const root = value?.root || value?.result?.root;810 const id = root?.id;811 if (!isHex64(id) || ![9, 11, 40002].includes(Number(root?.kind)) || tagValue(root, "h") !== room || replyRoot(root)) return "";812 return id;813 })();814 this.rootQueries.set(reference, query);815 }816 const query = this.rootQueries.get(reference);817 try {818 const root = await query;819 if (source && (this.source !== source || this.generation !== generation)) return "";820 if (root) { this.rememberRoot(reference, root); this.rememberRoot(eventID, root); }821 return root;822 } catch { return ""; }823 finally { if (this.rootQueries.get(reference) === query) this.rootQueries.delete(reference); }824 }825826 rememberRoot(eventID, root) {827 if (!isHex64(eventID) || !isHex64(root)) return;828 this.roots ||= new Map();829 this.roots.set(eventID, root);830 while (this.roots.size > 256) this.roots.delete(this.roots.keys().next().value);831 }832833 async receive(event, source = null, generation = this.generation) {834 if (!event || typeof event !== "object") return;835 if (source && (this.source !== source || this.generation !== generation)) return;836 const room = this.getAttribute("room");837 if (tagValue(event, "h") !== room && !(event.kind === 20001 && !tagValue(event, "h"))) return;838 if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:room-event", {detail: {room, event}}));839 if (event.kind === 7) { roomReact(event); return; }840 if (event.kind === 40003) { roomEdit(event); return; }841 if (!roomKinds.includes(event.kind)) return;842 const root = this.getAttribute("root");843 const reference = replyRoot(event);844 if (reference) {845 // The common direct-reply path is already canonical and can update the846 // UI synchronously. Unknown parents are resolved below so nested or847 // out-of-order events still use the original thread root.848 if (root && reference === root) {849 this.rememberRoot(event.id, root);850 roomAppend(event, {room, inThread: true, canonicalRoot: root});851 return;852 }853 if (!root && document.getElementById("msg-" + reference)) {854 this.rememberRoot(event.id, reference);855 roomAppend(event, {room, inThread: false, canonicalRoot: reference});856 return;857 }858 const canonical = await this.canonicalRoot(room, reference, source, generation, event.id);859 if (source && (this.source !== source || this.generation !== generation)) return;860 if (root && canonical !== root) return;861 if (!canonical) return;862 roomAppend(event, {room, inThread: Boolean(root), canonicalRoot: canonical});863 return;864 }865 if (root) return;866 roomAppend(event, {room, inThread: false});867 }868 }869870 tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, replyRoot, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown, roomAttachments, attachmentContent, attachmentNodes});871 tiny.roomElements = Object.freeze({RoomCompose, RoomCreate, RoomAction, RoomLive});872 customElements.define("room-compose", RoomCompose);873 customElements.define("room-create", RoomCreate);874 customElements.define("room-action", RoomAction);875 customElements.define("room-live", RoomLive);876})();