aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 06:55:14 -0700
committerAdam Soltys <[email protected]>2026-10-01 06:55:14 -0700
commit5d441ebbce6066c3636168ec5332ae9c2bdb8607 (patch)
tree7e70c783fc0be40eccb09af7499db586c5410526 /src/features/messages.js
parent981ff1c7f89b27ff3d7c2957e211365baff0a359 (diff)
downloadcoinosv3-5d441ebbce6066c3636168ec5332ae9c2bdb8607.tar.gz
coinosv3-5d441ebbce6066c3636168ec5332ae9c2bdb8607.zip
Serve a shared snapshot for the public feed
Build the default Popular feed once on the server and let browsers verify and paint its signed events before slow media finishes. Keep custom relays, pagination, filtering, and relay fallback on their existing paths.\n\nAdd stale-while-refresh persistence, service-worker bypass, development integration, and unit/browser coverage.
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js59
1 files changed, 49 insertions, 10 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index b3f40f2..b696267 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -15,7 +15,7 @@
import {
subscribeOn, publishOn, queryOn, fetchInboxRelays, relayAlive, liveRelayList, resetRelay,
npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44,
- PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread,
+ PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync,
} from '../nostr.js';
import {
channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId,
@@ -32,6 +32,7 @@ import { saveInbox } from '../dm-inbox.js';
import { mergeFeedWindow } from '../feed-window.js';
import { createThreadStore } from '../thread-cache.js';
import { createFeedCache, FEED_CACHE_POSTS, FEED_SEED_POSTS } from '../feed-cache.js';
+import { PUBLIC_FEED_RELAYS, popularCandidates } from '../popular-feed.js';
import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl } from '../recipient-search.js';
import { getNetwork } from '../api.js';
import { decodeBolt11 } from '../ark/lightning.js';
@@ -4050,7 +4051,7 @@ export function messagesFeature(ctx) {
const RELAY_PICKS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.snort.social',
'wss://nostr.mom', 'wss://nostr.bitcoiner.social', 'wss://eden.nostr.land', 'wss://nostr.land', 'wss://nostr.oxtr.dev',
'wss://relay.nostrplebs.com', 'wss://offchain.pub', 'wss://nostr21.com', 'wss://relay.nostr.net', 'wss://nostr-pub.wellorder.net'];
- const FIREHOSE_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
+ const FIREHOSE_RELAYS = PUBLIC_FEED_RELAYS;
const normRelay = (u) => {
let x = String(u || '').trim();
if (!x) return null;
@@ -4068,10 +4069,38 @@ export function messagesFeature(ctx) {
// latest reactions, reposts and zaps, counted per post by distinct
// people, then the posts enough people touched. Older pages walk the
// reactions back in time. The prose and language tests run at the door.
- const POPULAR_MIN = 2;
const POPULAR_ASK = 600; // reactions per round
const curateFeed = (evs) => { const lang = getLang(); return evs.filter((e) => !isMachinePost(e.content) && inLanguage(e.content, lang)); };
async function popularPass(extra, merge, c, def) {
+ // Only the default public feed shares a snapshot. Custom relays and older
+ // pages keep their own queries. A static/offline install falls back quickly.
+ if (extra.until == null && !feedRelays(def)) {
+ try {
+ const response = await fetch('/api/feed', { signal: AbortSignal.timeout(1500) });
+ if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) throw new Error('No feed cache');
+ const data = await response.json();
+ if (data.version !== 1 || !Number.isFinite(data.generatedAt) || Date.now() - data.generatedAt > 86400_000
+ || !Array.isArray(data.notes) || data.notes.length > 240 || !Array.isArray(data.profiles) || data.profiles.length > 240
+ || !Number.isFinite(data.popUntil)) throw new Error('Invalid feed cache');
+ const events = [...data.notes, ...data.profiles];
+ // HTTP is another untrusted source: retain the relay signature checks.
+ const valid = await verifyEventsAsync(events);
+ if (c.stopped) return false;
+ const posts = data.notes.filter((e, i) => valid[i] && e.kind === 1);
+ const usable = curateFeed(posts).filter((e) => !isReply(e) && !hidden(e));
+ if (usable.length) {
+ warmProfiles();
+ for (let i = 0; i < data.profiles.length; i++) {
+ const e = data.profiles[i];
+ if (valid[data.notes.length + i] && e.kind === 0) applyProfile(e.pubkey, e);
+ }
+ c.popUntil = c.popUntil || data.popUntil;
+ const got = await mergeFeed(posts, { ...merge, immediate: true }, c);
+ if (got) scheduleRepaint();
+ return got; // already-known posts also count as a successful cache hit
+ }
+ } catch {} // relay path also covers snapshots with no posts in this language
+ }
const relays = feedRelaysOr(def, FIREHOSE_RELAYS);
const wide = [...new Set([...relays, ...NOTE_RELAYS])];
let until = extra.until != null && c.popUntil ? c.popUntil - 1 : undefined;
@@ -4079,19 +4108,15 @@ export function messagesFeature(ctx) {
for (let round = 0; round < 3 && !c.stopped; round++) {
const re = await queryOn(wide, { kinds: [6, 7, 9735], limit: POPULAR_ASK, ...(until ? { until } : {}) }, 4500).catch(() => []);
if (!re.length) break;
- const counts = new Map();
- for (const r of re) {
- const who = r.kind === 9735 ? ((r.tags.find((x) => x[0] === 'P') || [])[1] || r.pubkey) : r.pubkey;
- for (const x of r.tags) if (x[0] === 'e' && /^[0-9a-f]{64}$/.test(x[1] || '')) (counts.get(x[1]) || counts.set(x[1], new Set()).get(x[1])).add(who);
- }
+ const candidates = popularCandidates(re);
const oldest = Math.min(...re.map((r) => r.created_at));
c.popUntil = c.popUntil && until ? Math.min(c.popUntil, oldest) : (c.popUntil || oldest);
until = oldest - 1;
- const ids = [...counts.entries()].filter(([, s]) => s.size >= POPULAR_MIN).sort((a, b) => b[1].size - a[1].size).slice(0, 80).map(([id]) => id);
+ const ids = candidates.ids;
if (!ids.length) continue;
const asks = [];
for (let i = 0; i < ids.length; i += 40) asks.push(queryOn(wide, { ids: ids.slice(i, i + 40) }, 4500).catch(() => []));
- const posts = (await Promise.all(asks)).flat().filter((e) => e && e.kind === 1 && (counts.get(e.id) || new Set()).size - (counts.get(e.id)?.has(e.pubkey) ? 1 : 0) >= POPULAR_MIN);
+ const posts = (await Promise.all(asks)).flat().filter(candidates.qualifies);
if (c.stopped) return got;
if (await mergeFeed(posts, merge, c)) { got = true; scheduleRepaint(); }
if (got && round >= 0 && (c.notes.length >= FEED_PAGE || extra.until != null)) break;
@@ -4972,6 +4997,20 @@ export function messagesFeature(ctx) {
}
if (c.booting) await c.boot;
if (c.stopped) return false;
+ if (opts.immediate && !c.notes.length && !opts.catchup) {
+ // The public snapshot already contains verified posts and profiles.
+ // Paint text now using the same progressive rows as the local cache;
+ // slow media/quotes must not hold the whole first page behind a spinner.
+ c.booting = true;
+ for (const e of add) staged.delete(e.id);
+ admitFeed(add, c);
+ c.boot = notesReady(c.notes.slice(0, FEED_PAGE)).finally(() => {
+ c.booting = false;
+ if (!c.stopped && c === feed) scheduleRepaint();
+ });
+ scheduleRepaint();
+ return true;
+ }
await notesReady(add);
if (c.stopped) return false;
for (const e of add) staged.delete(e.id);