aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 08:36:12 -0700
committerAdam Soltys <[email protected]>2026-10-09 08:36:12 -0700
commit57656c513e72646c4b55fd44b1ff2cdc96624c3f (patch)
tree0a77355555b05193bcff026aea083351583c2c48
parent9918e6ce7d1ea812921ee816913ed45e8e368906 (diff)
downloadcoinosv3-57656c513e72646c4b55fd44b1ff2cdc96624c3f.tar.gz
coinosv3-57656c513e72646c4b55fd44b1ff2cdc96624c3f.zip
Feeds: Everything and Follows + lists are no longer built-in chips; any custom feed can be one. The editor gains "Everyone on my lists and packs" (the people of all your other feeds and packs, read fresh each time the feed opens) and "Posts from anyone on hashtags I follow" (your topic feeds' tags and your kind-10015 interests, OR'd in beside the feed's people, or the whole feed when it has no people). The editor reads a feed as saved, never expanded, so the lists don't get copied into it
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MgAFLnkF7KrdhuyCLMNgtL
-rw-r--r--src/features/messages.js70
-rw-r--r--src/i18n.js5
-rw-r--r--tools/feed-custom-test.js31
3 files changed, 72 insertions, 34 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 59133dc..3a65725 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -442,9 +442,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); }
+ // a feed that takes in your lists is built from the other feeds: a
+ // changed list rebuilds it next time it's opened
+ for (const f of st().feeds) if (expands(f)) { const c = feedStates.get(f.id); if (c && c !== feed) feedStates.delete(f.id); }
clearTimeout(feedsPush);
feedsPush = setTimeout(() => { try { wallet.saveCache(); } catch {} }, 1000);
};
@@ -4735,11 +4735,12 @@ export function messagesFeature(ctx) {
// 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;
+ // everyone on your lists, and on top of that posts from anyone on a
+ // hashtag you follow — your topic feeds' tags and your NIP-51 interests
+ // (kind 10015). Not chips of their own: a custom feed ticks "everyone on
+ // my lists" (f.lists) and/or "hashtags I follow" (f.interests), and is
+ // expanded from your other feeds each time it's read, never stored so.
+ const 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();
@@ -4760,20 +4761,38 @@ export function messagesFeature(ctx) {
out.delete('');
return [...out].slice(0, 30);
}
+ const expands = (f) => !!f && (!!f.lists || !!f.interests);
+ function expandFeed(f) {
+ if (!expands(f) || f.all) return f;
+ const out = { ...f };
+ if (f.lists) {
+ const u = listsUnion();
+ out.authors = [...new Set([...(f.authors || []), ...u.authors])];
+ out.priv = [...new Set([...(f.priv || []), ...u.priv])];
+ const packs = new Map((f.packs || []).map((p) => [packKey(p), p]));
+ for (const p of u.packs) packs.set(packKey(p), p);
+ out.packs = [...packs.values()];
+ }
+ if (f.interests) {
+ const tags = followedTopics();
+ // nobody to read: the hashtags ARE the feed (posts from anyone on them)
+ const people = out.follows || (out.authors || []).length || (out.priv || []).length || (out.packs || []).length;
+ if (people) out.orTopics = tags;
+ else out.topics = [...new Set([...(f.topics || []), ...tags])];
+ }
+ return out;
+ }
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];
+ ...st().feeds.map(expandFeed)];
};
function feedDef(id = curFeedId) {
- 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;
+ if (id === FOLLOWING || id === EVERYTHING) return feedList().find((f) => f.id === id) || null;
+ const own = st().feeds.find((f) => f.id === id);
+ return own ? expandFeed(own) : adhocFeeds.get(id) || null;
}
const normTopic = (x) => String(x || '').trim().replace(/^#/, '').toLowerCase();
// ---- follow packs (NIP-51 kind 39089, what following.space publishes) ---
@@ -5028,7 +5047,7 @@ export function messagesFeature(ctx) {
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);
+ for (const f of st().feeds) if (f.interests && feedStates.get(f.id) !== feed) feedStates.delete(f.id);
scheduleRepaint();
}
}
@@ -11217,11 +11236,12 @@ export function messagesFeature(ctx) {
// a new feed of people is a list from the start; a feed made before
// lists existed stays private until its owner says otherwise
const isNew = !def || adhocFeeds.has(def.id);
+ def = (def && st().feeds.find((f) => f.id === def.id)) || def; // as saved, not expanded
ui.feedEdit = def
- ? { id: def.id, name: def.name || '', follows: !!def.follows, authors: [...(def.authors || [])], priv: [...(def.priv || [])], packs: [...(def.packs || [])],
+ ? { id: def.id, name: def.name || '', follows: !!def.follows, lists: !!def.lists, interests: !!def.interests, authors: [...(def.authors || [])], priv: [...(def.priv || [])], packs: [...(def.packs || [])],
topics: feedTopics(def).map((x) => '#' + x).join(' '), q: '', rows: null, pq: '', packRows: null, isNew, d: def.d || null, publish: isNew || !!def.d,
relays: (def.relays || []).map((r) => r.replace(/^wss:\/\//, '')).join(' '), all: !!def.all && !def.builtin, curated: !!def.curated }
- : { id: null, name: '', follows: false, authors: [], priv: [], packs: [], topics: '', q: '', rows: null, pq: '', packRows: null, isNew: true, d: null, publish: true,
+ : { id: null, name: '', follows: false, lists: false, interests: false, authors: [], priv: [], packs: [], topics: '', q: '', rows: null, pq: '', packRows: null, isNew: true, d: null, publish: true,
relays: '', all: false, curated: false };
render();
}
@@ -11239,15 +11259,17 @@ export function messagesFeature(ctx) {
if (relays.length < words.length) { toast(t('feedRelayBad')); return; }
// every post on its relays: a firehose needs the relays, nothing else
if (e.all && !relays.length) { toast(t('feedAllNeedsRelays')); return; }
- if (!e.all && !e.follows && !e.authors.length && !e.packs.length && !topics.length) { toast(t('feedNoQuery')); return; }
+ if (!e.all && !e.follows && !e.lists && !e.interests && !e.authors.length && !e.packs.length && !topics.length) { toast(t('feedNoQuery')); return; }
const name = e.name.trim().slice(0, 40) || (e.all ? relays[0].replace(/^wss:\/\//, '') : topics.length ? '#' + topics[0] : t('feedNewName'));
const s = st();
const keep = e.id && !adhocFeeds.has(e.id) && s.feeds.some((f) => f.id === e.id);
const id = keep ? e.id : 'f' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const def = e.all
? { id, name, follows: false, authors: [], packs: [], topics: [], all: true, curated: !!e.curated, relays, at: Date.now() }
- : { id, name, follows: e.follows, authors: e.authors.slice(0, FEED_AUTHORS_MAX), packs: e.packs.slice(0, 20), topics, relays, all: false, curated: false, at: Date.now() };
+ : { id, name, follows: e.follows, lists: !!e.lists, interests: !!e.interests, authors: e.authors.slice(0, FEED_AUTHORS_MAX), packs: e.packs.slice(0, 20), topics, relays, all: false, curated: false, at: Date.now() };
if (!relays.length) delete def.relays;
+ if (!def.lists) delete def.lists;
+ if (!def.interests) delete def.interests;
const prev = keep ? s.feeds.find((f) => f.id === id) : null;
// d, listAt, listContent and priv ride along from the previous copy
if (keep) s.feeds = s.feeds.map((f) => (f.id === id ? { ...f, ...def } : f)); else s.feeds.push(def);
@@ -11365,6 +11387,9 @@ export function messagesFeature(ctx) {
h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
h('input', { type: 'checkbox', checked: e.follows, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (ev) => { e.follows = ev.target.checked; render(); } }),
h('span', {}, t('feedFollowsToggle'))),
+ h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
+ h('input', { type: 'checkbox', class: 'feed-lists-toggle', checked: !!e.lists, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (ev) => { e.lists = ev.target.checked; } }),
+ h('span', {}, t('feedListsToggle'))),
...e.authors.map(person),
// a list's private members, kept where they were made
...(e.priv || []).map((pk) => h('div', { class: 'row gap6', style: 'align-items:center', title: t('feedPrivateMember') },
@@ -11401,6 +11426,11 @@ export function messagesFeature(ctx) {
h('span', { class: 'small muted' }, t('feedTopics')),
h('input', { type: 'text', value: e.topics, placeholder: t('feedTopicsHint'), autocapitalize: 'none', autocomplete: 'off', onInput: (ev) => { e.topics = ev.target.value; } }),
h('div', { class: 'small faint' }, t('feedTopicsHelp'))),
+ e.all ? null : h('div', { class: 'col', style: 'gap:4px' },
+ h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
+ h('input', { type: 'checkbox', class: 'feed-interests-toggle', checked: !!e.interests, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (ev) => { e.interests = ev.target.checked; } }),
+ h('span', {}, t('feedInterestsToggle'))),
+ h('div', { class: 'small faint' }, t('feedInterestsHelp'))),
// a feed of people is a nostr list (NIP-51 follow set) unless told not to be
!e.all && (e.authors.length || (e.priv || []).length || e.d)
? h('div', { class: 'col', style: 'gap:4px' },
diff --git a/src/i18n.js b/src/i18n.js
index 6081c2d..e078677 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -272,8 +272,6 @@ const STR = {
currencyLabel: 'Show amounts in',
feedTitle: 'Feed',
feedFollowingName: 'Following',
- feedMixName: 'Everything',
- feedFollowsListsName: 'Follows + lists',
feedNew: 'New feed',
feedNewName: 'New feed',
feedEdit: 'Edit feed',
@@ -281,6 +279,9 @@ const STR = {
feedNameHint: 'e.g. Bitcoin builders',
feedPeople: 'People',
feedFollowsToggle: 'Everyone I follow',
+ feedListsToggle: 'Everyone on my lists and packs',
+ feedInterestsToggle: 'Posts from anyone on hashtags I follow',
+ feedInterestsHelp: 'Your topic feeds\' hashtags and the interests on your nostr profile.',
feedAddPerson: 'Add someone…',
feedTopics: 'Topics',
feedTopicsHint: '#bitcoin #nostr',
diff --git a/tools/feed-custom-test.js b/tools/feed-custom-test.js
index b263d71..c5c7c44 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(['Everything', '*Following', 'Popular', '#gardenstr', '+']), JSON.stringify(await chips()));
+ check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['*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,7 +117,7 @@ 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(['Everything', 'Following', 'Popular', '#gardenstr', '*Coin talk', '+']), JSON.stringify(await chips()));
+ 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'] && 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']]);
@@ -132,7 +132,7 @@ try {
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()).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(['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()));
+ 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()));
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,29 +156,36 @@ 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)]');
+ console.log('\n[Everything and Follows + lists (Amethyst\'s model), made in the editor]');
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);
+ check('neither is a chip until you make it', !(await chips()).some((c) => /Everything|Follows \+ lists/.test(c)), JSON.stringify(await chips()));
+ const makeFeed = async (name, boxes) => {
+ for (let i = 0; i < 20 && (await page.$('.feed-lists-toggle')); i++) await sleep(250); // the last editor has closed
+ for (let i = 0; i < 10 && !(await page.$('.feed-lists-toggle')); i++) { await page.evaluate(() => document.querySelector('.feed-chip.add')?.click()); await sleep(400); }
+ await type('input[placeholder="e.g. Bitcoin builders"]', name);
+ for (const b of boxes) await page.evaluate((q) => document.querySelector(q).click(), b); // (the bottom nav covers the lower boxes)
+ await page.evaluate(() => { window.__reqs = []; });
+ await click('save'); await sleep(1500);
+ };
+ await makeFeed('Everything', ['input[type=checkbox]:not(.feed-all-toggle)', '.feed-lists-toggle', '.feed-interests-toggle']);
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);
+ await makeFeed('Follows + lists', ['input[type=checkbox]:not(.feed-all-toggle)', '.feed-lists-toggle']);
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(['Everything', 'Follows + lists', 'Following', 'Popular', '#gardenstr', 'Coin talk', '*Pack feed', '+']), JSON.stringify(await chips()));
+ // (the feed you were on is remembered: Follows + lists, 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', 'Everything', '*Follows + lists', '+']), JSON.stringify(await chips()));
// (the chat home no longer lists the feed with its summary: its editor shows the pack)
+ await chip('Pack feed'); await sleep(800);
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);
check('the pack feed kept its pack', await waitText('test pack', 3000));
@@ -188,7 +195,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(['Everything', 'Follows + lists', '*Following', 'Popular', '#gardenstr', 'Pack feed', '+']), JSON.stringify(await chips()));
+ check('deleting it lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', 'Pack feed', 'Everything', 'Follows + lists', '+']), 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');