diff options
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 |
