diff options
| author | Adam Soltys <[email protected]> | 2026-10-03 08:47:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-03 08:47:20 -0700 |
| commit | 4444c9ce12cc9c1ed3eca9470e0c1080f80d8027 (patch) | |
| tree | e7678a59cc3de7e50f102ded24ea6e45e24427d1 /src/features | |
| parent | a6a0e1a4c86d9081ee386071bf5921608edb224b (diff) | |
| download | coinosv3-4444c9ce12cc9c1ed3eca9470e0c1080f80d8027.tar.gz coinosv3-4444c9ce12cc9c1ed3eca9470e0c1080f80d8027.zip | |
Settings → Nostr: manage your relays
A Relays card edits the two relay lists other Nostr apps go by: the
NIP-65 list (each relay Read, Write or both) and the NIP-17 DM inbox.
Each relay shows whether it answers (a dot; 'unreachable' spelled out).
Both lists are replaceable events other clients write too, so the card
edits only a copy it looked up across the index and archive relays, looks
again just before publishing, and backs off (reloading the newer copy) if
another app saved since. Only a list that changed is republished, to the
relays it names, the ones it used to name, and the indexers.
Saving updates our own idea of where we write (outbox lookups and wallet
sync relays) and resubscribes the DM inbox at once.
tools/relays-settings-test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ULQZS9MQa6iAsXnrJUD9Du
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 188 |
1 files changed, 186 insertions, 2 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 0a1d5f2..7165dfc 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,7 +15,7 @@ import { subscribeOn, publishOn, queryOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, resetRelay, npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, - PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, + PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.js'; import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, @@ -7611,6 +7611,190 @@ export function messagesFeature(ctx) { : h('div', { class: 'small faint' }, t('followHistNone'))); } + // ---- relay management: your NIP-65 list and your DM inbox (NIP-17) ------- + // Both are replaceable events other clients write too, so the card only + // ever edits a copy it looked for far and wide first (the follow list was + // once overwritten from a two-relay lookup), and checks again just before + // publishing that nobody saved a newer one meanwhile. + const RELAY_LOOKUP = () => [...new Set([...PROFILE_RELAYS, ...PROFILE_INDEX_RELAYS, ...DM_RELAYS, + ...PROFILE_ARCHIVE_RELAYS, ...FOLLOW_HISTORY_RELAYS])]; + async function fetchOwnRelayLists(me) { + const evs = await queryOn(RELAY_LOOKUP(), { kinds: [10002, 10050], authors: [me] }, 6000).catch(() => []); + const newest = (k) => (evs || []).filter((e) => e.pubkey === me && e.kind === k).sort((a, b) => b.created_at - a.created_at)[0] || null; + return { r: newest(10002), dm: newest(10050) }; + } + const rowsOf10002 = (e) => { + const out = new Map(); + for (const x of (e && e.tags) || []) { + if (x[0] !== 'r') continue; + const url = normRelay(x[1]); + if (!url) continue; + const row = out.get(url) || { url, read: false, write: false }; + if (!x[2] || x[2] === 'read') row.read = true; + if (!x[2] || x[2] === 'write') row.write = true; + out.set(url, row); + } + return [...out.values()]; + }; + const urlsOf10050 = (e) => [...new Set(((e && e.tags) || []).filter((x) => x[0] === 'relay').map((x) => normRelay(x[1])).filter(Boolean))]; + const tagsOfRows = (rows) => rows.filter((r) => r.read || r.write) + .map((r) => (r.read && r.write ? ['r', r.url] : ['r', r.url, r.read ? 'read' : 'write'])); + const sameTags = (a, b) => JSON.stringify(a) === JSON.stringify(b); + + // How long a relay takes to open a socket, measured once per card visit. + function probeRelayLatency(url) { + const rm = ui.relayMgr; + if (!rm || rm.probe[url]) return; + rm.probe[url] = { state: 'wait' }; + const done = (state, ms) => { + if (rm.probe[url].state !== 'wait') return; + rm.probe[url] = { state, ms }; + try { ws.close(); } catch {} + if (ui.relayMgr === rm) render(); + }; + const t0 = performance.now(); + let ws; + try { ws = new WebSocket(url); } catch { done('bad'); return; } + ws.onopen = () => done('ok', Math.round(performance.now() - t0)); + ws.onerror = () => done('bad'); + setTimeout(() => done('bad'), 6000); + } + + function loadRelayMgr(me, inRender = false) { + const rm = ui.relayMgr = { me, loading: true, probe: {}, add: '', addDm: '' }; + if (!inRender) render(); + fetchOwnRelayLists(me).then((base) => { + if (ui.relayMgr !== rm) return; + rm.base = base; + rm.rows = base.r ? rowsOf10002(base.r) : PROFILE_RELAYS.map((url) => ({ url, read: true, write: true })); + rm.dm = base.dm ? urlsOf10050(base.dm) : [...DM_RELAYS]; + rm.loading = false; + render(); + }); + } + + async function saveRelayMgr() { + const rm = ui.relayMgr; + if (!rm || rm.saving || rm.loading) return; + const tags = tagsOfRows(rm.rows); + const dmTags = rm.dm.map((u) => ['relay', u]); + if (!tags.some((x) => !x[2] || x[2] === 'write') || !tags.some((x) => !x[2] || x[2] === 'read')) { toast(t('relaysNeedBoth')); return; } + if (!dmTags.length) { toast(t('relaysNeedDm')); return; } + const rChanged = !rm.base.r || !sameTags(tags, tagsOfRows(rowsOf10002(rm.base.r))); + const dmChanged = !rm.base.dm || !sameTags(rm.dm, urlsOf10050(rm.base.dm)); + if (!rChanged && !dmChanged) { toast(t('relaysUnchanged')); return; } + if (typeof navigator !== 'undefined' && navigator.onLine === false) { toast(t('msgSendFailed')); return; } + rm.saving = true; + render(); + try { + const id = await requireIdentity(); + if (id.pubkey !== rm.me) throw new Error(t('relaysWrongIdentity')); + // someone else's client may have saved since this card loaded + const now = await fetchOwnRelayLists(rm.me); + const newer = (a, b) => a && (!b || a.created_at > b.created_at); + if ((rChanged && newer(now.r, rm.base.r)) || (dmChanged && newer(now.dm, rm.base.dm))) { + toast(t('relaysChangedElsewhere')); + loadRelayMgr(rm.me); + return; + } + const sign = (partial) => (id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial)); + const stamp = (old) => Math.max(Math.floor(Date.now() / 1000), old ? old.created_at + 1 : 0); + const writes = tags.filter((x) => !x[2] || x[2] === 'write').map((x) => x[1]); + // the new list goes to the relays it names, the old ones (so they drop + // the stale copy) and the indexers other clients look lists up on + const everywhere = [...new Set([...PROFILE_RELAYS, ...PROFILE_INDEX_RELAYS, + ...rowsOf10002(rm.base.r).map((r) => r.url), ...tags.map((x) => x[1])])]; + if (rChanged) { + const evt = await sign({ kind: 10002, content: '', created_at: stamp(rm.base.r), tags }); + if (!(await publishOn(everywhere, evt))) throw new Error(t('msgSendFailed')); + rm.base.r = evt; + relayListCache.set(rm.me, Promise.resolve(writes.slice(0, 4))); + relayListsNow().set(rm.me, { r: writes, t: Date.now() }); + saveRelayLists(); + // wallet sync rides the same write relays (adoptSyncRelays) + setSyncConfig({ enabled: true, relays: [...new Set([...writes.slice(0, 4), ...DEFAULT_SYNC_RELAYS])].slice(0, 5) }); + } + if (dmChanged) { + const evt = await sign({ kind: 10050, content: '', created_at: stamp(rm.base.dm), tags: dmTags }); + const to = [...new Set([...everywhere, ...urlsOf10050(rm.base.dm), ...rm.dm, ...DM_RELAYS])]; + if (!(await publishOn(to, evt))) throw new Error(t('msgSendFailed')); + rm.base.dm = evt; + resubscribeStreams(); // read the inbox from the new list now + } + toast(t('relaysSaved')); + } catch (e) { + if (!(e instanceof NoIdentity)) toast(e.message || String(e)); + } finally { + rm.saving = false; + render(); + } + } + + function relaysCard() { + const me = mePk(); + if (!me) return null; + if (!ui.relayMgr || ui.relayMgr.me !== me) loadRelayMgr(me, true); + const rm = ui.relayMgr; + const head = [h('h3', {}, t('relaysTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('relaysDesc'))]; + if (rm.loading) { + return h('div', { class: 'card col relays-card', style: 'gap:10px' }, ...head, + h('div', { class: 'row', style: 'justify-content:center;padding:6px' }, h('span', { class: 'spinner sm' }))); + } + const dot = (url) => { + probeRelayLatency(url); + const p = rm.probe[url] || {}; + const color = p.state === 'ok' ? 'var(--green,#2e9d5b)' : p.state === 'bad' ? 'var(--red,#c0392b)' : 'var(--line)'; + return h('span', { title: p.state === 'ok' ? p.ms + ' ms' : p.state === 'bad' ? t('relaysDown') : '', + style: `flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:${color}` }); + }; + const status = (url) => { + const p = rm.probe[url] || {}; + // a green dot says it's up (the ms are its tooltip); only trouble is spelled out + return p.state === 'bad' ? h('span', { class: 'small faint', style: 'white-space:nowrap' }, t('relaysDown')) : null; + }; + const name = (url) => h('span', { class: 'grow mono', style: 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px' }, + url.replace(/^wss:\/\//, '')); + const x = (onClick) => h('button', { class: 'linklike', type: 'button', 'aria-label': t('remove'), style: 'padding:0 4px', onClick }, '×'); + const flag = (row, k, label) => h('button', { + class: 'feed-chip' + (row[k] ? ' on' : ''), type: 'button', style: 'padding:3px 9px;font-size:12px', + onClick: () => { row[k] = !row[k]; if (!row.read && !row.write) row[k === 'read' ? 'write' : 'read'] = true; render(); }, + }, label); + const adder = (key, cls, onAdd) => { + const go = (ev) => { + const url = normRelay(rm[key]); + if (!url || !/^[a-z0-9.-]+(:\d+)?$/i.test(new URL(url).host)) { if (rm[key].trim()) toast(t('relaysBadUrl')); return; } + rm[key] = ''; + const box = ev && ev.currentTarget && ev.currentTarget.parentNode.querySelector('input'); + if (box) box.value = ''; + onAdd(url); + render(); + }; + return h('div', { class: 'row gap6' }, + h('input', { type: 'text', class: 'grow ' + cls, placeholder: 'wss://relay.example.com', value: rm[key], + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + onInput: (ev) => { rm[key] = ev.target.value; }, + onKeydown: (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); go(ev); } } }), + h('button', { class: 'btn-sm', type: 'button', onClick: go }, t('modAdd'))); + }; + return h('div', { class: 'card col relays-card', style: 'gap:10px' }, ...head, + h('div', { class: 'small muted' }, t('relaysPublic')), + rm.base.r ? null : h('div', { class: 'small faint' }, t('relaysNoneYet')), + h('div', { class: 'col', style: 'gap:8px' }, ...rm.rows.map((row) => h('div', { class: 'row gap6 relay-row', style: 'align-items:center' }, + dot(row.url), name(row.url), status(row.url), + flag(row, 'read', t('relaysRead')), flag(row, 'write', t('relaysWrite')), + x(() => { rm.rows = rm.rows.filter((r) => r !== row); render(); })))), + adder('add', 'relay-add', (url) => { if (!rm.rows.some((r) => r.url === url)) rm.rows.push({ url, read: true, write: true }); }), + h('div', { class: 'small muted', style: 'margin-top:6px' }, t('relaysInbox')), + rm.base.dm ? null : h('div', { class: 'small faint' }, t('relaysNoneYet')), + h('div', { class: 'col', style: 'gap:8px' }, ...rm.dm.map((url) => h('div', { class: 'row gap6 relay-dm-row', style: 'align-items:center' }, + dot(url), name(url), status(url), + x(() => { rm.dm = rm.dm.filter((u) => u !== url); render(); })))), + adder('addDm', 'relay-dm-add', (url) => { if (!rm.dm.includes(url)) rm.dm.push(url); }), + h('div', { class: 'row gap6', style: 'margin-top:4px' }, + h('button', { class: 'grow relays-save', disabled: rm.saving, onClick: () => saveRelayMgr() }, rm.saving ? t('relaysSaving') : t('relaysSave')), + h('button', { class: 'btn-ghost', disabled: rm.saving, onClick: () => loadRelayMgr(me) }, t('relaysReload')))); + } + function moderationCard() { const m = mutesNow(); const e = ui.modEdit || (ui.modEdit = { word: '' }); @@ -11054,7 +11238,7 @@ export function messagesFeature(ctx) { id: 'messages', // group-chat state is device state in IndexedDB: it goes with the account wipeCache(bases) { import('../marmot-store.js').then((m) => { for (const b of bases) m.wipeMarmot(b); }).catch(() => {}); }, - nostrSettingsCards() { return [followHistoryCard(), moderationCard()]; }, + nostrSettingsCards() { return [relaysCard(), followHistoryCard(), moderationCard()]; }, // The app came back after being backgrounded. A phone freezes a hidden // tab: the relay sockets are cut and every post made in the meantime is // simply missing, which is why the feed used to sit there looking stale |
