diff --git a/src/lib/microcosm/hydrate.ts b/src/lib/microcosm/hydrate.ts index 35b4279..b820b2e 100644 --- a/src/lib/microcosm/hydrate.ts +++ b/src/lib/microcosm/hydrate.ts @@ -17,6 +17,7 @@ import { type AppBskyActorDefs, type AppBskyActorProfile, type AppBskyFeedDefs, + type AppBskyFeedGenerator, type AppBskyFeedPost, type AppBskyGraphDefs, type AppBskyGraphList, @@ -528,6 +529,52 @@ export async function hydrateLabeler( } } +/** + * Hydrate a `GeneratorView` for a feed-generator at-uri from microcosm. The + * generator record carries its display metadata and service DID; the creator is + * hydrated as a profile and `likeCount` is a Constellation like-backlink on the + * generator URI. + * + * Viewer state (whether the viewer likes/saves it) is not reconstructed here. + */ +export async function hydrateFeedGenerator( + generatorUri: string, + signal?: AbortSignal, +): Promise { + const parsed = parseAtUri(generatorUri) + if (!parsed) return undefined + + const [recordRes, creator, likeCount] = await Promise.all([ + getRecordByUri(generatorUri, undefined, signal).catch(() => undefined), + hydrateProfileBasic(parsed.did, signal).catch(() => undefined), + constellation + .getBacklinksCount(generatorUri, constellation.Sources.likes, signal) + .then(r => r.total) + .catch(() => undefined), + ]) + + if (!recordRes || !creator) return undefined + const record = recordRes.value as AppBskyFeedGenerator.Record + + return { + $type: 'app.bsky.feed.defs#generatorView', + uri: generatorUri, + cid: recordRes.cid ?? '', + did: record?.did ?? parsed.did, + // ProfileViewBasic is a structural subset of ProfileView; the feed UIs + // accept it. Cast at this boundary. + creator: creator as AppBskyActorDefs.ProfileView, + displayName: record?.displayName ?? '', + description: record?.description, + descriptionFacets: record?.descriptionFacets, + avatar: cdnUrl('avatar', parsed.did, record?.avatar), + likeCount, + acceptsInteractions: record?.acceptsInteractions, + contentMode: record?.contentMode, + indexedAt: record?.createdAt ?? new Date().toISOString(), + } +} + // --- helpers --- function parseAtUri(uri: string): {did: string; collection: string; rkey: string} | undefined { diff --git a/src/lib/microcosm/people.ts b/src/lib/microcosm/people.ts index d884e1a..6ee3009 100644 --- a/src/lib/microcosm/people.ts +++ b/src/lib/microcosm/people.ts @@ -7,10 +7,18 @@ * in X's own repo, so we read them directly from X's PDS via listRecords. Both * directions then hydrate each DID into a profile view. */ -import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api' +import { + type AppBskyActorDefs, + type AppBskyFeedDefs, + type AppBskyGraphDefs, +} from '@atproto/api' import {constellation} from '#/lib/microcosm' -import {hydrateList, hydrateProfileBasic} from '#/lib/microcosm/hydrate' +import { + hydrateFeedGenerator, + hydrateList, + hydrateProfileBasic, +} from '#/lib/microcosm/hydrate' import {getRecordByUri, listRecords} from '#/lib/microcosm/slingshot' export type ProfileListPage = { @@ -94,6 +102,43 @@ export type ProfileListsPage = { cursor?: string } +export type ActorFeedsPage = { + feeds: AppBskyFeedDefs.GeneratorView[] + cursor?: string +} + +/** + * One page of the feed generators created by `did`. These are the actor's own + * `app.bsky.feed.generator` records (read off their PDS via listRecords), each + * hydrated into a GeneratorView (with creator + likeCount). + */ +export async function buildActorFeedsPage( + did: string, + opts: {limit?: number; cursor?: string} = {}, + signal?: AbortSignal, +): Promise { + const page = await listRecords( + { + repo: did, + collection: 'app.bsky.feed.generator', + limit: opts.limit ?? 50, + cursor: opts.cursor, + }, + signal, + ) + + const feeds = await Promise.all( + page.records.map(rec => + hydrateFeedGenerator(rec.uri, signal).catch(() => undefined), + ), + ) + + return { + cursor: page.cursor, + feeds: feeds.filter(Boolean) as AppBskyFeedDefs.GeneratorView[], + } +} + /** * One page of the lists created by `did`. These are the actor's own * `app.bsky.graph.list` records (read off their PDS via listRecords); each is diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index b8a2a9f..ebd2ae3 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -20,6 +20,11 @@ import { } from '@tanstack/react-query' import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import { + hydrateFeedGenerator as mcHydrateFeedGenerator, + hydrateList as mcHydrateList, +} from '#/lib/microcosm/hydrate' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {GCTIME, STALE} from '#/state/queries' @@ -189,18 +194,34 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) { return useQuery({ staleTime: STALE.INFINITY, queryKey: feedSourceInfoQueryKey({uri}), - queryFn: async () => { + queryFn: async ({signal}) => { let view: FeedSourceInfo if (type === 'feed') { - const res = await agent.app.bsky.feed.getFeedGenerator({feed: uri}) - view = hydrateFeedGenerator(res.data.view) + let genView: AppBskyFeedDefs.GeneratorView | undefined + if (MICROCOSM_ENABLED) { + genView = await mcHydrateFeedGenerator(uri, signal).catch( + () => undefined, + ) + } + if (!genView) { + const res = await agent.app.bsky.feed.getFeedGenerator({feed: uri}) + genView = res.data.view + } + view = hydrateFeedGenerator(genView) } else { - const res = await agent.app.bsky.graph.getList({ - list: uri, - limit: 1, - }) - view = hydrateList(res.data.list) + let listView: AppBskyGraphDefs.ListView | undefined + if (MICROCOSM_ENABLED) { + listView = await mcHydrateList(uri, signal).catch(() => undefined) + } + if (!listView) { + const res = await agent.app.bsky.graph.getList({ + list: uri, + limit: 1, + }) + listView = res.data.list + } + view = hydrateList(listView) } return view @@ -463,35 +484,49 @@ export function usePinnedFeedsInfos() { let resolved = new Map() - // Get all feeds. We can do this in a batch. + // Get all feeds. Microcosm hydrates per-uri; the AppView can batch. const pinnedFeeds = pinnedItems.filter(feed => feed.type === 'feed') let feedsPromise = Promise.resolve() if (pinnedFeeds.length > 0) { - feedsPromise = agent.app.bsky.feed - .getFeedGenerators({ - feeds: pinnedFeeds.map(f => f.value), - }) - .then(res => { - for (let i = 0; i < res.data.feeds.length; i++) { - const feedView = res.data.feeds[i] - resolved.set(feedView.uri, hydrateFeedGenerator(feedView)) - } - }) + if (MICROCOSM_ENABLED) { + feedsPromise = Promise.all( + pinnedFeeds.map(async f => { + const view = await mcHydrateFeedGenerator(f.value).catch( + () => undefined, + ) + if (view) resolved.set(view.uri, hydrateFeedGenerator(view)) + }), + ).then(() => {}) + } else { + feedsPromise = agent.app.bsky.feed + .getFeedGenerators({ + feeds: pinnedFeeds.map(f => f.value), + }) + .then(res => { + for (let i = 0; i < res.data.feeds.length; i++) { + const feedView = res.data.feeds[i] + resolved.set(feedView.uri, hydrateFeedGenerator(feedView)) + } + }) + } } // Get all lists. This currently has to be done individually. const pinnedLists = pinnedItems.filter(feed => feed.type === 'list') - const listsPromises = pinnedLists.map(list => - agent.app.bsky.graph - .getList({ - list: list.value, - limit: 1, - }) - .then(res => { - const listView = res.data.list + const listsPromises = pinnedLists.map(async list => { + if (MICROCOSM_ENABLED) { + const listView = await mcHydrateList(list.value).catch(() => undefined) + if (listView) { resolved.set(listView.uri, hydrateList(listView)) - }), - ) + return + } + } + const res = await agent.app.bsky.graph.getList({ + list: list.value, + limit: 1, + }) + resolved.set(res.data.list.uri, hydrateList(res.data.list)) + }) await feedsPromise // Fail the whole query if it fails. await Promise.allSettled(listsPromises) // Ignore individual failing ones. @@ -582,28 +617,42 @@ export function useSavedFeeds() { let feedsPromise = Promise.resolve() if (savedFeeds.length > 0) { - feedsPromise = agent.app.bsky.feed - .getFeedGenerators({ - feeds: savedFeeds.map(f => f.value), - }) - .then(res => { - res.data.feeds.forEach(f => { - resolvedFeeds.set(f.uri, f) + if (MICROCOSM_ENABLED) { + feedsPromise = Promise.all( + savedFeeds.map(async f => { + const view = await mcHydrateFeedGenerator(f.value).catch( + () => undefined, + ) + if (view) resolvedFeeds.set(view.uri, view) + }), + ).then(() => {}) + } else { + feedsPromise = agent.app.bsky.feed + .getFeedGenerators({ + feeds: savedFeeds.map(f => f.value), }) - }) + .then(res => { + res.data.feeds.forEach(f => { + resolvedFeeds.set(f.uri, f) + }) + }) + } } - const listsPromises = savedLists.map(list => - agent.app.bsky.graph - .getList({ - list: list.value, - limit: 1, - }) - .then(res => { - const listView = res.data.list + const listsPromises = savedLists.map(async list => { + if (MICROCOSM_ENABLED) { + const listView = await mcHydrateList(list.value).catch(() => undefined) + if (listView) { resolvedLists.set(listView.uri, listView) - }), - ) + return + } + } + const res = await agent.app.bsky.graph.getList({ + list: list.value, + limit: 1, + }) + resolvedLists.set(res.data.list.uri, res.data.list) + }) await Promise.allSettled([feedsPromise, ...listsPromises]) @@ -661,16 +710,25 @@ export function useFeedInfo(feedUri: string | undefined) { return useQuery({ staleTime: STALE.INFINITY, queryKey: [feedInfoQueryKeyRoot, feedUri], - queryFn: async () => { + queryFn: async ({signal}) => { if (!feedUri) { return null } - const res = await agent.app.bsky.feed.getFeedGenerator({ - feed: feedUri, - }) + let view: AppBskyFeedDefs.GeneratorView | undefined + if (MICROCOSM_ENABLED) { + view = await mcHydrateFeedGenerator(feedUri, signal).catch( + () => undefined, + ) + } + if (!view) { + const res = await agent.app.bsky.feed.getFeedGenerator({ + feed: feedUri, + }) + view = res.data.view + } - const feedSourceInfo = hydrateFeedGenerator(res.data.view) + const feedSourceInfo = hydrateFeedGenerator(view) return feedSourceInfo }, }) diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts index 56e7c70..0018dbd 100644 --- a/src/state/queries/profile-feedgens.ts +++ b/src/state/queries/profile-feedgens.ts @@ -8,6 +8,8 @@ import { useInfiniteQuery, } from '@tanstack/react-query' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import {buildActorFeedsPage} from '#/lib/microcosm/people' import {useAgent} from '#/state/session' import {useModerationOpts} from '../preferences/moderation-opts' @@ -33,7 +35,22 @@ export function useProfileFeedgensQuery( RQPageParam >({ queryKey: RQKEY(did), - async queryFn({pageParam}: {pageParam: RQPageParam}) { + async queryFn({pageParam, signal}) { + if (MICROCOSM_ENABLED) { + try { + const page = await buildActorFeedsPage( + did, + {limit: PAGE_SIZE, cursor: pageParam}, + signal, + ) + if (page.feeds.length) { + page.feeds.sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0)) + return {feeds: page.feeds, cursor: page.cursor} + } + } catch { + // fall through to the AppView + } + } const res = await agent.app.bsky.feed.getActorFeeds({ actor: did, limit: PAGE_SIZE,