aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-15 13:08:33 -0700
committerAdam Soltys <[email protected]>2026-08-15 13:08:33 -0700
commitff445296de9c6375c1396d5824eea29d6767e7bb (patch)
tree6751c214ebb528d4c86d3ffd03b1a359c9b6aed8 /src/features
parentd45c2f2e5a3a7c7e50b701a82c15866a8dc96811 (diff)
downloadcoinosv3-ff445296de9c6375c1396d5824eea29d6767e7bb.tar.gz
coinosv3-ff445296de9c6375c1396d5824eea29d6767e7bb.zip
Threads speak back: a reply composer with proper NIP-10 markers, conversations fetched from the author's own relays, and a ⚡ tap that can never be swallowed by the row underneath
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js92
1 files changed, 87 insertions, 5 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index ec77a43..4b11019 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1056,20 +1056,42 @@ export function messagesFeature(ctx) {
const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])];
const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] }
+ // NIP-65: where this author actually writes. Their notes and threads live
+ // there first — our default relays are just the common ground.
+ const relayListCache = new Map(); // pk -> Promise<string[]>
+ function relaysOf(pk) {
+ let p = relayListCache.get(pk);
+ if (p) return p;
+ p = queryOn([...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [10002], authors: [pk] }, 3500)
+ .then((evs) => {
+ const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ if (!newest) return [];
+ return [...new Set(newest.tags
+ .filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//.test(x[1]))
+ .map((x) => x[1].replace(/\/$/, '')))].slice(0, 4);
+ })
+ .catch(() => []);
+ relayListCache.set(pk, p);
+ return p;
+ }
+ const notesRelays = async (pk) => [...new Set([...NOTE_RELAYS, ...(await relaysOf(pk))])];
+
function notesFor(pk) {
let c = notesCache.get(pk);
if (c) return c;
c = { status: 'loading', notes: [] };
notesCache.set(pk, c);
- queryOn(NOTE_RELAYS, { kinds: [1], authors: [pk], limit: 30 }, 4500).then((evs) => {
+ (async () => {
+ const evs = await queryOn(await notesRelays(pk), { kinds: [1], authors: [pk], limit: 30 }, 4500);
const seen = new Set();
c.notes = (evs || [])
.filter((e) => !seen.has(e.id) && seen.add(e.id))
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 20);
+ })().catch(() => {}).finally(() => {
c.status = 'ready';
if (ui.profilePk === pk) render();
- }).catch(() => { c.status = 'ready'; if (ui.profilePk === pk) render(); });
+ });
return c;
}
@@ -1152,7 +1174,9 @@ export function messagesFeature(ctx) {
style: 'gap:10px;align-items:flex-start;padding:10px 0'
+ (open ? ';cursor:pointer' : '')
+ (focus ? ';box-shadow:inset 3px 0 0 var(--accent,#7c3aed);padding-left:8px;margin-left:-8px' : ''),
- onClick: open ? () => openNoteThread(ev) : undefined,
+ // closest('button') guard: on touch, a ⚡ tap must never double as a
+ // row tap even if propagation quirks let the click reach us
+ onClick: open ? (e) => { if (e.target && e.target.closest && e.target.closest('button')) return; openNoteThread(ev); } : undefined,
},
avatar(pk, 'chat-avatar', false),
h('div', { class: 'col grow', style: 'min-width:0;gap:3px' },
@@ -1180,9 +1204,12 @@ export function messagesFeature(ctx) {
c = { status: 'loading', rootId, root: seed.id === rootId ? seed : null, replies: [] };
threadCache.set(rootId, c);
(async () => {
+ // the conversation's home relays are the root author's (NIP-10 outbox);
+ // the seed's author is the best guess until the root is known
+ const relays = await notesRelays((c.root || seed).pubkey);
const [roots, replies] = await Promise.all([
- c.root ? Promise.resolve([]) : queryOn(NOTE_RELAYS, { kinds: [1], ids: [rootId] }, 4000),
- queryOn(NOTE_RELAYS, { kinds: [1], '#e': [rootId], limit: 80 }, 4500),
+ c.root ? Promise.resolve([]) : queryOn(relays, { kinds: [1], ids: [rootId] }, 4000),
+ queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500),
]);
if (!c.root) c.root = (roots || [])[0] || null;
const seen = new Set([rootId]);
@@ -1201,6 +1228,36 @@ export function messagesFeature(ctx) {
ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev };
render();
}
+ // Publish a kind-1 reply to the focused note (NIP-10 markers), addressed to
+ // the conversation's own relays plus ours, and shown optimistically.
+ async function publishReply(c, s, text) {
+ const id = await identity();
+ if (!id) throw new Error(t('msgNoIdentity'));
+ const target = c.replies.find((e) => e.id === s.focusId) || c.root || s.seed;
+ const rootId = c.rootId;
+ const pTags = [...new Set([
+ target.pubkey,
+ ...(c.root ? [c.root.pubkey] : []),
+ ...target.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]),
+ ])].filter((pk) => pk !== id.pubkey).slice(0, 8);
+ const partial = {
+ kind: 1,
+ content: text,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ['e', rootId, '', 'root'],
+ ...(target.id !== rootId ? [['e', target.id, '', 'reply']] : []),
+ ...pTags.map((pk) => ['p', pk]),
+ ],
+ };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])];
+ const ok = await publishOn(relays, evt);
+ if (!ok) throw new Error(t('msgSendFailed'));
+ c.replies = [...c.replies, evt];
+ return evt;
+ }
+
function threadScreen() {
const s = ui.noteThread;
const c = threadFor(s.seed);
@@ -1217,6 +1274,31 @@ export function messagesFeature(ctx) {
: !c.replies.length
? h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))
: null),
+ h('div', { class: 'card row', style: 'gap:8px;align-items:center' },
+ h('input', {
+ type: 'text', class: 'grow thread-reply-input', placeholder: t('threadReplyHint'),
+ value: s.draft || '',
+ onInput: (e) => { s.draft = e.target.value; },
+ onKeydown: (e) => { if (e.key === 'Enter') e.target.closest('.card').querySelector('.thread-reply-send')?.click(); },
+ }),
+ h('button', {
+ class: 'btn-primary thread-reply-send', disabled: !!s.sending,
+ onClick: async () => {
+ const text = (s.draft || '').trim();
+ if (!text) return;
+ s.sending = true; render();
+ try {
+ await publishReply(c, s, text);
+ s.draft = '';
+ // the input may still be focused, and the morph won't touch a
+ // focused field's value — clear it by hand
+ const inp = document.querySelector('.thread-reply-input');
+ if (inp) inp.value = '';
+ toast(t('threadReplied'));
+ } catch (e) { toast(e.message); }
+ s.sending = false; render();
+ },
+ }, s.sending ? h('span', { class: 'spinner sm' }) : t('threadReplySend'))),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.noteThread = null; render(); } }, t('back')));
}