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-day84 // 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.peer253 ? 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})();