aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-18 12:06:04 -0700
committerAdam Soltys <[email protected]>2026-08-18 12:06:04 -0700
commita0374566a5c69d87e5a156f5fabecf5b9b45d6f9 (patch)
treecf87f4584724e9149c8ae1075d28007a2a9d203b /src
parent3d80fef6aeab03795c4257ab8c4f27113d7e9022 (diff)
downloadcoinosv3-a0374566a5c69d87e5a156f5fabecf5b9b45d6f9.tar.gz
coinosv3-a0374566a5c69d87e5a156f5fabecf5b9b45d6f9.zip
A message sends the instant you let go of it: the rumor hashes synchronously onto the screen as a dimmed bubble, crypto and relays catch up in the background, and one small tick marks the moment a relay takes it
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012u4oe1KPx9faCsDBLfZYqi
Diffstat (limited to 'src')
-rw-r--r--src/concord.js2
-rw-r--r--src/dm.js18
-rw-r--r--src/features/messages.js88
-rw-r--r--src/style.css3
4 files changed, 68 insertions, 43 deletions
diff --git a/src/concord.js b/src/concord.js
index 09e4f72..9f6803d 100644
--- a/src/concord.js
+++ b/src/concord.js
@@ -82,7 +82,7 @@ export const banlistEid = (cid) => bytesToHex(hkdf32(hexToBytes(cid), "concord/b
// ---- CORD-01 envelope ----
// wrap(1059, signed by stream key) > seal(20013 encrypted | 20014 plaintext, author-signed) > rumor
-const rumorWithId = (rumor) => ({ ...rumor, id: getEventHash({ ...rumor, tags: rumor.tags || [] }) });
+export const rumorWithId = (rumor) => ({ ...rumor, id: getEventHash({ ...rumor, tags: rumor.tags || [] }) });
// Sign a seal around a rumor. `signer` is a raw secret key (Uint8Array) or a
// { signEvent } adapter (nostr-login extension/bunker). Plaintext seals
diff --git a/src/dm.js b/src/dm.js
index 70cf46b..85498f2 100644
--- a/src/dm.js
+++ b/src/dm.js
@@ -47,18 +47,26 @@ export async function wrapDM(skOrSigner, receiverPk, rumor, extraTags = []) {
);
}
-// A DM to a peer: the kind 14 rumor (unsigned, true timestamp) plus wraps for
-// the recipient and for the sender's own inbox (the sent-copy).
-export async function makeDM(skOrSigner, peerPk, text) {
- const signer = asSigner(skOrSigner);
+// The kind 14 rumor alone, id included — synchronous, so a sender can put
+// the message on screen before any signing or encryption has run. Wrapping
+// the same rumor later yields the same id, so the sent-copy echo dedupes.
+export function makeDMRumor(senderPk, peerPk, text) {
const rumor = {
kind: 14,
- pubkey: signer.pubkey,
+ pubkey: senderPk,
content: text,
tags: [['p', peerPk]],
created_at: now(),
};
rumor.id = getEventHash(rumor);
+ return rumor;
+}
+
+// A DM to a peer: the kind 14 rumor (unsigned, true timestamp) plus wraps for
+// the recipient and for the sender's own inbox (the sent-copy).
+export async function makeDM(skOrSigner, peerPk, text) {
+ const signer = asSigner(skOrSigner);
+ const rumor = makeDMRumor(signer.pubkey, peerPk, text);
return {
rumor,
toPeer: await wrapDM(signer, peerPk, rumor),
diff --git a/src/features/messages.js b/src/features/messages.js
index 969f4e8..366bbbc 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -18,11 +18,11 @@ import {
PROFILE_RELAYS,
} from '../nostr.js';
import {
- channelKey, controlKey, guestbookKey, openWrap, wrapRumor,
+ channelKey, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId,
foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition,
communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle,
} from '../concord.js';
-import { makeDM, unwrapDM, wrapDM } from '../dm.js';
+import { makeDMRumor, unwrapDM, wrapDM } from '../dm.js';
import { saveInbox } from '../dm-inbox.js';
import { makeSearcher, resultRows, fallbackAvatar } from '../recipient-search.js';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
@@ -355,8 +355,10 @@ export function messagesFeature(ctx) {
function persistCache(room) {
const s = st();
for (const [chId, msgs] of room.byChannel) {
+ // Pending entries stay out of the cache: unconfirmed means the relay
+ // never took it, and a reload would resurrect it dimmed forever.
s.cache[chId] = [...msgs.values()]
- .filter((m) => !room.deletes.has(m.rumor.id))
+ .filter((m) => !room.deletes.has(m.rumor.id) && !m.pending)
.sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor))
.slice(-CACHE_MAX);
}
@@ -388,35 +390,37 @@ export function messagesFeature(ctx) {
async function sendMessage(room, chId) {
const text = (ui.msgDraft || '').trim();
- if (!text || ui.msgSending) return;
+ if (!text) return;
const id = await identity();
if (!id) { noIdToast(); return; }
- ui.msgSending = true;
+ // 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 clears it and shows the tick.
+ const { created_at, ms } = msTags(Date.now());
+ const rumor = rumorWithId({
+ kind: 9, pubkey: id.pubkey, content: text,
+ tags: [['channel', chId], ['epoch', String(EPOCH)], ms], created_at,
+ });
+ const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
+ const entry = { rumor, author: id.pubkey, pending: true };
+ msgs.set(rumor.id, entry);
+ clearDraft();
+ ui.msgStick = true;
render();
try {
- const { created_at, ms } = msTags(Date.now());
- const rumor = {
- kind: 9, pubkey: id.pubkey, content: text,
- tags: [['channel', chId], ['epoch', String(EPOCH)], ms], created_at,
- };
const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId));
- // optimistic insert (the relay echo will dedupe on wrap id)
- const opened = openWrap(wrap, room.chStream(chId));
- if (opened) {
- seenWraps.add(wrap.id);
- const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
- msgs.set(opened.rumor.id, opened);
- }
- clearDraft();
+ seenWraps.add(wrap.id); // our own echo has nothing to add
const ok = await publishOn(room.relays, wrap);
- if (!ok) toast(t('msgSendFailed'));
+ if (!ok) { toast(t('msgSendFailed')); return; } // stays dimmed, no tick
+ delete entry.pending;
+ render();
ensureJoined(room, id).catch(() => {});
persistCache(room);
} catch (e) {
+ // Signing or wrapping failed — the message never existed on the wire,
+ // so it leaves the screen rather than sit there looking sent.
+ msgs.delete(rumor.id);
toast(e.message || String(e));
- } finally {
- ui.msgSending = false;
- ui.msgStick = true;
render();
}
}
@@ -1004,26 +1008,34 @@ export function messagesFeature(ctx) {
async function sendDM(peer) {
const text = (ui.msgDraft || '').trim();
- if (!text || ui.msgSending) return;
+ if (!text) return;
const id = await identity();
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
- ui.msgSending = true;
+ // Same optimistic shape as channel sends: the rumor is synchronous, the
+ // bubble shows dimmed at once, and the tick lands when a relay takes the
+ // recipient's wrap. 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 };
+ threadOf(peer).set(rumor.id, entry);
+ clearDraft();
+ ui.msgStick = true;
render();
try {
- const { rumor, toPeer, toSelf } = await makeDM(id.signer, peer, text);
- noteDM(peer, rumor, true);
- clearDraft();
+ // 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);
publishOn(DM_RELAYS, toSelf);
- if (!ok) toast(t('msgSendFailed'));
+ if (!ok) { toast(t('msgSendFailed')); return; } // stays dimmed, no tick
+ delete entry.pending;
+ render();
persistDms();
} catch (e) {
+ threadOf(peer).delete(rumor.id);
toast(e.message || String(e));
- } finally {
- ui.msgSending = false;
- ui.msgStick = true;
render();
}
}
@@ -1036,7 +1048,8 @@ export function messagesFeature(ctx) {
.slice(0, 30);
s.dms = {};
for (const [peer, list] of byRecent)
- s.dms[peer] = list.slice(-CACHE_MAX).map((m) => ({ id: m.rumor.id, from: m.rumor.pubkey, text: m.rumor.content, t: m.rumor.created_at }));
+ s.dms[peer] = list.filter((m) => !m.pending).slice(-CACHE_MAX)
+ .map((m) => ({ id: m.rumor.id, from: m.rumor.pubkey, text: m.rumor.content, t: m.rumor.created_at }));
save(s);
}
@@ -1846,8 +1859,7 @@ export function messagesFeature(ctx) {
// animation, so re-stick once now and once when it has settled.
onFocus: () => { stickToBottom(); setTimeout(stickToBottom, 350); },
}),
- h('button', { class: 'btn-primary btn-sm', disabled: ui.msgSending, onClick: onSend },
- ui.msgSending ? h('span', { class: 'spinner sm' }) : t('msgSend'))));
+ h('button', { class: 'btn-primary btn-sm', onClick: onSend }, t('msgSend'))));
// ---- home ---------------------------------------------------------------
@@ -2065,7 +2077,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' : '') },
+ 'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') + (m.pending ? ' pending' : '') },
grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author),
h('div', { class: 'chat-body' },
grouped ? null : h('div', { class: 'chat-meta' },
@@ -2079,6 +2091,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'chat-bubble' },
text,
edit ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null,
+ mine && !m.pending ? h('span', { class: 'chat-tick' }, '✓') : null,
mine
? h('button', { class: 'chat-del', title: t('msgDelete'), onClick: () => deleteMessage(room, chId, m) }, '×')
: null),
@@ -2261,9 +2274,10 @@ export function messagesFeature(ctx) {
},
...(msgs.length
? msgs.map((m) =>
- h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') },
+ h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') + (m.pending ? ' pending' : '') },
h('div', { class: 'chat-body' },
- h('div', { class: 'chat-bubble' + (m.mine ? ' me' : '') }, m.rumor.content),
+ h('div', { class: 'chat-bubble' + (m.mine ? ' me' : '') }, m.rumor.content,
+ m.mine && !m.pending ? h('span', { class: 'chat-tick' }, '✓') : null),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
: [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
composer(t('msgDmPlaceholder'), () => sendDM(peer)));
diff --git a/src/style.css b/src/style.css
index 2433d82..e9f0466 100644
--- a/src/style.css
+++ b/src/style.css
@@ -386,6 +386,9 @@ 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, a small tick once a relay has it. */
+.chat-row.pending .chat-bubble { opacity: 0.55; }
+.chat-tick { font-size: 10px; margin-left: 6px; opacity: 0.6; user-select: none; }
.chat-del {
position: absolute; top: -7px; right: -7px; width: 18px; height: 18px;
display: none; align-items: center; justify-content: center;