From df8f44e23e9d1f188c099b1df98a5a02871738f5 Mon Sep 17 00:00:00 2001
From: Florian <45694132+flo-bit@users.noreply.github.com>
Date: Sun, 1 Feb 2026 17:34:15 +0100
Subject: [PATCH] add quoted post embed
---
src/lib/components/bluesky-post/index.ts | 239 +++++++++++-------
src/lib/components/post/embeds/Embed.svelte | 14 +
.../components/post/embeds/QuotedPost.svelte | 47 ++++
src/lib/components/post/index.ts | 32 ++-
4 files changed, 241 insertions(+), 91 deletions(-)
create mode 100644 src/lib/components/post/embeds/QuotedPost.svelte
diff --git a/src/lib/components/bluesky-post/index.ts b/src/lib/components/bluesky-post/index.ts
index 7c19a52..9e0231e 100644
--- a/src/lib/components/bluesky-post/index.ts
+++ b/src/lib/components/bluesky-post/index.ts
@@ -1,4 +1,4 @@
-import type { PostData, PostEmbed } from '../post';
+import type { PostData, PostEmbed, QuotedPostData } from '../post';
import type { PostView } from '@atcute/bluesky/types/app/feed/defs';
import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter';
@@ -11,95 +11,6 @@ function escapeHtml(str: string): string {
.replace(/'/g, ''');
}
-function blueskyEmbedTypeToEmbedType(type: string) {
- switch (type) {
- case 'app.bsky.embed.external#view':
- case 'app.bsky.embed.external':
- return 'external';
- case 'app.bsky.embed.images#view':
- case 'app.bsky.embed.images':
- return 'images';
- case 'app.bsky.embed.video#view':
- case 'app.bsky.embed.video':
- return 'video';
- default:
- return 'unknown';
- }
-}
-
-export function blueskyPostToPostData(
- data: PostView,
- baseUrl: string = 'https://bsky.app'
-): PostData {
- const post = data;
- // const reason = data.reason;
- // const reply = data.reply?.parent;
- // const replyId = reply?.uri?.split('/').pop();
- const id = post.uri.split('/').pop();
-
- return {
- id,
- href: `${baseUrl}/profile/${post.author.handle}/post/${id}`,
- // reposted:
- // reason && reason.$type === 'app.bsky.feed.defs#reasonRepost'
- // ? {
- // handle: reason.by.handle,
- // href: `${baseUrl}/profile/${reason.by.handle}`
- // }
- // : undefined,
-
- // replyTo:
- // reply && replyId
- // ? {
- // handle: reply.author.handle,
- // href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}`
- // }
- // : undefined,
- author: {
- displayName: post.author.displayName || '',
- handle: post.author.handle,
- avatar: post.author.avatar,
- href: `${baseUrl}/profile/${post.author.did}`
- },
- replyCount: post.replyCount ?? 0,
- repostCount: post.repostCount ?? 0,
- likeCount: post.likeCount ?? 0,
- createdAt: post.record.createdAt as string,
-
- embed: post.embed
- ? ({
- type: blueskyEmbedTypeToEmbedType(post.embed?.$type),
- // Cast to any to handle union type - properties are conditionally accessed
- images: (post.embed as any)?.images?.map((image: any) => ({
- alt: image.alt,
- thumb: image.thumb,
- aspectRatio: image.aspectRatio,
- fullsize: image.fullsize
- })),
- external: (post.embed as any)?.external
- ? {
- href: (post.embed as any).external.uri,
- title: (post.embed as any).external.title,
- description: (post.embed as any).external.description,
- thumb: (post.embed as any).external.thumb
- }
- : undefined,
- video: (post.embed as any)?.playlist
- ? {
- playlist: (post.embed as any).playlist,
- thumb: (post.embed as any).thumbnail,
- alt: (post.embed as any).alt,
- aspectRatio: (post.embed as any).aspectRatio
- }
- : undefined
- } as PostEmbed)
- : undefined,
-
- htmlContent: blueskyPostToHTML(post, baseUrl),
- labels: post.labels ? post.labels.map((label) => label.val) : undefined
- };
-}
-
interface MentionFeature {
$type: 'app.bsky.richtext.facet#mention';
did: string;
@@ -149,6 +60,154 @@ const RichText = ({ text, facets }: { text: string; facets?: Facet[] }, baseUrl:
return segments.map((v) => renderSegment(v, baseUrl)).join('');
};
+function blueskyEmbedTypeToEmbedType(type: string) {
+ switch (type) {
+ case 'app.bsky.embed.external#view':
+ case 'app.bsky.embed.external':
+ return 'external';
+ case 'app.bsky.embed.images#view':
+ case 'app.bsky.embed.images':
+ return 'images';
+ case 'app.bsky.embed.video#view':
+ case 'app.bsky.embed.video':
+ return 'video';
+ case 'app.bsky.embed.record#view':
+ case 'app.bsky.embed.record':
+ return 'record';
+ case 'app.bsky.embed.recordWithMedia#view':
+ case 'app.bsky.embed.recordWithMedia':
+ return 'recordWithMedia';
+ default:
+ return 'unknown';
+ }
+}
+
+function extractQuotedPost(recordView: any, baseUrl: string): QuotedPostData | null {
+ if (!recordView?.author) return null;
+
+ const id = recordView.uri?.split('/').pop();
+ const author = recordView.author;
+ const value = recordView.value as any;
+
+ let htmlContent = '';
+ if (value?.text) {
+ htmlContent = RichText({ text: value.text, facets: value.facets }, baseUrl).replace(
+ /\n/g,
+ '
'
+ );
+ }
+
+ // Convert nested media embeds (skip record embeds to avoid recursion)
+ let embed: PostEmbed | undefined;
+ const firstEmbed = recordView.embeds?.[0] as any;
+ if (firstEmbed) {
+ const embedType = blueskyEmbedTypeToEmbedType(firstEmbed.$type);
+ if (embedType !== 'record' && embedType !== 'recordWithMedia' && embedType !== 'unknown') {
+ embed = convertEmbed(firstEmbed, baseUrl);
+ }
+ }
+
+ return {
+ author: {
+ displayName: author.displayName || '',
+ handle: author.handle,
+ avatar: author.avatar,
+ href: `${baseUrl}/profile/${author.did}`
+ },
+ href: `${baseUrl}/profile/${author.handle}/post/${id}`,
+ htmlContent,
+ createdAt: value?.createdAt,
+ embed
+ };
+}
+
+function convertEmbed(embedView: any, baseUrl: string): PostEmbed {
+ const type = blueskyEmbedTypeToEmbedType(embedView?.$type);
+
+ switch (type) {
+ case 'images':
+ return {
+ type: 'images',
+ images: embedView.images?.map((image: any) => ({
+ alt: image.alt,
+ thumb: image.thumb,
+ aspectRatio: image.aspectRatio,
+ fullsize: image.fullsize
+ }))
+ };
+ case 'external':
+ return embedView.external
+ ? {
+ type: 'external',
+ external: {
+ href: embedView.external.uri,
+ title: embedView.external.title,
+ description: embedView.external.description,
+ thumb: embedView.external.thumb
+ }
+ }
+ : { type: 'unknown' };
+ case 'video':
+ return embedView.playlist
+ ? {
+ type: 'video',
+ video: {
+ playlist: embedView.playlist,
+ thumb: embedView.thumbnail,
+ alt: embedView.alt,
+ aspectRatio: embedView.aspectRatio
+ }
+ }
+ : { type: 'unknown' };
+ case 'record': {
+ const record = extractQuotedPost(embedView.record, baseUrl);
+ return record ? { type: 'record', record } : { type: 'unknown' };
+ }
+ case 'recordWithMedia': {
+ const record = extractQuotedPost(embedView.record?.record, baseUrl);
+ const media = embedView.media ? convertEmbed(embedView.media, baseUrl) : undefined;
+ if (record) {
+ return {
+ type: 'recordWithMedia',
+ record,
+ media: media ?? { type: 'unknown' }
+ };
+ }
+ return media ?? { type: 'unknown' };
+ }
+ default:
+ return { type: 'unknown' };
+ }
+}
+
+export function blueskyPostToPostData(
+ data: PostView,
+ baseUrl: string = 'https://bsky.app'
+): PostData {
+ const post = data;
+ const id = post.uri.split('/').pop();
+
+ return {
+ id,
+ href: `${baseUrl}/profile/${post.author.handle}/post/${id}`,
+ author: {
+ displayName: post.author.displayName || '',
+ handle: post.author.handle,
+ avatar: post.author.avatar,
+ href: `${baseUrl}/profile/${post.author.did}`
+ },
+ replyCount: post.replyCount ?? 0,
+ repostCount: post.repostCount ?? 0,
+ likeCount: post.likeCount ?? 0,
+ createdAt: post.record.createdAt as string,
+
+ embed: post.embed ? convertEmbed(post.embed, baseUrl) : undefined,
+
+ htmlContent: blueskyPostToHTML(post, baseUrl),
+ labels: post.labels ? post.labels.map((label) => label.val) : undefined
+ };
+}
+
export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') {
if (!post?.record) {
return '';
diff --git a/src/lib/components/post/embeds/Embed.svelte b/src/lib/components/post/embeds/Embed.svelte
index d233c49..aded3ea 100644
--- a/src/lib/components/post/embeds/Embed.svelte
+++ b/src/lib/components/post/embeds/Embed.svelte
@@ -3,6 +3,7 @@
import External from './External.svelte';
import Images from './Images.svelte';
import Video from './Video.svelte';
+ import QuotedPost from './QuotedPost.svelte';
const { embed }: { embed: PostEmbed } = $props();
@@ -14,6 +15,19 @@