aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-28 23:29:55 -0700
committerAdam Soltys <[email protected]>2026-09-28 23:29:55 -0700
commitd5598a131a9d160b929ff7ccbd650a4ef57b21c0 (patch)
tree4514d9524c2724a191790de6a473c4a375bdbe61
parent73ab54a004113b32a9a12661e7e7eb9151999ee3 (diff)
downloadcoinosv3-d5598a131a9d160b929ff7ccbd650a4ef57b21c0.tar.gz
coinosv3-d5598a131a9d160b929ff7ccbd650a4ef57b21c0.zip
"Save feed" on someone's timeline adds it to your feeds at once, instead of an editor listing every person they follow.
The editor copied their whole follow list into the form, one row each — long enough to freeze the browser. A saved timeline now keeps only the person (of: pk); their follow list is read from the relays when it is opened, so it stays small in sync and follows who they follow. Its ⋯ offers Delete rather than Edit, and list pickers leave it out. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
-rw-r--r--src/features/messages.js72
-rw-r--r--src/i18n.js1
-rw-r--r--tools/avatar-profile-test.js14
3 files changed, 70 insertions, 17 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 452377e..2cf799b 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -4173,7 +4173,7 @@ export function messagesFeature(ctx) {
if (!ui.listPick) return null;
const pk = ui.listPick;
const close = () => { ui.listPick = null; render(); };
- const feeds = st().feeds;
+ const feeds = st().feeds.filter((f) => !f.of); // a timeline is someone, not a list
const toggle = (f) => {
const s = st();
const def = s.feeds.find((x) => x.id === f.id);
@@ -4245,8 +4245,37 @@ export function messagesFeature(ctx) {
for (const pk of def.authors || []) if (pk) set.add(pk);
for (const pk of def.priv || []) if (pk) set.add(pk); // a list's private members
for (const pk of packAuthors(def)) set.add(pk);
+ if (def.of) for (const pk of ofFollows.get(def.of)?.authors || []) set.add(pk);
return [...set].slice(0, FEED_AUTHORS_MAX);
};
+ // Someone's timeline is their follow list, read from the relays when the
+ // feed is opened (and again after ten minutes) — never copied into the
+ // feed itself, so a saved one stays small in sync and follows them.
+ const ofFollows = new Map(); // pk -> { authors, at, loading }
+ const ofLoaded = (def) => !!ofFollows.get(def.of)?.at;
+ function loadOfFollows(def) {
+ const pk = def.of;
+ let f = ofFollows.get(pk);
+ if (!f) { f = { authors: [], at: 0, loading: false }; ofFollows.set(pk, f); }
+ if (f.loading || (f.at && Date.now() - f.at < 600_000)) return;
+ f.loading = true;
+ liveProfileOf(pk); // their name and face for the title
+ queryOn([...new Set([...(def.relays || []), ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000)
+ .catch(() => [])
+ .then((evs) => {
+ const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ const had = f.at;
+ if (newest || !had) f.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : [];
+ f.at = Date.now(); f.loading = false;
+ // whatever was built before the list was known
+ for (const id of [...feedStates.keys()]) {
+ if (feedDef(id)?.of !== pk || id === curFeedId) continue;
+ dropFeedState(id);
+ }
+ if (feedDef(curFeedId)?.of === pk) { dropFeedState(curFeedId); selectFeed(curFeedId); }
+ scheduleRepaint();
+ });
+ }
function feedNow() {
if (!feedDef(curFeedId)) curFeedId = FOLLOWING; // a feed deleted on another device
@@ -4308,6 +4337,7 @@ export function messagesFeature(ctx) {
// history restores ui.feedId and the view catches up from here.
function selectFeed(id) {
if (!feedDef(id)) return;
+ if (feedDef(id).of) loadOfFollows(feedDef(id));
if (id !== curFeedId) {
curFeedId = id;
if (!adhocFeeds.has(id)) { try { localStorage.setItem(FEED_LS, id); } catch {} }
@@ -4344,19 +4374,9 @@ export function messagesFeature(ctx) {
const id = 'of:' + pk + (readFrom.length ? '@' + readFrom.join(',') : '');
let def = adhocFeeds.get(id);
if (!def) {
- def = { id, name: '', follows: false, authors: [], packs: [], topics: [], of: pk, ofAt: 0, relays: readFrom };
+ def = { id, name: '', follows: false, authors: [], packs: [], topics: [], of: pk, relays: readFrom };
adhocFeeds.set(id, def);
- liveProfileOf(pk); // their name and face for the title
- queryOn([...new Set([...relays, ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000)
- .catch(() => [])
- .then((evs) => {
- const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
- def.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : [];
- def.ofAt = Date.now();
- dropFeedState(id); // whatever was built before the list was known
- if (curFeedId === id) selectFeed(id);
- scheduleRepaint();
- });
+ loadOfFollows({ ...def, relays: [...relays, ...readFrom] });
}
showFeed(id);
}
@@ -7682,7 +7702,7 @@ export function messagesFeature(ctx) {
onClick: () => switchFeed(f.id),
// someone's timeline carries the person, not a name — an empty
// label left a blank black pill
- }, f.of ? t('feedOfTitle', { name: displayName(f.of) }) : f.name || feedSummary(f))),
+ }, f.name || (f.of ? t('feedOfTitle', { name: displayName(f.of) }) : feedSummary(f)))),
h('button', { class: 'feed-chip add', type: 'button', title: t('feedNew'), 'aria-label': t('feedNew'), onClick: () => openFeedEditor(null) }, '+'));
}
// The sheets a post (or a person) can open: the ⋯ menu, the reaction
@@ -7703,7 +7723,9 @@ export function messagesFeature(ctx) {
item('\u{1F4E1}', t('feedRelays') + (feedRelays(def) ? ' \u00b7 ' + feedRelays(def).length : ''), () => { ui.feedRelayEdit = { id: def.id, input: '' }; }),
def.builtin || visitor ? null
: adhocFeeds.has(def.id)
- ? item('\u{1F4BE}', t('feedSaveAdhoc'), () => openFeedEditor(def))
+ ? item('\u{1F4BE}', t('feedSaveAdhoc'), () => (def.of ? saveTimelineFeed(def) : openFeedEditor(def)))
+ // a saved timeline is the person, nothing to edit — only to drop
+ : def.of ? item('\u{1F5D1}', t('feedDelete'), () => deleteFeed(def.id))
: item('\u270e', t('feedEdit'), () => openFeedEditor(def)),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
@@ -7853,7 +7875,7 @@ export function messagesFeature(ctx) {
}),
def.of ? avatar(def.of, 'chat-avatar mini', true) : null,
h('h3', { style: 'margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
- def.builtin && !def.all ? t('feedTitle') : def.of ? t('feedOfTitle', { name: displayName(def.of) }) : def.name),
+ def.builtin && !def.all ? t('feedTitle') : def.of && !def.name ? t('feedOfTitle', { name: displayName(def.of) }) : def.name),
// one ⋯ for the feed itself (share, relays, save or edit) and one
// pencil for a new post: the title keeps the room a phone has
h('button', {
@@ -7867,7 +7889,7 @@ export function messagesFeature(ctx) {
ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null,
visitor ? null : feedChips(),
visitor ? null : postComposer(),
- def.of && !def.ofAt
+ def.of && !ofLoaded(def)
? h('div', { class: 'row gap6', style: 'justify-content:center;align-items:center;padding:12px 0' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedOfLoading')))
: def.of && !hasQuery
@@ -7953,6 +7975,22 @@ export function messagesFeature(ctx) {
deleteFollowSet(prev).catch(() => {});
}
}
+ // "Save feed" on someone's timeline: into the chips at once. The editor
+ // would have copied their whole follow list in, one row per person.
+ function saveTimelineFeed(adhoc) {
+ const s = st();
+ const have = s.feeds.find((f) => f.of === adhoc.of && JSON.stringify(f.relays || []) === JSON.stringify(adhoc.relays || []));
+ const id = have ? have.id : 'f' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+ if (!have) {
+ s.feeds.push({ id, name: '', follows: false, authors: [], packs: [], topics: [], of: adhoc.of,
+ ...(adhoc.relays && adhoc.relays.length ? { relays: adhoc.relays } : {}), at: Date.now() });
+ save(s);
+ }
+ adhocFeeds.delete(adhoc.id);
+ feedStates.delete(adhoc.id);
+ switchFeed(id);
+ toast(t('feedSaved'));
+ }
function deleteFeed(id) {
const s = st();
const def = s.feeds.find((f) => f.id === id);
diff --git a/src/i18n.js b/src/i18n.js
index 02c60aa..afc76ef 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -242,6 +242,7 @@ const STR = {
feedTopicsHelp: 'Posts tagged with any of these. With people set as well, only their posts on these topics.',
feedDelete: 'Delete feed',
feedSaveAdhoc: 'Save feed',
+ feedSaved: 'Feed saved',
feedNoQuery: 'Add people or topics to this feed.',
feedOfTitle: '{name}’s timeline',
feedOfLoading: 'Reading who they follow…',
diff --git a/tools/avatar-profile-test.js b/tools/avatar-profile-test.js
index 0f59e10..6e84429 100644
--- a/tools/avatar-profile-test.js
+++ b/tools/avatar-profile-test.js
@@ -76,6 +76,20 @@ try {
await sleep(1500);
const chip = await page.evaluate(() => document.querySelector('.feed-chip.on')?.textContent || null);
check('someone’s timeline has a labelled chip', !!chip && /Bob Poster/.test(chip), JSON.stringify(chip));
+
+ // ⋯ → Save feed: straight into the chips, no editor listing their follows
+ await page.evaluate((l) => document.querySelector('button[aria-label="' + l + '"]').click(), 'Feed options');
+ await sleep(500);
+ const saved = await click('save feed');
+ await sleep(1000);
+ const savedState = await page.evaluate(() => ({ editor: /New feed/.test(document.body.innerText), chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent), on: document.querySelector('.feed-chip.on')?.textContent }));
+ check('“Save feed” on a timeline saves it without the editor', saved && !savedState.editor && /Bob Poster/.test(savedState.on || ''), JSON.stringify(savedState));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click());
+ await sleep(2000);
+ const kept = await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent));
+ check('...and it is still among the feeds after a reload', kept.some((c) => /(Bob Poster|npub1hwam).*’s timeline/.test(c)), JSON.stringify(kept));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a face opens its profile at the top' : '\n❌ failed');
process.exit(ok ? 0 : 1);