diff options
| author | Adam Soltys <[email protected]> | 2026-10-08 22:56:46 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-08 22:56:46 -0700 |
| commit | f555cba5723453fc48dfbcba234da351b37db2f0 (patch) | |
| tree | 5dbf95905fe0cbb9139bf6d6e71f2a33999eba5c /src | |
| parent | c8158d13fcd0278a7525e762fccea2a1329f7768 (diff) | |
| download | coinosv3-f555cba5723453fc48dfbcba234da351b37db2f0.tar.gz coinosv3-f555cba5723453fc48dfbcba234da351b37db2f0.zip | |
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 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 71 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/style.css | 2 |
3 files changed, 67 insertions, 8 deletions
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) diff --git a/src/i18n.js b/src/i18n.js index 29f77c3..0ec6366 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -272,6 +272,8 @@ const STR = { currencyLabel: 'Show amounts in', feedTitle: 'Feed', feedFollowingName: 'Following', + feedMixName: 'Everything', + feedFollowsListsName: 'Follows + lists', feedNew: 'New feed', feedNewName: 'New feed', feedEdit: 'Edit feed', diff --git a/src/style.css b/src/style.css index df90cbe..dabd7be 100644 --- a/src/style.css +++ b/src/style.css @@ -640,7 +640,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } .chat-thread-row { display: flex; gap: 10px; align-items: center; cursor: pointer; } /* list rows read at message size: names 16, previews/member counts 15 — the generic .small (13px) made the inbox feel like fine print */ -.chat-thread-row .chat-name { font-size: 16px; } +.chat-thread-row .chat-name { font-size: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chat-thread-row .small { font-size: 15px; } .chat-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .thread-when { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; } |
