your personal website on atproto - mirror
blento.app
1import type { PostData, PostEmbed } 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
14function 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
30export 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 console.log(JSON.parse(JSON.stringify(data)));
39
40 const id = post.uri.split('/').pop();
41
42 return {
43 id,
44 href: `${baseUrl}/profile/${post.author.handle}/post/${id}`,
45 // reposted:
46 // reason && reason.$type === 'app.bsky.feed.defs#reasonRepost'
47 // ? {
48 // handle: reason.by.handle,
49 // href: `${baseUrl}/profile/${reason.by.handle}`
50 // }
51 // : undefined,
52
53 // replyTo:
54 // reply && replyId
55 // ? {
56 // handle: reply.author.handle,
57 // href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}`
58 // }
59 // : undefined,
60 author: {
61 displayName: post.author.displayName || '',
62 handle: post.author.handle,
63 avatar: post.author.avatar,
64 href: `${baseUrl}/profile/${post.author.did}`
65 },
66 replyCount: post.replyCount ?? 0,
67 repostCount: post.repostCount ?? 0,
68 likeCount: post.likeCount ?? 0,
69 createdAt: post.record.createdAt as string,
70
71 embed: post.embed
72 ? ({
73 type: blueskyEmbedTypeToEmbedType(post.embed?.$type),
74 // Cast to any to handle union type - properties are conditionally accessed
75 images: (post.embed as any)?.images?.map((image: any) => ({
76 alt: image.alt,
77 thumb: image.thumb,
78 aspectRatio: image.aspectRatio,
79 fullsize: image.fullsize
80 })),
81 external: (post.embed as any)?.external
82 ? {
83 href: (post.embed as any).external.uri,
84 title: (post.embed as any).external.title,
85 description: (post.embed as any).external.description,
86 thumb: (post.embed as any).external.thumb
87 }
88 : undefined,
89 video: (post.embed as any)?.playlist
90 ? {
91 playlist: (post.embed as any).playlist,
92 thumb: (post.embed as any).thumbnail,
93 alt: (post.embed as any).alt,
94 aspectRatio: (post.embed as any).aspectRatio
95 }
96 : undefined
97 } as PostEmbed)
98 : undefined,
99
100 htmlContent: blueskyPostToHTML(post, baseUrl),
101 labels: post.labels ? post.labels.map((label) => label.val) : undefined
102 };
103}
104
105interface MentionFeature {
106 $type: 'app.bsky.richtext.facet#mention';
107 did: string;
108}
109
110interface LinkFeature {
111 $type: 'app.bsky.richtext.facet#link';
112 uri: string;
113}
114
115interface TagFeature {
116 $type: 'app.bsky.richtext.facet#tag';
117 tag: string;
118}
119
120type Feature = MentionFeature | LinkFeature | TagFeature;
121
122const renderSegment = (segment: RichtextSegment, baseUrl: string) => {
123 const { text, features } = segment;
124 const escaped = escapeHtml(text);
125
126 if (!features) {
127 return `<span>${escaped}</span>`;
128 }
129
130 // segments can have multiple features, use the first one
131 const feature = features[0] as Feature;
132
133 const createLink = (href: string, text: string) => {
134 return `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}">${text}</a>`;
135 };
136
137 switch (feature.$type) {
138 case 'app.bsky.richtext.facet#mention':
139 return createLink(`${baseUrl}/profile/${feature.did}`, escaped);
140 case 'app.bsky.richtext.facet#link':
141 return createLink(feature.uri, escaped);
142 case 'app.bsky.richtext.facet#tag':
143 return createLink(`${baseUrl}/hashtag/${feature.tag}`, escaped);
144 default:
145 return `<span>${escaped}</span>`;
146 }
147};
148
149const RichText = ({ text, facets }: { text: string; facets?: Facet[] }, baseUrl: string) => {
150 const segments = segmentize(text, facets);
151 return segments.map((v) => renderSegment(v, baseUrl)).join('');
152};
153
154export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') {
155 if (!post?.record) {
156 return '';
157 }
158
159 const html = RichText(
160 { text: post.record.text as string, facets: post.record.facets as Facet[] },
161 baseUrl
162 );
163
164 return html.replace(/\n/g, '<br>');
165}
166
167export { default as BlueskyPost } from './BlueskyPost.svelte';