From 1d21e3b9546496a4eccf8ec9ec45934999e07fd8 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 18 Aug 2026 12:16:36 -0700 Subject: Delivery moves at the speed of the best relay, not the worst: publish resolves on the first acceptance, DMs stop idling on the peer's inbox lookup, and the dimming departs — a sent message simply appears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012u4oe1KPx9faCsDBLfZYqi --- src/features/messages.js | 30 +++++++++++++++++------------- src/nostr.js | 19 +++++++++++++------ src/style.css | 2 -- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/features/messages.js b/src/features/messages.js index 20b2b03..6a73ad0 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -394,8 +394,9 @@ export function messagesFeature(ctx) { const id = await identity(); if (!id) { noIdToast(); return; } // The rumor id is a plain hash, so the message can be on screen before - // any signing, encryption or network runs. The pending flag renders as a - // dimmed bubble; a relay accepting the wrap brings it to full strength. + // any signing, encryption or network runs. The pending flag never shows — + // it only keeps unconfirmed messages out of the cache and marks what to + // withdraw if signing fails. const { created_at, ms } = msTags(Date.now()); const rumor = rumorWithId({ kind: 9, pubkey: id.pubkey, content: text, @@ -411,9 +412,8 @@ export function messagesFeature(ctx) { const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId)); seenWraps.add(wrap.id); // our own echo has nothing to add const ok = await publishOn(room.relays, wrap); - if (!ok) { toast(t('msgSendFailed')); return; } // stays dimmed + if (!ok) { toast(t('msgSendFailed')); return; } delete entry.pending; - render(); ensureJoined(room, id).catch(() => {}); persistCache(room); } catch (e) { @@ -1012,9 +1012,8 @@ export function messagesFeature(ctx) { const id = await identity(); if (!id) { noIdToast(); return; } if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; } - // Same optimistic shape as channel sends: the rumor is synchronous, the - // bubble shows dimmed at once, and undims when a relay takes the - // recipient's wrap. Wrapping the same rumor keeps the id, so the sent-copy + // Same optimistic shape as channel sends: the rumor is synchronous and on + // screen at once. Wrapping the same rumor keeps the id, so the sent-copy // echo folds into this entry instead of duplicating it. const rumor = makeDMRumor(id.pubkey, peer, text); const entry = { rumor, mine: true, pending: true }; @@ -1026,12 +1025,17 @@ export function messagesFeature(ctx) { // Sequential on purpose — a remote signer is happier signing one at a time. const toPeer = await wrapDM(id.signer, peer, rumor); const toSelf = await wrapDM(id.signer, id.pubkey, rumor); - const inbox = (await fetchInboxRelays(peer)).slice(0, 4); - const ok = await publishOn([...new Set([...inbox, ...DM_RELAYS])], toPeer); + // Our own relays take the wrap immediately; the peer's declared inbox + // (a lookup that can take seconds) is chased off the critical path. + // Publishing the same wrap twice is harmless — relays dedupe on id. + const ok = await publishOn(DM_RELAYS, toPeer); publishOn(DM_RELAYS, toSelf); - if (!ok) { toast(t('msgSendFailed')); return; } // stays dimmed + fetchInboxRelays(peer).then((inbox) => { + const extra = inbox.slice(0, 4).filter((r) => !DM_RELAYS.includes(r)); + if (extra.length) publishOn(extra, toPeer); + }).catch(() => {}); + if (!ok) { toast(t('msgSendFailed')); return; } delete entry.pending; - render(); persistDms(); } catch (e) { threadOf(peer).delete(rumor.id); @@ -2077,7 +2081,7 @@ export function messagesFeature(ctx) { const counts = new Map(); if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1); return h( - 'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') + (m.pending ? ' pending' : '') }, + 'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') }, grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author), h('div', { class: 'chat-body' }, grouped ? null : h('div', { class: 'chat-meta' }, @@ -2273,7 +2277,7 @@ export function messagesFeature(ctx) { }, ...(msgs.length ? msgs.map((m) => - h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') + (m.pending ? ' pending' : '') }, + h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') }, h('div', { class: 'chat-body' }, h('div', { class: 'chat-bubble' + (m.mine ? ' me' : '') }, m.rumor.content), h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))))) diff --git a/src/nostr.js b/src/nostr.js index 2166880..753d4a8 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -83,20 +83,27 @@ export async function queryOn(relays, filter, maxWait = 1500) { export async function publishOn(relays, evt) { try { const live = liveRelays(relays); - const results = await Promise.allSettled(pool.publish(live, evt)); // A rejected publish is a relay refusing the event (policy, rate limit, // ban). Swallowing that silently once hid a relay-side rejection behind // what looked like a client bug — always say which relay refused and why. - results.forEach((r, i) => { - if (r.status === 'rejected') { - const msg = r.reason?.message || String(r.reason || ''); + const pubs = pool.publish(live, evt).map((p, i) => + Promise.resolve(p).then(() => true, (reason) => { + const msg = reason?.message || String(reason || ''); // "relay connection errored/closed" means the socket never came up — // that's a sick relay, not a rejected event. if (/connect|closed|errored|timeout|network/i.test(msg)) markRelaySick(live[i], msg); else console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg); - } + return false; + })); + // The first acceptance IS delivery — one durable copy is enough, and + // waiting on allSettled meant every send moved at the pace of the + // slowest relay in the set. The stragglers keep settling in the + // background so the sick-list still learns from them. + return await new Promise((resolve) => { + let left = pubs.length; + if (!left) return resolve(false); + for (const p of pubs) p.then((ok) => { if (ok) resolve(true); else if (!--left) resolve(false); }); }); - return results.some((r) => r.status === 'fulfilled'); } catch { return false; } } diff --git a/src/style.css b/src/style.css index 1b059f3..2433d82 100644 --- a/src/style.css +++ b/src/style.css @@ -386,8 +386,6 @@ hr.divider { margin: 16px 0; } overflow-wrap: break-word; white-space: pre-wrap; } .chat-edited { font-size: 11px; color: var(--faint); } -/* Optimistic sends: dimmed while in flight, full strength once a relay has it. */ -.chat-row.pending .chat-bubble { opacity: 0.55; } .chat-del { position: absolute; top: -7px; right: -7px; width: 18px; height: 18px; display: none; align-items: center; justify-content: center; -- cgit v1.2.3