aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-02 22:55:10 -0700
committerAdam Soltys <[email protected]>2026-10-02 22:55:10 -0700
commit9a00260fa335d5c7d7362584b0f7dec665bdddfe (patch)
tree72b7f9aaeb8c901b3fc0fefb444e5793f9e34768
parentc1f6d1340f47b89467381dd59ade2947e03bc470 (diff)
downloadcoinosv3-9a00260fa335d5c7d7362584b0f7dec665bdddfe.tar.gz
coinosv3-9a00260fa335d5c7d7362584b0f7dec665bdddfe.zip
DM threads: choose by hand whether a message goes by White Noise or Nostr DM
Where a person can be reached both ways (a direct White Noise group with them, or a KeyPackage from another app), the thread header now carries a small pill naming the way the next message will travel. Tapping it pins White Noise or Nostr DM for that thread, or puts it back on Automatic (reply the way they last wrote). Threads with only one road show nothing. Co-Authored-By: Claude Fable 5.1 <[email protected]>
-rw-r--r--src/features/messages.js89
-rw-r--r--src/i18n.js7
-rw-r--r--tools/marmot-chat-test.js22
3 files changed, 108 insertions, 10 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index f7fb492..d874865 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -3056,21 +3056,60 @@ export function messagesFeature(ctx) {
// their last message came. A first message goes by White Noise only to
// someone whose KeyPackage was published by another app: a coinos wallet
// reads NIP-17 on every device it has, where an MLS group reaches just the
- // one device whose KeyPackage was picked.
+ // one device whose KeyPackage was picked. A route pinned by hand in the
+ // thread header (st().route) overrides all of that.
+ const wnTheirs = (peer) => {
+ let theirs = null;
+ for (const m of threads.get(peer)?.values() || [])
+ if (!m.mine && (!theirs || m.rumor.created_at > theirs.rumor.created_at)) theirs = m;
+ return theirs;
+ };
+ const routePref = (peer) => (st().route || {})[peer] || 'auto';
async function wnRoute(peer) {
const c = await whiteNoise();
if (!c) return null;
await c.load();
+ const pref = routePref(peer);
+ if (pref === 'nip17') return null;
const g = wnDirect(peer);
- let theirs = null;
- for (const m of threads.get(peer)?.values() || [])
- if (!m.mine && (!theirs || m.rumor.created_at > theirs.rumor.created_at)) theirs = m;
+ const lookup = () => Promise.race([c.lookup(peer), new Promise((r) => setTimeout(() => r(null), 3500))]);
+ if (pref === 'wn') return g || ((await lookup()) ? 'new' : null);
+ const theirs = wnTheirs(peer);
if (g) return theirs && !theirs.wn ? null : g;
if (theirs) return null;
- const cand = await Promise.race([c.lookup(peer), new Promise((r) => setTimeout(() => r(null), 3500))]);
+ const cand = await lookup();
return cand && cand.client !== 'coinos' ? 'new' : null;
}
+ // The same decision without waiting, for the thread header: which way the
+ // next message will go and what was pinned — or null when there is nothing
+ // to choose (they are not on White Noise, or only through another coinos
+ // wallet, where NIP-17 is simply the better road).
+ const wnCands = new Map(); // peer -> { at, cand } : their KeyPackage, as last looked up
+ function wnKnow(peer) {
+ const hit = wnCands.get(peer);
+ if (hit && Date.now() - hit.at < 10 * 60_000) return;
+ wnCands.set(peer, { at: Date.now(), cand: hit ? hit.cand : undefined });
+ whiteNoise().then((c) => (c ? c.lookup(peer) : null)).then((cand) => {
+ wnCands.set(peer, { at: Date.now(), cand: cand || null });
+ scheduleRepaint();
+ }).catch(() => {});
+ }
+ function routeChoice(peer) {
+ if (!wn) return null;
+ const g = wnDirect(peer), cand = (wnCands.get(peer) || {}).cand;
+ if (!g && !(cand && cand.client !== 'coinos')) return null;
+ const pref = routePref(peer), theirs = wnTheirs(peer);
+ const auto = g ? (theirs && !theirs.wn ? 'nip17' : 'wn') : (theirs ? 'nip17' : 'wn');
+ return { pref, now: pref === 'auto' ? auto : pref };
+ }
+ function setRoute(peer, pref) {
+ const s = st();
+ s.route ||= {};
+ if (pref === 'auto') delete s.route[peer]; else s.route[peer] = pref;
+ save(s);
+ }
+
async function sendWnDirect(peer, road, text) {
const c = wn;
const replyTo = ui.msgReplyTo && threadOf(peer).has(ui.msgReplyTo) ? ui.msgReplyTo : null;
@@ -10508,6 +10547,28 @@ export function messagesFeature(ctx) {
})));
}
+ // How messages to this person travel: automatic unless pinned.
+ function routeSheet(peer, choice) {
+ const close = () => { ui.msgRoutePick = false; render(); };
+ const item = (pref, label, hint) => h('button', {
+ class: 'msg-sheet-item', 'aria-pressed': String(choice.pref === pref),
+ onClick: () => { setRoute(peer, pref); close(); },
+ },
+ h('span', { class: 'msg-sheet-ico' }, choice.pref === pref ? '✓' : ''),
+ h('span', { class: 'col', style: 'gap:1px;text-align:left' },
+ h('span', {}, label),
+ h('span', { class: 'muted small' }, hint)));
+ return h('div', {
+ class: 'confirm-pop-backdrop',
+ onClick: (e) => { if (e.target === e.currentTarget) close(); },
+ },
+ h('div', { class: 'card col msg-sheet' },
+ h('div', { class: 'muted small', style: 'padding:4px 10px 6px' }, t('msgRouteTitle')),
+ item('auto', t('msgRouteAuto'), t('msgRouteAutoHint')),
+ item('wn', t('msgRouteWn'), t('msgRouteWnHint')),
+ item('nip17', t('msgRouteDm'), t('msgRouteDmHint'))));
+ }
+
function dmView() {
startDMs();
const peer = ui.msgPeer;
@@ -10515,7 +10576,8 @@ export function messagesFeature(ctx) {
const thread = threads.get(peer) || new Map();
const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at);
// a first message may go by White Noise: have their KeyPackage looked up before Send
- if (!msgs.some((m) => !m.mine)) whiteNoise().then((c) => c && c.lookup(peer)).catch(() => {});
+ wnKnow(peer);
+ const choice = routeChoice(peer);
// Looking at the thread is reading it — including anything that lands while
// it's still open, since every arrival repaints us.
markRead(dmRead(peer), newestFrom(msgs, (m) => !m.mine));
@@ -10555,11 +10617,17 @@ export function messagesFeature(ctx) {
};
return h('div', { class: 'card col chat-card chat-page' },
h('div', { class: 'row chat-head gap6', style: 'align-items:center' },
- backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; render(); }),
+ backBtn(() => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; ui.msgRoutePick = false; render(); }),
avatar(peer),
h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) },
h('div', { class: 'chat-title' }, displayName(peer)),
- h('div', { class: 'muted small' }, t('msgDmEncrypted')))),
+ h('div', { class: 'muted small' }, t('msgDmEncrypted'))),
+ // Only where there is a choice: this person can be reached two ways.
+ // The pill says which way the next message goes; tapping it pins one.
+ choice ? h('button', {
+ class: 'btn-sm route-pill', style: 'margin-left:auto;flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true',
+ onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); },
+ }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null),
h('div', {
class: 'chat-log',
onScroll: (e) => {
@@ -10586,7 +10654,8 @@ export function messagesFeature(ctx) {
: [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
dmReplyBar(),
composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer, (f) => sendDMFile(peer, f)),
- ui.msgSheet ? dmSheet(peer) : null);
+ ui.msgSheet ? dmSheet(peer) : null,
+ choice && ui.msgRoutePick ? routeSheet(peer, choice) : null);
}
// ---- notifications: what happened to your posts --------------------------
@@ -11226,7 +11295,7 @@ export function messagesFeature(ctx) {
pendingDirect.clear();
if (wn) { try { wn.stop(); } catch {} }
wn = null; wnLoading = null; wnReadied = false;
- wnFolds.clear(); wnReach.clear(); wnReactOf.clear();
+ wnFolds.clear(); wnReach.clear(); wnReactOf.clear(); wnCands.clear();
seenWraps.clear(); wrapLog = null; // the next account must decrypt wraps this one couldn't
clearTimeout(drainTimer);
pendingWraps.clear();
diff --git a/src/i18n.js b/src/i18n.js
index db3f7cf..a4c3f50 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -480,6 +480,13 @@ const STR = {
msgGroupAttachment: 'Attachment',
msgGroupFailed: "Couldn't update the group. Try again",
msgGroupSetup: 'Your signer is needed once to set up group chats',
+ msgRouteTitle: 'Send messages by',
+ msgRouteAuto: 'Automatic',
+ msgRouteAutoHint: 'Reply the way they last wrote',
+ msgRouteWn: 'White Noise',
+ msgRouteWnHint: 'Reaches their White Noise app',
+ msgRouteDm: 'Nostr DM',
+ msgRouteDmHint: 'Reaches any Nostr app that reads private messages',
msgSysAdded: '{a} added {b}',
msgSysJoined: '{b} joined',
msgSysRemoved: '{a} removed {b}',
diff --git a/tools/marmot-chat-test.js b/tools/marmot-chat-test.js
index 18f63b3..f3a25f7 100644
--- a/tools/marmot-chat-test.js
+++ b/tools/marmot-chat-test.js
@@ -191,6 +191,27 @@ try {
check('his reply lands in the same thread', !!await waitText('hey, bob here'));
await say('second message');
check('a reply goes back the same way', !!await until(() => JSON.stringify(wn(['messages', 'list', dmGid, '--limit', '20'], bob)).includes('second message')));
+
+ console.log('\n[the route can be pinned by hand]');
+ const pill = () => page.evaluate(() => document.querySelector('.route-pill')?.textContent || '');
+ check('the thread says which way it sends', /White Noise/.test(await pill()), await pill());
+ const wrapsBefore = (await relayQuery({ kinds: [1059], '#p': [bob] })).length;
+ await page.click('.route-pill');
+ await sleep(250);
+ await click('.msg-sheet-item', 'Nostr DM');
+ await sleep(300);
+ check('the pill follows the pin', /Nostr DM/.test(await pill()), await pill());
+ await say('this one by dm');
+ check('a pinned Nostr DM goes out as a gift wrap', !!await until(async () => (await relayQuery({ kinds: [1059], '#p': [bob] })).length > wrapsBefore, 15000));
+ await sleep(1500);
+ check('and not into the White Noise chat', !JSON.stringify(wn(['messages', 'list', dmGid, '--limit', '20'], bob)).includes('this one by dm'));
+ await page.click('.route-pill');
+ await sleep(250);
+ await click('.msg-sheet-item', 'Automatic');
+ await sleep(300);
+ check('automatic follows their last message again', /White Noise/.test(await pill()), await pill());
+ await say('back on auto');
+ check('and sends by White Noise', !!await until(() => JSON.stringify(wn(['messages', 'list', dmGid, '--limit', '20'], bob)).includes('back on auto')));
}
await page.screenshot({ path: shot('marmot-dm.png') });
@@ -237,6 +258,7 @@ try {
await click('button', 'Open');
await say('plain old dm');
check('the message shows', !!await waitText('plain old dm'));
+ check('no route choice where there is only one way', !(await page.$('.route-pill')));
check('a gift wrap went to their inbox', !!await until(async () => (await relayQuery({ kinds: [1059], '#p': [stranger] })).length > 0, 15000));
check('no group was founded for it', (await relayQuery({ kinds: [1059], '#p': [stranger] })).length <= 1);
} catch (e) { console.log(e); ok = false; }