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 ++++++++++++++++++++++++++++++++++++++++++----- src/i18n.js | 2 ++ src/style.css | 2 +- tools/feed-custom-test.js | 30 +++++++++++++++----- 4 files changed, 90 insertions(+), 15 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; } diff --git a/tools/feed-custom-test.js b/tools/feed-custom-test.js index ef595ff..b263d71 100644 --- a/tools/feed-custom-test.js +++ b/tools/feed-custom-test.js @@ -108,7 +108,7 @@ try { await waitText('receive', 20000); await openFeed(); await sleep(1200); - check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', '+']), JSON.stringify(await chips())); + check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['Everything', '*Following', 'Popular', '#gardenstr', '+']), JSON.stringify(await chips())); console.log('\n[a new feed: everyone I follow, on one topic]'); await page.click('.feed-chip.add'); await sleep(300); @@ -117,8 +117,8 @@ try { await page.click('input[type=checkbox]:not(.feed-all-toggle)'); // everyone I follow (the first box is the relays' every-post one) await type('input[placeholder="#bitcoin #nostr"]', '#Bitcoin, lightning'); await click('save'); await sleep(1500); - check('the new feed is on screen and selected', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', '*Coin talk', '+']), JSON.stringify(await chips())); - const req = (await page.evaluate(() => window.__reqs)).find((f) => f['#t']); + check('the new feed is on screen and selected', JSON.stringify(await chips()) === JSON.stringify(['Everything', 'Following', 'Popular', '#gardenstr', '*Coin talk', '+']), JSON.stringify(await chips())); + const req = (await page.evaluate(() => window.__reqs)).find((f) => f['#t'] && f.authors); check('it asks relays for the follows AND the topics', !!req && req.authors?.includes(AUTHOR) && JSON.stringify(req['#t']) === JSON.stringify(['bitcoin', 'lightning']), JSON.stringify(req || null)); const tagged = signed('fresh #bitcoin post', 0, [['t', 'bitcoin']]); const n = await page.evaluate((ev) => window.__inject(ev), tagged); @@ -130,9 +130,9 @@ try { console.log('\n[back to Following, and a #tag in a post]'); await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].find((c) => c.textContent.trim() === 'Following').click()); await sleep(1200); - check('Following again, with its own posts', (await chips())[0] === '*Following' && await waitText('plain post', 4000)); + check('Following again, with its own posts', (await chips()).includes('*Following') && await waitText('plain post', 4000)); await page.evaluate(() => { const a = [...document.querySelectorAll('.notes-feed a')].find((x) => x.textContent === '#bitcoin'); if (a) a.click(); }); await sleep(1200); - check('a #tag opens a topic feed of its own', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', 'Coin talk', '*#bitcoin', '+']) && await page.evaluate(() => { const m = document.querySelector('[aria-label="Feed options"]'); if (!m) return false; m.click(); return true; }) && await waitText('save feed', 2000) && await page.evaluate(() => { const b = [...document.querySelectorAll('.msg-sheet button')].find((x) => /back/i.test(x.textContent)); if (b) b.click(); return true; }), JSON.stringify(await chips())); + check('a #tag opens a topic feed of its own', JSON.stringify(await chips()) === JSON.stringify(['Everything', 'Following', 'Popular', '#gardenstr', 'Coin talk', '*#bitcoin', '+']) && await page.evaluate(() => { const m = document.querySelector('[aria-label="Feed options"]'); if (!m) return false; m.click(); return true; }) && await waitText('save feed', 2000) && await page.evaluate(() => { const b = [...document.querySelectorAll('.msg-sheet button')].find((x) => /back/i.test(x.textContent)); if (b) b.click(); return true; }), JSON.stringify(await chips())); const treq = (await page.evaluate(() => window.__reqs)).filter((f) => f['#t'] && !f.authors).pop(); check('...asked for by topic alone', !!treq && JSON.stringify(treq['#t']) === JSON.stringify(['bitcoin']), JSON.stringify(treq || null)); @@ -156,12 +156,28 @@ try { const anyMember = (await page.evaluate(() => window.__reqs)).some((f) => f.authors && f.authors.includes(MEMBER)); check('the feed asks relays for the pack\'s member', anyMember, JSON.stringify((await page.evaluate(() => window.__reqs)).map((f) => (f.authors || []).map((a) => a.slice(0, 8))))); + console.log('\n[Everything and Follows + lists (Amethyst\'s model)]'); + const chip = (name) => page.evaluate((n) => [...document.querySelectorAll('.feed-chip')].find((c) => c.textContent.trim() === n)?.click(), name); + await page.evaluate(() => { window.__reqs = []; }); + await chip('Everything'); await sleep(1500); + let reqs = await page.evaluate(() => window.__reqs); + check('Everything asks for follows + list people', reqs.some((f) => f.authors && f.authors.includes(AUTHOR) && !f['#t']) && reqs.some((f) => f.authors && f.authors.includes(MEMBER) && !f['#t']), JSON.stringify(reqs.map((f) => [(f.authors || []).length, f['#t'] || null]))); + check('...and for followed hashtags from anyone', reqs.some((f) => !f.authors && JSON.stringify(f['#t']) === JSON.stringify(['gardenstr']))); + await page.evaluate((ev) => window.__inject(ev), signed('a stranger on #gardenstr', 5, [['t', 'gardenstr']], OTHER_SK)); await sleep(1500); + check('a stranger\'s post on a followed hashtag lands in Everything', await waitText('a stranger on #gardenstr', 4000)); + await page.evaluate(() => { window.__reqs = []; }); + await chip('Follows + lists'); await sleep(1500); + reqs = await page.evaluate(() => window.__reqs); + check('Follows + lists asks for follows + list people, no hashtags', reqs.some((f) => f.authors && f.authors.includes(MEMBER)) && !reqs.some((f) => f['#t'])); + check('...and the stranger\'s post is not in it', !(await page.evaluate(() => document.body.innerText)).includes('a stranger on #gardenstr')); + await chip('Pack feed'); await sleep(800); + console.log('\n[the feeds survive a reload; a feed can be deleted]'); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await openFeed(); await sleep(1200); // (the feed you were on is remembered: the pack feed, saved last) - check('the saved feeds are still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', 'Coin talk', '*Pack feed', '+']), JSON.stringify(await chips())); + check('the saved feeds are still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['Everything', 'Follows + lists', 'Following', 'Popular', '#gardenstr', 'Coin talk', '*Pack feed', '+']), JSON.stringify(await chips())); // (the chat home no longer lists the feed with its summary: its editor shows the pack) await page.click('[aria-label="Feed options"]'); await sleep(300); await page.evaluate(() => [...document.querySelectorAll('.msg-sheet button')].find((b) => /edit feed/i.test(b.textContent)).click()); await sleep(400); @@ -172,7 +188,7 @@ try { await page.evaluate(() => [...document.querySelectorAll('.msg-sheet button')].find((b) => /edit feed/i.test(b.textContent)).click()); await sleep(400); check('the editor shows what was saved', await page.evaluate(() => document.querySelector('input[placeholder="#bitcoin #nostr"]').value === '#bitcoin #lightning' && document.querySelector('input[type=checkbox]:not(.feed-all-toggle)').checked)); await click('delete feed'); await sleep(1000); - check('deleting it lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', 'Pack feed', '+']), JSON.stringify(await chips())); + check('deleting it lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['Everything', 'Follows + lists', '*Following', 'Popular', '#gardenstr', 'Pack feed', '+']), JSON.stringify(await chips())); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ feeds: Following is one of many' : '\n❌ failed'); -- cgit v1.2.3