diff --git a/MICROCOSM_PLAN.md b/MICROCOSM_PLAN.md
index b7e56ba..7486f48 100644
--- a/MICROCOSM_PLAN.md
+++ b/MICROCOSM_PLAN.md
@@ -126,9 +126,14 @@ Source strings map directly to bsky interactions:
verified the feed_thumbnail/feed_fullsize CDN URLs resolve (200 JPEG).
- ✅ `hydrateProfileBasic(did)` → `ProfileViewBasic` (post author embedding).
+- ✅ viewer state (did *I* like/repost): reconstructed from Constellation
+ did-filtered backlinks -> like/repost record rkey -> at-uri. Wired into
+ `hydratePost` via `agent.did`. Verified live.
+- ✅ Image CDN swapped from Bluesky's `cdn.bsky.app` to the community-run
+ `cdn.blueat.net` (configurable via `IMG_CDN_URL`); same URL scheme, verified.
+
Remaining for Phase 3 (follow-ups, non-blocking):
- quote-record + video embeds (need a record fetch / video service)
-- viewer state (did *I* like/repost — Constellation `did` filter on my DID)
- thread reply-tree assembly (replaces app.bsky.unspecced.getPostThreadV2)
Original Phase 3 scope notes:
diff --git a/src/env/common.ts b/src/env/common.ts
index cc2ba3b..942d942 100644
--- a/src/env/common.ts
+++ b/src/env/common.ts
@@ -114,3 +114,13 @@ export const SPACEDUST_URL: string =
* identify your project and a contact.
*/
export const MICROCOSM_USER_AGENT = 'fih (microcosm client; scan@scanash.com)'
+
+/**
+ * Image CDN base used when reconstructing avatar/banner/feed image URLs from
+ * raw blob refs (during client-side hydration). Defaults to the community-run
+ * `cdn.blueat.net` mirror rather than Bluesky's first-party `cdn.bsky.app`, so
+ * the app doesn't depend on Bluesky CDN infrastructure. Same URL scheme:
+ * `/img//plain//@jpeg`.
+ */
+export const IMG_CDN_URL: string =
+ process.env.EXPO_PUBLIC_IMG_CDN_URL || 'https://cdn.blueat.net'
diff --git a/src/lib/microcosm/hydrate.ts b/src/lib/microcosm/hydrate.ts
index a9f6710..6e5c059 100644
--- a/src/lib/microcosm/hydrate.ts
+++ b/src/lib/microcosm/hydrate.ts
@@ -22,15 +22,18 @@ import {
import {constellation} from '#/lib/microcosm'
import {getRecordByUri, resolveMiniDoc} from '#/lib/microcosm/slingshot'
+import {IMG_CDN_URL} from '#/env'
/**
- * 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.
+ * Image CDN base. Avatars/banners/feed images are blob refs in the raw record;
+ * the AppView rewrites them to CDN URLs and we reconstruct the same scheme
+ * (`/img//plain//@jpeg`). Defaults to the community-run
+ * `cdn.blueat.net` (see `IMG_CDN_URL`) instead of Bluesky's `cdn.bsky.app`.
*
- * 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`).
+ * NOTE: a fully host-independent build would proxy blobs directly from the
+ * author's PDS (`/xrpc/com.atproto.sync.getBlob`).
*/
-const IMG_CDN = 'https://cdn.bsky.app/img'
+const IMG_CDN = `${IMG_CDN_URL}/img`
type BlobRef = {ref?: {$link?: string}; $link?: string}
@@ -151,17 +154,66 @@ export async function hydrateProfile(
}
/**
- * Hydrate a `PostView` for a post at-uri from microcosm.
- * Embeds and viewer state are not reconstructed here (follow-up work).
+ * Reconstruct the viewer's interaction state for a post: whether the viewer has
+ * a like / repost record pointing at it, and the at-uri of that record (which
+ * the UI needs to undo the interaction). Found by filtering Constellation
+ * backlinks to the viewer's own DID.
+ */
+async function hydrateViewerState(
+ postUri: string,
+ viewerDid: string,
+ signal?: AbortSignal,
+): Promise {
+ const [likeRes, repostRes] = await Promise.all([
+ constellation
+ .getBacklinks(
+ {
+ subject: postUri,
+ source: constellation.Sources.likes,
+ did: [viewerDid],
+ limit: 1,
+ },
+ signal,
+ )
+ .catch(() => undefined),
+ constellation
+ .getBacklinks(
+ {
+ subject: postUri,
+ source: constellation.Sources.reposts,
+ did: [viewerDid],
+ limit: 1,
+ },
+ signal,
+ )
+ .catch(() => undefined),
+ ])
+
+ const likeRec = likeRes?.records?.[0]
+ const repostRec = repostRes?.records?.[0]
+ return {
+ like: likeRec
+ ? `at://${likeRec.did}/${likeRec.collection}/${likeRec.rkey}`
+ : undefined,
+ repost: repostRec
+ ? `at://${repostRec.did}/${repostRec.collection}/${repostRec.rkey}`
+ : undefined,
+ }
+}
+
+/**
+ * Hydrate a `PostView` for a post at-uri from microcosm. If `viewerDid` is
+ * provided, the viewer's like/repost state is reconstructed too.
*/
export async function hydratePost(
postUri: string,
+ viewerDid?: string,
signal?: AbortSignal,
): Promise {
const parsed = parseAtUri(postUri)
if (!parsed) return undefined
- const [recordRes, author, likes, reposts, replies, quotes] =
+ const [recordRes, author, likes, reposts, replies, quotes, viewer] =
await Promise.all([
getRecordByUri(postUri, undefined, signal).catch(() => undefined),
hydrateProfileBasic(parsed.did, signal).catch(() => undefined),
@@ -169,6 +221,9 @@ export async function hydratePost(
constellation.repostCount(postUri, signal).catch(() => undefined),
constellation.replyCount(postUri, signal).catch(() => undefined),
constellation.quoteCount(postUri, signal).catch(() => undefined),
+ viewerDid
+ ? hydrateViewerState(postUri, viewerDid, signal).catch(() => undefined)
+ : Promise.resolve(undefined),
])
if (!recordRes || !author) return undefined
@@ -187,6 +242,7 @@ export async function hydratePost(
replyCount: replies?.total,
quoteCount: quotes?.total,
indexedAt: record?.createdAt ?? new Date().toISOString(),
+ viewer,
}
}
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 52feca3..af5b75e 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -39,7 +39,7 @@ export function usePostQuery(uri: string | undefined) {
if (MICROCOSM_ENABLED) {
try {
- const view = await hydratePost(urip.toString(), signal)
+ const view = await hydratePost(urip.toString(), agent.did, signal)
if (view) return view
} catch (e) {
logger.debug('microcosm hydratePost failed, falling back', {