diff --git a/src/lib/microcosm/constellation.ts b/src/lib/microcosm/constellation.ts index 32afdd7..ab0681d 100644 --- a/src/lib/microcosm/constellation.ts +++ b/src/lib/microcosm/constellation.ts @@ -88,6 +88,8 @@ export const Sources = { quotes: 'app.bsky.feed.post:embed.record.uri', /** Followers of an identity (subject = followed DID). */ followers: 'app.bsky.graph.follow:subject', + /** Membership records of a list (subject = list at-uri). */ + listItems: 'app.bsky.graph.listitem:list', /** Blocks of an identity (subject = blocked DID). */ blocks: 'app.bsky.graph.block:subject', } as const diff --git a/src/lib/microcosm/hydrate.ts b/src/lib/microcosm/hydrate.ts index 6a2ed4a..5c2f6c2 100644 --- a/src/lib/microcosm/hydrate.ts +++ b/src/lib/microcosm/hydrate.ts @@ -18,6 +18,8 @@ import { type AppBskyActorProfile, type AppBskyFeedDefs, type AppBskyFeedPost, + type AppBskyGraphDefs, + type AppBskyGraphList, type AtpAgent, } from '@atproto/api' @@ -439,6 +441,49 @@ export async function hydrateProfileBasic( } } +/** + * Hydrate a `ListView` for a list at-uri from microcosm. The list record gives + * its metadata; the creator is hydrated as a profile view; `listItemCount` is a + * Constellation backlink count (listitem records whose `list` points here). + * + * Viewer state (muted/blocked) and labels are not reconstructed and are omitted. + */ +export async function hydrateList( + listUri: string, + signal?: AbortSignal, +): Promise { + const parsed = parseAtUri(listUri) + if (!parsed) return undefined + + const [recordRes, creator, count] = await Promise.all([ + getRecordByUri(listUri, undefined, signal).catch(() => undefined), + hydrateProfileBasic(parsed.did, signal).catch(() => undefined), + constellation + .getBacklinksCount(listUri, constellation.Sources.listItems, signal) + .then(r => r.total) + .catch(() => undefined), + ]) + + if (!recordRes || !creator) return undefined + const record = recordRes.value as AppBskyGraphList.Record + + return { + $type: 'app.bsky.graph.defs#listView', + uri: listUri, + cid: recordRes.cid ?? '', + // ProfileViewBasic is structurally a subset of ProfileView; the list UIs + // accept it. Cast at this boundary. + creator: creator as AppBskyActorDefs.ProfileView, + name: record?.name ?? '', + purpose: record?.purpose ?? 'app.bsky.graph.defs#curatelist', + description: record?.description, + descriptionFacets: record?.descriptionFacets, + avatar: cdnUrl('avatar', parsed.did, record?.avatar), + listItemCount: count, + 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 a8e4305..1ecc9b9 100644 --- a/src/lib/microcosm/people.ts +++ b/src/lib/microcosm/people.ts @@ -1,15 +1,17 @@ /** - * Follower / following lists from Constellation, independent of the AppView. + * Follower / following lists, independent of the AppView. * - * Constellation gives us the DIDs of accounts linking to a subject (followers) - * via getBacklinkDids; we then hydrate each DID into a profile view. The - * "following" direction (who X follows) is NOT a backlink (X is the source), so - * it can't come from Constellation — that one still needs the AppView / PDS. + * Followers (who follows X) are a backlink: Constellation gives us the linking + * DIDs via getBacklinkDids. The "following" direction (who X follows) is NOT a + * backlink (X is the source), but it IS just the `app.bsky.graph.follow` records + * 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} from '@atproto/api' +import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api' import {constellation} from '#/lib/microcosm' import {hydrateProfileBasic} from '#/lib/microcosm/hydrate' +import {getRecordByUri, listRecords} from '#/lib/microcosm/slingshot' export type ProfileListPage = { profiles: AppBskyActorDefs.ProfileView[] @@ -46,6 +48,99 @@ export async function buildFollowersPage( } } +/** + * One page of the accounts `did` follows, as hydrated profile views. Reads the + * actor's own `app.bsky.graph.follow` records off their PDS and hydrates each + * record's `subject` DID. Paginates via the PDS listRecords cursor. + */ +export async function buildFollowsPage( + did: string, + opts: {limit?: number; cursor?: string} = {}, + signal?: AbortSignal, +): Promise { + const page = await listRecords( + { + repo: did, + collection: 'app.bsky.graph.follow', + limit: opts.limit ?? 30, + cursor: opts.cursor, + }, + signal, + ) + + const subjects = page.records + .map(rec => (rec.value as {subject?: string})?.subject) + .filter((s): s is string => !!s) + + const profiles = await Promise.all( + subjects.map(d => hydrateProfileBasic(d, signal).catch(() => undefined)), + ) + + return { + cursor: page.cursor, + // ProfileViewBasic is structurally a subset of ProfileView; the list UIs + // accept it. Cast at this boundary. + profiles: profiles.filter(Boolean) as AppBskyActorDefs.ProfileView[], + } +} + +export type ListMembersPage = { + items: AppBskyGraphDefs.ListItemView[] + cursor?: string +} + +/** + * One page of a list's members. Membership is `app.bsky.graph.listitem` records + * whose `list` points at the list (a Constellation backlink); each record's + * `subject` DID is hydrated into a profile view. Returns `ListItemView`s + * (`{uri, subject}`) matching the AppView's getList items shape. + */ +export async function buildListMembersPage( + listUri: string, + opts: {limit?: number; cursor?: string} = {}, + signal?: AbortSignal, +): Promise { + const page = await constellation.getBacklinks( + { + subject: listUri, + source: constellation.Sources.listItems, + limit: opts.limit ?? 30, + cursor: opts.cursor, + }, + signal, + ) + + const items = await Promise.all( + page.records.map( + async (ref): Promise => { + const itemUri = `at://${ref.did}/${ref.collection}/${ref.rkey}` + const rec = await getRecordByUri(itemUri, undefined, signal).catch( + () => undefined, + ) + const subjectDid = (rec?.value as {subject?: string} | undefined) + ?.subject + if (!subjectDid) return undefined + const subject = await hydrateProfileBasic(subjectDid, signal).catch( + () => undefined, + ) + if (!subject) return undefined + return { + $type: 'app.bsky.graph.defs#listItemView', + uri: itemUri, + // ProfileViewBasic is a structural subset of ProfileView; list UIs + // accept it. Cast at this boundary. + subject: subject as AppBskyActorDefs.ProfileView, + } + }, + ), + ) + + return { + cursor: page.cursor ?? undefined, + items: items.filter(Boolean) as AppBskyGraphDefs.ListItemView[], + } +} + /** One page of the accounts who liked a post, as hydrated profile views. */ export async function buildLikedByPage( postUri: string, diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts index c43c7bb..d730036 100644 --- a/src/state/queries/list-members.ts +++ b/src/state/queries/list-members.ts @@ -12,6 +12,9 @@ import { useQuery, } from '@tanstack/react-query' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import {hydrateList} from '#/lib/microcosm/hydrate' +import {buildListMembersPage} from '#/lib/microcosm/people' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' @@ -34,7 +37,20 @@ export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) { >({ staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(uri ?? ''), - async queryFn({pageParam}: {pageParam: RQPageParam}) { + async queryFn({pageParam, signal}) { + if (MICROCOSM_ENABLED) { + try { + const [members, list] = await Promise.all([ + buildListMembersPage(uri!, {limit, cursor: pageParam}, signal), + hydrateList(uri!, signal), + ]) + if (list && members.items.length) { + return {list, items: members.items, cursor: members.cursor} + } + } catch { + // fall through to the AppView + } + } const res = await agent.app.bsky.graph.getList({ list: uri!, // the enabled flag will prevent this from running until uri is set limit, @@ -67,6 +83,20 @@ export async function getAllListMembers(agent: AtpAgent, uri: string) { // We want to cap this at 6 pages, just for anything weird happening with the api let i = 0 while (hasMore && i < 6) { + if (MICROCOSM_ENABLED) { + try { + const page = await buildListMembersPage(uri, {limit: 50, cursor}) + if (page.items.length) { + listItems.push(...page.items) + hasMore = Boolean(page.cursor) + cursor = page.cursor + i++ + continue + } + } catch { + // fall through to the AppView for this page + } + } const res = await agent.app.bsky.graph.getList({ list: uri, limit: 50, diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index b5deb08..0cc89d3 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -14,6 +14,8 @@ import chunk from 'lodash.chunk' import {uploadBlob} from '#/lib/api' import {until} from '#/lib/async/until' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import {hydrateList} from '#/lib/microcosm/hydrate' import {type ImageMeta} from '#/state/gallery' import {STALE} from '#/state/queries' import {useAgent, useSession} from '#/state/session' @@ -29,10 +31,18 @@ export function useListQuery(uri?: string) { return useQuery({ staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(uri || ''), - async queryFn() { + async queryFn({signal}) { if (!uri) { throw new Error('URI not provided') } + if (MICROCOSM_ENABLED) { + try { + const list = await hydrateList(uri, signal) + if (list) return list + } catch { + // fall through to the AppView + } + } const res = await agent.app.bsky.graph.getList({ list: uri, limit: 1, diff --git a/src/state/queries/profile-follows.ts b/src/state/queries/profile-follows.ts index 1b15479..019fe42 100644 --- a/src/state/queries/profile-follows.ts +++ b/src/state/queries/profile-follows.ts @@ -6,6 +6,8 @@ import { useInfiniteQuery, } from '@tanstack/react-query' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import {buildFollowsPage} from '#/lib/microcosm/people' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' @@ -36,7 +38,25 @@ export function useProfileFollowsQuery( >({ staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(did || ''), - async queryFn({pageParam}: {pageParam: RQPageParam}) { + async queryFn({pageParam, signal}) { + if (MICROCOSM_ENABLED && did) { + try { + const page = await buildFollowsPage( + did, + {limit: limit || PAGE_SIZE, cursor: pageParam}, + signal, + ) + if (page.profiles.length) { + return { + subject: {did, handle: ''}, + follows: page.profiles, + cursor: page.cursor, + } + } + } catch { + // fall through to the AppView + } + } const res = await agent.app.bsky.graph.getFollows({ actor: did || '', limit: limit || PAGE_SIZE,