tinyrelayfile
tinyrelay/tinyclient/chat-presence.js/

tinyclient/chat-presence.js raw
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})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs