tinyrelayfile
tinyrelay/tinyclient/chat.js/

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

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs