tinyrelayfile
tinyrelay/tinyclient/components.js/

tinyclient/components.js raw
1// Custom elements for the plain HTML pages. Every element renders in the
2// light DOM with document APIs, takes plain lowercase attributes, and carries
3// no styling of its own; page CSS targets the element name directly.
4//
5// <rpc-form method="…" [compose="…"] [action="…"] [terms="…"] [json-params] [refresh]>
6// One signed NIP-86 management call. Children become the form body; the
7// element appends an <output> status line and a <json-view> result.
8// With refresh, the page reloads in place after the call succeeds.
9// <agent-grant>
10// Signs a kind 30392 agent grant from its fields. Generates the agent
11// key here and shows the nsec once, or takes a pasted public key.
12// <connect-list>
13// The home page cards: lists, reorders, retargets and removes configured
14// connections and adds from the catalog, saving with setconnections.
15// <signed-form action="…" method="PUT|POST|GET" [download]>
16// A NIP-98 signed upload of one file, or a signed download by name.
17// <publish-list kind="…" tag="…" [scheme="ws"]>
18// Signs a replaceable list event from textarea lines and posts it.
19// <json-view>
20// Renders JSON as tables, lists and definition lists, keeping the raw
21// text in a <details>. Server pages seed it with a <pre>; scripts set .value.
22// Tables sit inside a <scroll-box> so wide rows scroll sideways.
23// <view-artifact>
24// Shows a custom view image and keeps its fenced source in a native
25// disclosure when the image cannot be loaded.
26// <nostr-key hex="…">
27// Shortened key with the full hex as title; click copies it.
28// <file-mirror action="/mirror">
29// Signed BUD-04 mirror of a URL or Blossom URI into this relay.
30// <file-tools hash="…" type="…" size="…">
31// Shares one stored file: copies its Blossom URI or share link, decrypts
32// a share link fragment and sends a random-key file as a NIP-17 message.
33// <nostr-compose kind="…" [coordinate="30617:…"] [root="…" root-pubkey="…" root-kind="…"] [parent="…" …] [file="…" line="…" side="old|new"]>
34// Signs a NIP-34 issue, pull request, NIP-22 reply or status event and
35// posts it to the relay. A reply outside a repository omits the coordinate.
36// A reply under a pull request or patch may name one diff line through
37// the file, line and side attributes or hidden inputs of those names.
38// <wiki-compose name="…" [author="…" coordinate="30818:…" event="…"]>
39// Signs a NIP-54 article and posts it. Someone other than the author
40// publishes a fork of the version in view; a "propose" button also signs
41// a merge request to that author.
42// <nostr-react event="…" pubkey="…" [kind="…"]>
43// Signs a NIP-25 reaction to one event; the pressed button carries "+"
44// or "-". A request for a decision is approved or denied and a wiki
45// merge request accepted or rejected this way. The prompt method asks
46// for a visible confirmation first, which the answer link from a
47// notification uses.
48// <approval-item id="approval-…">
49// One request on the Approvals page. When the page opens with the item's
50// id and an answer in the query, it scrolls into view and offers that answer.
51// <share-link [url="…"] [title="…"]>
52// The device share sheet for the page; empty without browser support.
53// <push-toggle>
54// Opts this browser into relay notifications; asks for permission only
55// when pressed and registers the device with a signed request.
56// <file-upload> and <file-workspace-root> live in file-workspace.js;
57// <private-services> lives in private-services.js.
58(() => {
59 "use strict";
60
61 const tiny = window.tiny;
62 const el = (name, text) => {
63 const node = document.createElement(name);
64 if (text !== undefined) node.textContent = text;
65 return node;
66 };
67 const isHex64 = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
68 const isURL = value => typeof value === "string" && /^(https?|wss?):\/\/\S+$/.test(value);
69 const timeKey = /(^|_)(at|time|created|updated|expires|started|finished|since|until)$/;
70 const isTimestamp = (key, value) => Number.isInteger(value) && value > 1e9 && value < 4e9 && timeKey.test(String(key || ""));
71 const isPlain = value => value === null || typeof value !== "object";
72
73 // FormElement wraps light DOM children in a real <form> so labels, Enter
74 // to submit, and required validation work natively, then adds a status line.
75 class FormElement extends HTMLElement {
76 connectedCallback() {
77 if (this.form) return;
78 const form = el("form");
79 form.append(...this.childNodes);
80 this.form = form;
81 this.output = el("output");
82 this.output.setAttribute("role", "status");
83 this.append(form, this.output);
84 form.addEventListener("submit", async event => {
85 event.preventDefault();
86 if (this.submitting) return;
87 this.submitting = true;
88 // The button that sent the form, for elements whose buttons differ.
89 this.submitter = event.submitter || null;
90 this.busy(true);
91 try {
92 await this.submit(form);
93 } catch (err) {
94 this.report("Error: " + err.message, true);
95 } finally {
96 this.submitting = false;
97 this.busy(false);
98 }
99 });
100 }
101
102 busy(on) {
103 this.form.querySelectorAll("button").forEach(button => { button.disabled = on; });
104 }
105
106 report(text, error) {
107 this.output.textContent = text;
108 if (error) this.output.dataset.error = "";
109 else delete this.output.dataset.error;
110 }
111 }
112
113 // Feature modules use this narrow contract instead of reaching into the
114 // generic component implementation.
115 tiny.ui = Object.freeze({FormElement, el, isHex64});
116
117 const list = value => value.split(",").map(item => item.trim()).filter(Boolean);
118 const composers = {
119 member: f => [f.elements["member-pubkey"].value, {name: f.elements["member-name"].value, note: f.elements["member-note"].value, role: f.elements["member-role"].value}],
120 memberInvites: f => [{memberInvites: {depth: Number(f.elements["member-invite-depth"].value), quota: Number(f.elements["member-invite-quota"].value)}}],
121 succession: f => [{heir: f.elements["succession-heir"].value, afterDays: Number(f.elements["succession-days"].value)}],
122 identity: f => [{
123 contact: f.elements["identity-contact"].value,
124 banner: f.elements["identity-banner"].value,
125 postingPolicy: f.elements["identity-posting"].value,
126 privacyPolicy: f.elements["identity-privacy"].value,
127 tags: list(f.elements["identity-tags"].value),
128 languageTags: list(f.elements["identity-languages"].value),
129 relayCountries: list(f.elements["identity-countries"].value)
130 }]
131 };
132
133 // rpc makes one signed management call: the body is serialised once,
134 // signed as NIP-98, and sent once. Returns the result or throws the error.
135 const rpc = async (method, params = [], action) => {
136 const body = JSON.stringify({method, params});
137 const endpoint = tiny.localPath(action || "/manage/rpc");
138 const url = new URL(endpoint, location.href).href;
139 const response = await fetch(endpoint, {
140 method: "POST",
141 headers: {"content-type": "application/json", authorization: await tiny.authorization(url, "POST", new TextEncoder().encode(body))},
142 body
143 });
144 const text = await response.text();
145 let payload;
146 try { payload = JSON.parse(text); } catch { payload = undefined; }
147 if (!response.ok || payload?.error) throw Error(payload?.error || text || "Request failed (" + response.status + ")");
148 return payload && "result" in payload ? payload.result : payload ?? text;
149 };
150
151 class RpcForm extends FormElement {
152 params(form) {
153 const compose = composers[this.getAttribute("compose")];
154 if (compose) return compose(form);
155 let params = [...form.querySelectorAll("[name=param]")].map(input => {
156 try { return JSON.parse(input.value); } catch { return input.value; }
157 });
158 if (this.hasAttribute("json-params") && params.length === 1 && Array.isArray(params[0])) params = params[0];
159 return params;
160 }
161
162 async submit(form) {
163 const method = this.getAttribute("method") || form.elements.method?.value;
164 if (!method) throw Error("Choose a method.");
165 let params = this.params(form);
166 if (method === "claiminvite") {
167 const agree = form.querySelector("[name=termsAgreement]");
168 if (agree && !agree.checked) { this.report("Agree to the relay terms before joining."); return; }
169 const terms = this.getAttribute("terms");
170 if (terms) params = [params[0], await tiny.sha256hex(new TextEncoder().encode(terms))];
171 }
172 this.report("Signing…");
173 const result = await rpc(method, params, this.getAttribute("action"));
174 this.report("Done.");
175 if (this.hasAttribute("refresh") && tiny.navigate) {
176 await tiny.navigate(globalThis.location?.href);
177 return;
178 }
179 this.show(result);
180 }
181
182 show(result) {
183 if (!this.result) {
184 this.result = el("json-view");
185 this.append(this.result);
186 }
187 this.result.value = result;
188 }
189 }
190
191 class ViewArtifact extends HTMLElement {
192 connectedCallback() {
193 const image = this.querySelector("img"), source = this.querySelector("details");
194 if (!image || !source) return;
195 const failed = () => { image.hidden = true; source.open = true; };
196 image.addEventListener("error", failed, {once: true});
197 if (image.complete && image.naturalWidth === 0) failed();
198 }
199 }
200
201 // ViewForm adds a custom view: it composes the addcustomview object from
202 // its fields and shows the returned secret once, since the relay never
203 // shows it again.
204 class ViewForm extends RpcForm {
205 params(form) {
206 const value = name => (form.elements[name]?.value || "").trim();
207 const options = {
208 name: value("view-name"),
209 kinds: list(value("view-kinds")).map(Number),
210 languages: list(value("view-languages")),
211 transform: value("view-transform"),
212 trigger: value("view-trigger") || "write",
213 audience: value("view-audience") || "public"
214 };
215 if (value("view-max-bytes")) options.max_bytes = Number(value("view-max-bytes"));
216 if (value("view-secret")) options.secret = value("view-secret");
217 return [options];
218 }
219
220 show(result) {
221 const secret = result && typeof result === "object" ? result.secret : "";
222 if (!secret) return super.show(result);
223 const warning = el("p", "Copy the view's secret now. The relay signs each transform request with it and will not show it again.");
224 warning.setAttribute("role", "alert");
225 const label = el("label", "View secret ");
226 label.setAttribute("data-secret", "");
227 const input = el("input");
228 input.readOnly = true;
229 input.name = "secret";
230 input.value = secret;
231 input.addEventListener("focus", () => input.select());
232 label.append(input);
233 const link = el("a", "Show the new view");
234 link.href = globalThis.location?.pathname || "";
235 const note = el("p");
236 note.append(link);
237 this.form.reset();
238 this.append(warning, label, note);
239 }
240 }
241
242 class SignedForm extends FormElement {
243 async submit(form) {
244 const action = this.getAttribute("action");
245 const method = (this.getAttribute("method") || "POST").toUpperCase();
246 if (this.hasAttribute("download")) {
247 const name = form.elements.name.value.trim();
248 if (!name) return;
249 this.report("Signing…");
250 const response = await tiny.signedFetch(action + encodeURIComponent(name), "GET", new Uint8Array());
251 const href = URL.createObjectURL(await response.blob());
252 const anchor = el("a");
253 anchor.href = href;
254 anchor.download = name;
255 anchor.click();
256 setTimeout(() => URL.revokeObjectURL(href), 1000);
257 this.report("Downloaded " + name + ".");
258 return;
259 }
260 const file = form.querySelector("input[type=file]")?.files[0];
261 if (!file) { this.report("Choose a file first."); return; }
262 this.report("Signing…");
263 const response = await tiny.signedFetch(action, method, await file.arrayBuffer(), {contentType: file.type || "application/octet-stream"});
264 this.report("Done: " + await response.text());
265 }
266 }
267
268 class FileMirror extends FormElement {
269 async submit(form) {
270 let source = form.elements.namedItem("url").value.trim();
271 if (!source) throw Error("Enter a URL or Blossom URI.");
272 if (/^blossom:/i.test(source) && new URLSearchParams(source.split("?", 2)[1] || "").has("k")) throw Error("Encrypted Blossom URIs keep k client side; paste the public URI without its key.");
273 if (/^https?:/i.test(source)) { const url = new URL(source); url.hash = ""; source = url.href; }
274 const action = this.getAttribute("action") || "/mirror";
275 const body = JSON.stringify({url: source});
276 this.report("Signing…");
277 const response = await tiny.signedFetch(action, "PUT", body, {contentType: "application/json"});
278 let result;
279 try { result = await response.json(); } catch { result = await response.text(); }
280 this.report("Stored.");
281 const view = el("json-view");
282 view.value = result;
283 this.append(view);
284 }
285
286 connectedCallback() {
287 if (this.form) return;
288 this.innerHTML = '<form><label>URL or Blossom URI <input name="url" type="text" placeholder="https://… or blossom:…" required></label><button>Mirror</button></form>';
289 super.connectedCallback();
290 }
291 }
292
293 const {fromB64url} = tiny.util;
294
295 // FileTools shares one stored file: copy its Blossom URI or share link,
296 // send a random-key encrypted file as a NIP-17 message and decrypt a share
297 // link whose key travels in the URL fragment.
298 class FileTools extends HTMLElement {
299 disconnectedCallback() {
300 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);
301 if (this.previewURL && this.previewURL !== this.downloadURL) URL.revokeObjectURL(this.previewURL);
302 }
303
304 connectedCallback() {
305 if (this.bound) return;
306 this.bound = true;
307 // The encryption and messaging modules load on demand; the controls
308 // render right away and decryption waits for them.
309 this.ready = Promise.resolve(tiny.require?.("files")).catch(() => {});
310 this.innerHTML = '<h3>Share</h3><p><button type="button" data-copy>Copy Blossom URI</button> <button type="button" data-link>Copy share link</button> <share-link></share-link></p><form data-share><label>Send to <input name="recipient" inputmode="text" placeholder="npub or hex pubkey"></label><button>Send privately</button></form><output role="status"></output>';
311 this.output = this.querySelector("output");
312 this.querySelector("[data-copy]").addEventListener("click", () => this.copy(this.blossomURI()));
313 this.querySelector("[data-link]").addEventListener("click", () => this.copy(this.shareLink()));
314 this.querySelector("[data-share]").addEventListener("submit", event => { event.preventDefault(); this.share(event.currentTarget); });
315 // Private delivery needs the random key from the share link fragment.
316 const params = new URLSearchParams(location.hash.slice(1));
317 this.querySelector("[data-share]").hidden = !(params.get("key") && params.get("iv"));
318 this.updateFileControls();
319 this.ready.then(() => this.decryptFromFragment());
320 }
321
322 hash() { return this.getAttribute("hash") || ""; }
323 type() { return this.getAttribute("type") || "application/octet-stream"; }
324 updateFileControls() {
325 const available = Boolean(this.hash());
326 this.querySelectorAll("[data-copy], [data-link], [data-share] button").forEach(button => { button.disabled = !available; });
327 }
328 blossomURI() {
329 const params = new URLSearchParams(location.hash.slice(1));
330 const extension = params.has("manifest") ? (["2", "3"].includes(params.get("node") || "2") ? "bdir" : params.get("node") === "1" ? "bfile" : "bin") : "bin";
331 const uri = (params.get("enc") === "chk-v1" || params.has("manifest")) && params.get("key") ? tiny.blossom.encryption.createURI(this.hash(), extension, params.get("key")) : "blossom:" + this.hash();
332 return tiny.root ? uri : uri + (uri.includes("?") ? "&" : "?") + "xs=" + encodeURIComponent(new URL(location.href).host);
333 }
334 shareLink() {
335 const fragment = location.hash.slice(1);
336 const params = new URLSearchParams(fragment);
337 if ((params.get("key") && params.get("iv")) || ((params.get("enc") === "chk-v1" || params.has("manifest")) && params.get("key"))) return location.href.split("#", 1)[0] + "#" + fragment;
338 return location.href.split("#", 1)[0] + "#blossom=" + encodeURIComponent(this.hash());
339 }
340 async copy(value) {
341 try { await navigator.clipboard.writeText(value); this.say("Copied."); }
342 catch { this.say(value); }
343 }
344 say(value, error) { this.output.textContent = value; if (error) this.output.dataset.error = ""; else delete this.output.dataset.error; }
345
346 async decryptFromFragment() {
347 const params = new URLSearchParams(location.hash.slice(1));
348 if ((!params.get("key") || !params.get("iv")) && !(params.get("enc") === "chk-v1" && params.get("key"))) return;
349 try {
350 this.say("Decrypting…");
351 const response = await fetch(tiny.localPath("/files/raw?hash=" + encodeURIComponent(this.hash())));
352 if (!response.ok) throw Error("download failed (" + response.status + ")");
353 const encrypted = new Uint8Array(await response.arrayBuffer());
354 if (await tiny.sha256hex(encrypted) !== this.hash()) throw Error("ciphertext hash mismatch");
355 let plain;
356 if (params.get("enc") === "chk-v1") plain = await tiny.blossom.encryption.decryptCHK(encrypted, params.get("key"), this.hash());
357 else {
358 const key = await crypto.subtle.importKey("raw", fromB64url(params.get("key")), "AES-GCM", false, ["decrypt"]);
359 plain = await crypto.subtle.decrypt({name: "AES-GCM", iv: fromB64url(params.get("iv"))}, key, encrypted);
360 }
361 if (params.get("ox") && await tiny.sha256hex(plain) !== params.get("ox")) throw Error("plaintext hash mismatch");
362 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);
363 if (this.previewURL && this.previewURL !== this.downloadURL) URL.revokeObjectURL(this.previewURL);
364 const contentType = normalizePreviewType(params.get("type") || this.type());
365 const type = safeMediaPreviewType(contentType);
366 const name = params.get("name") || "decrypted-file";
367 this.previewURL = URL.createObjectURL(new Blob([plain], {type: type || "application/octet-stream"}));
368 this.renderPreview(contentType, name, plain);
369 const link = el("a"); this.downloadURL = this.previewURL; link.href = this.downloadURL; link.download = name; link.textContent = "download decrypted file"; this.append(link); this.say("Decrypted locally.");
370 } catch (error) { this.say("Could not decrypt this file: " + error.message, true); }
371 }
372
373 renderPreview(type, name, bytes) {
374 this.querySelector("[data-decrypted-preview]")?.remove();
375 const text = safeTextPreviewType(type);
376 if (!this.previewURL || (!safeMediaPreviewType(type) && !text)) return;
377 const wrapper = el("div");
378 wrapper.setAttribute("data-decrypted-preview", "");
379 const heading = el("p", "Decrypted preview: " + name);
380 heading.setAttribute("data-preview-name", "");
381 let media;
382 if (text) {
383 const pre = el("pre");
384 const limit = 256 * 1024;
385 const truncated = bytes.byteLength > limit;
386 pre.textContent = new TextDecoder().decode(bytes.slice(0, limit)) + (truncated ? "\n\n[Preview truncated. Download the file to see the rest.]" : "");
387 wrapper.append(heading, pre);
388 this.append(wrapper);
389 return;
390 }
391 if (type.startsWith("image/")) {
392 media = el("img");
393 media.alt = name;
394 } else if (type.startsWith("video/")) {
395 media = el("video");
396 media.controls = true;
397 media.preload = "metadata";
398 } else if (type.startsWith("audio/")) {
399 media = el("audio");
400 media.controls = true;
401 media.preload = "metadata";
402 }
403 if (!media) return;
404 media.src = this.previewURL;
405 wrapper.append(heading, media);
406 this.append(wrapper);
407 }
408
409 async share(form) {
410 if (this.sharing) return;
411 await this.ready;
412 const recipient = form.elements.namedItem("recipient").value.trim();
413 if (!recipient) { this.say("Enter a public key or npub.", true); return; }
414 const params = new URLSearchParams(location.hash.slice(1));
415 const signer = tiny.signer();
416 if (!tiny.files.messages || !signer) { this.say("Connect a NIP-44 signer first. Use the encrypted link, which keeps its key in the URL fragment.", true); return; }
417 if (!params.get("key") || !params.get("iv")) { this.say("NIP-17 sharing currently requires a random AES-GCM encrypted file.", true); return; }
418 this.sharing = true;
419 try {
420 const key = fromB64url(params.get("key")), nonce = fromB64url(params.get("iv"));
421 if (key.length !== 32 || nonce.length !== 12) throw Error("Invalid file key or nonce.");
422 const {hex} = tiny.util;
423 this.say("Preparing sealed NIP-17 delivery…");
424 await tiny.files.messages.share({recipient, fileURL: new URL(tiny.localPath("/files/raw?hash=" + this.hash()), location.href).href, ciphertextHash: this.hash(), plaintextHash: params.get("ox") || undefined, key: hex(key), nonce: hex(nonce), mimeType: params.get("type") || this.type(), size: Number(this.getAttribute("size") || 0) || undefined}, {signer});
425 this.say("Encrypted message delivered; sender copy saved.");
426 } catch (error) { this.say("NIP-17 delivery failed: " + error.message, true); } finally { this.sharing = false; }
427 }
428 }
429
430 const normalizePreviewType = value => String(value || "").toLowerCase().split(";", 1)[0].trim();
431 const safeMediaPreviewType = value => {
432 const type = normalizePreviewType(value);
433 if (/^image\/(?:avif|gif|jpeg|png|webp)$/.test(type)) return type;
434 if (/^video\/(?:mp4|ogg|webm)$/.test(type)) return type;
435 if (/^audio\/(?:aac|flac|mpeg|mp4|ogg|wav|webm)$/.test(type)) return type;
436 return "";
437 };
438 const safeTextPreviewType = value => {
439 const type = normalizePreviewType(value);
440 if (/^text\/(?:plain|markdown|css|javascript|x-(?:c|csrc|c\+\+src|java-source))$/.test(type)) return type;
441 if (/^application\/(?:json|javascript|xml|x-(?:javascript|yaml))$/.test(type)) return type;
442 if (type === "image/svg+xml" || type === "text/html" || type === "application/xhtml+xml") return type;
443 return "";
444 };
445
446 class PublishList extends FormElement {
447 async submit(form) {
448 const tag = this.getAttribute("tag");
449 const scheme = this.getAttribute("scheme");
450 const lines = form.elements.lines.value.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
451 .filter(line => scheme !== "ws" || /^wss?:\/\//i.test(line));
452 const event = await tiny.signing.signEvent({
453 kind: Number(this.getAttribute("kind")),
454 created_at: Math.floor(Date.now() / 1000),
455 tags: lines.map(line => [tag, line]),
456 content: ""
457 });
458 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event));
459 let result = null;
460 try { result = await response.clone().json(); } catch {}
461 if (result?.error || result?.accepted === false) throw Error(result?.error || result?.message || "The relay rejected the event.");
462 this.report("Published.");
463 }
464 }
465
466 // NostrCompose signs NIP-34 issue, pull request and conversation events.
467 // The relay receives the signed event directly, so the same control works
468 // with NIP-07 and the existing NIP-46 bridge.
469 class NostrCompose extends FormElement {
470 connectedCallback() {
471 super.connectedCallback();
472 // A comment link on a diff line opens the page at this compose.
473 if (this.id && globalThis.location?.hash === "#" + this.id) {
474 this.scrollIntoView?.({block: "center"});
475 this.form?.elements.content?.focus?.();
476 }
477 }
478
479 async submit(form) {
480 if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
481 const value = name => form.elements[name]?.value.trim() || "";
482 const mode = this.getAttribute("kind");
483 const kind = Number(mode === "status" ? value("status") : mode);
484 if (![1621, 1618, 1111, 1630, 1631, 1632, 1633].includes(kind)) throw Error("Choose a valid event type.");
485 const coordinate = this.getAttribute("coordinate") || "";
486 // A reply to a request for a decision may sit outside any repository.
487 if (coordinate ? !/^30617:[0-9a-f]{64}:.+$/.test(coordinate) : kind !== 1111) throw Error("The repository address is missing.");
488 const tags = coordinate ? [["a", coordinate]] : [];
489 if (kind === 1111 || kind >= 1630) {
490 const root = this.getAttribute("root"), pubkey = this.getAttribute("root-pubkey");
491 const rootKind = this.getAttribute("root-kind");
492 const rootKinds = coordinate ? ["1617", "1618", "1621"] : ["1111", "9", "11"];
493 if (!isHex64(root) || !isHex64(pubkey) || !rootKinds.includes(rootKind)) throw Error("The conversation address is missing.");
494 if (kind === 1111) {
495 const parent = this.getAttribute("parent") || root;
496 const parentPubkey = this.getAttribute("parent-pubkey") || pubkey;
497 const parentKind = this.getAttribute("parent-kind") || rootKind;
498 if (!isHex64(parent) || !isHex64(parentPubkey) || !["1111", rootKind].includes(parentKind)) throw Error("The reply address is invalid.");
499 tags.push(["E", root, "", pubkey], ["K", rootKind], ["P", pubkey], ["e", parent, "", parentPubkey], ["k", parentKind], ["p", parentPubkey]);
500 const anchor = name => value(name) || this.getAttribute(name) || "";
501 const file = anchor("file"), line = anchor("line"), side = anchor("side");
502 if (file || line || side) {
503 if (!["1617", "1618"].includes(rootKind)) throw Error("Line comments belong to a pull request or patch.");
504 if (!file || !/^[1-9]\d*$/.test(line) || !["old", "new"].includes(side)) throw Error("The line reference is incomplete: it needs a file, a line number and a side.");
505 tags.push(["file", file], ["line", line, side]);
506 }
507 } else {
508 tags.push(["e", root, "", "root"], ["p", pubkey]);
509 if (coordinate.split(":")[1] !== pubkey) tags.push(["p", coordinate.split(":")[1]]);
510 }
511 }
512 if (kind === 1621 || kind === 1618) {
513 if (!value("title")) throw Error("Enter a title.");
514 tags.push(["p", coordinate.split(":")[1]], ["subject", value("title")]);
515 const labels = [...new Set(value("labels").split(",").map(label => label.trim()).filter(Boolean))];
516 labels.forEach(label => tags.push(["t", label]));
517 }
518 if (kind === 1618) {
519 const commit = value("commit"), base = value("merge-base");
520 if (!/^[0-9a-f]{40}$/.test(commit) || (base && !/^[0-9a-f]{40}$/.test(base))) throw Error("Enter a full Git commit ID.");
521 let clone; try { clone = new URL(value("clone")); } catch { throw Error("Enter a clone URL."); }
522 if (!["https:", "http:"].includes(clone.protocol) || clone.username || clone.password || clone.hash) throw Error("Enter an HTTP or HTTPS clone URL without credentials.");
523 tags.push(["c", commit], ["clone", clone.href]);
524 if (base) tags.push(["merge-base", base]);
525 }
526 const content = form.elements.content?.value || "";
527 if ((kind === 1621 || kind === 1111) && !content.trim()) throw Error("Enter a message.");
528 const unsigned = {kind, created_at: Math.floor(Date.now() / 1000), tags, content};
529 // Keep a separate copy: extensions may mutate their input while signing.
530 const event = await tiny.signing.publish(unsigned);
531 this.report("Published.");
532 form.reset();
533 // Refresh through the shared navigation so the thread shows the new
534 // event without a full reload.
535 await tiny.navigate?.(globalThis.location?.href);
536 }
537 }
538
539 // ProfileForm publishes the signer's kind 0 profile: the fields shown here
540 // are merged over the profile the relay already holds, so a field another
541 // client set is kept. The event goes to this relay first, then to every
542 // relay listed, and the browser's name cache forgets the old name.
543 class ProfileForm extends FormElement {
544 static content(values, existing) {
545 let merged = {};
546 try { merged = typeof existing === "string" ? JSON.parse(existing || "{}") : {...(existing || {})}; } catch { merged = {}; }
547 if (!merged || typeof merged !== "object" || Array.isArray(merged)) merged = {};
548 for (const key of ["name", "display_name", "about", "picture", "banner", "website", "nip05", "lud16"]) {
549 if (!(key in values)) continue;
550 const value = String(values[key] ?? "").trim();
551 if (value) merged[key] = value; else delete merged[key];
552 }
553 for (const key of ["picture", "banner", "website"]) {
554 if (merged[key] && !/^https?:\/\//.test(merged[key])) throw Error("Use an http or https link for " + key.replace("_", " ") + ".");
555 }
556 return merged;
557 }
558
559 static relays(text) {
560 const seen = new Set();
561 for (const raw of String(text || "").split(/[\s,]+/)) {
562 const url = tiny.util.relayURL(raw);
563 if (url) seen.add(url);
564 }
565 if (seen.size > 12) throw Error("List at most 12 relays.");
566 return [...seen];
567 }
568
569 async submit(form) {
570 const value = name => form.elements[name]?.value ?? "";
571 const values = Object.fromEntries(["name", "display_name", "about", "picture", "banner", "website", "nip05", "lud16"].map(key => [key, value(key)]));
572 const content = ProfileForm.content(values, this.getAttribute("existing") || "{}");
573 const relays = ProfileForm.relays(value("relays"));
574 const unsigned = {kind: 0, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify(content)};
575 this.report("Signing…");
576 const event = await tiny.signing.signEvent(unsigned);
577 const pubkey = this.getAttribute("pubkey");
578 if (pubkey && event.pubkey !== pubkey) throw Error("The signer holds a different key than the one signed in.");
579 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
580 const result = await response.json().catch(() => ({}));
581 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the profile.");
582 this.forget(event.pubkey);
583 const lines = ["Published here."];
584 if (relays.length) {
585 if (!window.NostrSigner?.SimplePool) throw Error("Published here; the signer bundle is still loading, so other relays were skipped.");
586 const pool = new window.NostrSigner.SimplePool();
587 const outcomes = await Promise.allSettled(pool.publish(relays, event).map(p => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(Error("timed out")), 8000))])));
588 outcomes.forEach((outcome, index) => { lines.push(relays[index] + ": " + (outcome.status === "fulfilled" ? "ok" : "failed, " + (outcome.reason?.message || outcome.reason || "no answer"))); });
589 try { pool.close(relays); } catch {}
590 }
591 this.report(lines.join("\n"));
592 }
593
594 // forget drops the cached name so nostr-name shows the new one on the
595 // next page.
596 forget(pubkey) {
597 try {
598 const key = window.tinyNames?.cacheKey || "tiny:names:v1";
599 const cache = JSON.parse(localStorage.getItem(key) || "{}") || {};
600 delete cache[pubkey];
601 localStorage.setItem(key, JSON.stringify(cache));
602 } catch {}
603 }
604 }
605
606 // AgentGrant signs a kind 30392 agent grant: the agent's key, name, scope
607 // and expiry, signed by the owner or a moderator. The agent key is made
608 // here and its nsec shown once, or pasted as a public key. The secret never
609 // leaves the page; the relay only receives the signed grant.
610 class AgentGrant extends FormElement {
611 connectedCallback() {
612 super.connectedCallback();
613 if (this.toggled || !this.form) return;
614 this.toggled = true;
615 const mode = this.form.elements.key;
616 const toggle = () => { const label = this.form.elements.pubkey?.closest("label"); if (label) label.hidden = mode.value !== "paste"; };
617 mode?.addEventListener("change", toggle);
618 if (mode) toggle();
619 }
620
621 // tags builds the grant tags from the field values; it throws on any
622 // value the relay would refuse so nothing reaches the signer.
623 static tags(values, pubkey, now = Math.floor(Date.now() / 1000)) {
624 const text = name => String(values[name] ?? "").trim();
625 const split = name => text(name).split(/[\s,]+/).map(item => item.trim()).filter(Boolean);
626 if (!isHex64(pubkey)) throw Error("Enter the agent's public key as 64 lowercase hex characters.");
627 const name = text("name");
628 if (name.length > 64) throw Error("Keep the name to 64 characters.");
629 const tags = [["d", pubkey], ["p", pubkey]];
630 if (name) tags.push(["name", name]);
631 const expires = Math.floor(Date.parse(text("expires") + "T23:59:59Z") / 1000);
632 if (!Number.isFinite(expires) || expires <= now) throw Error("Choose an expiry date after today.");
633 if (expires > now + 365 * 86400) throw Error("Grants last at most 365 days.");
634 tags.push(["expiration", String(expires)]);
635 for (const room of [...new Set(split("rooms"))]) {
636 if (room.length > 128) throw Error("Room names are at most 128 characters.");
637 tags.push(["room", room]);
638 }
639 const seenRepos = new Set();
640 for (const line of text("repos").split(/\r?\n/).map(line => line.trim()).filter(Boolean)) {
641 const match = /^([0-9a-f]{64}):(.+):(propose|read|maintain)$/.exec(line);
642 if (!match || match[2].length > 256) throw Error("Repositories are owner pubkey:identifier:propose, read or maintain, one per line.");
643 if (seenRepos.has(match[1] + ":" + match[2])) continue;
644 seenRepos.add(match[1] + ":" + match[2]);
645 tags.push(["repo", line]);
646 }
647 for (const kind of [...new Set(split("kinds"))]) {
648 if (!/^\d{1,5}$/.test(kind) || Number(kind) > 65535) throw Error("Kinds are whole numbers from 0 to 65535.");
649 tags.push(["k", String(Number(kind))]);
650 }
651 const wiki = text("wiki");
652 if (wiki && wiki !== "propose" && wiki !== "edit") throw Error("Wiki access is propose or edit.");
653 if (wiki) tags.push(["wiki", wiki]);
654 // Sites: a label under the agent's key or *, then ttl=<days> and
655 // encrypted, one entry per line. The relay checks the label's key.
656 const seenSites = new Set();
657 for (const line of text("sites").split(/\r?\n/).map(line => line.trim()).filter(Boolean)) {
658 const [label, ...flags] = line.split(/\s+/);
659 if (label !== "*" && !/^npub1[a-z0-9]{58}$/.test(label) && !/^[0-9a-z]{50}[a-z0-9-]{1,13}$/.test(label))
660 throw Error("Sites are a site label or *, then ttl=<days> and encrypted, one per line.");
661 const tag = ["sites", label];
662 for (const flag of flags) {
663 const ttl = /^ttl=(\d{1,3})$/.exec(flag);
664 if (ttl && Number(ttl[1]) >= 1 && Number(ttl[1]) <= 365) tag.push("ttl=" + Number(ttl[1]));
665 else if (flag === "encrypted") tag.push("encrypted");
666 else throw Error("Sites take ttl=<days> from 1 to 365 and encrypted after the label.");
667 }
668 if (seenSites.has(label)) continue;
669 seenSites.add(label);
670 tags.push(tag);
671 }
672 const rate = text("rate");
673 if (rate) {
674 if (!/^\d+$/.test(rate) || Number(rate) < 1 || Number(rate) > 600) throw Error("Rate is 1 to 600 events per minute.");
675 tags.push(["rate", String(Number(rate))]);
676 }
677 return tags;
678 }
679
680 async submit(form) {
681 const value = name => form.elements[name]?.value ?? "";
682 const values = {name: value("name"), expires: value("expires"), rooms: value("rooms"), repos: value("repos"), kinds: value("kinds"), wiki: value("wiki"), sites: value("sites"), rate: value("rate")};
683 let secret = null, pubkey = value("pubkey").trim().toLowerCase();
684 if (value("key") !== "paste") {
685 if (!window.NostrSigner?.generateSecretKey) throw Error("The signer bundle is still loading.");
686 secret = window.NostrSigner.generateSecretKey();
687 pubkey = window.NostrSigner.getPublicKey(secret);
688 }
689 const tags = AgentGrant.tags(values, pubkey);
690 const unsigned = {kind: 30392, created_at: Math.floor(Date.now() / 1000), tags, content: ""};
691 this.report("Signing…");
692 const event = await tiny.signing.signEvent(unsigned);
693 if (event.pubkey === pubkey) throw Error("The agent needs its own key, not yours.");
694 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
695 const result = await response.json();
696 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the grant.");
697 this.report("Granted.");
698 if (secret) {
699 this.reveal(pubkey, secret);
700 return;
701 }
702 form.reset();
703 await tiny.navigate?.(globalThis.location?.href);
704 }
705
706 // reveal shows the generated secret once. The page is not reloaded so
707 // it stays on screen until the person leaves.
708 reveal(pubkey, secret) {
709 const nsec = tiny.util.bech32Encode("nsec", secret);
710 const warning = el("p", "Copy the agent's secret key now. It is not stored anywhere and will not be shown again.");
711 warning.setAttribute("role", "alert");
712 const secretLabel = el("label", "Agent secret key ");
713 secretLabel.setAttribute("data-secret", "");
714 const secretInput = el("input");
715 secretInput.readOnly = true;
716 secretInput.name = "nsec";
717 secretInput.value = nsec;
718 secretInput.addEventListener("focus", () => secretInput.select());
719 secretLabel.append(secretInput);
720 const keyLabel = el("label", "Agent public key ");
721 keyLabel.setAttribute("data-secret", "");
722 const keyInput = el("input");
723 keyInput.readOnly = true;
724 keyInput.name = "agent";
725 keyInput.value = pubkey;
726 keyLabel.append(keyInput);
727 const link = el("a", "Show the new agent");
728 link.href = (globalThis.location?.pathname || "") + "?agent=" + pubkey;
729 const note = el("p");
730 note.append(link);
731 this.append(warning, secretLabel, keyLabel, note);
732 }
733 }
734
735 class NostrKey extends HTMLElement {
736 static get observedAttributes() { return ["hex"]; }
737
738 connectedCallback() {
739 this.update();
740 if (this.bound) return;
741 this.bound = true;
742 this.addEventListener("click", () => {
743 navigator.clipboard?.writeText(this.getAttribute("hex") || "").then(() => {
744 const title = this.title;
745 this.title = "Copied";
746 setTimeout(() => { this.title = title; }, 1200);
747 }).catch(() => {});
748 });
749 }
750
751 attributeChangedCallback() { this.update(); }
752
753 update() {
754 const hex = this.getAttribute("hex") || "";
755 this.title = hex;
756 if (!this.textContent.trim()) this.textContent = hex.slice(0, 12);
757 }
758 }
759
760 class NostrAvatar extends HTMLElement {
761 connectedCallback() {
762 this.render();
763 }
764
765 attributeChangedCallback() { this.render(); }
766
767 static get observedAttributes() { return ["pubkey"]; }
768
769 render() {
770 const pubkey = String(this.getAttribute("pubkey") || "").toLowerCase();
771 const valid = /^[0-9a-f]{64}$/.test(pubkey);
772 const seed = valid ? pubkey : "0".repeat(64);
773 const fallback = tiny.localPath ? tiny.localPath("/avatars/v1/" + seed + ".svg") : "/avatars/v1/" + seed + ".svg";
774 const serial = (this.serial || 0) + 1;
775 this.serial = serial;
776 const changed = this.renderedPubkey !== undefined && this.renderedPubkey !== pubkey;
777 this.renderedPubkey = pubkey;
778 const restore = () => {
779 if (serial !== this.serial) return;
780 const image = el("img");
781 image.src = fallback;
782 image.alt = "";
783 image.loading = "lazy";
784 image.referrerPolicy = "no-referrer";
785 image.setAttribute("aria-hidden", "true");
786 this.replaceChildren(image);
787 };
788 const current = this.querySelector("img");
789 if (!valid) {
790 restore();
791 return;
792 }
793 if (changed || !current) restore();
794 else current.addEventListener("error", restore, {once: true});
795 const load = window.tinyNames?.load;
796 if (!load) return;
797 Promise.resolve(load({pubkey, refresh: true})).then(user => {
798 const raw = user?.image;
799 let url;
800 try {
801 const candidate = new URL(raw);
802 if (candidate.protocol === "http:" || candidate.protocol === "https:") url = candidate.href;
803 } catch {}
804 if (!url || serial !== this.serial) return restore();
805 const image = el("img");
806 image.src = url;
807 image.alt = "";
808 image.loading = "lazy";
809 image.referrerPolicy = "no-referrer";
810 image.setAttribute("aria-hidden", "true");
811 image.addEventListener("error", restore, {once: true});
812 this.replaceChildren(image);
813 }).catch(restore);
814 }
815 }
816
817 class RoomAvatar extends HTMLElement {
818 static get observedAttributes() { return ["picture", "fallback"]; }
819
820 connectedCallback() { this.render(); }
821
822 attributeChangedCallback() { this.render(); }
823
824 render(forceFallback = false) {
825 const serial = (this.serial || 0) + 1;
826 this.serial = serial;
827 let picture;
828 try {
829 const raw = this.getAttribute("picture") || "";
830 if (/^https?:\/\/\S+$/i.test(raw)) {
831 const candidate = new URL(raw);
832 picture = candidate.href;
833 }
834 } catch {}
835 let fallback;
836 try {
837 const candidate = new URL(this.getAttribute("fallback") || "", location.href);
838 if (candidate.origin === location.origin && /^(?:\/avatars|\/r\/[^/]+\/avatars)\/v1\/[0-9a-f]{64}\.svg$/.test(candidate.pathname)) fallback = candidate.href;
839 } catch {}
840 const src = forceFallback ? fallback : picture || fallback;
841 const current = this.querySelector("img");
842 if (current && current.src === src) {
843 if (picture && !forceFallback) {
844 if (current.complete && current.naturalWidth === 0) this.render(true);
845 else current.addEventListener("error", () => this.restore(serial), {once: true});
846 }
847 return;
848 }
849 this.replaceChildren();
850 if (!src) return;
851 const image = el("img");
852 image.src = src;
853 image.alt = "";
854 image.loading = "lazy";
855 image.referrerPolicy = "no-referrer";
856 image.setAttribute("aria-hidden", "true");
857 if (picture && !forceFallback) image.addEventListener("error", () => this.restore(serial), {once: true});
858 this.append(image);
859 }
860
861 restore(serial) {
862 if (serial !== this.serial) return;
863 this.render(true);
864 }
865 }
866
867 // PageLink is the crumb that names the current page. A tap copies the
868 // page's public address, the one the server put in url, and shows
869 // "copied" for a moment. Without JavaScript it is plain text.
870 class PageLink extends HTMLElement {
871 connectedCallback() {
872 if (this.bound) return;
873 this.bound = true;
874 this.addEventListener("click", () => {
875 const url = this.getAttribute("url") || location.href.split("#", 1)[0];
876 navigator.clipboard?.writeText(url).then(() => {
877 this.dataset.copied = "";
878 clearTimeout(this.timer);
879 this.timer = setTimeout(() => { delete this.dataset.copied; }, 1500);
880 }).catch(() => {});
881 });
882 }
883 }
884
885 // JsonView renders any JSON value. Arrays of objects become tables, objects
886 // become definition lists, scalar arrays become lists, and deep nesting
887 // falls back to compact JSON so a response never explodes the page.
888 // ShareLink offers the device's own share sheet for the current page, or
889 // the url and title attributes when set. It stays empty where the browser
890 // has no share support, so the page reads the same without it.
891 class ShareLink extends HTMLElement {
892 connectedCallback() {
893 if (this.bound) return;
894 this.bound = true;
895 if (!navigator.share) return;
896 const button = el("button", "Share…");
897 button.type = "button";
898 button.addEventListener("click", () => {
899 const data = {url: this.getAttribute("url") || location.href.split("#", 1)[0], title: this.getAttribute("title") || document.title};
900 navigator.share(data).catch(() => {});
901 });
902 this.replaceChildren(button);
903 }
904 }
905
906 // publishSigned asks the signer for a signature, checks that what came
907 // back is the event that was requested, since extensions may edit input,
908 // then posts it once and reports the relay's answer.
909 const publishSigned = async unsigned => {
910 return tiny.signing.publish(unsigned);
911 };
912 const unixNow = () => Math.floor(Date.now() / 1000);
913
914 // NostrReact signs a NIP-25 reaction to one event: "+" or "-" from the
915 // pressed button. The reaction names the event with e and its author with
916 // p, so the author's own subscription sees the answer. A request for a
917 // decision is approved or denied this way and a wiki merge request
918 // accepted or rejected. Nothing is signed without a press: the buttons
919 // submit the form, and prompt() asks once more before signing on behalf of
920 // a notification action.
921 const decisions = {"+": "approve", "-": "deny"};
922 const reactionWords = {"+": "Approved.", "-": "Denied.", accept: "Accepted.", approve: "Approved.", reject: "Rejected.", deny: "Denied."};
923 class NostrReact extends FormElement {
924 connectedCallback() {
925 if (this.form) return;
926 super.connectedCallback();
927 if (this.listening) return;
928 this.listening = true;
929 this.addEventListener("click", event => {
930 const button = event.target.closest("button[name=reaction]");
931 if (button) this.choice = button.value;
932 });
933 }
934
935 async submit(form, choice = this.choice ?? this.submitter?.value ?? form?.elements?.reaction?.value) {
936 const id = this.getAttribute("event"), pubkey = this.getAttribute("pubkey");
937 if (!isHex64(id) || !isHex64(pubkey)) throw Error("The request address is missing.");
938 if (!(choice in decisions)) throw Error("Choose Approve or Deny.");
939 const tags = [["e", id, "", pubkey], ["p", pubkey]];
940 const kind = this.getAttribute("kind");
941 if (/^\d+$/.test(kind || "")) tags.push(["k", kind]);
942 this.report("Signing…");
943 await publishSigned({kind: 7, created_at: unixNow(), tags, content: choice});
944 const label = (this.submitter?.textContent || "").trim().toLowerCase();
945 this.choice = null;
946 this.submitter = null;
947 this.report(reactionWords[label] || reactionWords[choice]);
948 await tiny.navigate?.((globalThis.location?.href || "").split("?", 1)[0]);
949 }
950
951 // prompt shows the confirmation for one answer; the signature waits for
952 // the confirm button. Without a signer it waits for one to connect.
953 prompt(answer) {
954 const choice = Object.keys(decisions).find(key => decisions[key] === answer);
955 if (!choice) return;
956 if (!window.nostr?.signEvent) {
957 this.report("Connect a signer to answer this request.");
958 document.addEventListener("tiny:signer", () => this.prompt(answer), {once: true});
959 return;
960 }
961 const question = el("span", (answer === "approve" ? "Approve" : "Deny") + " this request? ");
962 const confirm = el("button", "Confirm");
963 confirm.type = "button";
964 const cancel = el("button", "Cancel");
965 cancel.type = "button";
966 confirm.addEventListener("click", () => {
967 this.busy(true);
968 confirm.disabled = cancel.disabled = true;
969 this.submit(this.form, choice).catch(err => this.report("Error: " + err.message, true)).finally(() => this.busy(false));
970 });
971 cancel.addEventListener("click", () => this.report(""));
972 this.output.replaceChildren(question, confirm, " ", cancel);
973 delete this.output.dataset.error;
974 confirm.focus();
975 }
976 }
977
978 // WikiCompose publishes a NIP-54 article. When the signer is not the
979 // author of the version in view, the article is a fork of that version,
980 // and "propose" also signs a kind 818 merge request to that author. The
981 // name follows the title on a new page until it is edited by hand.
982 class WikiCompose extends FormElement {
983 connectedCallback() {
984 super.connectedCallback();
985 const title = this.form?.elements?.title, name = this.form?.elements?.name;
986 if (this.wired === this.form || !title || !name) return;
987 this.wired = this.form;
988 let follow = !this.getAttribute("name");
989 name.addEventListener("input", () => { follow = name.value.trim() === ""; });
990 name.addEventListener("change", () => { name.value = tiny.util.wikiName(name.value); });
991 title.addEventListener("input", () => { if (follow) name.value = tiny.util.wikiName(title.value); });
992 }
993
994 async submit(form) {
995 const value = key => form.elements[key]?.value.trim() || "";
996 const title = value("title"), d = tiny.util.wikiName(value("name") || title), summary = value("summary");
997 const content = form.elements.content?.value || "";
998 if (!title) throw Error("Enter a title.");
999 if (!d) throw Error("Enter a name with at least one letter or digit.");
1000 if (!content.trim()) throw Error("Enter the content.");
1001 const action = this.submitter?.value || "publish";
1002 const author = this.getAttribute("author") || "", coordinate = this.getAttribute("coordinate") || "", base = this.getAttribute("event") || "";
1003 if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
1004 const pubkey = typeof window.nostr.getPublicKey === "function" ? await window.nostr.getPublicKey() : "";
1005 const forking = isHex64(author) && author !== pubkey;
1006 if (action === "propose" && !forking) throw Error("This is your own version; publish it instead.");
1007 const tags = [["d", d], ["title", title]];
1008 if (summary) tags.push(["summary", summary]);
1009 if (forking) {
1010 if (!/^30818:[0-9a-f]{64}:.+$/.test(coordinate) || !isHex64(base)) throw Error("The version to fork is missing.");
1011 tags.push(["a", coordinate, "", "fork"], ["e", base, "", "fork"]);
1012 }
1013 const version = await publishSigned({kind: 30818, created_at: unixNow(), tags, content});
1014 if (action === "propose") {
1015 const request = {kind: 818, created_at: unixNow(), tags: [["a", coordinate], ["e", version.id, "", "source"], ["e", base], ["p", author]], content: summary || "Proposed version of " + title};
1016 await publishSigned(request);
1017 this.report("Published and proposed to " + author.slice(0, 12) + ".");
1018 } else {
1019 this.report("Published.");
1020 }
1021 const path = "/wiki/" + encodeURIComponent(d);
1022 const href = tiny.localPath ? tiny.localPath(path) : path;
1023 if (tiny.navigate) {
1024 globalThis.history?.pushState?.({}, "", href);
1025 await tiny.navigate(href);
1026 } else {
1027 globalThis.location?.assign?.(href);
1028 }
1029 }
1030 }
1031
1032 // ApprovalItem reacts to the query a notification action opens the page
1033 // with: id names the request and answer is approve, deny or reply.
1034 class ApprovalItem extends HTMLElement {
1035 connectedCallback() {
1036 if (this.bound) return;
1037 this.bound = true;
1038 const params = new URLSearchParams(location.search);
1039 if (this.id !== "approval-" + params.get("id")) return;
1040 this.setAttribute("data-focus", "");
1041 this.scrollIntoView?.({block: "center"});
1042 const answer = params.get("answer");
1043 if (answer === "reply") {
1044 const details = this.querySelector("details");
1045 if (details) details.open = true;
1046 this.querySelector("textarea")?.focus();
1047 } else if (answer === "approve" || answer === "deny") {
1048 // The reaction element must have wrapped its form first.
1049 queueMicrotask(() => this.querySelector("nostr-react")?.prompt(answer));
1050 }
1051 }
1052 }
1053
1054 // PushToggle opts this browser into relay notifications. Nothing happens
1055 // until the button is pressed: permission is requested then, and the
1056 // device subscription is registered with a signed request.
1057 const pushCategories = [["messages", "private messages"], ["replies", "replies to you"], ["mentions", "mentions"], ["approvals", "requests for a decision"], ["relay", "relay notices"]];
1058 // Social follows NIP-10 notes, NIP-23 articles and NIP-22 comments.
1059 const socialValue = (form, name) => String(form?.elements?.[name]?.value || "").trim();
1060 const socialDraftFields = ["title", "summary", "cover", "content", "tags", "media", "alt"];
1061 const socialDraftKey = node => {
1062 const actor = node.getAttribute("actor");
1063 if (!isHex64(actor)) return "";
1064 return "tiny.social.draft." + JSON.stringify([location.origin, tiny.root || "", actor, node.getAttribute("mode") || "note", node.getAttribute("address") || "", node.getAttribute("target") || ""]);
1065 };
1066 const socialHTTPURL = value => {
1067 if (!value) return "";
1068 let url; try { url = new URL(value); } catch { throw Error("Enter a valid HTTP or HTTPS URL."); }
1069 if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw Error("Media URLs must use HTTP or HTTPS without credentials.");
1070 return url.href;
1071 };
1072 const socialRelay = node => {
1073 const value = node.getAttribute("relay") || "";
1074 return /^wss?:\/\//.test(value) ? value : "";
1075 };
1076 const socialSend = async event => {
1077 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
1078 let result = {};
1079 try { result = await response.json(); } catch {}
1080 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the post.");
1081 return event;
1082 };
1083 class SocialCompose extends FormElement {
1084 connectedCallback() {
1085 if (this.form) return;
1086 super.connectedCallback();
1087 this.restoreDraft();
1088 this.form.addEventListener("input", () => this.saveDraft());
1089 this.querySelector("[data-preview]")?.addEventListener("click", () => this.preview().catch(error => this.report(error.message, true)));
1090 }
1091 saveDraft() {
1092 const key = socialDraftKey(this);
1093 if (!key) return;
1094 try {
1095 const values = Object.fromEntries(socialDraftFields.map(name => [name, this.form.elements[name]?.value || ""]));
1096 if (!Object.values(values).some(Boolean)) { localStorage.removeItem(key); return; }
1097 localStorage.setItem(key, JSON.stringify({values, articleID: this.articleID || "", publishedAt: this.publishedAt || 0}));
1098 } catch {}
1099 }
1100 restoreDraft() {
1101 const key = socialDraftKey(this);
1102 if (!key) return;
1103 try {
1104 const draft = JSON.parse(localStorage.getItem(key) || "null");
1105 if (!draft || typeof draft.values !== "object") return;
1106 for (const name of socialDraftFields) {
1107 const input = this.form.elements[name];
1108 if (input && !input.value && typeof draft.values[name] === "string") input.value = draft.values[name];
1109 }
1110 if (typeof draft.articleID === "string" && /^[a-z0-9-]{1,100}$/.test(draft.articleID)) this.articleID = draft.articleID;
1111 if (Number.isSafeInteger(draft.publishedAt) && draft.publishedAt > 0) this.publishedAt = draft.publishedAt;
1112 } catch {}
1113 }
1114 clearDraft() { try { const key = socialDraftKey(this); if (key) localStorage.removeItem(key); } catch {} }
1115 kind() {
1116 const mode = this.getAttribute("mode") || "note";
1117 if (mode === "article") return 30023;
1118 if (mode === "note") return 1;
1119 if (mode !== "comment") throw Error("Choose a valid post format.");
1120 const parent = this.getAttribute("target-kind") || "1";
1121 if (parent === "1") return 1;
1122 if (parent === "30023" || parent === "1111") return 1111;
1123 throw Error("This post does not support replies here.");
1124 }
1125 replyTags(kind) {
1126 const target = this.getAttribute("target") || "", author = this.getAttribute("target-pubkey") || "";
1127 const root = this.getAttribute("root") || target, rootAuthor = this.getAttribute("root-pubkey") || (root === target ? author : "");
1128 const hint = socialRelay(this);
1129 if (![target, author, root].every(isHex64)) throw Error("The reply address is incomplete. Reload the conversation.");
1130 if (kind === 1) {
1131 const tags = [["e", root, hint, "root", ...(isHex64(rootAuthor) ? [rootAuthor] : [])]];
1132 if (target !== root) tags.push(["e", target, hint, "reply", author]);
1133 const participants = new Set([...(isHex64(rootAuthor) ? [rootAuthor] : []), author]);
1134 try { for (const tag of JSON.parse(this.getAttribute("target-tags") || "[]")) if (Array.isArray(tag) && tag[0] === "p" && isHex64(tag[1])) participants.add(tag[1]); } catch {}
1135 for (const pubkey of participants) tags.push(["p", pubkey, hint]);
1136 return tags;
1137 }
1138 const address = this.getAttribute("address") || "";
1139 const parentKind = this.getAttribute("target-kind") || "30023";
1140 if (!isHex64(rootAuthor) || !address.startsWith("30023:" + rootAuthor + ":") || !["30023", "1111"].includes(parentKind)) throw Error("The article address is incomplete. Reload the conversation.");
1141 const tags = [["A", address, hint], ["K", "30023"], ["P", rootAuthor, hint]];
1142 if (parentKind === "30023") tags.push(["a", address, hint]);
1143 tags.push(["e", target, hint, author], ["k", parentKind], ["p", author, hint]);
1144 return tags;
1145 }
1146 tags(form, kind) {
1147 const tags = this.getAttribute("mode") === "comment" ? this.replyTags(kind) : [];
1148 if (kind === 30023) {
1149 if (!socialValue(form, "title")) throw Error("Give your article a title.");
1150 this.articleID ||= crypto.randomUUID();
1151 this.publishedAt ||= unixNow();
1152 tags.push(["d", this.articleID], ["title", socialValue(form, "title")], ["published_at", String(this.publishedAt)]);
1153 if (socialValue(form, "summary")) tags.push(["summary", socialValue(form, "summary")]);
1154 const cover = socialHTTPURL(socialValue(form, "cover"));
1155 if (cover) tags.push(["image", cover]);
1156 }
1157 const topics = new Set(socialValue(form, "tags").split(",").map(value => value.trim().replace(/^#/, "").toLowerCase()).filter(Boolean));
1158 for (const topic of topics) tags.push(["t", topic]);
1159 return tags;
1160 }
1161 async attachments(form) {
1162 const files = [...(form.elements["media-file"]?.files || [])];
1163 if (files.length > 10 || files.some(file => file.size > 256 * 1024 * 1024)) throw Error("Choose up to 10 attachments, each at most 256 MiB.");
1164 if (files.some(file => !/^(image|video|audio)\//.test(file.type))) throw Error("Choose an image, video or audio file.");
1165 this.mediaUploads ||= new WeakMap();
1166 const items = [];
1167 for (const file of files) {
1168 let item = this.mediaUploads.get(file);
1169 if (!item) {
1170 this.report("Uploading " + file.name + "…");
1171 const bytes = new Uint8Array(await file.arrayBuffer()), hash = await tiny.sha256hex(bytes);
1172 const path = "/upload?filename=" + encodeURIComponent(file.name) + "&purpose=file";
1173 const response = await tiny.signedFetch(path, "PUT", bytes, {contentType: file.type});
1174 const descriptor = await response.json();
1175 if (!response.ok || descriptor.sha256 !== hash || descriptor.size !== bytes.length) throw Error("The attachment could not be verified. Try again.");
1176 item = {url: socialHTTPURL(descriptor.url), type: file.type, hash, size: file.size, filename: file.name};
1177 if (!item.url) throw Error("The attachment URL is missing.");
1178 this.mediaUploads.set(file, item);
1179 }
1180 items.push({...item, alt: socialValue(form, "alt") || file.name});
1181 }
1182 const media = socialHTTPURL(socialValue(form, "media"));
1183 if (media && !items.some(item => item.url === media)) items.push({url: media, alt: socialValue(form, "alt")});
1184 return items;
1185 }
1186 async preview() {
1187 const output = this.querySelector("[data-preview-output]");
1188 if (!output) return;
1189 const response = await tiny.signedFetch("/social/preview", "POST", JSON.stringify({content: this.form.elements.content?.value || "", kind: this.kind()}));
1190 if (!response.ok) throw Error("Preview is unavailable. Your draft is still saved.");
1191 const result = await response.json();
1192 if (typeof result.html !== "string") throw Error("Preview is unavailable.");
1193 output.innerHTML = result.html;
1194 output.hidden = false;
1195 }
1196 async submit(form) {
1197 if (this.sending) return;
1198 this.sending = true;
1199 try {
1200 const kind = this.kind(), content = form.elements.content?.value || "";
1201 if (!content.trim() && !(form.elements["media-file"]?.files?.length) && !socialValue(form, "media")) throw Error("Write something or attach media first.");
1202 const tags = this.tags(form, kind);
1203 this.saveDraft();
1204 const attachments = await this.attachments(form);
1205 const urls = attachments.map(item => item.url).filter(url => !content.includes(url));
1206 const body = [content, ...urls].filter(Boolean).join("\n\n");
1207 for (const item of attachments) tags.push(["imeta", "url " + item.url, ...(item.type ? ["m " + item.type] : []), ...(item.hash ? ["x " + item.hash, "size " + item.size] : []), ...(item.alt ? ["alt " + item.alt] : [])]);
1208 const fingerprint = JSON.stringify({kind, tags, content: body});
1209 if (this.prepared?.fingerprint !== fingerprint) this.prepared = {fingerprint, unsigned: {kind, created_at: unixNow(), tags, content: body}};
1210 this.report("Signing and publishing…");
1211 this.prepared.event ||= await tiny.signing.signEvent(this.prepared.unsigned);
1212 const event = await socialSend(this.prepared.event);
1213 this.prepared = null;
1214 this.clearDraft();
1215 this.articleID = ""; this.publishedAt = 0;
1216 form.reset?.();
1217 this.report("Published.");
1218 try { await tiny.navigate?.(this.getAttribute("refresh") || location.href); } catch { this.report("Published. Refresh to see your post."); }
1219 return event;
1220 } finally { this.sending = false; }
1221 }
1222 }
1223 class SocialReaction extends HTMLElement {
1224 connectedCallback() {
1225 if (this.bound) return;
1226 this.bound = true;
1227 const button = this.querySelector("button");
1228 if (!button) return;
1229 button.addEventListener("click", () => this.react(button));
1230 }
1231 async react(button) {
1232 if (this.busy || button.getAttribute("aria-pressed") === "true") return;
1233 const target = this.getAttribute("event"), author = this.getAttribute("pubkey"), kind = this.getAttribute("kind"), hint = socialRelay(this);
1234 if (!isHex64(target) || !isHex64(author)) return;
1235 this.busy = true; button.disabled = true;
1236 try {
1237 const tags = [["e", target, hint, author], ["p", author, hint]];
1238 if (/^\d+$/.test(kind || "")) tags.push(["k", kind]);
1239 if (kind === "30023" && this.getAttribute("address")) tags.push(["a", this.getAttribute("address"), hint, author]);
1240 const content = this.getAttribute("content") || "+";
1241 this.prepared ||= await tiny.signing.signEvent({kind: 7, created_at: unixNow(), tags, content});
1242 await socialSend(this.prepared);
1243 button.setAttribute("aria-pressed", "true");
1244 try { await tiny.navigate?.(location.href); } catch { button.textContent = content === "+" ? "Liked" : content; }
1245 } catch (error) {
1246 button.disabled = false;
1247 let output = this.querySelector("output");
1248 if (!output) { output = el("output"); output.setAttribute("role", "status"); this.append(output); }
1249 output.textContent = "Error: " + error.message;
1250 } finally { this.busy = false; }
1251 }
1252 }
1253
1254 // Social end.
1255 class PushToggle extends HTMLElement {
1256 connectedCallback() {
1257 if (this.bound) return;
1258 this.bound = true;
1259 this.innerHTML = '<p><button type="button">Enable on this device</button></p><form hidden>' + pushCategories.map(([name, label]) => '<label><input type="checkbox" name="' + name + '" checked> ' + label + '</label>').join("") + '</form><output role="status"></output>';
1260 this.button = this.querySelector("button");
1261 this.form = this.querySelector("form");
1262 this.output = this.querySelector("output");
1263 const saved = (localStorage.getItem("tiny.push.categories") || "").split(",").filter(Boolean);
1264 if (saved.length) this.form.querySelectorAll("input").forEach(input => { input.checked = saved.includes(input.name); });
1265 this.form.addEventListener("change", () => this.register().catch(err => this.say("error: " + err.message, true)));
1266 if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
1267 this.button.disabled = true;
1268 this.say("This browser does not support notifications.");
1269 return;
1270 }
1271 this.button.addEventListener("click", () => this.toggle().catch(err => this.say("error: " + err.message, true)));
1272 this.refresh().catch(() => {});
1273 }
1274 say(text, error) { this.output.textContent = text; if (error) this.output.dataset.error = ""; else delete this.output.dataset.error; }
1275 async subscription() { return (await navigator.serviceWorker.ready).pushManager.getSubscription(); }
1276 async refresh() {
1277 const current = await this.subscription();
1278 this.enabled = Boolean(current) && localStorage.getItem("tiny.push") === current.endpoint;
1279 this.button.textContent = this.enabled ? "Disable on this device" : "Enable on this device";
1280 this.form.hidden = !this.enabled;
1281 if (!this.enabled && Notification.permission === "denied") this.say("Notifications are blocked in the browser settings.");
1282 }
1283 categories() { return [...this.form.querySelectorAll("input")].filter(input => input.checked).map(input => input.name); }
1284 // register sends the subscription with the chosen categories; it runs on
1285 // enable and again whenever a category changes.
1286 async register(current) {
1287 current = current || (await this.subscription());
1288 if (!current) return;
1289 const categories = this.categories();
1290 await tiny.signedFetch("/push/subscribe", "POST", JSON.stringify({subscription: current.toJSON(), categories}), {contentType: "application/json"});
1291 localStorage.setItem("tiny.push", current.endpoint);
1292 localStorage.setItem("tiny.push.categories", categories.join(","));
1293 this.say(categories.length ? "Notifications are on for this device." : "No categories chosen; nothing will arrive.");
1294 }
1295 async toggle() {
1296 this.button.disabled = true;
1297 try {
1298 if (this.enabled) {
1299 const current = await this.subscription();
1300 if (current) {
1301 await tiny.signedFetch("/push/unsubscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"});
1302 await current.unsubscribe();
1303 }
1304 localStorage.removeItem("tiny.push");
1305 this.say("Notifications are off on this device.");
1306 } else {
1307 if ((await Notification.requestPermission()) !== "granted") throw Error("Permission was not granted.");
1308 const {key} = await (await fetch(tiny.localPath("/push/key"), {credentials: "same-origin"})).json();
1309 const registration = await navigator.serviceWorker.ready;
1310 const current = (await registration.pushManager.getSubscription()) || (await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: tiny.util.fromB64url(key)}));
1311 await this.register(current);
1312 }
1313 await this.refresh();
1314 } finally { this.button.disabled = false; }
1315 }
1316 }
1317
1318 class JsonView extends HTMLElement {
1319 connectedCallback() {
1320 if (this.rendered) return;
1321 const seed = this.querySelector(":scope > pre");
1322 if (!seed) return;
1323 try { this.render(JSON.parse(seed.textContent), seed); } catch { /* leave the raw text */ }
1324 }
1325
1326 set value(value) { this.render(value); }
1327
1328 render(value, seed) {
1329 this.rendered = true;
1330 const raw = seed || el("pre");
1331 raw.textContent = JSON.stringify(value, null, 2);
1332 const details = el("details");
1333 details.append(el("summary", "JSON"), raw);
1334 this.replaceChildren(this.node(value, 0), details);
1335 }
1336
1337 node(value, depth, key) {
1338 if (isPlain(value)) return this.scalar(value, key);
1339 if (depth >= 3) return el("code", JSON.stringify(value));
1340 if (Array.isArray(value)) {
1341 if (!value.length) return el("code", "[]");
1342 if (value.every(item => item && typeof item === "object" && !Array.isArray(item))) return this.table(value, depth);
1343 const items = el("ul");
1344 value.forEach(item => { const li = el("li"); li.append(this.node(item, depth + 1)); items.append(li); });
1345 return items;
1346 }
1347 const keys = Object.keys(value);
1348 if (!keys.length) return el("code", "{}");
1349 const dl = el("dl");
1350 keys.forEach(name => {
1351 const dd = el("dd");
1352 dd.append(this.node(value[name], depth + 1, name));
1353 dl.append(el("dt", name), dd);
1354 });
1355 return dl;
1356 }
1357
1358 table(rows, depth) {
1359 const columns = [];
1360 rows.forEach(row => Object.keys(row).forEach(name => { if (!columns.includes(name)) columns.push(name); }));
1361 const table = el("table");
1362 const head = el("tr");
1363 columns.forEach(name => { const th = el("th", name); th.setAttribute("scope", "col"); head.append(th); });
1364 const thead = el("thead");
1365 thead.append(head);
1366 const tbody = el("tbody");
1367 rows.forEach(row => {
1368 const tr = el("tr");
1369 columns.forEach(name => {
1370 const td = el("td");
1371 if (name in row) td.append(isPlain(row[name]) ? this.scalar(row[name], name) : el("code", JSON.stringify(row[name])));
1372 tr.append(td);
1373 });
1374 tbody.append(tr);
1375 });
1376 table.append(thead, tbody);
1377 const box = el("scroll-box");
1378 box.append(table);
1379 return box;
1380 }
1381
1382 scalar(value, key) {
1383 if (value === null || value === undefined) return el("code", "null");
1384 if (typeof value === "boolean") return el("code", String(value));
1385 if (typeof value === "number") {
1386 if (!isTimestamp(key, value)) return el("code", String(value));
1387 const time = el("time", new Date(value * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC"));
1388 time.dateTime = new Date(value * 1000).toISOString();
1389 return time;
1390 }
1391 if (isHex64(value)) {
1392 const node = el("nostr-key", value.slice(0, 12));
1393 node.setAttribute("hex", value);
1394 return node;
1395 }
1396 if (isURL(value)) {
1397 const link = el("a", value);
1398 link.href = value;
1399 link.rel = "noopener";
1400 return link;
1401 }
1402 if (value.length > 160 || value.includes("\n")) return el("pre", value);
1403 return document.createTextNode(value);
1404 }
1405 }
1406
1407 // RelayLists reads the signed-in key's relay lists from this relay, says
1408 // whether this relay is on each, and republishes a list with this relay
1409 // added. Lists are replaceable events, so the new one carries every
1410 // existing entry plus this relay. Each publish goes to this relay and to
1411 // the relays already on the list so other clients find the update.
1412 class RelayLists extends HTMLElement {
1413 connectedCallback() {
1414 if (this.bound) return;
1415 this.bound = true;
1416 this.output = this.querySelector("output");
1417 this.addEventListener("click", event => {
1418 const button = event.target.closest("button[name]");
1419 const row = button?.closest("tr[data-kind]");
1420 if (!button || !row) return;
1421 button.disabled = true;
1422 const action = button.name === "add" ? this.add(row) : this.check(row);
1423 action.catch(err => this.say(row, "error: " + err.message)).finally(() => { button.disabled = false; });
1424 });
1425 }
1426
1427 say(row, text) {
1428 let note = row.querySelector("output");
1429 if (!note) { note = el("output"); row.lastElementChild.append(" ", note); }
1430 note.textContent = text;
1431 }
1432
1433 value(row) { return row.dataset.value === "server" ? this.getAttribute("server") : this.getAttribute("relay"); }
1434
1435 normal(value) { return String(value || "").trim().replace(/\/+$/, "").toLowerCase(); }
1436
1437 // socket opens one websocket, runs fn with send and a message queue, then closes.
1438 socket(url, fn, timeout = 8000) {
1439 return new Promise((resolve, reject) => {
1440 let ws;
1441 try { ws = new WebSocket(url); } catch (err) { reject(err); return; }
1442 const timer = setTimeout(() => { ws.close(); reject(Error("timed out talking to " + url)); }, timeout);
1443 const done = (value, err) => { clearTimeout(timer); ws.close(); err ? reject(err) : resolve(value); };
1444 ws.addEventListener("error", () => done(null, Error("could not reach " + url)));
1445 ws.addEventListener("open", () => fn(message => ws.send(JSON.stringify(message)), handler => ws.addEventListener("message", e => handler(JSON.parse(e.data))), done));
1446 });
1447 }
1448
1449 current(row) {
1450 const kind = Number(row.dataset.kind);
1451 return this.socket(this.getAttribute("relay"), (send, onMessage, done) => {
1452 let latest = null;
1453 onMessage(message => {
1454 if (message[0] === "EVENT" && message[2]?.kind === kind && (!latest || message[2].created_at > latest.created_at)) latest = message[2];
1455 if (message[0] === "EOSE") done(latest);
1456 });
1457 send(["REQ", "lists", {kinds: [kind], authors: [this.getAttribute("pubkey")], limit: 1}]);
1458 });
1459 }
1460
1461 entries(event, tag) {
1462 return (event?.tags || []).filter(t => t[0] === tag).map(t => t[1]);
1463 }
1464
1465 async check(row) {
1466 this.say(row, "checking…");
1467 const event = await this.current(row);
1468 if (!event) { this.say(row, "no list published here yet"); return; }
1469 const listed = this.entries(event, row.dataset.tag).some(v => this.normal(v) === this.normal(this.value(row)));
1470 this.say(row, listed ? "listed" : "not listed, " + this.entries(event, row.dataset.tag).length + " other entries");
1471 }
1472
1473 async add(row) {
1474 this.say(row, "reading…");
1475 const existing = await this.current(row);
1476 const value = this.value(row);
1477 const tags = (existing?.tags || []).filter(t => t.length);
1478 if (!this.entries(existing, row.dataset.tag).some(v => this.normal(v) === this.normal(value))) tags.push([row.dataset.tag, value]);
1479 const event = await tiny.signing.signEvent({kind: Number(row.dataset.kind), created_at: Math.floor(Date.now() / 1000), content: existing?.content || "", tags});
1480 const targets = [this.getAttribute("relay")];
1481 for (const t of tags) if ((t[0] === "r" || t[0] === "relay") && /^wss?:\/\//i.test(t[1]) && !targets.includes(t[1])) targets.push(t[1]);
1482 let accepted = 0;
1483 const failures = [];
1484 for (const url of targets) {
1485 try {
1486 await this.socket(url, (send, onMessage, done) => {
1487 onMessage(message => { if (message[0] === "OK" && message[1] === event.id) message[2] ? done(true) : done(null, Error(message[3] || "rejected")); });
1488 send(["EVENT", event]);
1489 }, 6000);
1490 accepted++;
1491 } catch (err) {
1492 failures.push(url.replace(/^wss?:\/\//, ""));
1493 }
1494 }
1495 if (!accepted) throw Error("no relay accepted the list: " + failures.join(", "));
1496 this.say(row, "listed, published to " + accepted + " of " + targets.length + " relays" + (failures.length ? " (failed: " + failures.join(", ") + ")" : ""));
1497 }
1498 }
1499
1500 // ConnectList edits the cards on the home page: the configured connections
1501 // in order, each with its audience, plus add from the catalog. Every change
1502 // saves the whole list with one signed call.
1503 class ConnectList extends HTMLElement {
1504 connectedCallback() {
1505 if (this.bound) return;
1506 this.bound = true;
1507 this.body = this.querySelector("tbody");
1508 this.select = this.querySelector("select[name=template]");
1509 this.output = this.querySelector("output");
1510 this.rows = [];
1511 this.catalog = [];
1512 this.addEventListener("click", event => {
1513 const button = event.target.closest("button[name]");
1514 if (!button) return;
1515 button.disabled = true;
1516 this.change(button.name, button.closest("tr")).catch(err => this.say("error: " + err.message, true)).finally(() => { button.disabled = false; });
1517 });
1518 this.addEventListener("change", event => {
1519 const select = event.target.closest("select[name=visibility]");
1520 if (select) this.change("visibility", select.closest("tr"), select.value).catch(err => this.say("error: " + err.message, true));
1521 });
1522 this.load().catch(err => this.say("error: " + err.message, true));
1523 }
1524
1525 // whenSigned runs once a signer is available; the bridge announces a
1526 // connected or resumed remote signer with the tiny:signer event.
1527 whenSigned(fn) {
1528 if (window.nostr?.signEvent) return fn();
1529 this.say("connect a signer to load this list");
1530 document.addEventListener("tiny:signer", () => fn(), {once: true});
1531 }
1532
1533 say(text, error) {
1534 this.output.textContent = text;
1535 if (error) this.output.dataset.error = "";
1536 else delete this.output.dataset.error;
1537 }
1538
1539 // load reads with the browser session first so showing the page never
1540 // asks the signer; a signed call is the fallback without a session.
1541 async load() {
1542 this.say("loading…");
1543 const response = await fetch(tiny.localPath("/connect.json?catalog=1"), {credentials: "same-origin", headers: {Accept: "application/json"}});
1544 if (response.status === 401) {
1545 this.whenSigned(() => this.loadSigned().catch(err => this.say("error: " + err.message, true)));
1546 return;
1547 }
1548 if (!response.ok) throw Error("Request failed (" + response.status + ")");
1549 const data = await response.json();
1550 this.rows = data.connections;
1551 this.catalog = data.catalog;
1552 this.render();
1553 this.say("");
1554 }
1555 async loadSigned() {
1556 [this.rows, this.catalog] = await Promise.all([rpc("listconnections"), rpc("listconnectiontemplates")]);
1557 this.render();
1558 this.say("");
1559 }
1560
1561 entry(name) { return this.catalog.find(c => c.name === name) || {name, title: name, visibility: "public", available: true}; }
1562
1563 render() {
1564 const audiences = ["public", "members", "owner"];
1565 const rows = this.rows.map((row, index) => {
1566 const name = row.template || row.name;
1567 const c = this.entry(name);
1568 const tr = el("tr");
1569 tr.dataset.index = index;
1570 tr.dataset.template = name;
1571 const app = el("td");
1572 if (c.app) app.append(el("b", c.app), " ");
1573 app.append(c.about || "");
1574 if (c.available === false) app.append(" ", el("small", "(feature off, hidden)"));
1575 const audience = el("select");
1576 audience.name = "visibility";
1577 const current = row.visibility === "auth" || row.visibility === "member" ? "members" : row.visibility || c.visibility;
1578 for (const value of audiences.includes(current) ? audiences : [current, ...audiences]) {
1579 const option = el("option", value);
1580 option.value = value;
1581 option.selected = value === current;
1582 audience.append(option);
1583 }
1584 const actions = el("td");
1585 for (const [label, action] of [["up", "up"], ["down", "down"], ["remove", "remove"]]) {
1586 const button = el("button", label);
1587 button.type = "button";
1588 button.name = action;
1589 if ((action === "up" && index === 0) || (action === "down" && index === this.rows.length - 1)) button.disabled = true;
1590 actions.append(button, " ");
1591 }
1592 const cell = el("td");
1593 cell.append(audience);
1594 tr.append(el("td", c.title || name), app, cell, actions);
1595 return tr;
1596 });
1597 if (!rows.length) {
1598 const empty = el("tr");
1599 const cell = el("td", "No cards. Add one from the catalog below.");
1600 cell.colSpan = 4;
1601 empty.append(cell);
1602 rows.push(empty);
1603 }
1604 this.body.replaceChildren(...rows);
1605 const used = new Set(this.rows.map(row => row.template || row.name));
1606 const options = [el("option", "choose one")];
1607 options[0].value = "";
1608 for (const c of this.catalog) {
1609 if (used.has(c.name)) continue;
1610 const option = el("option", c.title + (c.app ? " (" + c.app + ")" : "") + (c.available === false ? ", feature off" : ""));
1611 option.value = c.name;
1612 options.push(option);
1613 }
1614 this.select.replaceChildren(...options);
1615 }
1616
1617 async change(action, row, value) {
1618 const index = Number(row?.dataset.index);
1619 const next = this.rows.map(entry => ({...entry}));
1620 switch (action) {
1621 case "remove": next.splice(index, 1); break;
1622 case "up": if (index > 0) [next[index - 1], next[index]] = [next[index], next[index - 1]]; break;
1623 case "down": if (index < next.length - 1) [next[index + 1], next[index]] = [next[index], next[index + 1]]; break;
1624 case "visibility": next[index].visibility = value; break;
1625 case "add": {
1626 const name = this.select.value;
1627 if (!name) { this.say("choose a card to add"); return; }
1628 next.push({template: name, visibility: this.entry(name).visibility || "public"});
1629 break;
1630 }
1631 default: return;
1632 }
1633 this.say("saving…");
1634 this.rows = await rpc("setconnections", [next]);
1635 this.render();
1636 this.say("saved");
1637 }
1638 }
1639
1640 // ConnectCard copies a command or address, filling {input:name} from the
1641 // card's inputs, so a clone command carries the repository name typed.
1642 class ConnectCard extends HTMLElement {
1643 connectedCallback() {
1644 if (this.bound) return;
1645 this.bound = true;
1646 this.addEventListener("click", async event => {
1647 const button = event.target.closest("button[data-copy]");
1648 if (!button) return;
1649 const text = button.dataset.copy.replace(/\{input:([a-z]+)\}/g, (match, name) => this.querySelector(`input[name="${name}"]`)?.value.trim() || "<" + name + ">");
1650 const output = this.querySelector("output");
1651 try {
1652 await navigator.clipboard.writeText(text);
1653 if (output) output.textContent = "copied: " + text;
1654 } catch {
1655 if (output) output.textContent = text;
1656 }
1657 });
1658 }
1659 }
1660
1661 // ensureModules loads the feature bundle for any custom element on the
1662 // page whose definition has not arrived, such as after an in-place
1663 // navigation to the Files or Connect pages.
1664 const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"};
1665 // Opening Chat records the visit for the notification badge.
1666 const markInboxSeen = () => {
1667 if (!/\/(?:inbox|chat(?:\/dm\/[0-9a-f]{64})?)$/.test(location.pathname) || !document.getElementById("session-logout")) return;
1668 navigator.clearAppBadge?.().catch?.(() => {});
1669 fetch(tiny.localPath("/inbox/seen"), {method: "POST", credentials: "same-origin"}).catch(() => {});
1670 };
1671 document.addEventListener("tiny:navigation", markInboxSeen);
1672 markInboxSeen();
1673
1674 const ensureModules = () => {
1675 if (!tiny.require || typeof customElements.get !== "function") return;
1676 const wanted = new Set();
1677 for (const [tag, bundle] of Object.entries(bundlesFor)) {
1678 if (!customElements.get(tag) && document.querySelector(tag)) wanted.add(bundle);
1679 }
1680 wanted.forEach(bundle => tiny.require(bundle).catch(() => {}));
1681 };
1682 ensureModules();
1683
1684 customElements.define("rpc-form", RpcForm);
1685 customElements.define("view-artifact", ViewArtifact);
1686 customElements.define("view-form", ViewForm);
1687 customElements.define("connect-card", ConnectCard);
1688 customElements.define("connect-list", ConnectList);
1689 customElements.define("relay-lists", RelayLists);
1690 customElements.define("signed-form", SignedForm);
1691 customElements.define("file-mirror", FileMirror);
1692 customElements.define("file-tools", FileTools);
1693 customElements.define("publish-list", PublishList);
1694 customElements.define("nostr-compose", NostrCompose);
1695 customElements.define("agent-grant", AgentGrant);
1696 customElements.define("profile-form", ProfileForm);
1697 customElements.define("nostr-react", NostrReact);
1698 customElements.define("social-compose", SocialCompose);
1699 customElements.define("social-reaction", SocialReaction);
1700 customElements.define("approval-item", ApprovalItem);
1701 customElements.define("wiki-compose", WikiCompose);
1702 customElements.define("push-toggle", PushToggle);
1703 customElements.define("share-link", ShareLink);
1704 customElements.define("nostr-key", NostrKey);
1705 customElements.define("nostr-avatar", NostrAvatar);
1706 customElements.define("room-avatar", RoomAvatar);
1707 customElements.define("page-link", PageLink);
1708 customElements.define("json-view", JsonView);
1709
1710 // Fixi normally swaps a small target. Internal page links return the full
1711 // shell, so use one shared swap that keeps the shell's live nodes and makes
1712 // navigation behave like a normal document request. The links retain their
1713 // hrefs, which is the no-JavaScript fallback and keeps them bookmarkable.
1714 (() => {
1715 document.documentElement.setAttribute("data-js", "yes");
1716 const navTargets = ["#topbar", "#railbox", "#content", "#panel", "#footer"];
1717 const status = () => document.querySelector("#navigation-status");
1718 const sameDocument = href => {
1719 let url;
1720 try { url = new URL(href, location.href); } catch { return false; }
1721 if (url.origin !== location.origin || url.hash || url.protocol !== location.protocol) return false;
1722 if (!allowedRoute(routePath(url))) return false;
1723 return !/\.(?:json|xml|rss|atom|txt|svg|ico|webmanifest|js|css|png|jpg|jpeg|gif|webp|zip|tar)$/i.test(url.pathname);
1724 };
1725 const setBusy = busy => {
1726 document.querySelector("#content")?.setAttribute("aria-busy", busy ? "true" : "false");
1727 const node = status();
1728 if (node && (busy || node.textContent === "loading")) node.textContent = busy ? "loading" : "";
1729 };
1730 const routePath = url => {
1731 const root = (location.pathname.match(/^\/r\/[^/]+/) || [""])[0];
1732 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;
1733 return root ? url.pathname.slice(root.length) || "/" : url.pathname;
1734 };
1735 const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|account|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");
1736 let navigationSerial = 0, activeAbort;
1737 let renderedURL = location.href;
1738 const streams = new Set();
1739 const closeStreams = () => {
1740 streams.forEach(cfg => cfg.sse.close());
1741 streams.clear();
1742 };
1743 document.addEventListener("fx:sse:open", event => {
1744 const cfg = event.detail.cfg;
1745 if (!cfg.target.isConnected) { cfg.sse.close(); event.preventDefault(); return; }
1746 streams.add(cfg);
1747 });
1748 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));
1749 window.addEventListener("pagehide", closeStreams);
1750 const repairComponents = () => {
1751 document.querySelectorAll("rpc-form,view-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,social-compose,social-reaction,wiki-compose,room-compose,room-create,room-action,chat-decision").forEach(node => {
1752 if (node.form?.isConnected) return;
1753 node.form = null;
1754 node.output = null;
1755 node.connectedCallback();
1756 if (node.result && !node.result.isConnected) node.result = null;
1757 });
1758 document.querySelectorAll("json-view").forEach(node => {
1759 if (!node.rendered || !node.querySelector(":scope > pre")) return;
1760 node.rendered = false;
1761 node.connectedCallback();
1762 });
1763 };
1764 const releaseNavigation = () => {
1765 // Morphing preserves listeners even when the new link has no Fixi
1766 // attributes, as with a thread's back link to a message fragment.
1767 document.querySelectorAll("[data-fixi-nav]").forEach(node => {
1768 const handler = node.__fixi;
1769 if (!handler) return;
1770 node.removeEventListener(handler.evt, handler);
1771 delete node.__fixi;
1772 });
1773 };
1774 const swapShell = (text, url, push) => {
1775 if (url.__tinyNavigationSerial && url.__tinyNavigationSerial !== navigationSerial) return false;
1776 const routeChanged = renderedURL !== url.href;
1777 const parsed = new DOMParser().parseFromString(text, "text/html");
1778 const incoming = new Map(navTargets.map(selector => [selector, parsed.querySelector(selector)]));
1779 if ([...incoming.values()].some(node => !node)) return false;
1780 const focusID = document.activeElement?.id;
1781 closeStreams();
1782 releaseNavigation();
1783 navTargets.forEach(selector => morph(document.querySelector(selector), incoming.get(selector).outerHTML));
1784 repairComponents();
1785 document.title = parsed.title || document.title;
1786 if (push) history.pushState({}, "", url.href);
1787 renderedURL = url.href;
1788 if (routeChanged) {
1789 document.getElementById("nav-menu")?.removeAttribute("open");
1790 document.getElementById("context-menu")?.removeAttribute("open");
1791 const content = document.querySelector("#content");
1792 if (content) content.scrollTop = 0;
1793 if (typeof window.scrollTo === "function") window.scrollTo(0, 0);
1794 content?.focus({preventScroll: true});
1795 } else {
1796 const focus = focusID && document.getElementById(focusID);
1797 if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true});
1798 else document.querySelector("#content")?.focus({preventScroll: true});
1799 }
1800 document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}}));
1801 ensureModules();
1802 decorate();
1803 decorateForms();
1804 document.dispatchEvent(new CustomEvent("fx:process", {detail: {url: url.href}, bubbles: false}));
1805 return true;
1806 };
1807 const load = async (url, push) => {
1808 const serial = ++navigationSerial, controller = new AbortController();
1809 activeAbort?.();
1810 activeAbort = () => controller.abort();
1811 url.__tinyNavigationSerial = serial;
1812 setBusy(true);
1813 try {
1814 const response = await fetch(url.href, {headers: {"FX-Request": "true", Accept: "text/html"}, credentials: "same-origin", signal: controller.signal});
1815 if (!response.ok) throw Error("Navigation failed (" + response.status + ")");
1816 if (!swapShell(await response.text(), url, push)) throw Error("Page response did not contain the UI shell");
1817 } catch (error) {
1818 const node = status();
1819 if (serial === navigationSerial && node) node.textContent = "Unable to load page: " + error.message;
1820 throw error;
1821 } finally { if (serial === navigationSerial) setBusy(false); }
1822 };
1823 // navigate re-requests a page in place; components use it after a
1824 // publish. With push set it opens a new address as a history entry.
1825 tiny.navigate = (href, push = false) => load(new URL(href, location.href), push);
1826 document.addEventListener("fx:config", event => {
1827 const cfg = event.detail.cfg, elt = cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]");
1828 if (!elt?.matches?.("a[data-fixi-nav], form[data-fixi-nav]")) return;
1829 if ((cfg.trigger.type === "click" && cfg.trigger.button !== 0) || cfg.trigger.metaKey || cfg.trigger.ctrlKey || cfg.trigger.shiftKey || cfg.trigger.altKey) { cfg.drop = 1; cfg.preventTrigger = false; return; }
1830 const serial = ++navigationSerial;
1831 activeAbort?.();
1832 activeAbort = cfg.abort;
1833 cfg.__tinyNavigationSerial = serial;
1834 cfg.target = document.querySelector("#content");
1835 cfg.transition = false;
1836 cfg.swap = async current => {
1837 if (serial !== navigationSerial) return;
1838 const url = new URL(cfg.action, location.href);
1839 url.__tinyNavigationSerial = serial;
1840 if (!cfg.response.ok || !swapShell(cfg.text, url, true)) {
1841 const node = status();
1842 if (node) node.textContent = "Unable to load page";
1843 }
1844 };
1845 });
1846 document.addEventListener("fx:before", event => {
1847 if (event.detail.cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]")) setBusy(true);
1848 });
1849 document.addEventListener("fx:finally", event => {
1850 if (event.detail.cfg.__tinyNavigationSerial === navigationSerial) setBusy(false);
1851 });
1852 document.addEventListener("fx:after", event => {
1853 const cfg = event.detail.cfg;
1854 if (cfg.__tinyNavigationSerial || cfg.response.ok) return;
1855 event.preventDefault();
1856 const node = status();
1857 if (node) node.textContent = cfg.response.status === 401 ? "Sign in again to load this preview." : "Unable to load preview (" + cfg.response.status + ").";
1858 });
1859 document.addEventListener("fx:error", event => {
1860 const cfg = event.detail.cfg;
1861 if (cfg.__tinyNavigationSerial && cfg.__tinyNavigationSerial !== navigationSerial) return;
1862 const node = status();
1863 if (node) node.textContent = "Unable to load page: " + event.detail.error;
1864 });
1865 const decorate = () => document.querySelectorAll("a[href]").forEach(link => {
1866 if (link.hasAttribute("fx-action") || link.closest("[fx-ignore]") || link.target || link.hasAttribute("download") || !sameDocument(link.href)) return;
1867 link.setAttribute("data-fixi-nav", "");
1868 link.setAttribute("fx-action", link.href);
1869 link.setAttribute("fx-method", "get");
1870 link.setAttribute("fx-trigger", "click");
1871 });
1872 const decorateForms = () => document.querySelectorAll("form[method=get][action]").forEach(form => {
1873 if (form.hasAttribute("fx-action") || !sameDocument(form.action)) return;
1874 form.setAttribute("data-fixi-nav", "");
1875 form.setAttribute("fx-action", form.action);
1876 form.setAttribute("fx-method", "get");
1877 form.setAttribute("fx-trigger", "submit");
1878 });
1879 const prepare = () => {
1880 decorate();
1881 decorateForms();
1882 // Fixi's DOMContentLoaded handler runs before this footer script. Ask
1883 // it to initialize the attributes added after the initial scan.
1884 document.dispatchEvent(new CustomEvent("fx:process"));
1885 };
1886 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", prepare, {once: true});
1887 else prepare();
1888 window.addEventListener("popstate", () => load(new URL(location.href), false).catch(() => {}));
1889 })();
1890})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs