diff --git a/src/lib/components/layout/main/card/BlueskyPostCard.svelte b/src/lib/components/layout/main/card/BlueskyPostCard.svelte index 360ec4a..2302fa0 100644 --- a/src/lib/components/layout/main/card/BlueskyPostCard.svelte +++ b/src/lib/components/layout/main/card/BlueskyPostCard.svelte @@ -33,38 +33,76 @@ const rkey = parts[4]; return `https://bsky.app/profile/${did}/post/${rkey}`; } + + function getProfileUrl(handle: string): string { + return `https://bsky.app/profile/${handle}`; + } + + // Render rich text with facets (links, mentions, hashtags) + function renderRichText(text: string, facets?: any[]): string { + if (!facets || facets.length === 0) { + return escapeHtml(text); + } + + // Sort facets by byteStart to process them in order + const sortedFacets = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart); + + let result = ''; + let lastIndex = 0; + + for (const facet of sortedFacets) { + const { byteStart, byteEnd } = facet.index; + + // Add text before this facet + result += escapeHtml(text.slice(lastIndex, byteStart)); + + const facetText = text.slice(byteStart, byteEnd); + const feature = facet.features?.[0]; + + if (feature) { + if (feature.$type === 'app.bsky.richtext.facet#link') { + result += `${escapeHtml(facetText)}`; + } else if (feature.$type === 'app.bsky.richtext.facet#mention') { + result += `${escapeHtml(facetText)}`; + } else if (feature.$type === 'app.bsky.richtext.facet#tag') { + result += `${escapeHtml(facetText)}`; + } else { + result += escapeHtml(facetText); + } + } else { + result += escapeHtml(facetText); + } + + lastIndex = byteEnd; + } + + // Add remaining text after last facet + result += escapeHtml(text.slice(lastIndex)); + + return result; + } + + function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } -
- {#if loading} -
-
-
-
-
-
-
-
-
-
-
-
- {:else if error} -
-

{error}

-
- {:else if post} -
-
+{#snippet postContent(postData: BlueskyPost, depth: number = 0, isQuoted: boolean = false)} +
+ + {#if !isQuoted} +
Latest Post
+ {/if} -

- {post.text} -

- - {#if post.hasImages && post.imageUrls && post.imageUrls.length > 0} -
- {#each post.imageUrls as imageUrl} - Post attachment - {/each} + + + {#if postData.author.avatar} + {postData.author.displayName + {:else} +
+ + {(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()} +
{/if} +
+ + {postData.author.displayName || postData.author.handle} + + + @{postData.author.handle} + +
+ {#if isQuoted} +
+ + +
+ {@html renderRichText(postData.text, postData.facets)} +
- {#if post.quotedPost} -
+ {#if postData.hasVideo && postData.videoUrl} +
+
+ +
+ {#if postData.likeCount !== undefined} +
+
{/if} -
- {#if post.likeCount !== undefined} -
-
- {/if} + {#if postData.repostCount !== undefined} +
+
+ {/if} - {#if post.repostCount !== undefined} -
-
- {/if} + {#if postData.replyCount !== undefined} +
+
+ {/if} - {#if post.replyCount !== undefined} -
-
- {/if} + +
+
+{/snippet} - +
+ {#if loading} +
+
+
+
+
+
+
+
+
+
+ {:else if error} +
+

{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);