tinyrelayfile
tinyrelay/tinyclient/rooms.js/

tinyclient/rooms.js raw
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/") ? `![image](${file.url})` : file.type.startsWith("video/") ? `![video](${file.url})` : `[${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&params=" + 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})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs