your personal website on atproto - mirror blento.app
at switch-map 224 lines 6.1 kB view raw
1import type { PostData, PostEmbed, QuotedPostData } from '../post'; 2import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 3import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 4 5function escapeHtml(str: string): string { 6 return str 7 .replace(/&/g, '&amp;') 8 .replace(/</g, '&lt;') 9 .replace(/>/g, '&gt;') 10 .replace(/"/g, '&quot;') 11 .replace(/'/g, '&#39;'); 12} 13 14interface MentionFeature { 15 $type: 'app.bsky.richtext.facet#mention'; 16 did: string; 17} 18 19interface LinkFeature { 20 $type: 'app.bsky.richtext.facet#link'; 21 uri: string; 22} 23 24interface TagFeature { 25 $type: 'app.bsky.richtext.facet#tag'; 26 tag: string; 27} 28 29type Feature = MentionFeature | LinkFeature | TagFeature; 30 31const renderSegment = (segment: RichtextSegment, baseUrl: string) => { 32 const { text, features } = segment; 33 const escaped = escapeHtml(text); 34 35 if (!features) { 36 return `<span>${escaped}</span>`; 37 } 38 39 // segments can have multiple features, use the first one 40 const feature = features[0] as Feature; 41 42 const createLink = (href: string, text: string) => { 43 return `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}">${text}</a>`; 44 }; 45 46 switch (feature.$type) { 47 case 'app.bsky.richtext.facet#mention': 48 return createLink(`${baseUrl}/profile/${feature.did}`, escaped); 49 case 'app.bsky.richtext.facet#link': 50 return createLink(feature.uri, escaped); 51 case 'app.bsky.richtext.facet#tag': 52 return createLink(`${baseUrl}/hashtag/${feature.tag}`, escaped); 53 default: 54 return `<span>${escaped}</span>`; 55 } 56}; 57 58const RichText = ({ text, facets }: { text: string; facets?: Facet[] }, baseUrl: string) => { 59 const segments = segmentize(text, facets); 60 return segments.map((v) => renderSegment(v, baseUrl)).join(''); 61}; 62 63function 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 85function 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 124function 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 183export 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} 210 211export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') { 212 if (!post?.record) { 213 return ''; 214 } 215 216 const html = RichText( 217 { text: post.record.text as string, facets: post.record.facets as Facet[] }, 218 baseUrl 219 ); 220 221 return html.replace(/\n/g, '<br>'); 222} 223 224export { default as BlueskyPost } from './BlueskyPost.svelte';