your personal website on atproto - mirror
blento.app
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, '&')
8 .replace(/</g, '<')
9 .replace(/>/g, '>')
10 .replace(/"/g, '"')
11 .replace(/'/g, ''');
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';