Thread viewer for Bluesky
1/**
2 * Renders an embed (e.g. image or quoted post) inside the post view.
3 */
4
5class EmbedComponent {
6
7 /** @param {Post} post, @param {Embed} embed */
8 constructor(post, embed) {
9 this.post = post;
10 this.embed = embed;
11 }
12
13 /** @returns {AnyElement} */
14
15 buildElement() {
16 if (this.embed instanceof RawRecordEmbed) {
17 let quoteView = this.quotedPostPlaceholder();
18 this.loadQuotedPost(this.embed.record.uri, quoteView);
19 return quoteView;
20
21 } else if (this.embed instanceof RawRecordWithMediaEmbed) {
22 let wrapper = $tag('div');
23
24 let mediaView = new EmbedComponent(this.post, this.embed.media).buildElement();
25 let quoteView = this.quotedPostPlaceholder();
26 this.loadQuotedPost(this.embed.record.uri, quoteView);
27
28 wrapper.append(mediaView, quoteView);
29 return wrapper;
30
31 } else if (this.embed instanceof InlineRecordEmbed) {
32 return this.buildQuotedPostElement(this.embed);
33
34 } else if (this.embed instanceof InlineRecordWithMediaEmbed) {
35 let wrapper = $tag('div');
36
37 let mediaView = new EmbedComponent(this.post, this.embed.media).buildElement();
38 let quoteView = this.buildQuotedPostElement(this.embed);
39
40 wrapper.append(mediaView, quoteView);
41 return wrapper;
42
43 } else if (this.embed instanceof RawImageEmbed || this.embed instanceof InlineImageEmbed) {
44 return this.buildImagesComponent(this.embed);
45
46 } else if (this.embed instanceof RawLinkEmbed || this.embed instanceof InlineLinkEmbed) {
47 return this.buildLinkComponent(this.embed);
48
49 } else if (this.embed instanceof RawVideoEmbed || this.embed instanceof InlineVideoEmbed) {
50 return this.buildVideoComponent(this.embed);
51
52 } else {
53 return $tag('p', { text: `[${this.embed.type}]` });
54 }
55 }
56
57 /** @returns {AnyElement} */
58
59 quotedPostPlaceholder() {
60 return $tag('div.quote-embed', {
61 html: '<p class="post placeholder">Loading quoted post...</p>'
62 });
63 }
64
65 /** @param {InlineRecordEmbed | InlineRecordWithMediaEmbed} embed, @returns {AnyElement} */
66
67 buildQuotedPostElement(embed) {
68 let div = $tag('div.quote-embed');
69
70 if ([Post, BlockedPost, MissingPost, DetachedQuotePost].some(c => embed.post instanceof c)) {
71 let postView = new PostComponent(embed.post, 'quote').buildElement();
72 div.appendChild(postView);
73
74 } else if (embed.post instanceof FeedGeneratorRecord) {
75 return this.buildFeedGeneratorView(embed.post);
76
77 } else if (embed.post instanceof UserListRecord) {
78 return this.buildUserListView(embed.post);
79
80 } else if (embed.post instanceof StarterPackRecord) {
81 return this.buildStarterPackView(embed.post);
82
83 } else {
84 let p = $tag('p', { text: `[${embed.post.type}]` });
85 div.appendChild(p);
86 }
87
88 return div;
89 }
90
91 /** @params {RawLinkEmbed | InlineLinkEmbed} embed, @returns {AnyElement} */
92
93 buildLinkComponent(embed) {
94 let hostname;
95
96 try {
97 hostname = new URL(embed.url).hostname;
98 } catch (error) {
99 console.log("Invalid URL:" + error);
100
101 let a = $tag('a', { href: embed.url, text: embed.title || embed.url });
102 let p = $tag('p');
103 p.append('[Link: ', a, ']');
104 return p;
105 }
106
107 let a = $tag('a.link-card', { href: embed.url, target: '_blank' });
108 let box = $tag('div');
109
110 let domain = $tag('p.domain', { text: hostname });
111 let title = $tag('h2', { text: embed.title || embed.url });
112 box.append(domain, title);
113
114 if (embed.description) {
115 let text;
116
117 if (embed.description.length <= 300) {
118 text = embed.description;
119 } else {
120 text = embed.description.slice(0, 300) + '…';
121 }
122
123 box.append($tag('p.description', { text: text }));
124 }
125
126 a.append(box);
127
128 return a;
129 }
130
131 /** @param {FeedGeneratorRecord} feedgen, @returns {AnyElement} */
132
133 buildFeedGeneratorView(feedgen) {
134 let link = this.linkToFeedGenerator(feedgen);
135
136 let a = $tag('a.link-card.record', { href: link, target: '_blank' });
137 let box = $tag('div');
138
139 if (feedgen.avatar) {
140 let avatar = $tag('img.avatar');
141 avatar.src = feedgen.avatar;
142 box.append(avatar);
143 }
144
145 let title = $tag('h2', { text: feedgen.title });
146 title.append($tag('span.handle', { text: `• Feed by @${feedgen.author.handle}` }));
147 box.append(title);
148
149 if (feedgen.description) {
150 let description = $tag('p.description', { text: feedgen.description });
151 box.append(description);
152 }
153
154 let stats = $tag('p.stats');
155 stats.append($tag('i', 'fa-solid fa-heart'), ' ');
156 stats.append($tag('output', { text: feedgen.likeCount }));
157 box.append(stats);
158
159 a.append(box);
160 return a;
161 }
162
163 /** @param {FeedGeneratorRecord} feedgen, @returns {string} */
164
165 linkToFeedGenerator(feedgen) {
166 let { repo, rkey } = atURI(feedgen.uri);
167 return `https://bsky.app/profile/${repo}/feed/${rkey}`;
168 }
169
170 /** @param {UserListRecord} list, @returns {AnyElement} */
171
172 buildUserListView(list) {
173 let link = this.linkToUserList(list);
174
175 let a = $tag('a.link-card.record', { href: link, target: '_blank' });
176 let box = $tag('div');
177
178 if (list.avatar) {
179 let avatar = $tag('img.avatar');
180 avatar.src = list.avatar;
181 box.append(avatar);
182 }
183
184 let listType;
185
186 switch (list.purpose) {
187 case 'app.bsky.graph.defs#curatelist':
188 listType = "User list";
189 break;
190 case 'app.bsky.graph.defs#modlist':
191 listType = "Mute list";
192 break;
193 default:
194 listType = "List";
195 }
196
197 let title = $tag('h2', { text: list.title });
198 title.append($tag('span.handle', { text: `• ${listType} by @${list.author.handle}` }));
199 box.append(title);
200
201 if (list.description) {
202 let description = $tag('p.description', { text: list.description });
203 box.append(description);
204 }
205
206 a.append(box);
207 return a;
208 }
209
210 /** @param {StarterPackRecord} pack, @returns {AnyElement} */
211
212 buildStarterPackView(pack) {
213 let { repo, rkey } = atURI(pack.uri);
214 let link = `https://bsky.app/starter-pack/${repo}/${rkey}`;
215
216 let a = $tag('a.link-card.record', { href: link, target: '_blank' });
217 let box = $tag('div');
218
219 let title = $tag('h2', { text: pack.title });
220 title.append($tag('span.handle', { text: `• Starter pack by @${pack.author.handle}` }));
221 box.append(title);
222
223 if (pack.description) {
224 let description = $tag('p.description', { text: pack.description });
225 box.append(description);
226 }
227
228 a.append(box);
229 return a;
230 }
231
232 /** @param {UserListRecord} list, @returns {string} */
233
234 linkToUserList(list) {
235 let { repo, rkey } = atURI(list.uri);
236 return `https://bsky.app/profile/${repo}/lists/${rkey}`;
237 }
238
239 /** @params {RawImageEmbed | InlineImageEmbed} embed, @returns {AnyElement} */
240
241 buildImagesComponent(embed) {
242 let wrapper = $tag('div');
243
244 for (let image of embed.images) {
245 let p = $tag('p');
246 p.append('[');
247
248 // TODO: load image
249 let a = $tag('a', { text: "Image" });
250
251 if (image.fullsize) {
252 a.href = image.fullsize;
253 } else {
254 let cid = image.image.ref['$link'];
255 a.href = `https://cdn.bsky.app/img/feed_fullsize/plain/${this.post.author.did}/${cid}@jpeg`;
256 }
257
258 p.append(a);
259 p.append('] ');
260 wrapper.append(p);
261
262 if (image.alt) {
263 let details = $tag('details.image-alt');
264 details.append(
265 $tag('summary', { text: 'Show alt' }),
266 image.alt
267 );
268 wrapper.appendChild(details);
269 }
270 }
271
272 return wrapper;
273 }
274
275 /** @params {RawVideoEmbed | InlineVideoEmbed} embed, @returns {AnyElement} */
276
277 buildVideoComponent(embed) {
278 let wrapper = $tag('div');
279
280 // TODO: load thumbnail
281 let a = $tag('a', { text: "Video" });
282
283 if (embed.playlistURL) {
284 a.href = embed.playlistURL;
285 } else {
286 let cid = embed.video.ref['$link'];
287 a.href = `https://video.bsky.app/watch/${this.post.author.did}/${cid}/playlist.m3u8`;
288 }
289
290 let p = $tag('p');
291 p.append('[', a, ']');
292 wrapper.append(p);
293
294 if (embed.alt) {
295 let details = $tag('details.image-alt');
296 details.append(
297 $tag('summary', { text: 'Show alt' }),
298 embed.alt
299 );
300 wrapper.appendChild(details);
301 }
302
303 return wrapper;
304 }
305
306 /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */
307
308 async loadQuotedPost(uri, div) {
309 let record = await api.loadPostIfExists(uri);
310
311 if (record) {
312 let post = new Post(record);
313 let postView = new PostComponent(post, 'quote').buildElement();
314 div.replaceChildren(postView);
315 } else {
316 let post = new MissingPost(this.embed.record);
317 let postView = new PostComponent(post, 'quote').buildElement();
318 div.replaceChildren(postView);
319 }
320 }
321}