| 1 | /* Direct conversations use verified NIP-17 rumors. Plaintext lives only in this page's memory. */ |
| 2 | (() => { |
| 3 | "use strict" ; |
| 4 | const tiny = globalThis.tiny; |
| 5 | const protocol = () => tiny.chat.protocol; |
| 6 | const hex = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value); |
| 7 | const node = (name, text) => { |
| 8 | const result = document.createElement(name); |
| 9 | if (text !== undefined) result.textContent = text; |
| 10 | return result; |
| 11 | }; |
| 12 | const key = value => { try { return protocol().normalizePubkey(String(value || "" ).trim()); } catch { return "" ; } }; |
| 13 | const participants = rumor => [...new Set([rumor.pubkey, ...rumor.tags.filter(tag => tag[0] === "p" && hex(tag[1])).map(tag => tag[1])])]; |
| 14 | const states = new Map(); |
| 15 | const listeners = new Set(); |
| 16 | let generation = 0; |
| 17 | const stateFor = actor => { |
| 18 | if (!states.has(actor)) states.set(actor, {actor, generation, events: new Map(), wraps: new Map(), cursor: "" , loaded: false, pending: null, version: 0}); |
| 19 | return states.get(actor); |
| 20 | }; |
| 21 | const current = value => value.generation === generation && states.get(value.actor) === value; |
| 22 | const changed = value => { if (current(value)) { value.version++; for (const host of listeners) if (host.actor === value.actor) host.render(); } }; |
| 23 | const clear = () => { |
| 24 | generation++; |
| 25 | for (const value of states.values()) { value.events.clear(); value.wraps.clear(); } |
| 26 | states.clear(); |
| 27 | for (const host of listeners) { host.renderVersion = "" ; host.replaceChildren(node("p" , "Connect your signer to open direct chats." )); } |
| 28 | }; |
| 29 | const activeSigner = async actor => { |
| 30 | const signer = tiny.signer?.(); |
| 31 | if (!signer || key(await signer.getPublicKey()) !== actor) throw Error("Connect the signer for this account to open direct chats." ); |
| 32 | return signer; |
| 33 | }; |
| 34 | const checkSession = async (value, signer) => { |
| 35 | if (!current(value) || tiny.signer() !== signer || key(await signer.getPublicKey()) !== value.actor) throw Error("The signer changed. Reopen this conversation." ); |
| 36 | if (!current(value)) throw Error("The session ended." ); |
| 37 | }; |
| 38 | const cacheEvents = async (value, rows, signer) => { |
| 39 | for (const packet of rows) { |
| 40 | if (!current(value)) return; |
| 41 | let pending = value.wraps.get(packet.id); |
| 42 | if (!pending) { |
| 43 | pending = protocol().decrypt(packet, {actor: value.actor, signer}); |
| 44 | value.wraps.set(packet.id, pending); |
| 45 | } |
| 46 | try { |
| 47 | const {rumor} = await pending; |
| 48 | await checkSession(value, signer); |
| 49 | if (value.events.has(rumor.id)) continue; |
| 50 | const members = participants(rumor); |
| 51 | if ([14, 15].includes(rumor.kind) && (members.length !== 2 || !members.includes(value.actor))) continue; |
| 52 | if (![5, 7, 14, 15].includes(rumor.kind)) continue; |
| 53 | value.events.set(rumor.id, rumor); |
| 54 | } catch (error) { |
| 55 | if (!current(value)) return; |
| 56 | // A signer refusal can be retried. Structurally invalid events stay cached. |
| 57 | if (/cancel|reject|denied|support|connect|unable to decrypt/i.test(error.message)) { |
| 58 | value.wraps.delete(packet.id); |
| 59 | throw error; |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | }; |
| 64 | const fetchPage = async (value, cursor, signer) => { |
| 65 | const response = await tiny.signedFetch("/chat/events" + (cursor ? "?cursor=" + encodeURIComponent(cursor) : "" ), "GET" , new Uint8Array()); |
| 66 | const page = await response.json(); |
| 67 | if (!Array.isArray(page?.events) || typeof page.next_cursor !== "string" ) throw Error("Could not read the chat history." ); |
| 68 | await checkSession(value, signer); |
| 69 | await cacheEvents(value, page.events, signer); |
| 70 | return page; |
| 71 | }; |
| 72 | const load = async (actor, older = false) => { |
| 73 | const value = stateFor(actor); |
| 74 | if (value.pending) return value.pending; |
| 75 | if (older && !value.cursor) return value; |
| 76 | value.pending = (async () => { |
| 77 | const signer = await activeSigner(actor); |
| 78 | await checkSession(value, signer); |
| 79 | const wasLoaded = value.loaded, count = value.events.size; |
| 80 | let page = await fetchPage(value, older ? value.cursor : "" , signer); |
| 81 | if (!current(value)) return value; |
| 82 | if (older || !wasLoaded) value.cursor = page.next_cursor; |
| 83 | // Gift wraps deliberately use older dates. Refresh the entire two-day |
| 84 | // overlap so a new message cannot hide behind the first page of history. |
| 85 | if (wasLoaded && !older) { |
| 86 | const cutoff = Math.floor(Date.now() / 1000) - 172860; |
| 87 | const seen = new Set(); |
| 88 | while (page.next_cursor && page.events.length && page.events.at(-1).created_at >= cutoff && !seen.has(page.next_cursor)) { |
| 89 | seen.add(page.next_cursor); |
| 90 | page = await fetchPage(value, page.next_cursor, signer); |
| 91 | if (!current(value)) return value; |
| 92 | } |
| 93 | } |
| 94 | value.loaded = true; |
| 95 | if (count !== value.events.size || !wasLoaded || older) changed(value); |
| 96 | return value; |
| 97 | })().finally(() => { value.pending = null; }); |
| 98 | return value.pending; |
| 99 | }; |
| 100 | const chronological = value => [...value.events.values()].sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id)); |
| 101 | const peerFor = (rumor, actor) => participants(rumor).find(member => member !== actor); |
| 102 | const deleted = (value, rumor) => { |
| 103 | if (value.deletedVersion !== value.events.size) { |
| 104 | value.deletedIDs = new Set(); |
| 105 | for (const deletion of value.events.values()) { |
| 106 | if (deletion.kind !== 5) continue; |
| 107 | for (const tag of deletion.tags) { |
| 108 | const target = tag[0] === "e" && value.events.get(tag[1]); |
| 109 | if (target && target.pubkey === deletion.pubkey) value.deletedIDs.add(target.id); |
| 110 | } |
| 111 | } |
| 112 | value.deletedVersion = value.events.size; |
| 113 | } |
| 114 | return value.deletedIDs.has(rumor.id); |
| 115 | }; |
| 116 | const messages = (value, peer) => chronological(value).filter(rumor => [14, 15].includes(rumor.kind) && !deleted(value, rumor) && (!peer || peerFor(rumor, value.actor) === peer)); |
| 117 | const profile = pubkey => { |
| 118 | const author = node("span" ); author.setAttribute("data-author" , "" ); |
| 119 | const avatar = node("nostr-avatar" ); avatar.setAttribute("pubkey" , pubkey); avatar.setAttribute("aria-hidden" , "true" ); |
| 120 | const name = node("nostr-name" , pubkey.slice(0, 8)); name.setAttribute("pubkey" , pubkey); |
| 121 | author.append(avatar, name); return author; |
| 122 | }; |
| 123 | const time = timestamp => { |
| 124 | const date = new Date(timestamp * 1000); |
| 125 | const result = node("time" , date.toLocaleString()); result.dateTime = date.toISOString(); return result; |
| 126 | }; |
| 127 | const textBody = content => { |
| 128 | const body = node("p" ); |
| 129 | if (tiny.rooms?.linkify) tiny.rooms.linkify(body, content); else body.textContent = content; |
| 130 | return body; |
| 131 | }; |
| 132 | const targetID = rumor => rumor.tags.filter(tag => ["e" , "q" ].includes(tag[0]) && hex(tag[1])).at(-1)?.[1]; |
| 133 | const reactions = (value, target) => { |
| 134 | if (value.reactionVersion !== value.events.size) { |
| 135 | const grouped = new Map(); |
| 136 | for (const rumor of value.events.values()) { |
| 137 | if (rumor.kind !== 7 || deleted(value, rumor)) continue; |
| 138 | const target = value.events.get(targetID(rumor)); |
| 139 | if (!target || ![14, 15].includes(target.kind)) continue; |
| 140 | const allowed = new Set(participants(target)); |
| 141 | if (!allowed.has(rumor.pubkey) || participants(rumor).some(pubkey => !allowed.has(pubkey))) continue; |
| 142 | const label = !rumor.content || rumor.content === "+" ? "♥" : rumor.content; |
| 143 | if (!grouped.has(target.id)) grouped.set(target.id, new Map()); |
| 144 | grouped.get(target.id).set(rumor.pubkey + ":" + label, label); |
| 145 | } |
| 146 | value.reactionIndex = new Map(); |
| 147 | for (const [id, unique] of grouped) { |
| 148 | const counts = new Map(); |
| 149 | for (const label of unique.values()) counts.set(label, (counts.get(label) || 0) + 1); |
| 150 | value.reactionIndex.set(id, counts); |
| 151 | } |
| 152 | value.reactionVersion = value.events.size; |
| 153 | } |
| 154 | return value.reactionIndex.get(target.id) || new Map(); |
| 155 | }; |
| 156 | const accepted = values => Array.isArray(values) ? values.some(value => value === true) : values === true; |
| 157 | const deliver = async (value, input, previous, report) => { |
| 158 | const signer = await activeSigner(value.actor); |
| 159 | await checkSession(value, signer); |
| 160 | const pending = previous || {built: await protocol().build(input, {signer}), delivered: [false, false]}; |
| 161 | await checkSession(value, signer); |
| 162 | if (pending.built.rumor.pubkey !== value.actor) throw Error("The signer changed." ); |
| 163 | if (!pending.relays) { |
| 164 | const lists = await Promise.all([protocol().queryRelayList(input.recipient), protocol().queryRelayList(value.actor)]); |
| 165 | pending.relays = lists.map((list, index) => protocol().relayList(list, index ? value.actor : input.recipient)); |
| 166 | } |
| 167 | await checkSession(value, signer); |
| 168 | // Retain the exact gift wraps before network publication, including failure. |
| 169 | report(pending); |
| 170 | for (let index = 0; index < 2; index++) { |
| 171 | if (pending.delivered[index]) continue; |
| 172 | const result = await protocol().publishEvent(pending.built.events[index].event, pending.relays[index], {signer}); |
| 173 | await checkSession(value, signer); |
| 174 | if (!accepted(result)) throw Error(index ? "Sent to the recipient's relay. Your history copy failed; press Send again to retry it." : "The recipient's relay did not accept the message. Press Send to retry." ); |
| 175 | pending.delivered[index] = true; |
| 176 | if (index === 0) { value.events.set(pending.built.rumor.id, pending.built.rumor); changed(value); } |
| 177 | } |
| 178 | return pending; |
| 179 | }; |
| 180 | class ConversationView extends HTMLElement { |
| 181 | connectedCallback() { |
| 182 | this.actor = key(this.getAttribute("actor" )); this.peer = key(this.getAttribute("peer" )); |
| 183 | this.renderVersion = "" ; listeners.add(this); this.refresh(); |
| 184 | this.timer = setInterval(() => { if (document.visibilityState !== "hidden" ) this.refresh(); }, 10000); |
| 185 | } |
| 186 | disconnectedCallback() { listeners.delete(this); clearInterval(this.timer); } |
| 187 | async refresh(older = false) { |
| 188 | if (!this.actor || !tiny.signer?.()) return; |
| 189 | const epoch = generation; |
| 190 | try { await load(this.actor, older); if (epoch === generation && this.isConnected) this.render(); } |
| 191 | catch (error) { if (epoch === generation && this.isConnected) { let status = this.querySelector(":scope > output" ); if (!status) { status = node("output" ); status.setAttribute("role" , "status" ); this.append(status); } status.textContent = error.message; } } |
| 192 | } |
| 193 | unchanged(value) { |
| 194 | const version = [value.version, value.cursor, this.peer].join(":" ); |
| 195 | if (this.renderVersion === version) return true; |
| 196 | this.renderVersion = version; return false; |
| 197 | } |
| 198 | older(value) { |
| 199 | if (!value.cursor) return; |
| 200 | const button = node("button" , "Load older conversations" ); button.type = "button" ; |
| 201 | button.addEventListener("click" , async () => { button.disabled = true; try { await this.refresh(true); } finally { button.disabled = false; } }); |
| 202 | this.append(button); |
| 203 | } |
| 204 | } |
| 205 | class DirectChats extends ConversationView { |
| 206 | render() { |
| 207 | const value = stateFor(this.actor); if (this.unchanged(value)) return; |
| 208 | const groups = new Map(); for (const rumor of messages(value)) groups.set(peerFor(rumor, this.actor), rumor); |
| 209 | this.replaceChildren(); |
| 210 | for (const [peer, latest] of [...groups].sort((a, b) => b[1].created_at - a[1].created_at || a[0].localeCompare(b[0]))) { |
| 211 | const link = node("a" ); link.href = tiny.localPath("/chat/dm/" + peer); |
| 212 | if (location.pathname.endsWith("/chat/dm/" + peer)) link.setAttribute("aria-current" , "page" ); |
| 213 | link.append(profile(peer), node("p" , latest.kind === 15 ? "Encrypted attachment" : latest.content.slice(0, 120)), time(latest.created_at)); this.append(link); |
| 214 | } |
| 215 | if (!groups.size) this.append(node("p" , "No direct conversations yet." )); |
| 216 | if (!this.hasAttribute("compact" )) this.older(value); |
| 217 | } |
| 218 | } |
| 219 | class DirectThread extends ConversationView { |
| 220 | render() { |
| 221 | const value = stateFor(this.actor); if (this.unchanged(value)) return; |
| 222 | const near = !this.childElementCount || this.scrollHeight - this.scrollTop - this.clientHeight < 100; |
| 223 | const top = this.scrollTop, height = this.scrollHeight; |
| 224 | const rows = messages(value, this.peer), existing = new Map([...this.querySelectorAll(":scope > article" )].map(article => [article.id, article])); |
| 225 | const result = []; |
| 226 | if (value.cursor) { |
| 227 | const button = node("button" , "Load older messages" ); button.type = "button" ; button.addEventListener("click" , () => this.refresh(true)); result.push(button); |
| 228 | } |
| 229 | for (const rumor of rows) { |
| 230 | let article = existing.get("chat-message-" + rumor.id); |
| 231 | if (!article) { |
| 232 | article = node("article" ); article.id = "chat-message-" + rumor.id; |
| 233 | if (rumor.pubkey === this.actor) article.dataset.own = "" ; |
| 234 | const header = node("header" ); header.append(profile(rumor.pubkey), time(rumor.created_at)); article.append(header); |
| 235 | const parent = targetID(rumor); |
| 236 | if (parent) { const quote = node("blockquote" ); quote.dataset.quote = parent; article.append(quote); } |
| 237 | if (rumor.kind === 15 && tiny.chat.files) article.append(tiny.chat.files.render(rumor)); else article.append(textBody(rumor.content)); |
| 238 | const footer = node("footer" ); |
| 239 | const reply = node("button" , "Reply" ); reply.type = "button" ; |
| 240 | reply.addEventListener("click" , () => document.querySelector(`direct-compose[peer="${this.peer}"]` )?.replyTo(rumor)); footer.append(reply); |
| 241 | const react = node("button" , "♥" ); react.type = "button" ; react.title = "Like this message" ; |
| 242 | react.addEventListener("click" , async () => { |
| 243 | react.disabled = true; |
| 244 | try { await deliver(value, {recipient: this.peer, reaction: {id: rumor.id, pubkey: rumor.pubkey, kind: rumor.kind, content: "+" }}, react.pending, pending => { react.pending = pending; }); react.pending = null; } |
| 245 | catch (error) { let status = footer.querySelector("output" ); if (!status) { status = node("output" ); footer.append(status); } status.textContent = error.message; } |
| 246 | finally { react.disabled = false; } |
| 247 | }); footer.append(react, node("span" )); article.append(footer); |
| 248 | } |
| 249 | const quote = article.querySelector("blockquote[data-quote]" ); |
| 250 | if (quote) { |
| 251 | const parent = value.events.get(quote.dataset.quote); |
| 252 | quote.textContent = parent && [14, 15].includes(parent.kind) && peerFor(parent, this.actor) === this.peer |
| 253 | ? deleted(value, parent) ? "Message deleted" : parent.content.slice(0, 220) |
| 254 | : "Reply to an earlier message" ; |
| 255 | } |
| 256 | const summary = article.querySelector("footer > span" ); summary.textContent = [...reactions(value, rumor)].map(([label, count]) => `${label} ${count}` ).join(" | " ); |
| 257 | result.push(article); |
| 258 | } |
| 259 | if (!rows.length) result.push(node("p" , "Start the conversation with a private message." )); |
| 260 | // Move existing message nodes instead of rebuilding media players. |
| 261 | let before = this.firstChild; |
| 262 | for (const child of result) { if (before === child) before = before.nextSibling; else this.insertBefore(child, before); } |
| 263 | while (before) { const next = before.nextSibling; before.remove(); before = next; } |
| 264 | if (near) this.scrollTop = this.scrollHeight; |
| 265 | else this.scrollTop = top + Math.max(0, this.scrollHeight - height); |
| 266 | } |
| 267 | } |
| 268 | class DirectCompose extends tiny.ui.FormElement { |
| 269 | connectedCallback() { |
| 270 | if (this.form) return; |
| 271 | super.connectedCallback(); |
| 272 | this.querySelector("textarea" )?.addEventListener("keydown" , event => { |
| 273 | if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); if (!this.submitting) this.form.requestSubmit(); } |
| 274 | }); |
| 275 | } |
| 276 | replyTo(rumor) { |
| 277 | this.reply = {id: rumor.id, pubkey: rumor.pubkey}; |
| 278 | this.report("Replying to: " + rumor.content.slice(0, 100)); |
| 279 | let cancel = this.querySelector("[data-cancel-reply]" ); |
| 280 | if (!cancel) { cancel = node("button" , "Cancel reply" ); cancel.type = "button" ; cancel.setAttribute("data-cancel-reply" , "" ); cancel.addEventListener("click" , () => { this.reply = null; cancel.remove(); this.report("" ); }); this.append(cancel); } |
| 281 | this.querySelector("textarea" )?.focus(); |
| 282 | } |
| 283 | async submit(form) { |
| 284 | const actor = key(this.getAttribute("actor" )), peer = key(this.getAttribute("peer" )), content = String(form.elements.content.value || "" ); |
| 285 | if (!actor || !peer || actor === peer || !content.trim()) throw Error("Choose another person and write a message." ); |
| 286 | const value = stateFor(actor), pendingKey = JSON.stringify([actor, peer, content, this.reply?.id]); |
| 287 | if (this.pendingKey !== pendingKey) { this.pending = null; this.pendingKey = pendingKey; } |
| 288 | this.report(this.pending ? "Retrying delivery…" : "Encrypting and sending…" ); |
| 289 | await deliver(value, {recipient: peer, content, reply: this.reply}, this.pending, pending => { this.pending = pending; }); |
| 290 | if (!current(value) || !this.isConnected) return; |
| 291 | this.pending = null; |
| 292 | if (form.elements.content.value === content && pendingKey === JSON.stringify([actor, peer, content, this.reply?.id])) { |
| 293 | this.reply = null; this.querySelector("[data-cancel-reply]" )?.remove(); form.reset(); |
| 294 | } |
| 295 | this.report("Sent." ); |
| 296 | } |
| 297 | } |
| 298 | class DirectStart extends tiny.ui.FormElement { |
| 299 | async submit(form) { |
| 300 | const peer = key(form.elements.peer.value); if (!peer) throw Error("Enter an npub or a 64-character public key." ); |
| 301 | await tiny.navigate(tiny.localPath("/chat/dm/" + peer)); |
| 302 | } |
| 303 | } |
| 304 | class DirectSettings extends HTMLElement { |
| 305 | connectedCallback() { |
| 306 | const button = node("button" , "Receive chats here" ); button.type = "button" ; |
| 307 | const status = node("output" ); status.setAttribute("role" , "status" ); this.replaceChildren(button, status); |
| 308 | button.addEventListener("click" , async () => { |
| 309 | button.disabled = true; |
| 310 | try { |
| 311 | const actor = key(this.getAttribute("actor" )), value = stateFor(actor), signer = await activeSigner(actor); |
| 312 | const url = tiny.util.relayURL(this.getAttribute("relay" )); if (!url) throw Error("This relay's address is unavailable." ); |
| 313 | const rows = await protocol().queryRelayList(actor); |
| 314 | const events = Array.isArray(rows) ? rows : rows?.events || []; |
| 315 | const prior = events.filter(event => event.kind === 10050 && event.pubkey === actor && NostrSigner.verifyEvent(event)).sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id))[0]; |
| 316 | const tags = (prior?.tags || []).filter(tag => tag[0] !== "relay" || tiny.util.relayURL(tag[1])); |
| 317 | if (!tags.some(tag => tag[0] === "relay" && tiny.util.relayURL(tag[1]) === url)) tags.push(["relay" , url]); |
| 318 | await checkSession(value, signer); |
| 319 | await tiny.signing.publish({kind: 10050, created_at: Math.max(Math.floor(Date.now() / 1000), (prior?.created_at || 0) + 1), tags, content: prior?.content || "" }); |
| 320 | status.textContent = "This relay now receives your direct messages." ; |
| 321 | } catch (error) { status.textContent = error.message; } finally { button.disabled = false; } |
| 322 | }); |
| 323 | } |
| 324 | } |
| 325 | document.addEventListener("tiny:signer" , () => { clear(); for (const host of listeners) host.refresh(); }); |
| 326 | document.addEventListener("tiny:logout" , clear); |
| 327 | document.addEventListener("visibilitychange" , () => { if (document.visibilityState !== "hidden" ) for (const host of listeners) host.refresh(); }); |
| 328 | for (const [name, element] of [["direct-chats" , DirectChats], ["direct-thread" , DirectThread], ["direct-compose" , DirectCompose], ["direct-start" , DirectStart], ["direct-settings" , DirectSettings]]) customElements.define(name, element); |
| 329 | tiny.chat.ui = Object.freeze({state: states, stateFor, clear, cacheEvents, participants, reactions, load, deliver, messages}); |
| 330 | })(); |