From 6cd2c7b82b5f5a5257e76c80e14038f65ad75792 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Fri, 12 Jun 2026 08:43:10 -0800 Subject: [PATCH] microcosm Phase 3: client-side profile hydration - Add hydrate.ts: hydrateProfile/hydratePost/hydrateProfileBasic assembling app.bsky View objects from Slingshot records+identity + Constellation counts - Wire useProfileQuery to hydrate from microcosm (AppView fallback) behind flag - Reconstruct avatar/banner CDN URLs from blob refs (verified resolvable) - Clean up dead analytics-removal leftovers in profile.ts --- MICROCOSM_PLAN.md | 13 ++- src/lib/microcosm/hydrate.ts | 176 +++++++++++++++++++++++++++++++++++ src/state/queries/profile.ts | 34 ++++--- 3 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 src/lib/microcosm/hydrate.ts diff --git a/MICROCOSM_PLAN.md b/MICROCOSM_PLAN.md index 285136b..a6cb679 100644 --- a/MICROCOSM_PLAN.md +++ b/MICROCOSM_PLAN.md @@ -112,7 +112,18 @@ Source strings map directly to bsky interactions: - Then optionally use them to **override** the counts in hydrated views. ### Phase 3 — the hydration layer (the actual AppView replacement) -Build `src/lib/microcosm/hydrate.ts` that assembles Views client-side: +**Status: profile hydration DONE + wired; post hydration built, not yet wired.** + +`src/lib/microcosm/hydrate.ts` assembles Views client-side: +- ✅ `hydrateProfile(identifier)` → `ProfileViewDetailed` (MiniDoc + profile record + + Constellation follower count). Wired into `useProfileQuery` behind the flag, + with AppView fallback. Verified live: full profile incl. working CDN avatar. + Known gaps (partial by nature): `followsCount` (needs listRecords), viewer + state, verification. +- ✅ `hydratePost(uri)` / `hydrateProfileBasic(did)` → `PostView` / `ProfileViewBasic` + built; NOT yet wired (needs thread/reply assembly). + +Remaining for Phase 3: - `PostView` ← Slingshot getRecord(post) + Slingshot MiniDoc(author) + Constellation counts(likes/reposts/replies) + viewer-state (likes/reposts by *me*, via Constellation `did` filter on my DID). diff --git a/src/lib/microcosm/hydrate.ts b/src/lib/microcosm/hydrate.ts new file mode 100644 index 0000000..f5783d8 --- /dev/null +++ b/src/lib/microcosm/hydrate.ts @@ -0,0 +1,176 @@ +/** + * Client-side hydration: assemble `app.bsky.*` "View" objects from microcosm + * primitives, so the app can read profiles/posts without Bluesky's AppView. + * + * PostView/ProfileView = Slingshot record (the raw record) + * + Slingshot MiniDoc (author identity) + * + Constellation counts (likes/reposts/replies/followers) + * + * Hydration is necessarily PARTIAL. Some fields the AppView computes server-side + * are not reconstructable from these primitives (e.g. `followsCount` needs + * listRecords; viewer state needs the caller's own interaction records). Those + * are left undefined; the UI tolerates missing values. + * + * @see MICROCOSM_PLAN.md + */ +import { + type AppBskyActorDefs, + type AppBskyActorProfile, + type AppBskyFeedDefs, + type AppBskyFeedPost, +} from '@atproto/api' + +import {constellation} from '#/lib/microcosm' +import {getRecordByUri, resolveMiniDoc} from '#/lib/microcosm/slingshot' + +/** + * Bluesky image CDN base. Avatars/banners are blob refs in the raw record; the + * AppView rewrites them to CDN URLs. We reconstruct the same URLs. + * + * NOTE: this is still the bsky CDN. A fully host-independent build would instead + * proxy blobs from the author's PDS (`/xrpc/com.atproto.sync.getBlob`). + */ +const IMG_CDN = 'https://cdn.bsky.app/img' + +type BlobRef = {ref?: {$link?: string}; $link?: string} + +function blobCid(blob: unknown): string | undefined { + const b = blob as BlobRef | undefined + return b?.ref?.$link ?? b?.$link +} + +function cdnUrl( + preset: 'avatar' | 'avatar_thumbnail' | 'banner', + did: string, + blob: unknown, +): string | undefined { + const cid = blobCid(blob) + if (!cid) return undefined + return `${IMG_CDN}/${preset}/plain/${did}/${cid}@jpeg` +} + +/** + * Hydrate a `ProfileViewDetailed` for a DID or handle from microcosm. + * `followsCount` is omitted (not a backlink; would require listRecords). + */ +export async function hydrateProfile( + identifier: string, + signal?: AbortSignal, +): Promise { + const mini = await resolveMiniDoc(identifier, signal) + const {did, handle} = mini + + const [recordRes, followers] = await Promise.all([ + getRecordByUri( + `at://${did}/app.bsky.actor.profile/self`, + undefined, + signal, + ).catch(() => undefined), + constellation + .getBacklinksCount(did, constellation.Sources.followers, signal) + .catch(() => undefined), + ]) + + const profile = recordRes?.value as AppBskyActorProfile.Record | undefined + + return { + $type: 'app.bsky.actor.defs#profileViewDetailed', + did, + handle, + displayName: profile?.displayName, + description: profile?.description, + avatar: cdnUrl('avatar', did, profile?.avatar), + banner: cdnUrl('banner', did, profile?.banner), + followersCount: followers?.total, + // followsCount: omitted — requires listRecords of app.bsky.graph.follow + labels: extractSelfLabels(did, profile), + indexedAt: undefined, + createdAt: profile?.createdAt, + pinnedPost: profile?.pinnedPost, + } +} + +/** + * Hydrate a `PostView` for a post at-uri from microcosm. + * Embeds and viewer state are not reconstructed here (follow-up work). + */ +export async function hydratePost( + postUri: string, + signal?: AbortSignal, +): Promise { + const parsed = parseAtUri(postUri) + if (!parsed) return undefined + + const [recordRes, author, likes, reposts, replies, quotes] = + await Promise.all([ + getRecordByUri(postUri, undefined, signal).catch(() => undefined), + hydrateProfileBasic(parsed.did, signal).catch(() => undefined), + constellation.likeCount(postUri, signal).catch(() => undefined), + constellation.repostCount(postUri, signal).catch(() => undefined), + constellation.replyCount(postUri, signal).catch(() => undefined), + constellation.quoteCount(postUri, signal).catch(() => undefined), + ]) + + if (!recordRes || !author) return undefined + const record = recordRes.value as AppBskyFeedPost.Record + + return { + $type: 'app.bsky.feed.defs#postView', + uri: postUri, + cid: recordRes.cid ?? '', + author, + record, + likeCount: likes?.total, + repostCount: reposts?.total, + replyCount: replies?.total, + quoteCount: quotes?.total, + indexedAt: record?.createdAt ?? new Date().toISOString(), + } +} + +/** A lightweight `ProfileViewBasic` (no counts) for embedding as a post author. */ +export async function hydrateProfileBasic( + did: string, + signal?: AbortSignal, +): Promise { + const mini = await resolveMiniDoc(did, signal) + const recordRes = await getRecordByUri( + `at://${mini.did}/app.bsky.actor.profile/self`, + undefined, + signal, + ).catch(() => undefined) + const profile = recordRes?.value as AppBskyActorProfile.Record | undefined + return { + $type: 'app.bsky.actor.defs#profileViewBasic', + did: mini.did, + handle: mini.handle, + displayName: profile?.displayName, + avatar: cdnUrl('avatar_thumbnail', mini.did, profile?.avatar), + labels: extractSelfLabels(mini.did, profile), + } +} + +// --- helpers --- + +function parseAtUri(uri: string): {did: string; collection: string; rkey: string} | undefined { + const m = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/) + if (!m) return undefined + return {did: m[1], collection: m[2], rkey: m[3]} +} + +/** Convert a record's self-labels into a label-view array bound to the DID. */ +function extractSelfLabels( + did: string, + profile: AppBskyActorProfile.Record | undefined, +): AppBskyActorDefs.ProfileViewDetailed['labels'] { + const labels = profile?.labels + if (!labels || !('values' in labels) || !Array.isArray(labels.values)) { + return undefined + } + return labels.values.map(v => ({ + src: did, + uri: `at://${did}/app.bsky.actor.profile/self`, + val: v.val, + cts: new Date(0).toISOString(), + })) +} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 11825cd..1982cef 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -22,6 +22,9 @@ import { import {uploadBlob} from '#/lib/api' import {until} from '#/lib/async/until' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import {hydrateProfile} from '#/lib/microcosm/hydrate' +import {logger} from '#/logger' import {updateProfileShadow} from '#/state/cache/profile-shadow' import {type Shadow} from '#/state/cache/types' import {type ImageMeta} from '#/state/gallery' @@ -35,7 +38,6 @@ import { import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' import {useAgent, useSession} from '#/state/session' import * as userActionHistory from '#/state/userActionHistory' -import {toClout} from '#/lib/numbers' import type * as bsky from '#/types/bsky' import { ProgressGuideAction, @@ -77,7 +79,17 @@ export function useProfileQuery({ staleTime, refetchOnWindowFocus: true, queryKey: RQKEY(did ?? ''), - queryFn: async () => { + queryFn: async ({signal}) => { + if (MICROCOSM_ENABLED) { + try { + return await hydrateProfile(did ?? '', signal) + } catch (e) { + // Hydration is partial/best-effort; fall back to the AppView. + logger.debug('microcosm hydrateProfile failed, falling back', { + safeMessage: String(e), + }) + } + } const res = await agent.getProfile({actor: did ?? ''}) return res.data }, @@ -364,31 +376,23 @@ export function useProfileFollowMutationQueue( } function useProfileFollowMutation( - logContext: string, - profile: Shadow, - position?: number, - contextProfileDid?: string, + _logContext: string, + _profile: Shadow, + _position?: number, + _contextProfileDid?: string, ) { - const {currentAccount} = useSession() const agent = useAgent() - const queryClient = useQueryClient() const {captureAction} = useProgressGuideControls() return useMutation<{uri: string; cid: string}, Error, {did: string}>({ mutationFn: async ({did}) => { - let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined - if (currentAccount) { - ownProfile = findProfileQueryData(queryClient, currentAccount.did) - } captureAction(ProgressGuideAction.Follow) return await agent.follow(did) }, }) } -function useProfileUnfollowMutation( - logContext: string, -) { +function useProfileUnfollowMutation(_logContext: string) { const agent = useAgent() return useMutation({ mutationFn: async ({followUri}) => { -- 2.51.2