aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js71
-rw-r--r--src/i18n.js2
-rw-r--r--src/style.css2
-rw-r--r--tools/feed-custom-test.js30
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');