diff --git a/MICROCOSM_PLAN.md b/MICROCOSM_PLAN.md index 97cbd16..975b51a 100644 --- a/MICROCOSM_PLAN.md +++ b/MICROCOSM_PLAN.md @@ -153,8 +153,19 @@ Original Phase 3 scope notes: - **This is the bulk of the work** and where "build a mini-AppView" lives. ### Phase 4 — feeds & timeline (hardest; may stay hybrid) -- **Following timeline:** read my `app.bsky.graph.follow` records (Constellation - or Slingshot) → fan-out fetch recent posts per follow → merge client-side. +**Status: following-feed builder DONE (not yet wired to the home screen).** +- ✅ `src/lib/microcosm/feed.ts buildFollowingFeed(viewerDid)`: lists the + viewer's follows from their PDS, fans out per-author `listRecords` of recent + posts (Slingshot doesn't proxy listRecords → go to each author's PDS via + MiniDoc), merges by createdAt, hydrates the top slice. Added + `slingshot.listRecords` (PDS-routed) helper. Verified end-to-end: a real + merged timeline, newest-first. + CAVEAT: N+1 fan-out is slow (~14s for 15 follows in a quick test). Needs + caching + parallelism tuning before wiring to the home feed; viable for the + concept, not a perf-ready timeline yet. NOT wired into `usePostFeedQuery`. + +- **Following timeline (original notes):** read my `app.bsky.graph.follow` + records → fan-out fetch recent posts per follow → merge client-side. Expensive; viable for modest follow counts, needs caching/windowing. - **Algorithmic feeds (`getFeed`)**: require a feed generator producing a skeleton; cannot be reproduced from microcosm primitives alone. Either keep diff --git a/src/lib/microcosm/feed.ts b/src/lib/microcosm/feed.ts new file mode 100644 index 0000000..5ed8586 --- /dev/null +++ b/src/lib/microcosm/feed.ts @@ -0,0 +1,123 @@ +/** + * A "following" timeline assembled client-side from microcosm + PDS reads, + * independent of Bluesky's AppView. + * + * There is no timeline primitive in atproto — a timeline is an aggregation. We + * build one by fan-out: + * 1. list the viewer's `app.bsky.graph.follow` records (their PDS) + * 2. for a bounded set of follows, list each one's recent posts (their PDS) + * 3. merge by createdAt, hydrate the top slice into PostViews + * + * This is inherently expensive (one request per followed account), so it's + * bounded by `maxFollows`. It is best-effort and meant for modest follow + * counts; it is NOT a replacement for an algorithmic feed. + */ +import {type AppBskyFeedDefs, type AppBskyFeedPost} from '@atproto/api' + +import {hydratePost} from '#/lib/microcosm/hydrate' +import {listRecords} from '#/lib/microcosm/slingshot' + +export type FollowingFeedItem = { + post: AppBskyFeedDefs.PostView +} + +export type FollowingFeedPage = { + feed: FollowingFeedItem[] +} + +type Candidate = {uri: string; createdAt: string; did: string} + +async function listFollows( + viewerDid: string, + max: number, + signal?: AbortSignal, +): Promise { + const dids: string[] = [] + let cursor: string | undefined + while (dids.length < max) { + const page = await listRecords( + {repo: viewerDid, collection: 'app.bsky.graph.follow', limit: 100, cursor}, + signal, + ) + for (const rec of page.records) { + const subject = (rec.value as {subject?: string})?.subject + if (subject) dids.push(subject) + if (dids.length >= max) break + } + if (!page.cursor || page.records.length === 0) break + cursor = page.cursor + } + return dids +} + +async function recentPostsFor( + did: string, + perAuthor: number, + signal?: AbortSignal, +): Promise { + try { + const page = await listRecords( + {repo: did, collection: 'app.bsky.feed.post', limit: perAuthor}, + signal, + ) + return page.records.map(rec => ({ + uri: rec.uri, + did, + createdAt: + (rec.value as AppBskyFeedPost.Record)?.createdAt ?? + new Date(0).toISOString(), + })) + } catch { + return [] + } +} + +/** + * Build one page of a following timeline. Defaults are conservative because + * each followed account is a separate network request. + */ +export async function buildFollowingFeed( + viewerDid: string, + opts: { + /** Max follows to fan out over (cost bound). */ + maxFollows?: number + /** Recent posts to pull per followed account. */ + perAuthor?: number + /** Final number of posts to return (most recent across all). */ + limit?: number + } = {}, + signal?: AbortSignal, +): Promise { + const maxFollows = opts.maxFollows ?? 50 + const perAuthor = opts.perAuthor ?? 5 + const limit = opts.limit ?? 30 + + const follows = await listFollows(viewerDid, maxFollows, signal) + + // Fan out with bounded concurrency to be polite to PDSs. + const candidates: Candidate[] = [] + const CONCURRENCY = 8 + for (let i = 0; i < follows.length; i += CONCURRENCY) { + const batch = follows.slice(i, i + CONCURRENCY) + const results = await Promise.all( + batch.map(did => recentPostsFor(did, perAuthor, signal)), + ) + for (const r of results) candidates.push(...r) + } + + // Most-recent-first, take the top slice, then hydrate. + candidates.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + const top = candidates.slice(0, limit) + + const posts = await Promise.all( + top.map(c => + hydratePost(c.uri, viewerDid, signal).catch(() => undefined), + ), + ) + + return { + feed: posts + .filter((p): p is AppBskyFeedDefs.PostView => !!p) + .map(post => ({post})), + } +} diff --git a/src/lib/microcosm/slingshot.ts b/src/lib/microcosm/slingshot.ts index ce689a5..f4a8a01 100644 --- a/src/lib/microcosm/slingshot.ts +++ b/src/lib/microcosm/slingshot.ts @@ -130,3 +130,43 @@ export function resolveMiniDoc( signal, ) } + +export type ListedRecord = {uri: string; cid: string; value: unknown} +export type ListRecordsResponse = {records: ListedRecord[]; cursor?: string} + +/** + * List records from a repo. Slingshot does not proxy `listRecords`, so we + * resolve the repo's PDS via MiniDoc and query the PDS directly. Returns the + * most recent records first (`reverse: false`, newest at top is the PDS default + * ordering by rkey, which is time-ordered for TID rkeys). + */ +export async function listRecords( + args: { + repo: string + collection: string + limit?: number + cursor?: string + reverse?: boolean + }, + signal?: AbortSignal, +): Promise { + const mini = await resolveMiniDoc(args.repo, signal) + const url = new URL('/xrpc/com.atproto.repo.listRecords', mini.pds) + url.searchParams.set('repo', mini.did) + url.searchParams.set('collection', args.collection) + if (args.limit != null) url.searchParams.set('limit', String(args.limit)) + if (args.cursor) url.searchParams.set('cursor', args.cursor) + if (args.reverse != null) + url.searchParams.set('reverse', String(args.reverse)) + const res = await fetch(url.toString(), { + headers: {Accept: 'application/json', 'User-Agent': MICROCOSM_USER_AGENT}, + signal, + }) + if (!res.ok) { + throw new SlingshotError( + `listRecords failed: ${res.status}`, + res.status, + ) + } + return (await res.json()) as ListRecordsResponse +}