From f555cba5723453fc48dfbcba234da351b37db2f0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 8 Oct 2026 22:56:46 -0700 Subject: Amethyst's feed model: an Everything feed (your follows, everyone on your lists and packs, and posts from anyone on a hashtag you follow: your topic feeds' tags and your NIP-51 interests, kind 10015) and a Follows + lists feed beside Following, built from your feeds and offered once there's something to add. Long names in search results end in an ellipsis instead of running off the card Co-Authored-By: Claude Opus 5.5 --- src/features/messages.js | 71 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 7 deletions(-) (limited to 'src/features/messages.js') diff --git a/src/features/messages.js b/src/features/messages.js index b80c87b..48596f2 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -441,6 +441,9 @@ export function messagesFeature(ctx) { // A feed added, edited or deleted here goes out on the next sync. let feedsPush = null; const feedsChanged = () => { + // Everything and Follows + lists are built from the other feeds: a + // changed list rebuilds them next time they're opened + for (const id of [MIX, FOLLOWS_LISTS]) { const c = feedStates.get(id); if (c && c !== feed) feedStates.delete(id); } clearTimeout(feedsPush); feedsPush = setTimeout(() => { try { wallet.saveCache(); } catch {} }, 1000); }; @@ -4710,15 +4713,45 @@ export function messagesFeature(ctx) { // Two feeds everyone has: the people you follow, and everything the coinos // relay carries. Each remembers its own relay choice in the state. const EVERYTHING = 'all'; + // Amethyst's feed model, for the people who asked: everyone you follow plus + // everyone on your lists, and on top of that ("Everything") posts from + // anyone on a hashtag you follow — your topic feeds' tags and your NIP-51 + // interests (kind 10015). Built from your feeds each time, never stored; + // offered once there's something beyond your follows to add. + const MIX = 'mix', FOLLOWS_LISTS = 'follows-lists', INTERESTS_KIND = 10015; + const INTERESTS = 'interests'; // feature state: { tags, at } from your kind 10015 + function listsUnion() { + const authors = new Set(), priv = new Set(), packs = new Map(); + for (const f of st().feeds) { + for (const pk of f.authors || []) authors.add(pk); + for (const pk of f.priv || []) priv.add(pk); + for (const p of f.packs || []) packs.set(packKey(p), p); + } + return { authors: [...authors], priv: [...priv], packs: [...packs.values()] }; + } + function followedTopics() { + const out = new Set(); + try { for (const x of (wallet.loadFeatureState(INTERESTS, {}) || {}).tags || []) out.add(normTopic(x)); } catch {} + for (const f of st().feeds) { + if (f.follows || (f.authors || []).length || (f.packs || []).length || (f.priv || []).length) continue; + for (const x of f.topics || []) out.add(normTopic(x)); + } + out.delete(''); + return [...out].slice(0, 30); + } const feedList = () => { const br = st().builtinRelays || {}; + const lists = listsUnion(), orTopics = followedTopics(); + const hasLists = lists.authors.length || lists.priv.length || lists.packs.length; return [ + ...(hasLists || orTopics.length ? [{ id: MIX, name: t('feedMixName'), follows: true, ...lists, orTopics, builtin: true, relays: br[MIX] || [] }] : []), + ...(hasLists && orTopics.length ? [{ id: FOLLOWS_LISTS, name: t('feedFollowsListsName'), follows: true, ...lists, builtin: true, relays: br[FOLLOWS_LISTS] || [] }] : []), { id: FOLLOWING, name: t('feedFollowingName'), follows: true, builtin: true, relays: br[FOLLOWING] || [] }, { id: EVERYTHING, name: t('feedPopular'), follows: false, authors: [], packs: [], topics: [], all: true, curated: true, builtin: true, relays: oldPopularDefault(br[EVERYTHING]) ? [] : br[EVERYTHING] || [] }, ...st().feeds]; }; function feedDef(id = curFeedId) { - if (id === FOLLOWING || id === EVERYTHING) return feedList().find((f) => f.id === id); + if (id === FOLLOWING || id === EVERYTHING || id === MIX || id === FOLLOWS_LISTS) return feedList().find((f) => f.id === id) || null; return st().feeds.find((f) => f.id === id) || adhocFeeds.get(id) || null; } const normTopic = (x) => String(x || '').trim().replace(/^#/, '').toLowerCase(); @@ -4824,6 +4857,8 @@ export function messagesFeature(ctx) { resolvePacks(def); return out; } + // posts from anyone on these tags, as well as the feed's people (Everything) + const orTopicsOf = (def) => [...new Set(((def && def.orTopics) || []).map(normTopic).filter(Boolean))]; const feedTopics = (def = feedDef()) => [...new Set(((def && def.topics) || []).map(normTopic).filter(Boolean))].slice(0, 20); const feedHasQuery = (def = feedDef()) => !!def && (!!def.all || feedAuthors(def).length > 0 || feedTopics(def).length > 0); // A feed's own relay list, when it has one, replaces the usual choice @@ -4965,7 +5000,18 @@ export function messagesFeature(ctx) { const mine = await Promise.resolve(relaysOf(me)).catch(() => []); const relays = [...new Set([...zapRelays(), ...(mine || []), ...PACK_RELAYS])]; let evs; - try { evs = await queryOn(relays, { kinds: [LIST_KIND], authors: [me] }, 5000); } catch { listsAt = 0; return; } + try { evs = await queryOn(relays, { kinds: [LIST_KIND, INTERESTS_KIND], authors: [me] }, 5000); } catch { listsAt = 0; return; } + // your followed hashtags (NIP-51 interests): Everything reads them + const interest = (evs || []).filter((e) => e.kind === INTERESTS_KIND).sort((a, b) => b.created_at - a.created_at)[0]; + if (interest) { + const prev = wallet.loadFeatureState(INTERESTS, {}) || {}; + if (!(prev.at >= interest.created_at)) { + wallet.saveFeatureState(INTERESTS, { tags: interest.tags.filter((x) => x[0] === 't' && x[1]).map((x) => normTopic(x[1])), at: interest.created_at }); + feedStates.delete(MIX); + scheduleRepaint(); + } + } + evs = (evs || []).filter((e) => e.kind === LIST_KIND); const newest = new Map(); for (const ev of evs || []) { const d = dTagOf(ev); @@ -5340,8 +5386,11 @@ export function messagesFeature(ctx) { return; } try { - await queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, - ...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, merge, c, 3000); + const orT = orTopicsOf(def); + await Promise.all([ + queryFeed(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, + ...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, merge, c, 3000), + orT.length ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE, '#t': orT }, merge, c, 3000) : null]); } catch {} finally { if (!c.stopped) { c.status = 'ready'; @@ -5354,10 +5403,11 @@ export function messagesFeature(ctx) { // by its people, on its topics — stand in until the relays answer. function borrowedNotes(def) { if (!def || def.all) return []; - const authors = new Set(feedAuthors(def)), topics = new Set(feedTopics(def)); + const authors = new Set(feedAuthors(def)), topics = new Set(feedTopics(def)), orT = new Set(orTopicsOf(def)); if (!authors.size && !topics.size) return []; - const fits = (e) => (!authors.size || authors.has(e.pubkey)) - && (!topics.size || (e.tags || []).some((x) => x[0] === 't' && topics.has(normTopic(x[1])))); + const tagged = (e, set) => (e.tags || []).some((x) => x[0] === 't' && set.has(normTopic(x[1]))); + const fits = (e) => ((!authors.size || authors.has(e.pubkey)) + && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT)); const seen = new Set(), out = []; for (const o of feedStates.values()) { for (const e of o.notes) if (!seen.has(e.id) && fits(e) && !hidden(e)) { seen.add(e.id); out.push(e); } @@ -6408,6 +6458,10 @@ export function messagesFeature(ctx) { const tag = topics.length ? { '#t': topics } : {}; let got = false; if (def.curated) return popularPass(extra, merge, c, def); + const orT = orTopicsOf(def); + const orPass = orT.length + ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) + : null; if (!authors.length) { // topics alone: nobody's outbox to read, so the wide relays, one ask; // the firehose asks its relays for everything @@ -6445,6 +6499,7 @@ export function messagesFeature(ctx) { await readPlan(additional); })()]); } + await orPass; return got; } // Whatever a refresh finds goes behind the pill whenever posts are already @@ -6605,6 +6660,8 @@ export function messagesFeature(ctx) { const authors = feedAuthors(def); if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; } if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: FEED_KINDS, ...tag, since }, on)); return; } + const orT = orTopicsOf(def); + if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, '#t': orT, since }, on)); const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); for (const { relays, authors: a } of plan) for (let i = 0; i < a.length; i += REQ_AUTHORS) -- cgit v1.2.3