| 1 | // Room custom elements and rendering/live-update helpers. This feature module |
| 2 | // 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 room |
| 8 | // 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 live |
| 12 | // stream. Each signed event is built here, signed by the connected signer, |
| 13 | // verified unchanged and published once to /events. room-message markup |
| 14 | // 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 nostr |
| 46 | // 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 chat |
| 62 | // messages: paragraphs with line breaks, fenced code, headings, lists, |
| 63 | // inline code, emphasis, links, and bare http(s) or nostr: references. It |
| 64 | // 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 attachment |
| 217 | // 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 short |
| 321 | // 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 the |
| 393 | // 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 viewer |
| 405 | // 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 the |
| 445 | // 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 and |
| 468 | // 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 | }; |
| 473 | |
| 474 | // 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 | } |
| 519 | |
| 520 | disconnectedCallback() { |
| 521 | this.uploadController?.abort(); |
| 522 | this.releasePreviews(); |
| 523 | } |
| 524 | |
| 525 | 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 | } |
| 531 | |
| 532 | releasePreviews() { |
| 533 | for (const entry of this.pendingFiles || []) { |
| 534 | if (entry.preview) URL.revokeObjectURL(entry.preview); |
| 535 | delete entry.preview; |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | busy(on) { |
| 540 | super.busy(on); |
| 541 | this.form?.querySelectorAll("input, textarea" ).forEach(input => { input.disabled = on; }); |
| 542 | } |
| 543 | |
| 544 | chooseFiles(files) { |
| 545 | try { this.addFiles(files); this.report("" ); } |
| 546 | catch (error) { this.report(error.message, true); } |
| 547 | } |
| 548 | |
| 549 | 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 | } |
| 558 | |
| 559 | 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 | } |
| 599 | |
| 600 | 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 relay |
| 618 | // 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 | } |
| 636 | |
| 637 | 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 | } |
| 662 | |
| 663 | 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 | } |
| 685 | |
| 686 | // RoomCreate signs a NIP-29 create (kind 9007) with an id derived from the |
| 687 | // name, then opens the new room. |
| 688 | class RoomCreate extends FormElement { |
| 689 | // event builds the kind 9007 room creation. The id comes from the name |
| 690 | // unless one is given, so a client that wants a fixed id, such as a Buzz |
| 691 | // 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 | } |
| 702 | |
| 703 | 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 | } |
| 713 | |
| 714 | // RoomAction signs one NIP-29 management event for a room: add a member |
| 715 | // (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 | } |
| 742 | |
| 743 | 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 | } |
| 752 | |
| 753 | // RoomLive follows a room's stream: new messages join the timeline, and |
| 754 | // 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(); } } |
| 760 | |
| 761 | 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 | } |
| 783 | |
| 784 | 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 | } |
| 795 | |
| 796 | 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 | } |
| 825 | |
| 826 | 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 | } |
| 832 | |
| 833 | 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 the |
| 846 | // UI synchronously. Unknown parents are resolved below so nested or |
| 847 | // 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 | } |
| 869 | |
| 870 | 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 | })(); |