diff options
| author | Adam Soltys <[email protected]> | 2026-09-20 21:24:14 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-20 21:24:14 -0700 |
| commit | 9ef5c7633508cfecd19050bbcc637e091738de7e (patch) | |
| tree | a73ca8e39c87e38285fa7c8b73a48f4185dbfdd3 | |
| parent | 482025aa8b049433efda985526df6b3b3cc0e9a0 (diff) | |
| download | coinosv3-9ef5c7633508cfecd19050bbcc637e091738de7e.tar.gz coinosv3-9ef5c7633508cfecd19050bbcc637e091738de7e.zip | |
Feeds: Following is one of many; topics from search and #tags; the bolt is amber only for your own zap.
Custom feeds live in the synced messages state — a name, everyone you follow
and/or hand-picked people, and topics (hashtags, any of them; with people
set as well, only their posts on those topics — the way a nostr filter
composes authors and #t). Each feed keeps its own notes, clock and disk
cache; a chips row under the feed title switches between them and + opens
the editor (people are added through the same search the Send form uses).
A word in the header search is a topic too: 'Posts on this topic' opens a
session feed for it, as does a #tag in any post body — Save feed keeps it.
Topic-only feeds read the big public relays as well as ours, since a hashtag
has no author whose outbox we could follow.
The zap icon used to go amber once anyone had zapped a post; now only when
you have, like the heart.
tools/feed-custom-test.js drives the editor, the relay filter, a tagged post
landing, the #tag link, reload persistence and delete.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
| -rw-r--r-- | src/features/messages.js | 310 | ||||
| -rw-r--r-- | src/i18n.js | 19 | ||||
| -rw-r--r-- | src/style.css | 6 | ||||
| -rw-r--r-- | tools/feed-custom-test.js | 130 |
4 files changed, 411 insertions, 54 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index c8189ff..73acf23 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -83,6 +83,7 @@ export function messagesFeature(ctx) { s.notify ||= {}; // { [cid]: true } — communities that may buzz your phone (opt-in) s.drafts ||= {}; // { ['dm:'+pk | 'ch:'+id]: text } — half-typed messages s.zaps ||= {}; // { [eventId]: { s: sats, m: 1 } } — last known zap tallies + s.feeds ||= []; // custom feeds: { id, name, follows, authors: [pk], topics: [tag] } for (const c of s.communities) c.added_at ||= Date.now(); // pre-multi-community shape: joined was { [pubkey]: true } for coinos for (const k of Object.keys(s.joined)) @@ -3587,9 +3588,37 @@ export function messagesFeature(ctx) { const FEED_PAGE = 20; // posts on screen at once, grown as you scroll const FEED_KEEP = 200; // in memory const FEED_STORE = 50; // ...and on disk - let feed = null; // { status, notes, end, loadingMore } + let feed = null; // the feed on screen: { id, status, notes, shown, end, loadingMore, at } let feedUnsubs = []; - let feedAt = 0; + // ---- many feeds ---------------------------------------------------------- + // "Following" is one feed among any number the user defines: a set of + // people (or everyone they follow), a set of topics (hashtags, any of + // them), or both at once — people AND topics, the way a nostr filter + // composes them. Definitions live in the synced messages state, so a feed + // made on the phone is on the laptop too; each feed keeps its own notes, + // clock and disk cache. A topic opened from search or a #tag in a post is + // a feed too, until saved just a session one. + const FOLLOWING = 'following'; + const FEED_LS = 'btc-wallet-feed'; // the feed last on screen, remembered per device + const feedStates = new Map(); // id -> state + const adhocFeeds = new Map(); // id -> definition, this session only + let curFeedId = (() => { try { return localStorage.getItem(FEED_LS) || FOLLOWING; } catch { return FOLLOWING; } })(); + const feedList = () => [{ id: FOLLOWING, name: t('feedFollowingName'), follows: true, builtin: true }, ...st().feeds]; + function feedDef(id = curFeedId) { + if (id === FOLLOWING) return feedList()[0]; + return st().feeds.find((f) => f.id === id) || adhocFeeds.get(id) || null; + } + const normTopic = (x) => String(x || '').trim().replace(/^#/, '').toLowerCase(); + const feedTopics = (def = feedDef()) => [...new Set(((def && def.topics) || []).map(normTopic).filter(Boolean))].slice(0, 20); + const feedHasQuery = (def = feedDef()) => !!def && (feedAuthors(def).length > 0 || feedTopics(def).length > 0); + const feedCacheKey = (id) => (id === FOLLOWING ? FEED_CACHE : FEED_CACHE + ':' + id); + const saveFeedCache = (c) => { + if (adhocFeeds.has(c.id)) return; // a session feed leaves nothing behind + try { wallet.saveFeatureState(feedCacheKey(c.id), c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {} + }; + // posts on a topic come from the big public relays as well as ours: a + // hashtag has no author whose outbox we could read + const TOPIC_RELAYS = [...new Set([...NOTE_RELAYS, 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol'])]; // A reply, as against a post that merely POINTS at another one. NIP-10 // marks a reply's e tags 'root' or 'reply'; a quote's are 'mention', and @@ -3610,26 +3639,65 @@ export function messagesFeature(ctx) { // posts a wider net found in one six-hour window were theirs. const FEED_AUTHORS_MAX = 2000; const REQ_AUTHORS = 400; // authors per REQ, so one filter stays a sane size - const feedAuthors = () => [...followsNow().set].slice(0, FEED_AUTHORS_MAX); + const feedAuthors = (def = feedDef()) => { + if (!def) return []; + const set = new Set(def.follows ? followsNow().set : []); + for (const pk of def.authors || []) if (pk) set.add(pk); + return [...set].slice(0, FEED_AUTHORS_MAX); + }; function feedNow() { - if (!feed) { + if (!feedDef(curFeedId)) curFeedId = FOLLOWING; // a feed deleted on another device + let c = feedStates.get(curFeedId); + if (!c) { let stored = []; - try { stored = wallet.loadFeatureState(FEED_CACHE, []) || []; } catch {} - feed = { status: stored.length ? 'ready' : 'loading', notes: stored, shown: FEED_PAGE }; + try { stored = wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {} + c = { id: curFeedId, status: stored.length ? 'ready' : 'loading', notes: stored, shown: FEED_PAGE, at: 0 }; + feedStates.set(curFeedId, c); + feed = c; // the cached page is on screen already; warm what it shows so the // scroll below the fold, and the next boot, paint whole too notesReady(stored.slice(0, FEED_PAGE)).catch(() => {}); - refreshFeed(); + refreshFeed({}, c); + } + feed = c; + return c; + } + function switchFeed(id) { + if (!feedDef(id)) return; + if (id !== curFeedId) { + curFeedId = id; + if (!adhocFeeds.has(id)) { try { localStorage.setItem(FEED_LS, id); } catch {} } } - return feed; + stopFeedWatch(); + const c = feedNow(); + c.unseen = 0; c.shown = FEED_PAGE; + watchFeed(); + refreshFeed({}, c); + try { window.scrollTo({ top: 0 }); } catch {} + render(); } - // A follow list that changed means a feed built from the wrong authors — - // rebuilt in place, not offered behind the pill. + // A follow list that changed means every feed built on it has the wrong + // authors — the one on screen is rebuilt in place, not offered behind the + // pill; the others are simply forgotten and rebuilt when next opened. function feedAuthorsChanged() { - if (!feed) return; - feedAt = 0; - refreshFeed({ live: false }); + for (const c of [...feedStates.values()]) { + const def = feedDef(c.id); + if (!def || !def.follows) continue; + if (c === feed) { c.at = 0; refreshFeed({ live: false }, c); } else feedStates.delete(c.id); + } + } + // A topic straight from search or a #tag in a post: a feed for this + // session (a saved single-topic feed with the same tag is reused). + function openTopicFeed(tag) { + tag = normTopic(tag); + if (!tag) return; + const saved = st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag); + const id = saved ? saved.id : 'topic:' + tag; + if (!saved) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag] }); + ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; + ui.chatOpen = true; ui.msgView = 'feed'; + switchFeed(id); } // How far from the top counts as "reading", rather than "sitting at the top // of the feed". Inserting a post above what someone is reading moves the @@ -3734,8 +3802,7 @@ export function messagesFeature(ctx) { // Posts at the door: filtered in synchronously, so the same note from a // second relay is dropped while the first copy is still warming. const feedStaged = new Set(); - async function mergeFeed(evs, opts = {}) { - const c = feedNow(); + async function mergeFeed(evs, opts = {}, c = feedNow()) { const known = new Set([...c.notes, ...(c.catchup || [])].map((e) => e.id)); const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey) && !known.has(e.id) && !feedStaged.has(e.id) && known.add(e.id) && feedStaged.add(e.id)); @@ -3761,10 +3828,10 @@ export function messagesFeature(ctx) { // posts arriving at the TOP shouldn't cost you the ones you'd scrolled to const fresh = add.filter((e) => e.created_at >= (c.notes[0] || {}).created_at).length; if (fresh) c.shown = Math.min((c.shown || FEED_PAGE) + fresh, c.notes.length); - try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {} + saveFeedCache(c); if (above) { c.unseen = (c.unseen || 0) + add.length; - if (ui.chatOpen && ui.msgView === 'feed') holdScroll(render); + if (ui.chatOpen && ui.msgView === 'feed' && c === feed) holdScroll(render); } return true; } @@ -3785,18 +3852,27 @@ export function messagesFeature(ctx) { // as disruptive as a live arrival if you were reading halfway down, so it // goes behind the pill too. A first load, or paging older posts onto the // bottom, does not. - async function feedPass(extra = {}, merge = {}) { - const authors = feedAuthors(); - if (!authors.length) return false; + async function feedPass(extra = {}, merge = {}, c = feedNow()) { + const def = feedDef(c.id); + if (!def) return false; + const authors = feedAuthors(def), topics = feedTopics(def); + const tag = topics.length ? { '#t': topics } : {}; + let got = false; + if (!authors.length) { + // topics alone: nobody's outbox to read, so the wide relays, one ask + if (!topics.length) return false; + const evs = await queryOn(TOPIC_RELAYS, { kinds: [1], ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); + if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); } + return got; + } await fetchRelayLists(authors); const plan = outboxPlan(authors); - let got = false; await Promise.all(plan.flatMap(({ relays, authors: a }) => { const chunks = []; for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS)); return chunks.map(async (chunk) => { - const evs = await queryOn(relays, { kinds: [1], authors: chunk, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); - if (await mergeFeed(evs, merge)) { got = true; scheduleRepaint(); } + const evs = await queryOn(relays, { kinds: [1], authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); + if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); } }); })); return got; @@ -3805,17 +3881,17 @@ export function messagesFeature(ctx) { // on screen — the cached page at boot included. Posts used to slide in a // beat after the page painted, moving what you had started reading. Only // a first load (nothing to disturb) or a rebuilt follow list goes straight in. - async function refreshFeed(opts = {}) { - if (!feedAuthors().length) { if (feed) feed.status = 'ready'; return; } - if (!opts.force && Date.now() - feedAt < 30_000) return; - feedAt = Date.now(); - const catchup = opts.live != null ? !!opts.live : !!(feed && feed.notes.length); - const topBefore = feed && feed.notes[0] ? feed.notes[0].created_at : 0; - try { await feedPass({}, { catchup }); } catch {} finally { - if (feed) feed.status = 'ready'; - if (catchup) settleCatchup(topBefore); else scheduleRepaint(); + async function refreshFeed(opts = {}, c = feedNow()) { + if (!feedHasQuery(feedDef(c.id))) { c.status = 'ready'; return; } + if (!opts.force && Date.now() - c.at < 30_000) return; + c.at = Date.now(); + const catchup = opts.live != null ? !!opts.live : !!c.notes.length; + const topBefore = c.notes[0] ? c.notes[0].created_at : 0; + try { await feedPass({}, { catchup }, c); } catch {} finally { + c.status = 'ready'; + if (catchup) settleCatchup(topBefore, c); else scheduleRepaint(); } - watchFeed(); + if (c === feed) watchFeed(); } // ---- coming back to the feed --------------------------------------------- // The catch-up asks every relay for its newest posts, with no lower bound, @@ -3828,8 +3904,7 @@ export function messagesFeature(ctx) { // a lot — you've been gone a while: the feed goes to the top, at the // newest post, the way it would on a fresh open const FEED_CATCHUP_MAX = 20; - function settleCatchup(topBefore) { - const c = feed; + function settleCatchup(topBefore, c = feed) { if (!c) return; const add = c.catchup || []; c.catchup = []; @@ -3842,8 +3917,8 @@ export function messagesFeature(ctx) { const inside = add.filter((e) => !seen.has(e.id) && e.created_at > edge).length; c.notes = [...c.notes, ...add.filter((e) => !seen.has(e.id))] .sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP); - try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {} - const onScreen = ui.chatOpen && ui.msgView === 'feed'; + saveFeedCache(c); + const onScreen = ui.chatOpen && ui.msgView === 'feed' && c === feed; if (!onScreen || fresh > FEED_CATCHUP_MAX) { c.shown = FEED_PAGE; render(); @@ -3885,12 +3960,17 @@ export function messagesFeature(ctx) { // the same relays the pass above reads, one subscription each. function watchFeed() { stopFeedWatch(); - if (!feedAuthors().length || !ui.chatOpen || ui.msgView !== 'feed') return; + const def = feedDef(); + if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return; + const c = feedNow(); const since = Math.floor(Date.now() / 1000) - 60; - for (const { relays, authors } of outboxPlan(feedAuthors())) - for (let i = 0; i < authors.length; i += REQ_AUTHORS) - feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors: authors.slice(i, i + REQ_AUTHORS), since }, - (ev) => { mergeFeed([ev], { live: true }).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); })); + const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {}; + const on = (ev) => { mergeFeed([ev], { live: true }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; + const authors = feedAuthors(def); + if (!authors.length) { feedUnsubs.push(subscribeOn(TOPIC_RELAYS, { kinds: [1], ...tag, since }, on)); return; } + for (const { relays, authors: a } of outboxPlan(authors)) + for (let i = 0; i < a.length; i += REQ_AUTHORS) + feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); } function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } @@ -3908,11 +3988,11 @@ export function messagesFeature(ctx) { } if (c.status !== 'ready' || c.loadingMore || c.end) return; const oldest = c.notes[c.notes.length - 1]; - if (!oldest || !feedAuthors().length) { c.end = true; return; } + if (!oldest || !feedHasQuery()) { c.end = true; return; } c.loadingMore = true; render(); try { - if (await feedPass({ until: oldest.created_at - 1 })) c.shown += FEED_PAGE; + if (await feedPass({ until: oldest.created_at - 1 }, {}, c)) c.shown += FEED_PAGE; else c.end = true; } catch {} finally { c.loadingMore = false; @@ -3939,10 +4019,11 @@ export function messagesFeature(ctx) { // nip05 (a word character sits before those), and never inside a URL // (the URL token starts earlier and swallows it). const MENTION = '(?<![\\w.@/])@[A-Za-z0-9_]{2,32}(?![\\w@])'; + const HASHTAG = '(?<![\\w#/&])#[A-Za-z0-9_\\u00C0-\\uFFFF]{2,64}(?![\\w])'; // A bare npub/nprofile — with or without an @ in front, no nostr: prefix — // is how Vector (and people) write a mention; it resolves to the name too. const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])'; - const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + ')', 'gi'); + const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi'); const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i'); // A YouTube link is a video, so show the video. All three shapes it comes @@ -4337,6 +4418,11 @@ export function messagesFeature(ctx) { href: '#', title: t('searchPeopleFor', { q: part.slice(1) }), onClick: (e) => { e.preventDefault(); e.stopPropagation(); openPeopleSearch(part.slice(1)); }, }, part)); + } else if (/^#[A-Za-z0-9_\u00C0-\uFFFF]{2,64}$/.test(part)) { + out.push(h('a', { + href: '#', title: t('searchTopicFor', { q: part.slice(1) }), + onClick: (e) => { e.preventDefault(); e.stopPropagation(); openTopicFeed(part.slice(1)); }, + }, part)); } else if (emojiAt && part.includes(':')) { for (const p of splitEmoji(part, emojiAt)) out.push(typeof p === 'string' ? p : emojiImg(p.code, p.url)); } else out.push(part); @@ -4693,7 +4779,7 @@ export function messagesFeature(ctx) { // shifts when a wallet comes online; without one it is just the number btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine, zapHold && zapHold.onClick, - 'note-zap' + (zapSats ? ' zapped' : '') + (zapFlying ? ' flying' : ''), + 'note-zap' + (zapMine ? ' zapped' : '') + (zapFlying ? ' flying' : ''), zapHold && { ...zapHold, onClick: undefined }), // who did all that: a small chevron, always in place — dimmed until // there is someone to show, so the first reaction doesn't reflow the bar @@ -5631,9 +5717,24 @@ export function messagesFeature(ctx) { } // ---- the feed view -------------------------------------------------------- + // The row of feeds under the title: Following, then yours, then + for + // a new one. A topic opened for the session sits at the end until saved. + function feedChips() { + const items = feedList(); + const cur = feedDef(); + if (cur && adhocFeeds.has(cur.id)) items.push(cur); + return h('div', { class: 'row feed-chips', 'data-key': 'feed-chips' }, + ...items.map((f) => h('button', { + class: 'feed-chip' + (f.id === curFeedId ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id, + onClick: () => switchFeed(f.id), + }, f.name)), + h('button', { class: 'feed-chip add', type: 'button', title: t('feedNew'), 'aria-label': t('feedNew'), onClick: () => openFeedEditor(null) }, '+')); + } function feedView() { const c = feedNow(); - const authors = feedAuthors(); + const def = feedDef(); + const authors = feedAuthors(def); + const hasQuery = feedHasQuery(def); const visible = c.notes.filter((ev) => !isMuted(ev.pubkey)); // Every child keyed by its post, hairlines included, so the morph // reconciles the list by post: a new one at the top is inserted as its @@ -5660,16 +5761,25 @@ export function messagesFeature(ctx) { pill, h('div', { class: 'row gap6', style: 'align-items:center' }, backBtn(() => { ui.msgView = 'home'; stopFeedWatch(); render(); }), - h('h3', { style: 'margin:0' }, t('feedTitle')), + h('h3', { style: 'margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, def.builtin ? t('feedTitle') : def.name), + def.builtin ? null + : adhocFeeds.has(def.id) + ? h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedSaveAdhoc')) + : h('button', { class: 'btn-sm', title: t('feedEdit'), 'aria-label': t('feedEdit'), onClick: () => openFeedEditor(def) }, '\u270e'), h('button', { - class: 'btn-sm', style: 'margin-left:auto', + class: 'btn-sm', style: 'margin-left:auto;flex-shrink:0', onClick: () => { ui.profCompose = ui.profCompose == null ? (draftFor(POST_DRAFT) || '') : null; render(); }, }, t('profNewPost'))), + feedChips(), postComposer(), - !authors.length - ? h('div', { class: 'col', style: 'gap:8px' }, - h('div', { class: 'small muted' }, t('feedNoFollows')), - h('button', { class: 'btn-sm', onClick: () => { stopFeedWatch(); hook('openUserSearch'); render(); } }, t('feedFindPeople'))) + !hasQuery + ? def.builtin + ? h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t('feedNoFollows')), + h('button', { class: 'btn-sm', onClick: () => { stopFeedWatch(); hook('openUserSearch'); render(); } }, t('feedFindPeople'))) + : h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'small muted' }, t('feedNoQuery')), + h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit'))) : c.status === 'loading' && !visible.length ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : !visible.length @@ -5683,6 +5793,88 @@ export function messagesFeature(ctx) { } + // ---- defining a feed ---------------------------------------------------- + // Name, the people (everyone you follow and/or a hand-picked few) and the + // topics. People AND topics narrows to their posts on those topics; either + // alone is the whole of it. Saved into the synced state, so every device + // has the same feeds. + function openFeedEditor(def) { + ui.feedEdit = def + ? { id: def.id, name: def.name || '', follows: !!def.follows, authors: [...(def.authors || [])], + topics: feedTopics(def).map((x) => '#' + x).join(' '), q: '', rows: null, isNew: adhocFeeds.has(def.id) } + : { id: null, name: '', follows: false, authors: [], topics: '', q: '', rows: null, isNew: true }; + render(); + } + const feedPeopleSearcher = makeSearcher((q, rows) => { + const e = ui.feedEdit; + if (e && e.q === q) { e.rows = rows; render(); } + for (const r of rows || []) if (r.pk) prefetchProfilePage(r.pk); + }); + function saveFeedEditor() { + const e = ui.feedEdit; + if (!e) return; + const topics = [...new Set(e.topics.split(/[\s,]+/).map(normTopic).filter(Boolean))].slice(0, 20); + if (!e.follows && !e.authors.length && !topics.length) { toast(t('feedNoQuery')); return; } + const name = e.name.trim().slice(0, 40) || (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 = { id, name, follows: e.follows, authors: e.authors.slice(0, FEED_AUTHORS_MAX), topics, at: Date.now() }; + if (keep) s.feeds = s.feeds.map((f) => (f.id === id ? { ...f, ...def } : f)); else s.feeds.push(def); + save(s); + if (e.id && adhocFeeds.has(e.id)) adhocFeeds.delete(e.id); + feedStates.delete(id); // its query changed: rebuild from scratch + ui.feedEdit = null; + switchFeed(id); + } + function deleteFeed(id) { + const s = st(); + s.feeds = s.feeds.filter((f) => f.id !== id); + save(s); + feedStates.delete(id); + try { wallet.saveFeatureState(feedCacheKey(id), []); } catch {} + ui.feedEdit = null; + if (curFeedId === id) switchFeed(FOLLOWING); else render(); + } + function feedEditView() { + const e = ui.feedEdit; + const person = (pk) => h('div', { class: 'row gap6', style: 'align-items:center' }, + avatar(pk, 'chat-avatar mini', false), + h('span', { class: 'grow', style: 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, displayName(pk)), + h('button', { class: 'btn-sm', type: 'button', 'aria-label': t('remove'), onClick: () => { e.authors = e.authors.filter((x) => x !== pk); render(); } }, '\u00d7')); + const picks = (e.rows || []).filter((r) => r.pk && !e.authors.includes(r.pk)).slice(0, 8); + return h('div', { class: 'card col chat-page', style: 'gap:14px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + backBtn(() => { ui.feedEdit = null; render(); }), + h('h3', { style: 'margin:0' }, e.isNew ? t('feedNew') : t('feedEdit'))), + h('label', { class: 'col', style: 'gap:4px' }, + h('span', { class: 'small muted' }, t('feedName')), + h('input', { type: 'text', value: e.name, placeholder: t('feedNameHint'), maxlength: '40', onInput: (ev) => { e.name = ev.target.value; } })), + h('div', { class: 'col', style: 'gap:8px' }, + h('span', { class: 'small muted' }, t('feedPeople')), + 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'))), + ...e.authors.map(person), + h('input', { + type: 'text', class: 'user-search-input', placeholder: t('feedAddPerson'), value: e.q, + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + onInput: (ev) => { e.q = ev.target.value; feedPeopleSearcher.update(e.q); }, + }), + picks.length + ? h('div', { class: 'list' }, resultRows(h, picks, (r) => { + if (!r.pk) return; + e.authors.push(r.pk); e.q = ''; e.rows = null; render(); + }, (pk, node) => hook('wrapAvatar', pk, node))) + : null), + h('label', { class: 'col', style: 'gap:4px' }, + 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'))), + h('button', { class: 'btn-primary btn-block', onClick: saveFeedEditor }, t('save')), + e.id && !e.isNew ? h('button', { class: 'btn-ghost btn-block', onClick: () => deleteFeed(e.id) }, t('feedDelete')) : null); + } + // ---- user search: the header magnifier ---------------------------------- // Same engine the Send form and DMs use (registrar names + Primal cache + // NIP-50 relays); tapping a result opens their profile, and profile-Back @@ -5702,6 +5894,17 @@ export function messagesFeature(ctx) { autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); }, }), + // a word is a topic too: posts tagged with it, as a feed + (() => { + const m = /^#?([A-Za-z0-9_\u00C0-\uFFFF]{2,64})$/.exec(s.q.trim()); + if (!m) return null; + const tag = normTopic(m[1]); + return h('div', { class: 'list' }, h('div', { class: 'item chat-thread-row', onClick: () => openTopicFeed(tag) }, + h('div', { class: 'chat-avatar fallback' }, '#'), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('span', { class: 'chat-name' }, '#' + tag), + h('div', { class: 'muted small' }, t('searchTopicRow'))))); + })(), s.rows && s.rows.length ? h('div', { class: 'list' }, resultRows(h, s.rows, (r) => { if (r.pk) { openProfile(r.pk); render(); } @@ -6763,6 +6966,7 @@ export function messagesFeature(ctx) { // ---- feature ------------------------------------------------------------ function messagesTab() { + if (ui.feedEdit) return feedEditView(); if (ui.msgView === 'feed') return feedView(); if (ui.msgView === 'notifs') return notifView(); if (ui.msgView === 'room') return roomView(); diff --git a/src/i18n.js b/src/i18n.js index 066ebce..b3f1d28 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -226,6 +226,23 @@ const STR = { worthNowInline: '{v} now', currencyLabel: 'Show amounts in', feedTitle: 'Feed', + feedFollowingName: 'Following', + feedNew: 'New feed', + feedNewName: 'New feed', + feedEdit: 'Edit feed', + feedName: 'Name', + feedNameHint: 'e.g. Bitcoin builders', + feedPeople: 'People', + feedFollowsToggle: 'Everyone I follow', + feedAddPerson: 'Add someone…', + feedTopics: 'Topics', + feedTopicsHint: '#bitcoin #nostr', + feedTopicsHelp: 'Posts tagged with any of these. With people set as well, only their posts on these topics.', + feedDelete: 'Delete feed', + feedSaveAdhoc: 'Save feed', + feedNoQuery: 'Add people or topics to this feed.', + searchTopicRow: 'Posts on this topic', + searchTopicFor: 'Posts tagged #{q}', alertsTitle: 'Notifications', alertsSub: 'Zaps, reactions and replies to your posts', alertsNew: '{n} new', @@ -972,7 +989,7 @@ const STR = { threadReplySend: 'Reply', threadReplied: 'Reply published', threadRootMissing: 'The original post could not be found.', - searchUsersHint: 'Name, npub, or payment address…', + searchUsersHint: 'Name, npub, address, or #topic…', searchUsersEmpty: 'Find anyone on nostr and open their profile.', searchNoResults: 'No one found.', notifTitle: 'Notifications', diff --git a/src/style.css b/src/style.css index 4e93009..e9bc882 100644 --- a/src/style.css +++ b/src/style.css @@ -590,6 +590,12 @@ hr.divider { margin: 16px 0; } post's full width so each is a comfortable target, quiet until one carries a count or you've used it. */ .note-acts { margin-top: 8px; justify-content: space-between; gap: 4px; max-width: 340px; } +/* the feeds row: pills that scroll sideways, the one on screen filled */ +.feed-chips { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; } +.feed-chips::-webkit-scrollbar { display: none; } +.feed-chip { flex: 0 0 auto; border: 1px solid var(--line); background: var(--surface2); color: var(--muted); border-radius: 999px; padding: 5px 12px; font-size: 14px; line-height: 1.2; cursor: pointer; white-space: nowrap; max-width: 180px; overflow: hidden; text-overflow: ellipsis; } +.feed-chip.on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); } +.feed-chip.add { font-weight: 600; padding: 5px 11px; } .note-act { background: none; border: 0; padding: 4px 6px; margin: 0; line-height: 1; color: var(--faint); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; diff --git a/tools/feed-custom-test.js b/tools/feed-custom-test.js new file mode 100644 index 0000000..18c1cf0 --- /dev/null +++ b/tools/feed-custom-test.js @@ -0,0 +1,130 @@ +// Custom feeds: Following is one feed among many. A new feed from the + +// chip (people and/or topics), the filter it asks relays for, a tagged +// post landing in it, switching back, a #tag in a post opening a topic +// feed, and deleting a feed. No relays are contacted for real — the +// WebSocket shim answers the subscriptions. +// Run: bun tools/feed-custom-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const SK = generateSecretKey(); +const AUTHOR = getPublicKey(SK); +const OTHER_SK = generateSecretKey(); +const signed = (content, ago, tags = [], sk = SK) => finalizeEvent({ kind: 1, created_at: Math.floor(Date.now() / 1000) - ago, tags, content }, sk); +const seeded = [signed('hello from the feed, about #bitcoin today', 300), signed('plain post', 600)]; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5297, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + window.__reqs = []; + const subs = []; + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ' && m[2] && Array.isArray(m[2].kinds) && m[2].kinds.includes(1)) { window.__reqs.push(m[2]); subs.push([ws, m[1], m[2]]); } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); + // hand a post to every open kind-1 subscription whose filter it matches + window.__inject = (ev) => { + let n = 0; + for (const [ws, id, f] of subs) { + if (ws.readyState !== 1) continue; + if (f.authors && !f.authors.includes(ev.pubkey)) continue; + if (f['#t'] && !ev.tags.some((t) => t[0] === 't' && f['#t'].includes(t[1]))) continue; + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); + n++; + } + return n; + }; +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const chips = () => page.evaluate(() => [...document.querySelectorAll('.feed-chip')].map((c) => (c.classList.contains('on') ? '*' : '') + c.textContent.trim())); +const type = async (sel, text) => { await page.click(sel, { clickCount: 3 }); await page.type(sel, text); }; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5297/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(500); + await click('import existing'); await sleep(400); + await page.waitForSelector('textarea'); + const mn = generateMnemonic(wordlist); + await page.type('textarea', mn); + await click('open wallet'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); + await sleep(800); + await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); + await sleep(1200); + check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['*Following', '+']), 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); + check('the editor opens', await waitText('new feed', 3000)); + await type('input[placeholder="e.g. Bitcoin builders"]', 'Coin talk'); + await page.click('input[type=checkbox]'); + 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', '*Coin talk', '+']), JSON.stringify(await chips())); + const req = (await page.evaluate(() => window.__reqs)).find((f) => f['#t']); + 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); + await sleep(2800); + check('a tagged post lands in it', n > 0 && await waitText('fresh #bitcoin post', 4000), n + ' sub(s)'); + const untagged = signed('an untagged post', 0); + await page.evaluate((ev) => window.__inject(ev), untagged); await sleep(1500); + check('an untagged one does not', !(await page.evaluate(() => document.body.innerText)).includes('an untagged post')); + + 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)); + 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', 'Coin talk', '*#bitcoin', '+']) && await page.evaluate(() => !![...document.querySelectorAll('button')].find((b) => /save feed/i.test(b.textContent))), 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)); + + console.log('\n[the feeds survive a reload; a feed can be deleted]'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); await sleep(800); + await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); await sleep(1200); + check('the saved feed is still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Coin talk', '+']), JSON.stringify(await chips())); + await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].find((c) => c.textContent.trim() === 'Coin talk').click()); await sleep(800); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /edit feed/i.test(b.getAttribute('aria-label') || '')).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]').checked)); + await click('delete feed'); await sleep(1000); + check('deleting it lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['*Following', '+']), 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'); +process.exit(ok ? 0 : 1); |
