1// Custom elements for the plain HTML pages. Every element renders in the2// light DOM with document APIs, takes plain lowercase attributes, and carries3// 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; the7// 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 agent11// 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 configured14// 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 raw21// 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 native25// 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, decrypts32// 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 and35// 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 through37// 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 author40// publishes a fork of the version in view; a "propose" button also signs41// 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 wiki45// merge request accepted or rejected this way. The prompt method asks46// for a visible confirmation first, which the answer link from a47// notification uses.48// <approval-item id="approval-…">49// One request on the Approvals page. When the page opens with the item's50// 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 only55// 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";6061 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";7273 // FormElement wraps light DOM children in a real <form> so labels, Enter74 // 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 }101102 busy(on) {103 this.form.querySelectorAll("button").forEach(button => { button.disabled = on; });104 }105106 report(text, error) {107 this.output.textContent = text;108 if (error) this.output.dataset.error = "";109 else delete this.output.dataset.error;110 }111 }112113 // Feature modules use this narrow contract instead of reaching into the114 // generic component implementation.115 tiny.ui = Object.freeze({FormElement, el, isHex64});116117 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 };132133 // 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 body143 });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 };150151 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 }161162 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 }181182 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 }190191 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 }200201 // ViewForm adds a custom view: it composes the addcustomview object from202 // its fields and shows the returned secret once, since the relay never203 // 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 }219220 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 }241242 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 }267268 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 }285286 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 }292293 const {fromB64url} = tiny.util;294295 // 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 share297 // 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 }303304 connectedCallback() {305 if (this.bound) return;306 this.bound = true;307 // The encryption and messaging modules load on demand; the controls308 // 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 }321322 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; }345346 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 }372373 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 }408409 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 }429430 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 };445446 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 }465466 // NostrCompose signs NIP-34 issue, pull request and conversation events.467 // The relay receives the signed event directly, so the same control works468 // 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 }478479 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 new534 // event without a full reload.535 await tiny.navigate?.(globalThis.location?.href);536 }537 }538539 // ProfileForm publishes the signer's kind 0 profile: the fields shown here540 // are merged over the profile the relay already holds, so a field another541 // client set is kept. The event goes to this relay first, then to every542 // 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 }558559 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 }568569 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 }593594 // forget drops the cached name so nostr-name shows the new one on the595 // 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 }605606 // AgentGrant signs a kind 30392 agent grant: the agent's key, name, scope607 // and expiry, signed by the owner or a moderator. The agent key is made608 // here and its nsec shown once, or pasted as a public key. The secret never609 // 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 }620621 // tags builds the grant tags from the field values; it throws on any622 // 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> and655 // 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 }679680 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 }705706 // reveal shows the generated secret once. The page is not reloaded so707 // 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 }734735 class NostrKey extends HTMLElement {736 static get observedAttributes() { return ["hex"]; }737738 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 }750751 attributeChangedCallback() { this.update(); }752753 update() {754 const hex = this.getAttribute("hex") || "";755 this.title = hex;756 if (!this.textContent.trim()) this.textContent = hex.slice(0, 12);757 }758 }759760 class NostrAvatar extends HTMLElement {761 connectedCallback() {762 this.render();763 }764765 attributeChangedCallback() { this.render(); }766767 static get observedAttributes() { return ["pubkey"]; }768769 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 }816817 class RoomAvatar extends HTMLElement {818 static get observedAttributes() { return ["picture", "fallback"]; }819820 connectedCallback() { this.render(); }821822 attributeChangedCallback() { this.render(); }823824 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 }860861 restore(serial) {862 if (serial !== this.serial) return;863 this.render(true);864 }865 }866867 // PageLink is the crumb that names the current page. A tap copies the868 // page's public address, the one the server put in url, and shows869 // "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 }884885 // JsonView renders any JSON value. Arrays of objects become tables, objects886 // become definition lists, scalar arrays become lists, and deep nesting887 // 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, or889 // the url and title attributes when set. It stays empty where the browser890 // 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 }905906 // publishSigned asks the signer for a signature, checks that what came907 // 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);913914 // NostrReact signs a NIP-25 reaction to one event: "+" or "-" from the915 // pressed button. The reaction names the event with e and its author with916 // p, so the author's own subscription sees the answer. A request for a917 // decision is approved or denied this way and a wiki merge request918 // accepted or rejected. Nothing is signed without a press: the buttons919 // submit the form, and prompt() asks once more before signing on behalf of920 // 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 }934935 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 }950951 // prompt shows the confirmation for one answer; the signature waits for952 // 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 }977978 // WikiCompose publishes a NIP-54 article. When the signer is not the979 // 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. The981 // 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 }993994 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 }10311032 // ApprovalItem reacts to the query a notification action opens the page1033 // 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 }10531054 // PushToggle opts this browser into relay notifications. Nothing happens1055 // until the button is pressed: permission is requested then, and the1056 // 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", "website", "content", "tags", "media", "media-type", "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 socialAudioType = value => {1073 const path = (() => { try { return new URL(value).pathname.toLowerCase(); } catch { return ""; } })();1074 return path.endsWith(".mp3") ? "audio/mpeg" : path.endsWith(".m4a") || path.endsWith(".mp4") ? "audio/mp4" : path.endsWith(".aac") ? "audio/aac" : path.endsWith(".ogg") || path.endsWith(".oga") ? "audio/ogg" : path.endsWith(".opus") ? "audio/opus" : path.endsWith(".wav") ? "audio/wav" : path.endsWith(".flac") ? "audio/flac" : "";1075 };1076 const socialRelay = node => {1077 const value = node.getAttribute("relay") || "";1078 return /^wss?:\/\//.test(value) ? value : "";1079 };1080 const socialSend = async event => {1081 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});1082 let result = {};1083 try { result = await response.json(); } catch {}1084 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the post.");1085 return event;1086 };1087 class SocialCompose extends FormElement {1088 connectedCallback() {1089 if (this.form) return;1090 super.connectedCallback();1091 this.restoreDraft();1092 this.form.addEventListener("input", () => this.saveDraft());1093 this.querySelector("[data-preview]")?.addEventListener("click", () => this.preview().catch(error => this.report(error.message, true)));1094 }1095 saveDraft() {1096 const key = socialDraftKey(this);1097 if (!key) return;1098 try {1099 const values = Object.fromEntries(socialDraftFields.map(name => [name, this.form.elements[name]?.value || ""]));1100 if (!Object.values(values).some(Boolean)) { localStorage.removeItem(key); return; }1101 localStorage.setItem(key, JSON.stringify({values, articleID: this.articleID || "", publishedAt: this.publishedAt || 0}));1102 } catch {}1103 }1104 restoreDraft() {1105 const key = socialDraftKey(this);1106 if (!key) return;1107 try {1108 const draft = JSON.parse(localStorage.getItem(key) || "null");1109 if (!draft || typeof draft.values !== "object") return;1110 for (const name of socialDraftFields) {1111 const input = this.form.elements[name];1112 if (input && !input.value && typeof draft.values[name] === "string") input.value = draft.values[name];1113 }1114 if (typeof draft.articleID === "string" && /^[a-z0-9-]{1,100}$/.test(draft.articleID)) this.articleID = draft.articleID;1115 if (Number.isSafeInteger(draft.publishedAt) && draft.publishedAt > 0) this.publishedAt = draft.publishedAt;1116 } catch {}1117 }1118 clearDraft() { try { const key = socialDraftKey(this); if (key) localStorage.removeItem(key); } catch {} }1119 kind() {1120 const mode = this.getAttribute("mode") || "note";1121 if (mode === "article") return 30023;1122 if (mode === "note") return 1;1123 if (mode === "podcast") return 54;1124 if (mode === "podcast-show") return 10154;1125 if (mode !== "comment") throw Error("Choose a valid post format.");1126 const parent = this.getAttribute("target-kind") || "1";1127 if (parent === "1") return 1;1128 if (["54", "30023", "1111"].includes(parent)) return 1111;1129 throw Error("This post does not support replies here.");1130 }1131 replyTags(kind) {1132 const target = this.getAttribute("target") || "", author = this.getAttribute("target-pubkey") || "";1133 const root = this.getAttribute("root") || target, rootAuthor = this.getAttribute("root-pubkey") || (root === target ? author : "");1134 const hint = socialRelay(this);1135 if (![target, author, root].every(isHex64)) throw Error("The reply address is incomplete. Reload the conversation.");1136 if (kind === 1) {1137 const tags = [["e", root, hint, "root", ...(isHex64(rootAuthor) ? [rootAuthor] : [])]];1138 if (target !== root) tags.push(["e", target, hint, "reply", author]);1139 const participants = new Set([...(isHex64(rootAuthor) ? [rootAuthor] : []), author]);1140 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 {}1141 for (const pubkey of participants) tags.push(["p", pubkey, hint]);1142 return tags;1143 }1144 const address = this.getAttribute("address") || "";1145 const parentKind = this.getAttribute("target-kind") || "30023";1146 const rootKind = this.getAttribute("root-kind") || parentKind;1147 if (rootKind === "54") {1148 if (!isHex64(rootAuthor)) throw Error("The podcast address is incomplete. Reload the conversation.");1149 const tags = [["E", root, hint, rootAuthor], ["K", "54"], ["P", rootAuthor, hint]];1150 tags.push(["e", target, hint, author], ["k", parentKind], ["p", author, hint]);1151 return tags;1152 }1153 if (!isHex64(rootAuthor) || !address.startsWith("30023:" + rootAuthor + ":") || !["30023", "1111"].includes(parentKind)) throw Error("The article address is incomplete. Reload the conversation.");1154 const tags = [["A", address, hint], ["K", "30023"], ["P", rootAuthor, hint]];1155 if (parentKind === "30023") tags.push(["a", address, hint]);1156 tags.push(["e", target, hint, author], ["k", parentKind], ["p", author, hint]);1157 return tags;1158 }1159 tags(form, kind) {1160 const tags = this.getAttribute("mode") === "comment" ? this.replyTags(kind) : [];1161 if (kind === 30023 || kind === 54 || kind === 10154) {1162 if (!socialValue(form, "title")) throw Error(kind === 54 ? "Give your podcast episode a title." : kind === 10154 ? "Give your podcast show a title." : "Give your article a title.");1163 }1164 if (kind === 30023) {1165 this.articleID ||= crypto.randomUUID();1166 this.publishedAt ||= unixNow();1167 tags.push(["d", this.articleID], ["title", socialValue(form, "title")], ["published_at", String(this.publishedAt)]);1168 if (socialValue(form, "summary")) tags.push(["summary", socialValue(form, "summary")]);1169 const cover = socialHTTPURL(socialValue(form, "cover"));1170 if (cover) tags.push(["image", cover]);1171 }1172 if (kind === 54) {1173 tags.push(["title", socialValue(form, "title")]);1174 if (socialValue(form, "summary")) tags.push(["description", socialValue(form, "summary")]);1175 const cover = socialHTTPURL(socialValue(form, "cover"));1176 if (cover) tags.push(["image", cover]);1177 }1178 if (kind === 10154) {1179 let original = [];1180 try { const parsed = JSON.parse(this.getAttribute("show-tags") || "[]"); if (Array.isArray(parsed)) original = parsed; } catch {}1181 const preserved = original.filter(tag => Array.isArray(tag) && !["title", "description", "image", "summary", "website"].includes(tag[0]));1182 const existingWebsites = original.filter(tag => Array.isArray(tag) && tag[0] === "website");1183 tags.push(...preserved);1184 tags.push(["title", socialValue(form, "title")]);1185 if (socialValue(form, "summary")) tags.push(["description", socialValue(form, "summary")]);1186 const cover = socialHTTPURL(socialValue(form, "cover"));1187 if (cover) tags.push(["image", cover]);1188 const website = socialHTTPURL(socialValue(form, "website"));1189 if (website) tags.push(["website", website], ...existingWebsites.slice(1));1190 else tags.push(...existingWebsites);1191 }1192 const topics = new Set(socialValue(form, "tags").split(",").map(value => value.trim().replace(/^#/, "").toLowerCase()).filter(Boolean));1193 for (const topic of topics) tags.push(["t", topic]);1194 return tags;1195 }1196 async attachments(form) {1197 const files = [...(form.elements["media-file"]?.files || [])];1198 if (files.length > 10 || files.some(file => file.size > 256 * 1024 * 1024)) throw Error("Choose up to 10 attachments, each at most 256 MiB.");1199 const podcast = this.kind() === 54;1200 if (files.some(file => podcast ? !/^audio\//.test(file.type) : !/^(image|video|audio)\//.test(file.type))) throw Error(podcast ? "Choose an audio file." : "Choose an image, video or audio file.");1201 this.mediaUploads ||= new WeakMap();1202 const items = [];1203 for (const file of files) {1204 let item = this.mediaUploads.get(file);1205 if (!item) {1206 this.report("Uploading " + file.name + "…");1207 const bytes = new Uint8Array(await file.arrayBuffer()), hash = await tiny.sha256hex(bytes);1208 const path = "/upload?filename=" + encodeURIComponent(file.name) + "&purpose=file";1209 const response = await tiny.signedFetch(path, "PUT", bytes, {contentType: file.type});1210 const descriptor = await response.json();1211 if (!response.ok || descriptor.sha256 !== hash || descriptor.size !== bytes.length) throw Error("The attachment could not be verified. Try again.");1212 item = {url: socialHTTPURL(descriptor.url), type: file.type, hash, size: file.size, filename: file.name};1213 if (!item.url) throw Error("The attachment URL is missing.");1214 this.mediaUploads.set(file, item);1215 }1216 items.push({...item, alt: socialValue(form, "alt") || file.name});1217 }1218 const media = socialHTTPURL(socialValue(form, "media"));1219 if (media && !items.some(item => item.url === media)) items.push({url: media, type: socialValue(form, "media-type") || socialAudioType(media), alt: socialValue(form, "alt")});1220 return items;1221 }1222 async preview() {1223 const output = this.querySelector("[data-preview-output]");1224 if (!output) return;1225 const response = await tiny.signedFetch("/social/preview", "POST", JSON.stringify({content: this.form.elements.content?.value || "", kind: this.kind()}));1226 if (!response.ok) throw Error("Preview is unavailable. Your draft is still saved.");1227 const result = await response.json();1228 if (typeof result.html !== "string") throw Error("Preview is unavailable.");1229 output.innerHTML = result.html;1230 output.hidden = false;1231 }1232 async submit(form) {1233 if (this.sending) return;1234 this.sending = true;1235 try {1236 const kind = this.kind(), content = form.elements.content?.value || "";1237 const podcast = kind === 54, show = kind === 10154;1238 if (!show && !content.trim() && !(form.elements["media-file"]?.files?.length) && !socialValue(form, "media")) throw Error("Write something or attach media first.");1239 const tags = this.tags(form, kind);1240 this.saveDraft();1241 const attachments = await this.attachments(form);1242 if (podcast && (!attachments.length || attachments.some(item => item.type && !/^audio\//.test(item.type)))) throw Error("Add at least one audio track.");1243 const urls = podcast || show ? [] : attachments.map(item => item.url).filter(url => !content.includes(url));1244 const body = show ? "" : [content, ...urls].filter(Boolean).join("\n\n");1245 for (const item of attachments) {1246 const imeta = ["imeta", "url " + item.url, ...(item.type ? ["m " + item.type] : []), ...(item.hash ? ["x " + item.hash, "size " + item.size] : []), ...(item.alt ? ["alt " + item.alt] : [])];1247 if (podcast) tags.push(["audio", item.url, ...(item.type ? [item.type] : [])]);1248 tags.push(...(podcast || !show ? [imeta] : []));1249 }1250 const fingerprint = JSON.stringify({kind, tags, content: body});1251 if (this.prepared?.fingerprint !== fingerprint) this.prepared = {fingerprint, unsigned: {kind, created_at: unixNow(), tags, content: body}};1252 this.report("Signing and publishing…");1253 this.prepared.event ||= await tiny.signing.signEvent(this.prepared.unsigned);1254 const event = await socialSend(this.prepared.event);1255 this.prepared = null;1256 this.clearDraft();1257 this.articleID = ""; this.publishedAt = 0;1258 form.reset?.();1259 this.report("Published.");1260 try { await tiny.navigate?.(this.getAttribute("refresh") || location.href); } catch { this.report("Published. Refresh to see your post."); }1261 return event;1262 } finally { this.sending = false; }1263 }1264 }1265 class SocialReaction extends HTMLElement {1266 connectedCallback() {1267 if (this.bound) return;1268 this.bound = true;1269 const button = this.querySelector("button");1270 if (!button) return;1271 button.addEventListener("click", () => this.react(button));1272 }1273 async react(button) {1274 if (this.busy || button.getAttribute("aria-pressed") === "true") return;1275 const target = this.getAttribute("event"), author = this.getAttribute("pubkey"), kind = this.getAttribute("kind"), hint = socialRelay(this);1276 if (!isHex64(target) || !isHex64(author)) return;1277 this.busy = true; button.disabled = true;1278 try {1279 const tags = [["e", target, hint, author], ["p", author, hint]];1280 if (/^\d+$/.test(kind || "")) tags.push(["k", kind]);1281 if (kind === "30023" && this.getAttribute("address")) tags.push(["a", this.getAttribute("address"), hint, author]);1282 const content = this.getAttribute("content") || "+";1283 this.prepared ||= await tiny.signing.signEvent({kind: 7, created_at: unixNow(), tags, content});1284 await socialSend(this.prepared);1285 button.setAttribute("aria-pressed", "true");1286 try { await tiny.navigate?.(location.href); } catch { button.textContent = content === "+" ? "Liked" : content; }1287 } catch (error) {1288 button.disabled = false;1289 let output = this.querySelector("output");1290 if (!output) { output = el("output"); output.setAttribute("role", "status"); this.append(output); }1291 output.textContent = "Error: " + error.message;1292 } finally { this.busy = false; }1293 }1294 }12951296 // Social end.1297 class PushToggle extends HTMLElement {1298 connectedCallback() {1299 if (this.bound) return;1300 this.bound = true;1301 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>';1302 this.button = this.querySelector("button");1303 this.form = this.querySelector("form");1304 this.output = this.querySelector("output");1305 const saved = (localStorage.getItem("tiny.push.categories") || "").split(",").filter(Boolean);1306 if (saved.length) this.form.querySelectorAll("input").forEach(input => { input.checked = saved.includes(input.name); });1307 this.form.addEventListener("change", () => this.register().catch(err => this.say("error: " + err.message, true)));1308 if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {1309 this.button.disabled = true;1310 this.say("This browser does not support notifications.");1311 return;1312 }1313 this.button.addEventListener("click", () => this.toggle().catch(err => this.say("error: " + err.message, true)));1314 this.refresh().catch(() => {});1315 }1316 say(text, error) { this.output.textContent = text; if (error) this.output.dataset.error = ""; else delete this.output.dataset.error; }1317 async subscription() { return (await navigator.serviceWorker.ready).pushManager.getSubscription(); }1318 async refresh() {1319 const current = await this.subscription();1320 this.enabled = Boolean(current) && localStorage.getItem("tiny.push") === current.endpoint;1321 this.button.textContent = this.enabled ? "Disable on this device" : "Enable on this device";1322 this.form.hidden = !this.enabled;1323 if (!this.enabled && Notification.permission === "denied") this.say("Notifications are blocked in the browser settings.");1324 }1325 categories() { return [...this.form.querySelectorAll("input")].filter(input => input.checked).map(input => input.name); }1326 // register sends the subscription with the chosen categories; it runs on1327 // enable and again whenever a category changes.1328 async register(current) {1329 current = current || (await this.subscription());1330 if (!current) return;1331 const categories = this.categories();1332 await tiny.signedFetch("/push/subscribe", "POST", JSON.stringify({subscription: current.toJSON(), categories}), {contentType: "application/json"});1333 localStorage.setItem("tiny.push", current.endpoint);1334 localStorage.setItem("tiny.push.categories", categories.join(","));1335 this.say(categories.length ? "Notifications are on for this device." : "No categories chosen; nothing will arrive.");1336 }1337 async toggle() {1338 this.button.disabled = true;1339 try {1340 if (this.enabled) {1341 const current = await this.subscription();1342 if (current) {1343 await tiny.signedFetch("/push/unsubscribe", "POST", JSON.stringify(current.toJSON()), {contentType: "application/json"});1344 await current.unsubscribe();1345 }1346 localStorage.removeItem("tiny.push");1347 this.say("Notifications are off on this device.");1348 } else {1349 if ((await Notification.requestPermission()) !== "granted") throw Error("Permission was not granted.");1350 const {key} = await (await fetch(tiny.localPath("/push/key"), {credentials: "same-origin"})).json();1351 const registration = await navigator.serviceWorker.ready;1352 const current = (await registration.pushManager.getSubscription()) || (await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: tiny.util.fromB64url(key)}));1353 await this.register(current);1354 }1355 await this.refresh();1356 } finally { this.button.disabled = false; }1357 }1358 }13591360 class JsonView extends HTMLElement {1361 connectedCallback() {1362 if (this.rendered) return;1363 const seed = this.querySelector(":scope > pre");1364 if (!seed) return;1365 try { this.render(JSON.parse(seed.textContent), seed); } catch { /* leave the raw text */ }1366 }13671368 set value(value) { this.render(value); }13691370 render(value, seed) {1371 this.rendered = true;1372 const raw = seed || el("pre");1373 raw.textContent = JSON.stringify(value, null, 2);1374 const details = el("details");1375 details.append(el("summary", "JSON"), raw);1376 this.replaceChildren(this.node(value, 0), details);1377 }13781379 node(value, depth, key) {1380 if (isPlain(value)) return this.scalar(value, key);1381 if (depth >= 3) return el("code", JSON.stringify(value));1382 if (Array.isArray(value)) {1383 if (!value.length) return el("code", "[]");1384 if (value.every(item => item && typeof item === "object" && !Array.isArray(item))) return this.table(value, depth);1385 const items = el("ul");1386 value.forEach(item => { const li = el("li"); li.append(this.node(item, depth + 1)); items.append(li); });1387 return items;1388 }1389 const keys = Object.keys(value);1390 if (!keys.length) return el("code", "{}");1391 const dl = el("dl");1392 keys.forEach(name => {1393 const dd = el("dd");1394 dd.append(this.node(value[name], depth + 1, name));1395 dl.append(el("dt", name), dd);1396 });1397 return dl;1398 }13991400 table(rows, depth) {1401 const columns = [];1402 rows.forEach(row => Object.keys(row).forEach(name => { if (!columns.includes(name)) columns.push(name); }));1403 const table = el("table");1404 const head = el("tr");1405 columns.forEach(name => { const th = el("th", name); th.setAttribute("scope", "col"); head.append(th); });1406 const thead = el("thead");1407 thead.append(head);1408 const tbody = el("tbody");1409 rows.forEach(row => {1410 const tr = el("tr");1411 columns.forEach(name => {1412 const td = el("td");1413 if (name in row) td.append(isPlain(row[name]) ? this.scalar(row[name], name) : el("code", JSON.stringify(row[name])));1414 tr.append(td);1415 });1416 tbody.append(tr);1417 });1418 table.append(thead, tbody);1419 const box = el("scroll-box");1420 box.append(table);1421 return box;1422 }14231424 scalar(value, key) {1425 if (value === null || value === undefined) return el("code", "null");1426 if (typeof value === "boolean") return el("code", String(value));1427 if (typeof value === "number") {1428 if (!isTimestamp(key, value)) return el("code", String(value));1429 const time = el("time", new Date(value * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC"));1430 time.dateTime = new Date(value * 1000).toISOString();1431 return time;1432 }1433 if (isHex64(value)) {1434 const node = el("nostr-key", value.slice(0, 12));1435 node.setAttribute("hex", value);1436 return node;1437 }1438 if (isURL(value)) {1439 const link = el("a", value);1440 link.href = value;1441 link.rel = "noopener";1442 return link;1443 }1444 if (value.length > 160 || value.includes("\n")) return el("pre", value);1445 return document.createTextNode(value);1446 }1447 }14481449 // RelayLists reads the signed-in key's relay lists from this relay, says1450 // whether this relay is on each, and republishes a list with this relay1451 // added. Lists are replaceable events, so the new one carries every1452 // existing entry plus this relay. Each publish goes to this relay and to1453 // the relays already on the list so other clients find the update.1454 class RelayLists extends HTMLElement {1455 connectedCallback() {1456 if (this.bound) return;1457 this.bound = true;1458 this.output = this.querySelector("output");1459 this.addEventListener("click", event => {1460 const button = event.target.closest("button[name]");1461 const row = button?.closest("tr[data-kind]");1462 if (!button || !row) return;1463 button.disabled = true;1464 const action = button.name === "add" ? this.add(row) : this.check(row);1465 action.catch(err => this.say(row, "error: " + err.message)).finally(() => { button.disabled = false; });1466 });1467 }14681469 say(row, text) {1470 let note = row.querySelector("output");1471 if (!note) { note = el("output"); row.lastElementChild.append(" ", note); }1472 note.textContent = text;1473 }14741475 value(row) { return row.dataset.value === "server" ? this.getAttribute("server") : this.getAttribute("relay"); }14761477 normal(value) { return String(value || "").trim().replace(/\/+$/, "").toLowerCase(); }14781479 // socket opens one websocket, runs fn with send and a message queue, then closes.1480 socket(url, fn, timeout = 8000) {1481 return new Promise((resolve, reject) => {1482 let ws;1483 try { ws = new WebSocket(url); } catch (err) { reject(err); return; }1484 const timer = setTimeout(() => { ws.close(); reject(Error("timed out talking to " + url)); }, timeout);1485 const done = (value, err) => { clearTimeout(timer); ws.close(); err ? reject(err) : resolve(value); };1486 ws.addEventListener("error", () => done(null, Error("could not reach " + url)));1487 ws.addEventListener("open", () => fn(message => ws.send(JSON.stringify(message)), handler => ws.addEventListener("message", e => handler(JSON.parse(e.data))), done));1488 });1489 }14901491 current(row) {1492 const kind = Number(row.dataset.kind);1493 return this.socket(this.getAttribute("relay"), (send, onMessage, done) => {1494 let latest = null;1495 onMessage(message => {1496 if (message[0] === "EVENT" && message[2]?.kind === kind && (!latest || message[2].created_at > latest.created_at)) latest = message[2];1497 if (message[0] === "EOSE") done(latest);1498 });1499 send(["REQ", "lists", {kinds: [kind], authors: [this.getAttribute("pubkey")], limit: 1}]);1500 });1501 }15021503 entries(event, tag) {1504 return (event?.tags || []).filter(t => t[0] === tag).map(t => t[1]);1505 }15061507 async check(row) {1508 this.say(row, "checking…");1509 const event = await this.current(row);1510 if (!event) { this.say(row, "no list published here yet"); return; }1511 const listed = this.entries(event, row.dataset.tag).some(v => this.normal(v) === this.normal(this.value(row)));1512 this.say(row, listed ? "listed" : "not listed, " + this.entries(event, row.dataset.tag).length + " other entries");1513 }15141515 async add(row) {1516 this.say(row, "reading…");1517 const existing = await this.current(row);1518 const value = this.value(row);1519 const tags = (existing?.tags || []).filter(t => t.length);1520 if (!this.entries(existing, row.dataset.tag).some(v => this.normal(v) === this.normal(value))) tags.push([row.dataset.tag, value]);1521 const event = await tiny.signing.signEvent({kind: Number(row.dataset.kind), created_at: Math.floor(Date.now() / 1000), content: existing?.content || "", tags});1522 const targets = [this.getAttribute("relay")];1523 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]);1524 let accepted = 0;1525 const failures = [];1526 for (const url of targets) {1527 try {1528 await this.socket(url, (send, onMessage, done) => {1529 onMessage(message => { if (message[0] === "OK" && message[1] === event.id) message[2] ? done(true) : done(null, Error(message[3] || "rejected")); });1530 send(["EVENT", event]);1531 }, 6000);1532 accepted++;1533 } catch (err) {1534 failures.push(url.replace(/^wss?:\/\//, ""));1535 }1536 }1537 if (!accepted) throw Error("no relay accepted the list: " + failures.join(", "));1538 this.say(row, "listed, published to " + accepted + " of " + targets.length + " relays" + (failures.length ? " (failed: " + failures.join(", ") + ")" : ""));1539 }1540 }15411542 // ConnectList edits the cards on the home page: the configured connections1543 // in order, each with its audience, plus add from the catalog. Every change1544 // saves the whole list with one signed call.1545 class ConnectList extends HTMLElement {1546 connectedCallback() {1547 if (this.bound) return;1548 this.bound = true;1549 this.body = this.querySelector("tbody");1550 this.select = this.querySelector("select[name=template]");1551 this.output = this.querySelector("output");1552 this.rows = [];1553 this.catalog = [];1554 this.addEventListener("click", event => {1555 const button = event.target.closest("button[name]");1556 if (!button) return;1557 button.disabled = true;1558 this.change(button.name, button.closest("tr")).catch(err => this.say("error: " + err.message, true)).finally(() => { button.disabled = false; });1559 });1560 this.addEventListener("change", event => {1561 const select = event.target.closest("select[name=visibility]");1562 if (select) this.change("visibility", select.closest("tr"), select.value).catch(err => this.say("error: " + err.message, true));1563 });1564 this.load().catch(err => this.say("error: " + err.message, true));1565 }15661567 // whenSigned runs once a signer is available; the bridge announces a1568 // connected or resumed remote signer with the tiny:signer event.1569 whenSigned(fn) {1570 if (window.nostr?.signEvent) return fn();1571 this.say("connect a signer to load this list");1572 document.addEventListener("tiny:signer", () => fn(), {once: true});1573 }15741575 say(text, error) {1576 this.output.textContent = text;1577 if (error) this.output.dataset.error = "";1578 else delete this.output.dataset.error;1579 }15801581 // load reads with the browser session first so showing the page never1582 // asks the signer; a signed call is the fallback without a session.1583 async load() {1584 this.say("loading…");1585 const response = await fetch(tiny.localPath("/connect.json?catalog=1"), {credentials: "same-origin", headers: {Accept: "application/json"}});1586 if (response.status === 401) {1587 this.whenSigned(() => this.loadSigned().catch(err => this.say("error: " + err.message, true)));1588 return;1589 }1590 if (!response.ok) throw Error("Request failed (" + response.status + ")");1591 const data = await response.json();1592 this.rows = data.connections;1593 this.catalog = data.catalog;1594 this.render();1595 this.say("");1596 }1597 async loadSigned() {1598 [this.rows, this.catalog] = await Promise.all([rpc("listconnections"), rpc("listconnectiontemplates")]);1599 this.render();1600 this.say("");1601 }16021603 entry(name) { return this.catalog.find(c => c.name === name) || {name, title: name, visibility: "public", available: true}; }16041605 render() {1606 const audiences = ["public", "members", "owner"];1607 const rows = this.rows.map((row, index) => {1608 const name = row.template || row.name;1609 const c = this.entry(name);1610 const tr = el("tr");1611 tr.dataset.index = index;1612 tr.dataset.template = name;1613 const app = el("td");1614 if (c.app) app.append(el("b", c.app), " ");1615 app.append(c.about || "");1616 if (c.available === false) app.append(" ", el("small", "(feature off, hidden)"));1617 const audience = el("select");1618 audience.name = "visibility";1619 const current = row.visibility === "auth" || row.visibility === "member" ? "members" : row.visibility || c.visibility;1620 for (const value of audiences.includes(current) ? audiences : [current, ...audiences]) {1621 const option = el("option", value);1622 option.value = value;1623 option.selected = value === current;1624 audience.append(option);1625 }1626 const actions = el("td");1627 for (const [label, action] of [["up", "up"], ["down", "down"], ["remove", "remove"]]) {1628 const button = el("button", label);1629 button.type = "button";1630 button.name = action;1631 if ((action === "up" && index === 0) || (action === "down" && index === this.rows.length - 1)) button.disabled = true;1632 actions.append(button, " ");1633 }1634 const cell = el("td");1635 cell.append(audience);1636 tr.append(el("td", c.title || name), app, cell, actions);1637 return tr;1638 });1639 if (!rows.length) {1640 const empty = el("tr");1641 const cell = el("td", "No cards. Add one from the catalog below.");1642 cell.colSpan = 4;1643 empty.append(cell);1644 rows.push(empty);1645 }1646 this.body.replaceChildren(...rows);1647 const used = new Set(this.rows.map(row => row.template || row.name));1648 const options = [el("option", "choose one")];1649 options[0].value = "";1650 for (const c of this.catalog) {1651 if (used.has(c.name)) continue;1652 const option = el("option", c.title + (c.app ? " (" + c.app + ")" : "") + (c.available === false ? ", feature off" : ""));1653 option.value = c.name;1654 options.push(option);1655 }1656 this.select.replaceChildren(...options);1657 }16581659 async change(action, row, value) {1660 const index = Number(row?.dataset.index);1661 const next = this.rows.map(entry => ({...entry}));1662 switch (action) {1663 case "remove": next.splice(index, 1); break;1664 case "up": if (index > 0) [next[index - 1], next[index]] = [next[index], next[index - 1]]; break;1665 case "down": if (index < next.length - 1) [next[index + 1], next[index]] = [next[index], next[index + 1]]; break;1666 case "visibility": next[index].visibility = value; break;1667 case "add": {1668 const name = this.select.value;1669 if (!name) { this.say("choose a card to add"); return; }1670 next.push({template: name, visibility: this.entry(name).visibility || "public"});1671 break;1672 }1673 default: return;1674 }1675 this.say("saving…");1676 this.rows = await rpc("setconnections", [next]);1677 this.render();1678 this.say("saved");1679 }1680 }16811682 // ConnectCard copies a command or address, filling {input:name} from the1683 // card's inputs, so a clone command carries the repository name typed.1684 class ConnectCard extends HTMLElement {1685 connectedCallback() {1686 if (this.bound) return;1687 this.bound = true;1688 this.addEventListener("click", async event => {1689 const button = event.target.closest("button[data-copy]");1690 if (!button) return;1691 const text = button.dataset.copy.replace(/\{input:([a-z]+)\}/g, (match, name) => this.querySelector(`input[name="${name}"]`)?.value.trim() || "<" + name + ">");1692 const output = this.querySelector("output");1693 try {1694 await navigator.clipboard.writeText(text);1695 if (output) output.textContent = "copied: " + text;1696 } catch {1697 if (output) output.textContent = text;1698 }1699 });1700 }1701 }17021703 // ensureModules loads the feature bundle for any custom element on the1704 // page whose definition has not arrived, such as after an in-place1705 // navigation to the Files or Connect pages.1706 const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"};1707 // Opening Chat records the visit for the notification badge.1708 const markInboxSeen = () => {1709 if (!/\/(?:inbox|chat(?:\/dm\/[0-9a-f]{64})?)$/.test(location.pathname) || !document.getElementById("session-logout")) return;1710 navigator.clearAppBadge?.().catch?.(() => {});1711 fetch(tiny.localPath("/inbox/seen"), {method: "POST", credentials: "same-origin"}).catch(() => {});1712 };1713 document.addEventListener("tiny:navigation", markInboxSeen);1714 markInboxSeen();17151716 const ensureModules = () => {1717 if (!tiny.require || typeof customElements.get !== "function") return;1718 const wanted = new Set();1719 for (const [tag, bundle] of Object.entries(bundlesFor)) {1720 if (!customElements.get(tag) && document.querySelector(tag)) wanted.add(bundle);1721 }1722 wanted.forEach(bundle => tiny.require(bundle).catch(() => {}));1723 };1724 ensureModules();17251726 customElements.define("rpc-form", RpcForm);1727 customElements.define("view-artifact", ViewArtifact);1728 customElements.define("view-form", ViewForm);1729 customElements.define("connect-card", ConnectCard);1730 customElements.define("connect-list", ConnectList);1731 customElements.define("relay-lists", RelayLists);1732 customElements.define("signed-form", SignedForm);1733 customElements.define("file-mirror", FileMirror);1734 customElements.define("file-tools", FileTools);1735 customElements.define("publish-list", PublishList);1736 customElements.define("nostr-compose", NostrCompose);1737 customElements.define("agent-grant", AgentGrant);1738 customElements.define("profile-form", ProfileForm);1739 customElements.define("nostr-react", NostrReact);1740 customElements.define("social-compose", SocialCompose);1741 customElements.define("social-reaction", SocialReaction);1742 customElements.define("approval-item", ApprovalItem);1743 customElements.define("wiki-compose", WikiCompose);1744 customElements.define("push-toggle", PushToggle);1745 customElements.define("share-link", ShareLink);1746 customElements.define("nostr-key", NostrKey);1747 customElements.define("nostr-avatar", NostrAvatar);1748 customElements.define("room-avatar", RoomAvatar);1749 customElements.define("page-link", PageLink);1750 customElements.define("json-view", JsonView);17511752 // Fixi normally swaps a small target. Internal page links return the full1753 // shell, so use one shared swap that keeps the shell's live nodes and makes1754 // navigation behave like a normal document request. The links retain their1755 // hrefs, which is the no-JavaScript fallback and keeps them bookmarkable.1756 (() => {1757 document.documentElement.setAttribute("data-js", "yes");1758 const navTargets = ["#topbar", "#railbox", "#content", "#panel", "#footer"];1759 const status = () => document.querySelector("#navigation-status");1760 const sameDocument = href => {1761 let url;1762 try { url = new URL(href, location.href); } catch { return false; }1763 if (url.origin !== location.origin || url.hash || url.protocol !== location.protocol) return false;1764 if (!allowedRoute(routePath(url))) return false;1765 return !/\.(?:json|xml|rss|atom|txt|svg|ico|webmanifest|js|css|png|jpg|jpeg|gif|webp|zip|tar)$/i.test(url.pathname);1766 };1767 const setBusy = busy => {1768 document.querySelector("#content")?.setAttribute("aria-busy", busy ? "true" : "false");1769 const node = status();1770 if (node && (busy || node.textContent === "loading")) node.textContent = busy ? "loading" : "";1771 };1772 const routePath = url => {1773 const root = (location.pathname.match(/^\/r\/[^/]+/) || [""])[0];1774 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;1775 return root ? url.pathname.slice(root.length) || "/" : url.pathname;1776 };1777 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))?\/?|\/manage\/agents\/[^/]+\/?|\/(?:invite|e|a|wiki|social)\/.+|\/repos\/[^/]+\/[^/]+(?:\/.*)?|\/file\/[0-9a-f]{64}\/?|\/files\/(?:sites|rooms|storage)\/?|\/approvals\/[0-9a-f]{64}\/?|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");1778 let navigationSerial = 0, activeAbort;1779 let renderedURL = location.href;1780 const streams = new Set();1781 const closeStreams = () => {1782 streams.forEach(cfg => cfg.sse.close());1783 streams.clear();1784 };1785 document.addEventListener("fx:sse:open", event => {1786 const cfg = event.detail.cfg;1787 if (!cfg.target.isConnected) { cfg.sse.close(); event.preventDefault(); return; }1788 streams.add(cfg);1789 });1790 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));1791 window.addEventListener("pagehide", closeStreams);1792 const repairComponents = () => {1793 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 => {1794 if (node.form?.isConnected) return;1795 node.form = null;1796 node.output = null;1797 node.connectedCallback();1798 if (node.result && !node.result.isConnected) node.result = null;1799 });1800 document.querySelectorAll("json-view").forEach(node => {1801 if (!node.rendered || !node.querySelector(":scope > pre")) return;1802 node.rendered = false;1803 node.connectedCallback();1804 });1805 };1806 const releaseNavigation = () => {1807 // Morphing preserves listeners even when the new link has no Fixi1808 // attributes, as with a thread's back link to a message fragment.1809 document.querySelectorAll("[data-fixi-nav]").forEach(node => {1810 const handler = node.__fixi;1811 if (!handler) return;1812 node.removeEventListener(handler.evt, handler);1813 delete node.__fixi;1814 });1815 };1816 const swapShell = (text, url, push) => {1817 if (url.__tinyNavigationSerial && url.__tinyNavigationSerial !== navigationSerial) return false;1818 const routeChanged = renderedURL !== url.href;1819 const parsed = new DOMParser().parseFromString(text, "text/html");1820 const incoming = new Map(navTargets.map(selector => [selector, parsed.querySelector(selector)]));1821 if ([...incoming.values()].some(node => !node)) return false;1822 const focusID = document.activeElement?.id;1823 closeStreams();1824 releaseNavigation();1825 navTargets.forEach(selector => morph(document.querySelector(selector), incoming.get(selector).outerHTML));1826 repairComponents();1827 document.title = parsed.title || document.title;1828 if (push) history.pushState({}, "", url.href);1829 renderedURL = url.href;1830 if (routeChanged) {1831 document.getElementById("nav-menu")?.removeAttribute("open");1832 document.getElementById("context-menu")?.removeAttribute("open");1833 // A room timeline opens at its newest message; every other route at the top.1834 const content = document.querySelector("#content");1835 const timeline = document.getElementById("messages");1836 if (content) content.scrollTop = timeline ? content.scrollHeight : 0;1837 if (typeof window.scrollTo === "function") window.scrollTo(0, timeline ? document.documentElement.scrollHeight : 0);1838 content?.focus({preventScroll: true});1839 } else {1840 const focus = focusID && document.getElementById(focusID);1841 if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true});1842 else document.querySelector("#content")?.focus({preventScroll: true});1843 }1844 document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}}));1845 ensureModules();1846 decorate();1847 decorateForms();1848 document.dispatchEvent(new CustomEvent("fx:process", {detail: {url: url.href}, bubbles: false}));1849 return true;1850 };1851 const load = async (url, push) => {1852 const serial = ++navigationSerial, controller = new AbortController();1853 activeAbort?.();1854 activeAbort = () => controller.abort();1855 url.__tinyNavigationSerial = serial;1856 setBusy(true);1857 try {1858 const response = await fetch(url.href, {headers: {"FX-Request": "true", Accept: "text/html"}, credentials: "same-origin", signal: controller.signal});1859 if (!response.ok) throw Error("Navigation failed (" + response.status + ")");1860 if (!swapShell(await response.text(), url, push)) throw Error("Page response did not contain the UI shell");1861 } catch (error) {1862 const node = status();1863 if (serial === navigationSerial && node) node.textContent = "Unable to load page: " + error.message;1864 throw error;1865 } finally { if (serial === navigationSerial) setBusy(false); }1866 };1867 // navigate re-requests a page in place; components use it after a1868 // publish. With push set it opens a new address as a history entry.1869 tiny.navigate = (href, push = false) => load(new URL(href, location.href), push);1870 document.addEventListener("fx:config", event => {1871 const cfg = event.detail.cfg, elt = cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]");1872 if (!elt?.matches?.("a[data-fixi-nav], form[data-fixi-nav]")) return;1873 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; }1874 const serial = ++navigationSerial;1875 activeAbort?.();1876 activeAbort = cfg.abort;1877 cfg.__tinyNavigationSerial = serial;1878 cfg.target = document.querySelector("#content");1879 cfg.transition = false;1880 cfg.swap = async current => {1881 if (serial !== navigationSerial) return;1882 const url = new URL(cfg.action, location.href);1883 url.__tinyNavigationSerial = serial;1884 if (!cfg.response.ok || !swapShell(cfg.text, url, true)) {1885 const node = status();1886 if (node) node.textContent = "Unable to load page";1887 }1888 };1889 });1890 document.addEventListener("fx:before", event => {1891 if (event.detail.cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]")) setBusy(true);1892 });1893 document.addEventListener("fx:finally", event => {1894 if (event.detail.cfg.__tinyNavigationSerial === navigationSerial) setBusy(false);1895 });1896 document.addEventListener("fx:after", event => {1897 const cfg = event.detail.cfg;1898 if (cfg.__tinyNavigationSerial || cfg.response.ok) return;1899 event.preventDefault();1900 const node = status();1901 if (node) node.textContent = cfg.response.status === 401 ? "Sign in again to load this preview." : "Unable to load preview (" + cfg.response.status + ").";1902 });1903 document.addEventListener("fx:error", event => {1904 const cfg = event.detail.cfg;1905 if (cfg.__tinyNavigationSerial && cfg.__tinyNavigationSerial !== navigationSerial) return;1906 const node = status();1907 if (node) node.textContent = "Unable to load page: " + event.detail.error;1908 });1909 const decorate = () => document.querySelectorAll("a[href]").forEach(link => {1910 if (link.hasAttribute("fx-action") || link.closest("[fx-ignore]") || link.target || link.hasAttribute("download") || !sameDocument(link.href)) return;1911 link.setAttribute("data-fixi-nav", "");1912 link.setAttribute("fx-action", link.href);1913 link.setAttribute("fx-method", "get");1914 link.setAttribute("fx-trigger", "click");1915 });1916 const decorateForms = () => document.querySelectorAll("form[method=get][action]").forEach(form => {1917 if (form.hasAttribute("fx-action") || !sameDocument(form.action)) return;1918 form.setAttribute("data-fixi-nav", "");1919 form.setAttribute("fx-action", form.action);1920 form.setAttribute("fx-method", "get");1921 form.setAttribute("fx-trigger", "submit");1922 });1923 const prepare = () => {1924 decorate();1925 decorateForms();1926 // Fixi's DOMContentLoaded handler runs before this footer script. Ask1927 // it to initialize the attributes added after the initial scan.1928 document.dispatchEvent(new CustomEvent("fx:process"));1929 };1930 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", prepare, {once: true});1931 else prepare();1932 window.addEventListener("popstate", () => load(new URL(location.href), false).catch(() => {}));1933 })();1934})();