| 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 | })(); |