aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js158
1 files changed, 126 insertions, 32 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 706dbe3..d6307cb 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -3916,7 +3916,13 @@ export function messagesFeature(ctx) {
// A feed's own relay list, when it has one, replaces the usual choice
// (the authors' outboxes, or the wide relays for a topic) everywhere the
// feed reads. Independent of what the feed asks for.
- const FIREHOSE_RELAYS = ['wss://relay.coinos.io'];
+ // Popular relays that answer a bare "everything" query (probed 2026-09-27;
+ // paid/search relays like nostr.wine and nostr.band answer nothing and are
+ // left out). The firehose reads a few of them by default.
+ const RELAY_PICKS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.snort.social',
+ 'wss://nostr.mom', 'wss://nostr.bitcoiner.social', 'wss://eden.nostr.land', 'wss://nostr.land', 'wss://nostr.oxtr.dev',
+ 'wss://relay.nostrplebs.com', 'wss://offchain.pub', 'wss://nostr21.com', 'wss://relay.nostr.net', 'wss://nostr-pub.wellorder.net'];
+ const FIREHOSE_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
const normRelay = (u) => {
let x = String(u || '').trim();
if (!x) return null;
@@ -4304,6 +4310,7 @@ export function messagesFeature(ctx) {
function openFeedUrl(path, search) {
let q; try { q = new URLSearchParams(search || ''); } catch { q = new URLSearchParams(); }
const readFrom = urlRelays(q);
+ for (const r of readFrom) { try { resetRelay(r); } catch {} } // named in the link: tried even if it was sick
if (!path) return openQueryFeed(q);
if (/^t\//i.test(path)) { const tag = normTopic(decodeURIComponent(path.slice(2))); if (!tag) return false; openTopicFeed(tag, readFrom); return true; }
const pk = parseNostrPubkey(path);
@@ -4345,6 +4352,7 @@ export function messagesFeature(ctx) {
const dropFeedState = (id) => { const c = feedStates.get(id); if (c) c.stopped = true; feedStates.delete(id); };
function setFeedRelays(def, relays) {
relays = [...new Set(relays.map(normRelay).filter(Boolean))].slice(0, 12);
+ for (const r of relays) { try { resetRelay(r); } catch {} } // chosen by hand: tried again even if it was sick
if (def.builtin) { const s = st(); s.builtinRelays = { ...(s.builtinRelays || {}), [def.id]: relays }; save(s); }
else if (adhocFeeds.has(def.id)) def.relays = relays;
else { const s = st(); s.feeds = s.feeds.map((f) => (f.id === def.id ? { ...f, relays } : f)); save(s); }
@@ -4357,29 +4365,52 @@ export function messagesFeature(ctx) {
const usualRelays = (def) => (def.all ? FIREHOSE_RELAYS : (feedAuthors(def).length ? null : TOPIC_RELAYS));
function relayPanel(def) {
const e = ui.feedRelayEdit;
- const list = feedRelays(def) || [];
+ const custom = feedRelays(def);
const usual = usualRelays(def);
- const add = () => {
+ const list = custom || usual || [];
+ const host = (u) => u.replace(/^wss?:\/\//, '');
+ const commit = (next) => { setFeedRelays(def, next); e.other = null; e.input = ''; render(); };
+ // each relay is a pick from the popular list (the one in place stays
+ // offered even when it isn't popular), or Other… for one typed in
+ const rowSelect = (u, i) => h('select', { class: 'grow', style: 'min-width:0', onChange: (ev) => {
+ const v = ev.target.value;
+ if (v === '__other') { e.other = u; e.input = ''; render(); return; }
+ const next = list.slice(); next[i] = v; commit(next);
+ } },
+ ...[...new Set([u, ...RELAY_PICKS])].filter((x) => x === u || !list.includes(x)).map((x) => h('option', { value: x, selected: x === u ? 'selected' : undefined }, host(x))),
+ h('option', { value: '__other' }, t('feedRelayOther')));
+ const inputRow = (onSave) => h('div', { class: 'row gap6' },
+ h('input', { type: 'text', class: 'grow', placeholder: t('feedRelayPh'), value: e.input, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', autofocus: true,
+ onInput: (ev) => { e.input = ev.target.value; }, onKeyDown: (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); onSave(); } } }),
+ h('button', { class: 'btn-sm', type: 'button', onClick: onSave }, t('feedRelayAdd')),
+ h('button', { class: 'btn-sm', type: 'button', 'aria-label': t('back'), onClick: () => { e.other = null; render(); } }, '\u00d7'));
+ const saveOther = (i) => () => {
const r = normRelay(e.input);
if (!r) { toast(t('feedRelayBad')); return; }
- e.input = '';
- setFeedRelays(def, [...(feedRelays(def) || usual || []), r]);
- render();
+ const next = list.slice(); if (i == null) next.push(r); else next[i] = r;
+ commit(next);
};
return h('div', { class: 'card col', style: 'gap:8px;padding:10px 12px;background:var(--surface2)' },
h('div', { class: 'small muted' }, t('feedRelaysHint')),
- ...(list.length ? list : (usual || [])).map((u) => h('div', { class: 'row gap6', style: 'align-items:center' },
- h('span', { class: 'small mono grow', style: 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, u.replace(/^wss:\/\//, '')),
- list.length
- ? h('button', { class: 'btn-sm', type: 'button', 'aria-label': t('remove'), onClick: () => { setFeedRelays(def, list.filter((x) => x !== u)); render(); } }, '\u00d7')
- : h('span', { class: 'small faint' }, t('feedRelayUsual')))),
+ ...list.map((u, i) => e.other === u
+ ? inputRow(saveOther(i))
+ : h('div', { class: 'row gap6', style: 'align-items:center' },
+ rowSelect(u, i),
+ h('button', { class: 'btn-sm', type: 'button', 'aria-label': t('remove'), onClick: () => commit(list.filter((_, j) => j !== i)) }, '\u00d7'))),
!list.length && !usual ? h('div', { class: 'small faint' }, t('feedRelaysOutbox')) : null,
+ e.other === '__new'
+ ? inputRow(saveOther(null))
+ : h('select', { 'aria-label': t('feedRelayPick'), onChange: (ev) => {
+ const v = ev.target.value; ev.target.value = '';
+ if (!v) return;
+ if (v === '__other') { e.other = '__new'; e.input = ''; render(); return; }
+ commit([...list, v]);
+ } },
+ h('option', { value: '', selected: 'selected' }, t('feedRelayPick')),
+ ...RELAY_PICKS.filter((x) => !list.includes(x)).map((x) => h('option', { value: x }, host(x))),
+ h('option', { value: '__other' }, t('feedRelayOther'))),
h('div', { class: 'row gap6' },
- h('input', { type: 'text', class: 'grow', placeholder: t('feedRelayPh'), value: e.input, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
- onInput: (ev) => { e.input = ev.target.value; }, onKeyDown: (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } } }),
- h('button', { class: 'btn-sm', type: 'button', onClick: add }, t('feedRelayAdd'))),
- h('div', { class: 'row gap6' },
- list.length ? h('button', { class: 'btn-ghost btn-sm', type: 'button', onClick: () => { setFeedRelays(def, []); render(); } }, t('feedRelaysDefault')) : null,
+ custom ? h('button', { class: 'btn-ghost btn-sm', type: 'button', onClick: () => commit([]) }, t('feedRelaysDefault')) : null,
h('button', { class: 'btn-sm', type: 'button', style: 'margin-left:auto', onClick: () => { ui.feedRelayEdit = null; render(); } }, t('feedRelaysDone'))));
}
async function shareFeed(def) {
@@ -5782,16 +5813,17 @@ export function messagesFeature(ctx) {
s.add(first); s.add(ev.id);
}
const SPAM_DUPES = 5;
- const isSpammer = (pk) => modOn('spam') && (spamBy.get(pk) || { size: 0 }).size >= SPAM_DUPES && !isFollowing(pk) && !isMe(pk);
+ const isSpammer = (pk) => modOn('spam') && (spamBy.get(pk) || { size: 0 }).size >= SPAM_DUPES && !trusted(pk) && !isMe(pk);
const HASHTAG_MAX = 10;
const hashtagSpam = (ev) => ev.kind === 1 && modOn('spam') && !isFollowing(ev.pubkey)
&& ((ev.tags || []).filter((x) => x[0] === 't').length > HASHTAG_MAX
|| (String(ev.content || '').match(/(^|\s)#[\p{L}\p{N}_]{2,}/gu) || []).length > HASHTAG_MAX);
function hiddenWhy(ev) {
if (!ev || !ev.pubkey || isMe(ev.pubkey)) return null;
- const m = mutesNow();
- if (m.set.has(ev.pubkey)) return 'muted';
- if (ev.kind === 1) {
+ const bm = baselineMutes();
+ for (const m of bm ? [mutesNow(), bm] : [mutesNow()]) {
+ if (m.set.has(ev.pubkey)) return 'muted';
+ if (ev.kind !== 1) continue;
if (m.threads.size && m.threads.has(rootIdOf(ev))) return 'thread';
const text = String(ev.content || '').toLowerCase();
if (m.words.length && m.words.some((w) => text.includes(w))) return 'word';
@@ -5801,14 +5833,14 @@ export function messagesFeature(ctx) {
}
}
if (reportsNow().mine.has(ev.id)) return 'reported';
- if (!isFollowing(ev.pubkey)) {
+ if (!trusted(ev.pubkey)) {
if (isSpammer(ev.pubkey) || (modOn('spam') && spamDup.has(ev.id))) return 'spam';
if (hashtagSpam(ev)) return 'hashtags';
}
return null;
}
const hidden = (ev) => !!hiddenWhy(ev);
- const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || isSpammer(pk) || reportsNow().mine.has(pk));
+ const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || (baselineMutes() || { set: new Set() }).set.has(pk) || isSpammer(pk) || reportsNow().mine.has(pk));
const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk));
// ---- reports (NIP-56 kind 1984) from the people you follow --------------
@@ -5834,9 +5866,8 @@ export function messagesFeature(ctx) {
try { wallet.saveFeatureState(REPORTS, { ev: toObj(r.ev), pk: toObj(r.pk), mine: [...r.mine].slice(-500), at: r.at }); } catch {}
}
// A report typed 'other' says nothing anyone can act on and is ignored.
- function noteReport(ev) {
- const r = reportsNow();
- const mine = myPubkeys().includes(ev.pubkey);
+ function noteReport(ev) { indexReport(reportsNow(), ev, myPubkeys().includes(ev.pubkey)); }
+ function indexReport(r, ev, mine) {
const hex = (v) => /^[0-9a-f]{64}$/.test(v || '');
let es = 0;
for (const x of ev.tags || []) {
@@ -5880,16 +5911,78 @@ export function messagesFeature(ctx) {
// folds everything they write, so it takes two follows to agree — one
// mis-tapped "impersonation" on a well-known key used to fold their whole
// timeline for everyone who followed the tapper.
+ // ---- a reader with no web of trust borrows coinos's -------------------
+ // A visitor, or a wallet that follows almost nobody yet, has no follows
+ // whose flags could fold anything (and a visitor no mute list at all), so
+ // the public feeds would show the relays raw. They get the moderation the
+ // coinos account gets: its mute list (visitors) and its follows' reports,
+ // read from the relays every few hours and kept on the device for every
+ // wallet. A wallet's own mutes always count too.
+ const CURATOR_PK = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';
+ const BASELINE_KEY = 'coinos-mod-baseline';
+ const BASELINE_MS = 6 * 3600_000;
+ const BASELINE_MIN_FOLLOWS = 5;
+ let baseline = null; // { follows: Set, mutes (withSets), ev: Map, pk: Map, mine: Set, at }
+ let baselineSync = null;
+ function baselineNow() {
+ if (!baseline) {
+ let b = null;
+ try { b = JSON.parse(localStorage.getItem(BASELINE_KEY) || 'null'); } catch {}
+ const toMap = (o) => new Map(Object.entries(o || {}).map(([k, v]) => [k, new Set(v)]));
+ baseline = { follows: new Set((b && b.follows) || []), mutes: withSets({ tags: (b && b.mutes) || [], priv: [], content: '', at: 0 }),
+ ev: toMap(b && b.ev), pk: toMap(b && b.pk), mine: new Set(), at: (b && b.at) || 0 };
+ }
+ return baseline;
+ }
+ const usingBaseline = () => !mePk() || followsNow().set.size < BASELINE_MIN_FOLLOWS;
+ const baselineMutes = () => (mePk() ? null : baselineNow().mutes);
+ // someone a web of trust vouches for (the reader's own, or the borrowed
+ // one while their own is thin): exempt from the heuristics
+ const trusted = (pk) => isFollowing(pk) || (usingBaseline() && baselineNow().follows.has(pk));
+ async function syncBaseline() {
+ const b = baselineNow();
+ if (baselineSync || Date.now() - b.at < BASELINE_MS) return baselineSync;
+ baselineSync = (async () => {
+ try {
+ const evs = await queryOn(zapRelays(), { kinds: [3, 10000], authors: [CURATOR_PK] }, 6000).catch(() => []);
+ const newest = (k) => (evs || []).filter((e) => e.kind === k).sort((x, y) => y.created_at - x.created_at)[0];
+ const k3 = newest(3), k10 = newest(10000);
+ if (!k3) return; // the relays were away: keep what we have, ask again soon
+ const follows = hexList(k3.tags.filter((x) => x[0] === 'p').map((x) => x[1]));
+ const muteTags = k10 ? (k10.tags || []).filter((x) => ['p', 't', 'word', 'e'].includes(x[0])).slice(0, 2000) : b.mutes.tags;
+ const authors = [CURATOR_PK, ...follows].slice(0, FEED_AUTHORS_MAX);
+ const from = new Set(authors);
+ const r = { ev: new Map(), pk: new Map(), mine: new Set() };
+ await fetchRelayLists(authors);
+ const got = await Promise.all(outboxPlan(authors).flatMap(({ relays, authors: a }) => {
+ const chunks = [];
+ for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS));
+ return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500 }, 5000).catch(() => []));
+ }));
+ for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) indexReport(r, ev, false);
+ baseline = { follows: new Set(follows), mutes: withSets({ tags: muteTags, priv: [], content: '', at: k10 ? k10.created_at : 0 }), ev: r.ev, pk: r.pk, mine: new Set(), at: Date.now() };
+ const toObj = (m) => Object.fromEntries([...m.entries()].slice(-1500).map(([k, v]) => [k, [...v].slice(0, 50)]));
+ try { localStorage.setItem(BASELINE_KEY, JSON.stringify({ follows, mutes: muteTags, ev: toObj(r.ev), pk: toObj(r.pk), at: baseline.at })); } catch {}
+ scheduleRepaint();
+ } catch {} finally { baselineSync = null; }
+ })();
+ return baselineSync;
+ }
const PERSON_REPORTS_MIN = 2;
function reportsOn(ev) {
if (!ev || !ev.id || !modOn('reports') || isMe(ev.pubkey)) return null;
- const r = reportsNow();
- const f = followsNow().set;
- const onPost = new Set(), onPerson = new Set();
- for (const pk of r.ev.get(ev.id) || []) if (f.has(pk) && pk !== ev.pubkey) onPost.add(pk);
- for (const pk of r.pk.get(ev.pubkey) || []) if (f.has(pk) && pk !== ev.pubkey) onPerson.add(pk);
- if (!onPost.size && onPerson.size < PERSON_REPORTS_MIN) return null;
- return [...new Set([...onPost, ...onPerson])];
+ const flagsFrom = (r, f) => {
+ const onPost = new Set(), onPerson = new Set();
+ for (const pk of r.ev.get(ev.id) || []) if (f.has(pk) && pk !== ev.pubkey) onPost.add(pk);
+ for (const pk of r.pk.get(ev.pubkey) || []) if (f.has(pk) && pk !== ev.pubkey) onPerson.add(pk);
+ if (!onPost.size && onPerson.size < PERSON_REPORTS_MIN) return null;
+ return [...new Set([...onPost, ...onPerson])];
+ };
+ const own = mePk() ? flagsFrom(reportsNow(), followsNow().set) : null;
+ if (own) return own;
+ if (!usingBaseline()) return null;
+ const b = baselineNow();
+ return flagsFrom(b, b.follows);
}
// A flagged post, folded: who flagged it, and a tap to see it anyway.
function foldedRow(ev, by) {
@@ -7233,6 +7326,7 @@ export function messagesFeature(ctx) {
function feedView() {
syncFollowSets().catch(() => {}); // throttled inside
syncReports().catch(() => {}); // likewise
+ if (usingBaseline()) syncBaseline()?.catch?.(() => {}); // a reader without a web of trust borrows one
// Back/Forward restored another feed's id: catch up before painting
if (ui.feedId && ui.feedId !== curFeedId && feedDef(ui.feedId)) selectFeed(ui.feedId);
const c = feedNow();