your personal website on atproto - mirror blento.app
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

add quoted post embed

Florian df8f44e2 f0db04ac

+241 -91
+149 -90
src/lib/components/bluesky-post/index.ts
··· 1 - import type { PostData, PostEmbed } from '../post'; 1 + import type { PostData, PostEmbed, QuotedPostData } from '../post'; 2 2 import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 3 3 import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 4 4 ··· 11 11 .replace(/'/g, '&#39;'); 12 12 } 13 13 14 - function blueskyEmbedTypeToEmbedType(type: string) { 15 - switch (type) { 16 - case 'app.bsky.embed.external#view': 17 - case 'app.bsky.embed.external': 18 - return 'external'; 19 - case 'app.bsky.embed.images#view': 20 - case 'app.bsky.embed.images': 21 - return 'images'; 22 - case 'app.bsky.embed.video#view': 23 - case 'app.bsky.embed.video': 24 - return 'video'; 25 - default: 26 - return 'unknown'; 27 - } 28 - } 29 - 30 - export function blueskyPostToPostData( 31 - data: PostView, 32 - baseUrl: string = 'https://bsky.app' 33 - ): PostData { 34 - const post = data; 35 - // const reason = data.reason; 36 - // const reply = data.reply?.parent; 37 - // const replyId = reply?.uri?.split('/').pop(); 38 - const id = post.uri.split('/').pop(); 39 - 40 - return { 41 - id, 42 - href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 43 - // reposted: 44 - // reason && reason.$type === 'app.bsky.feed.defs#reasonRepost' 45 - // ? { 46 - // handle: reason.by.handle, 47 - // href: `${baseUrl}/profile/${reason.by.handle}` 48 - // } 49 - // : undefined, 50 - 51 - // replyTo: 52 - // reply && replyId 53 - // ? { 54 - // handle: reply.author.handle, 55 - // href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}` 56 - // } 57 - // : undefined, 58 - author: { 59 - displayName: post.author.displayName || '', 60 - handle: post.author.handle, 61 - avatar: post.author.avatar, 62 - href: `${baseUrl}/profile/${post.author.did}` 63 - }, 64 - replyCount: post.replyCount ?? 0, 65 - repostCount: post.repostCount ?? 0, 66 - likeCount: post.likeCount ?? 0, 67 - createdAt: post.record.createdAt as string, 68 - 69 - embed: post.embed 70 - ? ({ 71 - type: blueskyEmbedTypeToEmbedType(post.embed?.$type), 72 - // Cast to any to handle union type - properties are conditionally accessed 73 - images: (post.embed as any)?.images?.map((image: any) => ({ 74 - alt: image.alt, 75 - thumb: image.thumb, 76 - aspectRatio: image.aspectRatio, 77 - fullsize: image.fullsize 78 - })), 79 - external: (post.embed as any)?.external 80 - ? { 81 - href: (post.embed as any).external.uri, 82 - title: (post.embed as any).external.title, 83 - description: (post.embed as any).external.description, 84 - thumb: (post.embed as any).external.thumb 85 - } 86 - : undefined, 87 - video: (post.embed as any)?.playlist 88 - ? { 89 - playlist: (post.embed as any).playlist, 90 - thumb: (post.embed as any).thumbnail, 91 - alt: (post.embed as any).alt, 92 - aspectRatio: (post.embed as any).aspectRatio 93 - } 94 - : undefined 95 - } as PostEmbed) 96 - : undefined, 97 - 98 - htmlContent: blueskyPostToHTML(post, baseUrl), 99 - labels: post.labels ? post.labels.map((label) => label.val) : undefined 100 - }; 101 - } 102 - 103 14 interface MentionFeature { 104 15 $type: 'app.bsky.richtext.facet#mention'; 105 16 did: string; ··· 148 59 const segments = segmentize(text, facets); 149 60 return segments.map((v) => renderSegment(v, baseUrl)).join(''); 150 61 }; 62 + 63 + function blueskyEmbedTypeToEmbedType(type: string) { 64 + switch (type) { 65 + case 'app.bsky.embed.external#view': 66 + case 'app.bsky.embed.external': 67 + return 'external'; 68 + case 'app.bsky.embed.images#view': 69 + case 'app.bsky.embed.images': 70 + return 'images'; 71 + case 'app.bsky.embed.video#view': 72 + case 'app.bsky.embed.video': 73 + return 'video'; 74 + case 'app.bsky.embed.record#view': 75 + case 'app.bsky.embed.record': 76 + return 'record'; 77 + case 'app.bsky.embed.recordWithMedia#view': 78 + case 'app.bsky.embed.recordWithMedia': 79 + return 'recordWithMedia'; 80 + default: 81 + return 'unknown'; 82 + } 83 + } 84 + 85 + function extractQuotedPost(recordView: any, baseUrl: string): QuotedPostData | null { 86 + if (!recordView?.author) return null; 87 + 88 + const id = recordView.uri?.split('/').pop(); 89 + const author = recordView.author; 90 + const value = recordView.value as any; 91 + 92 + let htmlContent = ''; 93 + if (value?.text) { 94 + htmlContent = RichText({ text: value.text, facets: value.facets }, baseUrl).replace( 95 + /\n/g, 96 + '<br>' 97 + ); 98 + } 99 + 100 + // Convert nested media embeds (skip record embeds to avoid recursion) 101 + let embed: PostEmbed | undefined; 102 + const firstEmbed = recordView.embeds?.[0] as any; 103 + if (firstEmbed) { 104 + const embedType = blueskyEmbedTypeToEmbedType(firstEmbed.$type); 105 + if (embedType !== 'record' && embedType !== 'recordWithMedia' && embedType !== 'unknown') { 106 + embed = convertEmbed(firstEmbed, baseUrl); 107 + } 108 + } 109 + 110 + return { 111 + author: { 112 + displayName: author.displayName || '', 113 + handle: author.handle, 114 + avatar: author.avatar, 115 + href: `${baseUrl}/profile/${author.did}` 116 + }, 117 + href: `${baseUrl}/profile/${author.handle}/post/${id}`, 118 + htmlContent, 119 + createdAt: value?.createdAt, 120 + embed 121 + }; 122 + } 123 + 124 + function convertEmbed(embedView: any, baseUrl: string): PostEmbed { 125 + const type = blueskyEmbedTypeToEmbedType(embedView?.$type); 126 + 127 + switch (type) { 128 + case 'images': 129 + return { 130 + type: 'images', 131 + images: embedView.images?.map((image: any) => ({ 132 + alt: image.alt, 133 + thumb: image.thumb, 134 + aspectRatio: image.aspectRatio, 135 + fullsize: image.fullsize 136 + })) 137 + }; 138 + case 'external': 139 + return embedView.external 140 + ? { 141 + type: 'external', 142 + external: { 143 + href: embedView.external.uri, 144 + title: embedView.external.title, 145 + description: embedView.external.description, 146 + thumb: embedView.external.thumb 147 + } 148 + } 149 + : { type: 'unknown' }; 150 + case 'video': 151 + return embedView.playlist 152 + ? { 153 + type: 'video', 154 + video: { 155 + playlist: embedView.playlist, 156 + thumb: embedView.thumbnail, 157 + alt: embedView.alt, 158 + aspectRatio: embedView.aspectRatio 159 + } 160 + } 161 + : { type: 'unknown' }; 162 + case 'record': { 163 + const record = extractQuotedPost(embedView.record, baseUrl); 164 + return record ? { type: 'record', record } : { type: 'unknown' }; 165 + } 166 + case 'recordWithMedia': { 167 + const record = extractQuotedPost(embedView.record?.record, baseUrl); 168 + const media = embedView.media ? convertEmbed(embedView.media, baseUrl) : undefined; 169 + if (record) { 170 + return { 171 + type: 'recordWithMedia', 172 + record, 173 + media: media ?? { type: 'unknown' } 174 + }; 175 + } 176 + return media ?? { type: 'unknown' }; 177 + } 178 + default: 179 + return { type: 'unknown' }; 180 + } 181 + } 182 + 183 + export function blueskyPostToPostData( 184 + data: PostView, 185 + baseUrl: string = 'https://bsky.app' 186 + ): PostData { 187 + const post = data; 188 + const id = post.uri.split('/').pop(); 189 + 190 + return { 191 + id, 192 + href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 193 + author: { 194 + displayName: post.author.displayName || '', 195 + handle: post.author.handle, 196 + avatar: post.author.avatar, 197 + href: `${baseUrl}/profile/${post.author.did}` 198 + }, 199 + replyCount: post.replyCount ?? 0, 200 + repostCount: post.repostCount ?? 0, 201 + likeCount: post.likeCount ?? 0, 202 + createdAt: post.record.createdAt as string, 203 + 204 + embed: post.embed ? convertEmbed(post.embed, baseUrl) : undefined, 205 + 206 + htmlContent: blueskyPostToHTML(post, baseUrl), 207 + labels: post.labels ? post.labels.map((label) => label.val) : undefined 208 + }; 209 + } 151 210 152 211 export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') { 153 212 if (!post?.record) {
+14
src/lib/components/post/embeds/Embed.svelte
··· 3 3 import External from './External.svelte'; 4 4 import Images from './Images.svelte'; 5 5 import Video from './Video.svelte'; 6 + import QuotedPost from './QuotedPost.svelte'; 6 7 7 8 const { embed }: { embed: PostEmbed } = $props(); 8 9 </script> ··· 14 15 <External data={embed} /> 15 16 {:else if embed.type === 'video' && embed.video} 16 17 <Video data={embed} /> 18 + {:else if embed.type === 'record' && embed.record} 19 + <QuotedPost record={embed.record} /> 20 + {:else if embed.type === 'recordWithMedia' && embed.record} 21 + {#if embed.media} 22 + {#if embed.media.type === 'images'} 23 + <Images data={embed.media} /> 24 + {:else if embed.media.type === 'external' && embed.media.external} 25 + <External data={embed.media} /> 26 + {:else if embed.media.type === 'video' && embed.media.video} 27 + <Video data={embed.media} /> 28 + {/if} 29 + {/if} 30 + <QuotedPost record={embed.record} /> 17 31 {:else if embed.type === 'unknown'} 18 32 <div 19 33 class="text-base-700 dark:text-base-300 bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-600/30 rounded-2xl border p-4 text-sm"
+47
src/lib/components/post/embeds/QuotedPost.svelte
··· 1 + <script lang="ts"> 2 + import type { QuotedPostData } from '..'; 3 + import { sanitize } from '$lib/sanitize'; 4 + import Images from './Images.svelte'; 5 + import External from './External.svelte'; 6 + import Video from './Video.svelte'; 7 + 8 + const { record }: { record: QuotedPostData } = $props(); 9 + </script> 10 + 11 + <div 12 + class="border-base-300 dark:border-base-600/30 bg-base-950/5 dark:bg-base-950/20 overflow-hidden rounded-2xl border text-sm" 13 + > 14 + <div class="p-3"> 15 + <div class="flex items-center gap-2"> 16 + {#if record.author.avatar} 17 + <img src={record.author.avatar} alt="" class="size-5 rounded-full object-cover" /> 18 + {/if} 19 + <div class="flex items-baseline gap-1.5 overflow-hidden text-xs"> 20 + {#if record.author.displayName} 21 + <span class="text-base-900 dark:text-base-50 truncate font-semibold"> 22 + {record.author.displayName} 23 + </span> 24 + {/if} 25 + <span class="text-base-500 dark:text-base-400 truncate"> 26 + @{record.author.handle} 27 + </span> 28 + </div> 29 + </div> 30 + {#if record.htmlContent} 31 + <div class="text-base-800 dark:text-base-200 accent:text-base-900 mt-1.5 line-clamp-3"> 32 + {@html sanitize(record.htmlContent, { ADD_ATTR: ['target'] })} 33 + </div> 34 + {/if} 35 + </div> 36 + {#if record.embed} 37 + <div class="px-3 pb-3"> 38 + {#if record.embed.type === 'images'} 39 + <Images data={record.embed} /> 40 + {:else if record.embed.type === 'external' && record.embed.external} 41 + <External data={record.embed} /> 42 + {:else if record.embed.type === 'video' && record.embed.video} 43 + <Video data={record.embed} /> 44 + {/if} 45 + </div> 46 + {/if} 47 + </div>
+31 -1
src/lib/components/post/index.ts
··· 39 39 }; 40 40 }; 41 41 42 + export type QuotedPostData = { 43 + author: { 44 + displayName: string; 45 + handle: string; 46 + avatar?: string; 47 + href?: string; 48 + }; 49 + href?: string; 50 + htmlContent?: string; 51 + createdAt?: string; 52 + embed?: PostEmbed; 53 + }; 54 + 55 + export type PostEmbedRecord = { 56 + type: 'record'; 57 + record: QuotedPostData; 58 + }; 59 + 60 + export type PostEmbedRecordWithMedia = { 61 + type: 'recordWithMedia'; 62 + record: QuotedPostData; 63 + media: PostEmbed; 64 + }; 65 + 42 66 export type UnknownEmbed = { 43 67 type: 'unknown'; 44 68 } & Record<string, unknown>; 45 69 46 - export type PostEmbed = PostEmbedImage | PostEmbedExternal | PostEmbedVideo | UnknownEmbed; 70 + export type PostEmbed = 71 + | PostEmbedImage 72 + | PostEmbedExternal 73 + | PostEmbedVideo 74 + | PostEmbedRecord 75 + | PostEmbedRecordWithMedia 76 + | UnknownEmbed; 47 77 48 78 export type PostData = { 49 79 href?: string;