- {#if post.likeCount !== undefined}
-
-
- {formatNumber(post.likeCount)}
-
- {/if}
+ {#if postData.repostCount !== undefined}
+
+
+ {formatNumber(postData.repostCount)}
+
+ {/if}
- {#if post.repostCount !== undefined}
-
-
- {formatNumber(post.repostCount)}
-
- {/if}
+ {#if postData.replyCount !== undefined}
+
+
+ {formatNumber(postData.replyCount)}
+
+ {/if}
- {#if post.replyCount !== undefined}
-
-
- {formatNumber(post.replyCount)}
-
- {/if}
+
+
+ {#if loading}
+
+ {:else if error}
+
+ {:else if post}
+ {@render postContent(post, 0, false)}
{:else}
No posts found
diff --git a/src/lib/services/atproto.ts b/src/lib/services/atproto.ts
index b026053..b29f7d3 100644
--- a/src/lib/services/atproto.ts
+++ b/src/lib/services/atproto.ts
@@ -63,6 +63,169 @@ async function resolvePublicIdentity(): Promise
{
return data;
}
+// Helper: build a direct blob URL for a PDS
+function buildPdsBlobUrl(pds: string, did: string, cid: string) {
+ return `${pds.replace(/\/$/, '')}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
+}
+
+// Helper: robustly pull a CID / blob reference from various image/video shapes
+function extractCidFromImageObject(img: any): string | null {
+ if (!img) return null;
+ // Common shapes: img.image.ref.$link, img.ref.$link, img.cid
+ if (img.image && img.image.ref && img.image.ref.$link) return img.image.ref.$link as string;
+ if (img.ref && img.ref.$link) return img.ref.$link as string;
+ if (img.cid) return img.cid as string;
+ if (typeof img === 'string') return img; // sometimes it's just a cid string
+ return null;
+}
+
+/**
+ * Robust extractor: hunts through `value` shapes for images (and video blobs).
+ * - returns up to `limit` URLs (built using `resolvedPds` when available)
+ * - supports value.embed (images, recordWithMedia, record), value.embeds arrays,
+ * and nested structures.
+ * - also detects 'app.bsky.embed.video' shapes and returns the video blob URL first
+ */
+function extractImageUrlsFromValue(value: any, resolvedPds: ResolvedIdentity | null, defaultDid: string, limit = 4): string[] {
+ const urls: string[] = [];
+
+ try {
+ const embed = (value as any)?.embed ?? null;
+
+ if (embed) {
+ // images view
+ if (embed.$type === 'app.bsky.embed.images#view' && Array.isArray(embed.images)) {
+ for (const img of embed.images) {
+ const cid = extractCidFromImageObject(img);
+ if (cid && resolvedPds) urls.push(buildPdsBlobUrl(resolvedPds.pds, defaultDid, cid));
+ if (urls.length >= limit) return urls;
+ }
+ }
+
+ // video embed (push video blob URL if available)
+ if (embed.$type === 'app.bsky.embed.video#view' || embed.$type === 'app.bsky.embed.video') {
+ const videoCid =
+ (embed as any)?.jobStatus?.blob ??
+ (embed as any)?.video?.ref?.$link ??
+ (embed as any)?.video?.cid ??
+ null;
+ if (videoCid && resolvedPds) {
+ urls.push(buildPdsBlobUrl(resolvedPds.pds, defaultDid, videoCid));
+ if (urls.length >= limit) return urls;
+ }
+ }
+
+ // recordWithMedia with embedded media.images
+ if (embed.$type === 'app.bsky.embed.recordWithMedia#view') {
+ const media = embed.media;
+ if (media && media.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) {
+ for (const img of media.images) {
+ const cid = extractCidFromImageObject(img);
+ if (cid && resolvedPds) urls.push(buildPdsBlobUrl(resolvedPds.pds, defaultDid, cid));
+ if (urls.length >= limit) return urls;
+ }
+ }
+
+ // The quoted record itself may contain images in record.value or embeds
+ const quotedRecord = embed.record;
+ if (quotedRecord) {
+ const quotedValue = quotedRecord.value ?? quotedRecord.record?.value ?? null;
+ if (quotedValue) {
+ const nested = extractImageUrlsFromValue(quotedValue, resolvedPds, defaultDid, limit - urls.length);
+ urls.push(...nested);
+ if (urls.length >= limit) return urls;
+ }
+ }
+ }
+
+ // record#view where embed.record may contain value or embeds
+ if (embed.$type === 'app.bsky.embed.record#view' && embed.record) {
+ const quoted = embed.record;
+ const quotedValue = quoted.value ?? quoted.record?.value ?? null;
+ if (quotedValue) {
+ const nested = extractImageUrlsFromValue(quotedValue, resolvedPds, defaultDid, limit - urls.length);
+ urls.push(...nested);
+ if (urls.length >= limit) return urls;
+ }
+ }
+ }
+
+ // embeds array (older/newer shapes can place embeds here)
+ if (Array.isArray((value as any).embeds)) {
+ for (const e of (value as any).embeds) {
+ if (e.$type === 'app.bsky.embed.images#view' && Array.isArray(e.images)) {
+ for (const img of e.images) {
+ const cid = extractCidFromImageObject(img);
+ if (cid && resolvedPds) urls.push(buildPdsBlobUrl(resolvedPds.pds, defaultDid, cid));
+ if (urls.length >= limit) return urls;
+ }
+ }
+
+ if (e.$type === 'app.bsky.embed.video#view' || e.$type === 'app.bsky.embed.video') {
+ const videoCid = (e as any)?.jobStatus?.blob ?? (e as any)?.video?.ref?.$link ?? (e as any)?.video?.cid ?? null;
+ if (videoCid && resolvedPds) {
+ urls.push(buildPdsBlobUrl(resolvedPds.pds, defaultDid, videoCid));
+ if (urls.length >= limit) return urls;
+ }
+ }
+
+ if (e.$type === 'app.bsky.embed.recordWithMedia#view') {
+ const media = e.media;
+ if (media && media.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) {
+ for (const img of media.images) {
+ const cid = extractCidFromImageObject(img);
+ if (cid && resolvedPds) urls.push(buildPdsBlobUrl(resolvedPds.pds, defaultDid, cid));
+ if (urls.length >= limit) return urls;
+ }
+ }
+
+ const quotedRec = e.record ?? e.record?.record ?? null;
+ const quotedValue = quotedRec?.value ?? null;
+ if (quotedValue) {
+ const nested = extractImageUrlsFromValue(quotedValue, resolvedPds, defaultDid, limit - urls.length);
+ urls.push(...nested);
+ if (urls.length >= limit) return urls;
+ }
+ }
+ }
+ }
+
+ // (value as any).embed?.images shape
+ if ((value as any)?.embed?.images && Array.isArray((value as any).embed.images)) {
+ for (const img of (value as any).embed.images) {
+ const cid = extractCidFromImageObject(img);
+ if (cid && resolvedPds) urls.push(buildPdsBlobUrl(resolvedPds.pds, defaultDid, cid));
+ if (urls.length >= limit) return urls;
+ }
+ }
+
+ // deep search fallback for any 'images' arrays or cid-like strings
+ const stack = [value];
+ while (stack.length && urls.length < limit) {
+ const node = stack.pop();
+ if (!node || typeof node !== 'object') continue;
+ if (Array.isArray(node.images)) {
+ for (const img of node.images) {
+ const cid = extractCidFromImageObject(img);
+ if (cid && resolvedPds) {
+ urls.push(buildPdsBlobUrl(resolvedPds.pds, defaultDid, cid));
+ if (urls.length >= limit) break;
+ }
+ }
+ }
+ for (const k of Object.keys(node)) {
+ const v = node[k];
+ if (v && typeof v === 'object') stack.push(v);
+ }
+ }
+ } catch (err) {
+ // be conservative: if anything goes wrong here, just return what we have
+ console.warn('Error extracting image/video URLs from value:', err);
+ }
+
+ return urls.slice(0, limit);
+}
+
// --- Dynamic agent creation using PUBLIC_ATPROTO_DID ---
let resolvedAgent: AtpAgent | null = null;
let pdsAgent: AtpAgent | null = null;
@@ -103,7 +266,7 @@ async function withFallback(
operation: (agent: AtpAgent) => Promise,
usePDSFirst = false
): Promise {
- const agents = usePDSFirst
+ const agents = usePDSFirst
? [getPDSAgent, () => Promise.resolve(defaultAgent)]
: [() => Promise.resolve(defaultAgent), getPDSAgent];
@@ -153,7 +316,7 @@ export interface License {
export interface BasedOnItem {
section?: string;
- name: string;
+ name?: string;
url?: string;
description?: string;
type?: string;
@@ -161,7 +324,7 @@ export interface BasedOnItem {
export interface RelatedService {
section?: string;
- name: string;
+ name?: string;
url?: string;
description?: string;
relationship?: string;
@@ -176,7 +339,7 @@ export interface Repository {
export interface Credit {
section?: string;
- name: string;
+ name?: string;
type: string;
url?: string;
author?: string;
@@ -355,35 +518,64 @@ export interface BlogPostsData {
posts: BlogPost[];
}
+export interface Facet {
+ index: {
+ byteStart: number;
+ byteEnd: number;
+ };
+ features: Array<{
+ $type: string;
+ uri?: string;
+ did?: string;
+ tag?: string;
+ }>;
+}
+
+export interface ExternalLink {
+ uri: string;
+ title: string;
+ description?: string;
+ thumb?: string;
+}
+
+export interface PostAuthor {
+ did: string;
+ handle: string;
+ displayName?: string;
+ avatar?: string;
+}
+
export interface BlueskyPost {
text: string;
createdAt: string;
uri: string;
+ author: PostAuthor;
likeCount?: number;
repostCount?: number;
replyCount?: number;
hasImages: boolean;
imageUrls?: string[];
- quotedPost?: {
- text: string;
- author: {
- displayName?: string;
- handle: string;
- avatar?: string;
- };
- createdAt: string;
- hasImages: boolean;
- imageUrls?: string[];
- };
+ imageAlts?: string[];
+ hasVideo?: boolean;
+ videoUrl?: string;
+ videoThumbnail?: string;
+ quotedPostUri?: string;
+ quotedPost?: BlueskyPost;
+ facets?: Facet[];
+ externalLink?: ExternalLink;
}
export async function fetchLatestBlueskyPost(): Promise {
- const cacheKey = `blueskypost:${PUBLIC_ATPROTO_DID}`;
+ console.log('[fetchLatestBlueskyPost] Starting fetch...');
+ const cacheKey = `blueskypost:latest:${PUBLIC_ATPROTO_DID}`;
const cached = cache.get(cacheKey);
- if (cached) return cached;
+ if (cached) {
+ console.log('[fetchLatestBlueskyPost] Returning cached post');
+ return cached;
+ }
try {
- // Try PDS first for post records
+ console.log('[fetchLatestBlueskyPost] Fetching records from repo...');
const records = await withFallback(async (agent) => {
const response = await agent.com.atproto.repo.listRecords({
repo: PUBLIC_ATPROTO_DID,
@@ -391,145 +583,443 @@ export async function fetchLatestBlueskyPost(): Promise {
limit: 10
});
return response.data.records;
- }, true); // usePDSFirst = true
+ }, true);
- if (records.length === 0) return null;
+ console.log('[fetchLatestBlueskyPost] Records fetched:', records.length);
+
+ if (records.length === 0) {
+ console.warn('[fetchLatestBlueskyPost] No records found');
+ return null;
+ }
- // Find first non-reply post
const nonReplyPost = records.find(record => {
const value = record.value as any;
return !value.reply;
});
- if (!nonReplyPost) return null;
+ if (!nonReplyPost) {
+ console.warn('[fetchLatestBlueskyPost] No non-reply post found');
+ return null;
+ }
- const record = nonReplyPost;
- const value = record.value as any;
+ console.log('[fetchLatestBlueskyPost] Found non-reply post:', nonReplyPost.uri);
+ const post = await fetchPostFromUri(nonReplyPost.uri, 0);
- // Get post thread to fetch engagement metrics and processed images
+ if (!post) {
+ console.warn('[fetchLatestBlueskyPost] fetchPostFromUri returned null');
+ return null;
+ }
+
+ console.log('[fetchLatestBlueskyPost] Post fetched successfully, caching...');
+ cache.set(cacheKey, post);
+ return post;
+ } catch (error) {
+ console.error('[fetchLatestBlueskyPost] Failed to fetch latest Bluesky post:', error);
+ return null;
+ }
+}
+
+export async function fetchBlueskyPostByUri(uri: string): Promise {
+ const cacheKey = `blueskypost:${uri}`;
+ const cached = cache.get(cacheKey);
+ if (cached) return cached;
+
+ try {
+ // Extract DID from URI: at://did:plc:xxx/app.bsky.feed.post/rkey
+ const parts = uri.split('/');
+ const did = parts[2];
+ const rkey = parts[4];
+
+ // Try to get post thread from Bluesky public API first
let likeCount, repostCount, replyCount;
let imageUrls: string[] | undefined;
let hasImages = false;
- let quotedPost: BlueskyPost['quotedPost'];
-
- // Try to get engagement data from Bluesky public API first
+ let hasVideo = false;
+ let videoUrl: string | undefined;
+ let quotedPostUri: string | undefined;
+ let text = '';
+ let createdAt = '';
+
try {
const threadResponse = await defaultAgent.getPostThread({
- uri: record.uri,
+ uri: uri,
depth: 0
});
-
+
if (threadResponse.data.thread && 'post' in threadResponse.data.thread) {
const postData = threadResponse.data.thread.post;
+ const postEmbed = (postData as any).embed ?? null;
+ const record = (postData as any).record;
+
+ text = record.text || '';
+ createdAt = record.createdAt || new Date().toISOString();
likeCount = postData.likeCount;
repostCount = postData.repostCount;
replyCount = postData.replyCount;
-
- // Extract images from the embed
- if (postData.embed && '$type' in postData.embed) {
- if (postData.embed.$type === 'app.bsky.embed.images#view' && 'images' in postData.embed) {
- hasImages = true;
- imageUrls = postData.embed.images.map((img: any) => img.thumb || img.fullsize);
- }
- // Handle quote posts
- else if (postData.embed.$type === 'app.bsky.embed.record#view' && 'record' in postData.embed) {
- const quotedRecord = postData.embed.record;
- if ('value' in quotedRecord && 'author' in quotedRecord) {
- quotedPost = {
- text: (quotedRecord.value as any).text || '',
- author: {
- displayName: quotedRecord.author.displayName,
- handle: quotedRecord.author.handle,
- avatar: quotedRecord.author.avatar
- },
- createdAt: (quotedRecord.value as any).createdAt || '',
- hasImages: false,
- imageUrls: undefined
- };
- }
+
+ let resolvedForThread: ResolvedIdentity | null = null;
+ try {
+ resolvedForThread = await resolvePublicIdentity();
+ } catch (e) {
+ // not fatal
+ }
+
+ const urlsFromPost = extractImageUrlsFromValue(postData as any, resolvedForThread, did, 4);
+ if (urlsFromPost.length > 0) {
+ hasImages = true;
+ imageUrls = urlsFromPost;
+ }
+
+ // Detect video
+ const videoCid = (postEmbed as any)?.jobStatus?.blob ?? (postEmbed as any)?.video?.ref?.$link ?? (postEmbed as any)?.video?.cid ?? null;
+ if (videoCid && resolvedForThread) {
+ try {
+ const vurl = buildPdsBlobUrl(resolvedForThread.pds, did, videoCid);
+ hasVideo = true;
+ videoUrl = vurl;
+ imageUrls = imageUrls ? [vurl, ...imageUrls] : [vurl];
+ } catch (e) {
+ console.warn('Failed to resolve PDS for post video blob:', e);
}
- // Handle quote posts with images (recordWithMedia)
- else if (postData.embed.$type === 'app.bsky.embed.recordWithMedia#view') {
- // Extract main post images
- if ('media' in postData.embed && postData.embed.media.$type === 'app.bsky.embed.images#view') {
- hasImages = true;
- imageUrls = postData.embed.media.images.map((img: any) => img.thumb || img.fullsize);
- }
- // Extract quoted post
- if ('record' in postData.embed && 'record' in postData.embed.record) {
- const quotedRecord = postData.embed.record.record;
- if ('value' in quotedRecord && 'author' in quotedRecord) {
- let quotedImages: string[] | undefined;
- let quotedHasImages = false;
-
- // Check if quoted post has images
- if ('embeds' in quotedRecord && Array.isArray(quotedRecord.embeds)) {
- const imageEmbed = quotedRecord.embeds.find((e: any) => e.$type === 'app.bsky.embed.images#view');
- if (imageEmbed && 'images' in imageEmbed) {
- quotedHasImages = true;
- quotedImages = imageEmbed.images.map((img: any) => img.thumb || img.fullsize);
- }
- }
-
- quotedPost = {
- text: (quotedRecord.value as any).text || '',
- author: {
- displayName: quotedRecord.author.displayName,
- handle: quotedRecord.author.handle,
- avatar: quotedRecord.author.avatar
- },
- createdAt: (quotedRecord.value as any).createdAt || '',
- hasImages: quotedHasImages,
- imageUrls: quotedImages
- };
- }
- }
+ }
+
+ // Extract quoted post URI if present
+ if (postEmbed && (postEmbed.$type === 'app.bsky.embed.record#view' || postEmbed.$type === 'app.bsky.embed.recordWithMedia#view')) {
+ const quotedRecord = postEmbed.$type === 'app.bsky.embed.recordWithMedia#view' ? postEmbed.record : postEmbed.record;
+ if (quotedRecord && 'uri' in quotedRecord) {
+ quotedPostUri = quotedRecord.uri;
}
}
}
} catch (err) {
- console.warn('Failed to fetch post thread data from Bluesky API:', err);
- }
-
- // Fallback: load blobs directly from the user's PDS
- if (!imageUrls && value.embed?.images?.length > 0) {
- hasImages = true;
+ console.warn('Failed to fetch post thread from Bluesky API:', err);
+ // Fall back to PDS
try {
- const resolved = await resolvePublicIdentity();
- imageUrls = value.embed.images.map((img: any) => {
- const blobRef = img.image?.ref?.$link;
- if (blobRef) {
- // Construct direct PDS blob URL
- return `${resolved.pds}/xrpc/com.atproto.sync.getBlob?did=${PUBLIC_ATPROTO_DID}&cid=${blobRef}`;
+ const records = await withFallback(async (agent) => {
+ const response = await agent.com.atproto.repo.getRecord({
+ repo: did,
+ collection: 'app.bsky.feed.post',
+ rkey: rkey
+ });
+ return response.data;
+ }, true);
+
+ const value = records.value as any;
+ text = value.text || '';
+ createdAt = value.createdAt || new Date().toISOString();
+
+ // Check for images
+ if (value.embed?.images?.length > 0) {
+ hasImages = true;
+ try {
+ const resolved = await resolvePublicIdentity();
+ imageUrls = value.embed.images.map((img: any) => {
+ const blobRef = img.image?.ref?.$link ?? img.ref?.$link ?? img.cid;
+ if (blobRef) {
+ return buildPdsBlobUrl(resolved.pds, did, blobRef);
+ }
+ return null;
+ }).filter((url: string | null) => url !== null).slice(0, 4) as string[];
+ } catch (resolveErr) {
+ console.warn('Failed to resolve PDS for blob fallback:', resolveErr);
+ imageUrls = [];
}
- return null;
- }).filter((url: string | null) => url !== null);
- } catch (resolveErr) {
- console.warn('Failed to resolve PDS for blob fallback:', resolveErr);
- imageUrls = [];
+ }
+
+ // Extract quoted post URI from record
+ if (value.embed?.record) {
+ const quotedUri = value.embed.record.uri ?? value.embed.record.record?.uri ?? null;
+ if (quotedUri) {
+ quotedPostUri = quotedUri;
+ }
+ }
+ } catch (fallbackErr) {
+ console.error('Failed to fetch post from PDS:', fallbackErr);
+ return null;
}
}
const data: BlueskyPost = {
- text: value.text,
- createdAt: value.createdAt,
- uri: record.uri,
+ text,
+ createdAt,
+ uri,
likeCount,
repostCount,
replyCount,
hasImages,
imageUrls,
- quotedPost
+ hasVideo,
+ videoUrl,
+ quotedPostUri
};
cache.set(cacheKey, data);
return data;
} catch (error) {
- console.error('Failed to fetch latest Bluesky post from all sources:', error);
+ console.error('Failed to fetch Bluesky post:', error);
+ return null;
+ }
+}
+
+// Recursive fetch for quoted posts, up to 2 levels deep
+async function fetchPostFromUri(uri: string, depth: number): Promise {
+ console.log(`[fetchPostFromUri] Starting fetch at depth ${depth} for URI:`, uri);
+
+ if (depth >= 2) {
+ console.log(`[fetchPostFromUri] Max depth reached (${depth}), stopping recursion`);
+ return null;
+ }
+
+ try {
+ console.log(`[fetchPostFromUri] Fetching post thread from Bluesky API...`);
+ const threadResponse = await defaultAgent.getPostThread({ uri, depth: 0 });
+
+ if (!threadResponse.data.thread || !('post' in threadResponse.data.thread)) {
+ console.warn(`[fetchPostFromUri] No valid thread data found for URI:`, uri);
+ return null;
+ }
+
+ const postData = threadResponse.data.thread.post;
+ console.log(`[fetchPostFromUri] Post data received:`, {
+ uri: postData.uri,
+ author: postData.author.handle,
+ hasEmbed: !!postData.embed,
+ embedType: postData.embed?.$type
+ });
+
+ const value = postData.record as any;
+ const embed = (postData as any).embed ?? null;
+
+ // Extract author information
+ const author: PostAuthor = {
+ did: postData.author.did,
+ handle: postData.author.handle,
+ displayName: postData.author.displayName,
+ avatar: postData.author.avatar
+ };
+ console.log(`[fetchPostFromUri] Author extracted:`, author);
+
+ // Get the author's DID for blob resolution
+ const authorDid = postData.author.did;
+
+ let imageUrls: string[] | undefined;
+ let imageAlts: string[] | undefined;
+ let hasImages = false;
+ let hasVideo = false;
+ let videoUrl: string | undefined;
+ let videoThumbnail: string | undefined;
+ let quotedPost: BlueskyPost | undefined;
+ let quotedPostUri: string | undefined;
+ let externalLink: ExternalLink | undefined;
+
+ // Extract facets from the record
+ const facets: Facet[] | undefined = value.facets;
+ console.log(`[fetchPostFromUri] Facets found:`, facets?.length || 0);
+
+ // Resolve PDS for the author (not PUBLIC_ATPROTO_DID)
+ let authorPds: string;
+ try {
+ console.log(`[fetchPostFromUri] Resolving PDS for author DID:`, authorDid);
+ const resolveResponse = await fetch(
+ `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(authorDid)}`
+ );
+ if (resolveResponse.ok) {
+ const resolveData = await resolveResponse.json();
+ authorPds = resolveData.pds;
+ console.log(`[fetchPostFromUri] PDS resolved:`, authorPds);
+ } else {
+ // Fallback to public.api.bsky.app for blob serving
+ authorPds = 'https://bsky.social';
+ console.log(`[fetchPostFromUri] PDS resolution failed, using fallback:`, authorPds);
+ }
+ } catch (e) {
+ console.warn('[fetchPostFromUri] Failed to resolve PDS for author, using fallback:', e);
+ authorPds = 'https://bsky.social';
+ }
+
+ // Handle images
+ if (embed?.$type === 'app.bsky.embed.images#view' && Array.isArray(embed.images)) {
+ console.log(`[fetchPostFromUri] Processing images embed, count:`, embed.images.length);
+ hasImages = true;
+ imageUrls = [];
+ imageAlts = [];
+ for (const img of embed.images) {
+ const cid = extractCidFromImageObject(img);
+ console.log(`[fetchPostFromUri] Image CID extracted:`, cid);
+ if (cid) {
+ const blobUrl = buildPdsBlobUrl(authorPds, authorDid, cid);
+ console.log(`[fetchPostFromUri] Built blob URL:`, blobUrl);
+ imageUrls.push(blobUrl);
+ imageAlts.push(img.alt || '');
+ }
+ }
+ console.log(`[fetchPostFromUri] Final image URLs:`, imageUrls);
+ }
+
+ // Handle video
+ if (embed?.$type === 'app.bsky.embed.video#view') {
+ console.log(`[fetchPostFromUri] Processing video embed`);
+ const videoCid = embed.video?.ref?.$link ?? embed.video?.cid ?? null;
+ console.log(`[fetchPostFromUri] Video CID:`, videoCid);
+ if (videoCid) {
+ hasVideo = true;
+ videoUrl = buildPdsBlobUrl(authorPds, authorDid, videoCid);
+ console.log(`[fetchPostFromUri] Video URL:`, videoUrl);
+ }
+ // Extract video thumbnail if available
+ const thumbnailCid = embed.thumbnail?.ref?.$link ?? embed.thumbnail?.cid ?? null;
+ if (thumbnailCid) {
+ videoThumbnail = buildPdsBlobUrl(authorPds, authorDid, thumbnailCid);
+ console.log(`[fetchPostFromUri] Video thumbnail URL:`, videoThumbnail);
+ }
+ }
+
+ // Handle external link card
+ if (embed?.$type === 'app.bsky.embed.external#view') {
+ console.log(`[fetchPostFromUri] Processing external link embed`);
+ const external = embed.external;
+ if (external) {
+ externalLink = {
+ uri: external.uri,
+ title: external.title,
+ description: external.description
+ };
+ console.log(`[fetchPostFromUri] External link:`, externalLink.uri);
+ // Extract thumbnail if available
+ const thumbCid = external.thumb?.ref?.$link ?? external.thumb?.cid ?? null;
+ if (thumbCid) {
+ externalLink.thumb = buildPdsBlobUrl(authorPds, authorDid, thumbCid);
+ console.log(`[fetchPostFromUri] External link thumbnail:`, externalLink.thumb);
+ }
+ }
+ }
+
+ // Handle recordWithMedia (quoted post with media)
+ if (embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
+ console.log(`[fetchPostFromUri] Processing recordWithMedia embed`);
+ const media = embed.media;
+ console.log(`[fetchPostFromUri] Media type:`, media?.$type);
+
+ // Extract images from media
+ if (media?.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) {
+ console.log(`[fetchPostFromUri] Processing images in recordWithMedia, count:`, media.images.length);
+ hasImages = true;
+ imageUrls = [];
+ imageAlts = [];
+ for (const img of media.images) {
+ const cid = extractCidFromImageObject(img);
+ console.log(`[fetchPostFromUri] Media image CID:`, cid);
+ if (cid) {
+ const blobUrl = buildPdsBlobUrl(authorPds, authorDid, cid);
+ console.log(`[fetchPostFromUri] Media image blob URL:`, blobUrl);
+ imageUrls.push(blobUrl);
+ imageAlts.push(img.alt || '');
+ }
+ }
+ console.log(`[fetchPostFromUri] Final media image URLs:`, imageUrls);
+ }
+
+ // Extract video from media
+ if (media?.$type === 'app.bsky.embed.video#view') {
+ console.log(`[fetchPostFromUri] Processing video in recordWithMedia`);
+ const videoCid = media.video?.ref?.$link ?? media.video?.cid ?? null;
+ console.log(`[fetchPostFromUri] Media video CID:`, videoCid);
+ if (videoCid) {
+ hasVideo = true;
+ videoUrl = buildPdsBlobUrl(authorPds, authorDid, videoCid);
+ console.log(`[fetchPostFromUri] Media video URL:`, videoUrl);
+ }
+ const thumbnailCid = media.thumbnail?.ref?.$link ?? media.thumbnail?.cid ?? null;
+ if (thumbnailCid) {
+ videoThumbnail = buildPdsBlobUrl(authorPds, authorDid, thumbnailCid);
+ console.log(`[fetchPostFromUri] Media video thumbnail:`, videoThumbnail);
+ }
+ }
+
+ // Extract external link from media
+ if (media?.$type === 'app.bsky.embed.external#view') {
+ console.log(`[fetchPostFromUri] Processing external link in recordWithMedia`);
+ const external = media.external;
+ if (external) {
+ externalLink = {
+ uri: external.uri,
+ title: external.title,
+ description: external.description
+ };
+ console.log(`[fetchPostFromUri] Media external link:`, externalLink.uri);
+ const thumbCid = external.thumb?.ref?.$link ?? external.thumb?.cid ?? null;
+ if (thumbCid) {
+ externalLink.thumb = buildPdsBlobUrl(authorPds, authorDid, thumbCid);
+ console.log(`[fetchPostFromUri] Media external thumbnail:`, externalLink.thumb);
+ }
+ }
+ }
+
+ // Extract quoted record
+ const quotedRecord = embed.record;
+ console.log(`[fetchPostFromUri] Quoted record in recordWithMedia:`, quotedRecord?.uri);
+ if (quotedRecord && typeof quotedRecord.uri === 'string') {
+ quotedPostUri = quotedRecord.uri;
+ console.log(`[fetchPostFromUri] Recursively fetching quoted post at depth ${depth + 1}:`, quotedPostUri);
+ if (quotedPostUri) {
+ quotedPost = await fetchPostFromUri(quotedPostUri, depth + 1) ?? undefined;
+ console.log(`[fetchPostFromUri] Quoted post fetched:`, quotedPost ? 'success' : 'failed');
+ }
+ }
+ }
+
+ // Handle simple quoted post (without media)
+ if (embed?.$type === 'app.bsky.embed.record#view') {
+ console.log(`[fetchPostFromUri] Processing simple record embed (quoted post)`);
+ const quotedRecord = embed.record;
+ console.log(`[fetchPostFromUri] Quoted record:`, quotedRecord?.uri);
+ if (quotedRecord && typeof quotedRecord.uri === 'string') {
+ quotedPostUri = quotedRecord.uri;
+ console.log(`[fetchPostFromUri] Recursively fetching quoted post at depth ${depth + 1}:`, quotedPostUri);
+ if (quotedPostUri) {
+ quotedPost = await fetchPostFromUri(quotedPostUri, depth + 1) ?? undefined;
+ console.log(`[fetchPostFromUri] Quoted post fetched:`, quotedPost ? 'success' : 'failed');
+ }
+ }
+ }
+
+ const post: BlueskyPost = {
+ text: value.text,
+ createdAt: value.createdAt,
+ uri: postData.uri,
+ author,
+ likeCount: postData.likeCount,
+ repostCount: postData.repostCount,
+ replyCount: postData.replyCount,
+ hasImages,
+ imageUrls,
+ imageAlts,
+ hasVideo,
+ videoUrl,
+ videoThumbnail,
+ quotedPostUri,
+ quotedPost,
+ facets,
+ externalLink
+ };
+
+ console.log(`[fetchPostFromUri] Post construction complete at depth ${depth}:`, {
+ hasImages,
+ imageCount: imageUrls?.length,
+ hasVideo,
+ hasQuotedPost: !!quotedPost,
+ hasExternalLink: !!externalLink
+ });
+
+ return post;
+ } catch (err) {
+ console.error(`[fetchPostFromUri] Failed to fetch post at depth ${depth}:`, err);
return null;
}
}
+
+
export async function fetchBlogPosts(): Promise {
const cacheKey = `blogposts:${PUBLIC_ATPROTO_DID}`;
const cached = cache.get(cacheKey);