diff options
| -rw-r--r-- | src/features/messages.js | 71 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/style.css | 2 | ||||
| -rw-r--r-- | 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'); |
