1(() => {2 "use strict";3 const tiny = globalThis.tiny;4 const PRESENCE = 20001, TYPING = 20002;5 const hex = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);6 const tag = (event, name) => (event.tags || []).find(item => item[0] === name)?.[1] || "";7 const seconds = () => Math.floor(Date.now() / 1000);8 const parse = (event, room, now = seconds()) => {9 if (!event || ![PRESENCE, TYPING].includes(event.kind) || !hex(event.pubkey)) return null;10 const scope = tag(event, "h");11 if (scope !== room && !(event.kind === PRESENCE && !scope)) return null;12 const ttl = event.kind === PRESENCE ? 180 : 5;13 if (!Number.isSafeInteger(event.created_at) || event.created_at > now + 5 || event.created_at + ttl <= now) return null;14 let status = String(event.content || "").trim();15 try { const data = JSON.parse(status); if (typeof data?.status === "string") status = data.status; if (typeof data?.typing === "boolean") status = data.typing ? "typing" : "stopped"; } catch {}16 if (event.kind === TYPING) status = ["stopped", "false", "idle", "offline"].includes(status.toLowerCase()) ? "stopped" : "typing";17 return {pubkey:event.pubkey, kind:event.kind, status:status.slice(0,128) || "online", at:event.created_at, expires:event.created_at+ttl};18 };19 const readPreference = async signal => {20 const response = await fetch(tiny.localPath("/chat/preferences"), {credentials:"same-origin",cache:"no-store",signal});21 if (!response.ok) throw Error("Chat preferences could not be loaded.");22 return (await response.json()).share_presence === true;23 };24 class ChatPreferences extends HTMLElement {25 connectedCallback() {26 this.actor = this.getAttribute("actor"); this.input = this.querySelector("input"); this.output = this.querySelector("output");27 if (!hex(this.actor) || !this.input) return;28 this.controller = new AbortController(); this.input.disabled = true;29 this.onChange = () => this.save();30 this.input.addEventListener("change",this.onChange);31 this.onLogout = () => { this.controller.abort(); this.input.disabled = true; };32 document.addEventListener("tiny:logout",this.onLogout);33 this.load();34 }35 disconnectedCallback() { this.controller?.abort(); this.input?.removeEventListener("change",this.onChange); document.removeEventListener("tiny:logout",this.onLogout); }36 async load() {37 try {38 this.saved = await readPreference(this.controller.signal);39 if (!this.isConnected || this.controller.signal.aborted) return;40 this.input.checked = this.saved; this.input.disabled = false;41 } catch(error) { if (error.name !== "AbortError") this.output.textContent = error.message; }42 }43 async save() {44 if (this.saving) return;45 this.saving = true; this.input.disabled = true;46 try {47 const signer = tiny.signer();48 if (!signer || await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) throw Error("Connect the signer for this account.");49 if (!this.isConnected || this.controller.signal.aborted) return;50 const response = await tiny.signedFetch("/chat/preferences","POST",JSON.stringify({share_presence:this.input.checked}),{contentType:"application/json",signal:this.controller.signal});51 this.saved = (await response.json()).share_presence === true;52 if (!this.isConnected || this.controller.signal.aborted) return;53 this.input.checked = this.saved; this.output.textContent = "Saved.";54 document.dispatchEvent(new CustomEvent("tiny:chat-preferences",{detail:{actor:this.actor,share_presence:this.saved}}));55 } catch(error) {56 this.input.checked = this.saved;57 if (error.name !== "AbortError") this.output.textContent = error.message;58 } finally { this.saving = false; this.input.disabled = this.controller.signal.aborted; }59 }60 }61 class ChatPresence extends HTMLElement {62 connectedCallback() {63 this.room = this.getAttribute("room"); this.actor = this.getAttribute("actor");64 if (!/^[a-z0-9_-]{1,64}$/.test(this.room || "")) return;65 this.values = new Map(); this.entries = new Map(); this.last = {}; this.pending = new Set(); this.generation = 0;66 this.status = this.querySelector("output"); this.controller = new AbortController();67 this.enabled = false; this.signerReadyVersion = 0; this.preferenceReload = false;68 this.onEvent = event => { if (event.detail?.room === this.room) this.receive(event.detail.event); };69 this.onInput = event => { if (event.target.matches('room-compose textarea')) { this.inputAt = seconds(); this.publish(TYPING, event.target.value ? "typing" : "stopped"); } };70 this.onVisibility = () => { this.generation++; this.values.clear(); this.paint(); if (document.hidden) { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); } else { this.loadPreference(); this.tick(); } };71 this.onIdentity = () => { this.enabled = false; this.generation++; this.values.clear(); this.paint(); };72 this.clearFailure = () => {73 this.failed = false;74 if (this.status?.textContent.includes("Presence could not be shared")) {75 this.status.textContent = "";76 this.paint();77 }78 };79 this.onSigner = () => { this.onIdentity(); this.signerReadyVersion++; this.clearFailure(); this.loadPreference(); };80 this.onSignerStatus = event => {81 if (event.detail?.status !== "ready") return;82 this.signerReadyVersion++; this.clearFailure();83 this.loadPreference();84 };85 this.onPreference = event => { if (event.detail?.actor === this.actor) this.applyPreference(event.detail.share_presence === true); };86 document.addEventListener("tiny:room-event", this.onEvent);87 document.addEventListener("input", this.onInput);88 document.addEventListener("visibilitychange", this.onVisibility);89 document.addEventListener("tiny:signer", this.onSigner);90 document.addEventListener("tiny:signer-status", this.onSignerStatus);91 document.addEventListener("tiny:logout", this.onIdentity);92 document.addEventListener("tiny:chat-preferences",this.onPreference);93 this.loadPreference();94 this.timer = setInterval(() => this.tick(),1000);95 }96 disconnectedCallback() {97 this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true);98 this.enabled = false; this.generation++; this.controller?.abort(); clearInterval(this.timer);99 document.removeEventListener("tiny:room-event",this.onEvent); document.removeEventListener("input",this.onInput);100 document.removeEventListener("visibilitychange",this.onVisibility); document.removeEventListener("tiny:signer",this.onSigner); document.removeEventListener("tiny:signer-status",this.onSignerStatus); document.removeEventListener("tiny:logout",this.onIdentity);101 document.removeEventListener("tiny:chat-preferences",this.onPreference); this.values?.clear();102 }103 applyPreference(enabled) {104 if (this.enabled === enabled) return;105 this.enabled = enabled; this.generation++;106 if (enabled) this.tick();107 else { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); }108 }109 async loadPreference() {110 if (!hex(this.actor) || this.failed || this.controller.signal.aborted) return;111 if (this.loadingPreference) { this.preferenceReload = true; return; }112 this.loadingPreference = true; const generation = this.generation;113 try {114 const enabled = await readPreference(this.controller.signal);115 if (this.isConnected && !this.controller.signal.aborted && generation === this.generation) this.applyPreference(enabled);116 } catch { this.applyPreference(false); }117 finally {118 this.loadingPreference = false; this.preferenceCheckedAt = seconds();119 if (this.preferenceReload && this.isConnected && !this.controller.signal.aborted) {120 this.preferenceReload = false;121 this.loadPreference();122 }123 }124 }125 receive(event) {126 const value = parse(event,this.room); if (!value || value.pubkey === this.actor) return;127 const id = value.kind+":"+value.pubkey, previous = this.values.get(id);128 if (previous && previous.at > value.at) return;129 this.values.set(id,value);130 while (this.values.size > 256) this.values.delete(this.values.keys().next().value);131 this.paint();132 }133 tick() {134 if (document.hidden) return;135 this.paint();136 if (seconds()-(this.preferenceCheckedAt || 0) >= 60) this.loadPreference();137 if (this.enabled) this.publish(PRESENCE,"online");138 }139 async publish(kind, content, leaving = false) {140 if ((!this.enabled && !leaving) || (!this.last[kind] && leaving) || (document.hidden && !leaving) || !hex(this.actor) || this.pending.has(kind)) return;141 const at = seconds(), interval = kind === PRESENCE ? 60 : 3;142 if (!leaving && at-(this.last[kind] || 0) < interval) return;143 const signer = tiny.signer?.(); if (!signer?.signEvent) return;144 const generation = this.generation, readyVersion = this.signerReadyVersion;145 this.pending.add(kind);146 try {147 if (await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) return;148 if (!leaving && (generation !== this.generation || !this.isConnected || !this.enabled || document.hidden)) return;149 await tiny.signing.publish({kind, created_at:at, tags:[["h",this.room]], content});150 this.last[kind] = at;151 } catch {152 const stale = generation !== this.generation || readyVersion !== this.signerReadyVersion || signer !== tiny.signer?.();153 if (stale || leaving || !this.isConnected) return;154 this.enabled = false; this.failed = true;155 if (this.status) this.status.textContent = "Presence could not be shared. Reconnect your signer to try again.";156 } finally { this.pending.delete(kind); }157 }158 paint() {159 if (!this.status) return;160 const now = seconds(); for (const [id,value] of this.values) if (value.expires <= now) this.values.delete(id);161 const values = [...this.values.values()].filter(v => v.status !== "offline" && v.status !== "stopped"), typers = new Set(values.filter(v => v.kind === TYPING).map(v => v.pubkey));162 const visible = values.filter(v => v.kind === TYPING || !typers.has(v.pubkey)).sort((a,b) => a.pubkey.localeCompare(b.pubkey)).slice(0,8);163 const active = new Set(visible.map(value => value.pubkey));164 for (const [key, entry] of this.entries) if (!active.has(key)) { entry.node.remove(); this.entries.delete(key); }165 visible.forEach((value,index) => {166 let entry = this.entries.get(value.pubkey);167 if (!entry) {168 const node = document.createElement("span"), name = document.createElement("nostr-name"), status = document.createElement("span");169 name.setAttribute("pubkey",value.pubkey); name.textContent = value.pubkey.slice(0,12);170 node.append(name,status); entry = {node,status}; this.entries.set(value.pubkey,entry);171 }172 const text = " "+(value.kind === TYPING ? "is typing…" : value.status);173 if (entry.status.textContent !== text) entry.status.textContent = text;174 if (this.status.children[index] !== entry.node) this.status.insertBefore(entry.node,this.status.children[index] || null);175 });176 }177 }178 tiny.roomPresence = Object.freeze({parse, PRESENCE,TYPING});179 customElements.define("chat-presence",ChatPresence);180 customElements.define("chat-preferences",ChatPreferences);181})();