diff --git a/src/lib/microcosm/hydrate.ts b/src/lib/microcosm/hydrate.ts index 5c2f6c2..35b4279 100644 --- a/src/lib/microcosm/hydrate.ts +++ b/src/lib/microcosm/hydrate.ts @@ -20,6 +20,8 @@ import { type AppBskyFeedPost, type AppBskyGraphDefs, type AppBskyGraphList, + type AppBskyLabelerDefs, + type AppBskyLabelerService, type AtpAgent, } from '@atproto/api' @@ -484,6 +486,48 @@ export async function hydrateList( } } +/** + * Hydrate a `LabelerViewDetailed` for a labeler DID from microcosm. The labeler + * service record (`app.bsky.labeler.service/self`) carries its policies and + * report scopes directly; the creator is hydrated as a profile and `likeCount` + * is a Constellation like-backlink on the service URI. + * + * Viewer state (whether the viewer likes/subscribes) is not reconstructed here. + */ +export async function hydrateLabeler( + did: string, + signal?: AbortSignal, +): Promise { + const serviceUri = `at://${did}/app.bsky.labeler.service/self` + + const [recordRes, creator, likeCount] = await Promise.all([ + getRecordByUri(serviceUri, undefined, signal).catch(() => undefined), + hydrateProfileBasic(did, signal).catch(() => undefined), + constellation + .getBacklinksCount(serviceUri, constellation.Sources.likes, signal) + .then(r => r.total) + .catch(() => undefined), + ]) + + if (!recordRes || !creator) return undefined + const record = recordRes.value as AppBskyLabelerService.Record + + return { + $type: 'app.bsky.labeler.defs#labelerViewDetailed', + uri: serviceUri, + cid: recordRes.cid ?? '', + // ProfileViewBasic is a structural subset of ProfileView; the labeler UIs + // accept it. Cast at this boundary. + creator: creator as AppBskyActorDefs.ProfileView, + policies: record?.policies ?? {labelValues: []}, + likeCount, + reasonTypes: record?.reasonTypes, + subjectTypes: record?.subjectTypes, + subjectCollections: record?.subjectCollections, + 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 1ecc9b9..d884e1a 100644 --- a/src/lib/microcosm/people.ts +++ b/src/lib/microcosm/people.ts @@ -10,7 +10,7 @@ import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api' import {constellation} from '#/lib/microcosm' -import {hydrateProfileBasic} from '#/lib/microcosm/hydrate' +import {hydrateList, hydrateProfileBasic} from '#/lib/microcosm/hydrate' import {getRecordByUri, listRecords} from '#/lib/microcosm/slingshot' export type ProfileListPage = { @@ -89,6 +89,42 @@ export type ListMembersPage = { cursor?: string } +export type ProfileListsPage = { + lists: AppBskyGraphDefs.ListView[] + cursor?: string +} + +/** + * 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 + * hydrated into a full ListView (with creator + listItemCount). Does NOT include + * lists the actor merely subscribes to - those aren't records in their repo. + */ +export async function buildProfileListsPage( + did: string, + opts: {limit?: number; cursor?: string} = {}, + signal?: AbortSignal, +): Promise { + const page = await listRecords( + { + repo: did, + collection: 'app.bsky.graph.list', + limit: opts.limit ?? 30, + cursor: opts.cursor, + }, + signal, + ) + + const lists = await Promise.all( + page.records.map(rec => hydrateList(rec.uri, signal).catch(() => undefined)), + ) + + return { + cursor: page.cursor, + lists: lists.filter(Boolean) as AppBskyGraphDefs.ListView[], + } +} + /** * 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 diff --git a/src/state/queries/labeler.ts b/src/state/queries/labeler.ts index e725186..7eeedf6 100644 --- a/src/state/queries/labeler.ts +++ b/src/state/queries/labeler.ts @@ -3,6 +3,8 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {z} from 'zod' import {MAX_LABELERS} from '#/lib/constants' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import {hydrateLabeler} from '#/lib/microcosm/hydrate' import {GCTIME, STALE} from '#/state/queries' import { preferencesQueryKey, @@ -37,7 +39,15 @@ export function useLabelerInfoQuery({ return useQuery({ enabled: !!did && enabled !== false, queryKey: labelerInfoQueryKey(did as string), - queryFn: async () => { + queryFn: async ({signal}) => { + if (MICROCOSM_ENABLED) { + try { + const view = await hydrateLabeler(did!, signal) + if (view) return view + } catch { + // fall through to the AppView + } + } const res = await agent.app.bsky.labeler.getServices({ dids: [did!], detailed: true, @@ -52,7 +62,19 @@ export function useLabelersInfoQuery({dids}: {dids: string[]}) { return useQuery({ enabled: !!dids.length, queryKey: labelersInfoQueryKey(dids), - queryFn: async () => { + queryFn: async ({signal}) => { + if (MICROCOSM_ENABLED) { + try { + const views = ( + await Promise.all( + dids.map(did => hydrateLabeler(did, signal).catch(() => undefined)), + ) + ).filter(Boolean) as AppBskyLabelerDefs.LabelerView[] + if (views.length === dids.length) return views + } catch { + // fall through to the AppView + } + } const res = await agent.app.bsky.labeler.getServices({dids}) return res.data.views as AppBskyLabelerDefs.LabelerView[] }, @@ -66,7 +88,19 @@ export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) { queryKey: createLabelersDetailedInfoQueryKey(dids), gcTime: GCTIME.INFINITY, staleTime: STALE.MINUTES.ONE, - queryFn: async () => { + queryFn: async ({signal}) => { + if (MICROCOSM_ENABLED) { + try { + const views = ( + await Promise.all( + dids.map(did => hydrateLabeler(did, signal).catch(() => undefined)), + ) + ).filter(Boolean) as AppBskyLabelerDefs.LabelerViewDetailed[] + if (views.length === dids.length) return views + } catch { + // fall through to the AppView + } + } const res = await agent.app.bsky.labeler.getServices({ dids, detailed: true, diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 965da72..5a2aa38 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -5,6 +5,8 @@ import { useInfiniteQuery, } from '@tanstack/react-query' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import {buildProfileListsPage} from '#/lib/microcosm/people' import {useAgent} from '#/state/session' import {useModerationOpts} from '../preferences/moderation-opts' @@ -26,7 +28,21 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { RQPageParam >({ queryKey: RQKEY(did), - async queryFn({pageParam}: {pageParam: RQPageParam}) { + async queryFn({pageParam, signal}) { + if (MICROCOSM_ENABLED) { + try { + const page = await buildProfileListsPage( + did, + {limit: PAGE_SIZE, cursor: pageParam}, + signal, + ) + if (page.lists.length) { + return {lists: page.lists, cursor: page.cursor} + } + } catch { + // fall through to the AppView + } + } const res = await agent.app.bsky.graph.getLists({ actor: did, limit: PAGE_SIZE,