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 {HTMLElement} */
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 {HTMLElement} */
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 {HTMLElement} */
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 {HTMLElement} */
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 if (hostname == 'media.tenor.com') {
129 a.addEventListener('click', (e) => {
130 e.preventDefault();
131 this.displayGIFInline(a, embed);
132 });
133 }
134
135 return a;
136 }
137
138 /** @param {HTMLElement} a, @param {RawLinkEmbed | InlineLinkEmbed} embed */
139
140 displayGIFInline(a, embed) {
141 let gifDiv = $tag('div.gif');
142 let img = $tag('img', { src: embed.url }, HTMLImageElement);
143 img.style.opacity = '0';
144 img.style.maxHeight = '200px';
145 gifDiv.append(img);
146 a.replaceWith(gifDiv);
147
148 img.addEventListener('load', (e) => {
149 if (img.naturalWidth > img.naturalHeight) {
150 img.style.maxHeight = '200px';
151 } else {
152 img.style.maxWidth = '200px';
153 img.style.maxHeight = '400px';
154 }
155
156 img.style.opacity = '';
157 });
158
159 let staticPic;
160
161 if (typeof embed.thumb == 'string') {
162 staticPic = embed.thumb;
163 } else {
164 staticPic = `https://cdn.bsky.app/img/avatar/plain/${this.post.author.did}/${embed.thumb.ref.$link}@jpeg`;
165 }
166
167 img.addEventListener('click', (e) => {
168 if (img.classList.contains('static')) {
169 img.src = embed.url;
170 img.classList.remove('static');
171 } else {
172 img.src = staticPic;
173 img.classList.add('static');
174 }
175 });
176 }
177
178 /** @param {FeedGeneratorRecord} feedgen, @returns {HTMLElement} */
179
180 buildFeedGeneratorView(feedgen) {
181 let link = this.linkToFeedGenerator(feedgen);
182
183 let a = $tag('a.link-card.record', { href: link, target: '_blank' });
184 let box = $tag('div');
185
186 if (feedgen.avatar) {
187 let avatar = $tag('img.avatar', HTMLImageElement);
188 avatar.src = feedgen.avatar;
189 box.append(avatar);
190 }
191
192 let title = $tag('h2', { text: feedgen.title });
193 title.append($tag('span.handle', { text: `• Feed by @${feedgen.author.handle}` }));
194 box.append(title);
195
196 if (feedgen.description) {
197 let description = $tag('p.description', { text: feedgen.description });
198 box.append(description);
199 }
200
201 let stats = $tag('p.stats');
202 stats.append($tag('i', 'fa-solid fa-heart'), ' ');
203 stats.append($tag('output', { text: feedgen.likeCount }));
204 box.append(stats);
205
206 a.append(box);
207 return a;
208 }
209
210 /** @param {FeedGeneratorRecord} feedgen, @returns {string} */
211
212 linkToFeedGenerator(feedgen) {
213 let { repo, rkey } = atURI(feedgen.uri);
214 return `https://bsky.app/profile/${repo}/feed/${rkey}`;
215 }
216
217 /** @param {UserListRecord} list, @returns {HTMLElement} */
218
219 buildUserListView(list) {
220 let link = this.linkToUserList(list);
221
222 let a = $tag('a.link-card.record', { href: link, target: '_blank' });
223 let box = $tag('div');
224
225 if (list.avatar) {
226 let avatar = $tag('img.avatar', HTMLImageElement);
227 avatar.src = list.avatar;
228 box.append(avatar);
229 }
230
231 let listType;
232
233 switch (list.purpose) {
234 case 'app.bsky.graph.defs#curatelist':
235 listType = "User list";
236 break;
237 case 'app.bsky.graph.defs#modlist':
238 listType = "Mute list";
239 break;
240 default:
241 listType = "List";
242 }
243
244 let title = $tag('h2', { text: list.title });
245 title.append($tag('span.handle', { text: `• ${listType} by @${list.author.handle}` }));
246 box.append(title);
247
248 if (list.description) {
249 let description = $tag('p.description', { text: list.description });
250 box.append(description);
251 }
252
253 a.append(box);
254 return a;
255 }
256
257 /** @param {StarterPackRecord} pack, @returns {HTMLElement} */
258
259 buildStarterPackView(pack) {
260 let { repo, rkey } = atURI(pack.uri);
261 let link = `https://bsky.app/starter-pack/${repo}/${rkey}`;
262
263 let a = $tag('a.link-card.record', { href: link, target: '_blank' });
264 let box = $tag('div');
265
266 let title = $tag('h2', { text: pack.title });
267 title.append($tag('span.handle', { text: `• Starter pack by @${pack.author.handle}` }));
268 box.append(title);
269
270 if (pack.description) {
271 let description = $tag('p.description', { text: pack.description });
272 box.append(description);
273 }
274
275 a.append(box);
276 return a;
277 }
278
279 /** @param {UserListRecord} list, @returns {string} */
280
281 linkToUserList(list) {
282 let { repo, rkey } = atURI(list.uri);
283 return `https://bsky.app/profile/${repo}/lists/${rkey}`;
284 }
285
286 /** @params {RawImageEmbed | InlineImageEmbed} embed, @returns {HTMLElement} */
287
288 buildImagesComponent(embed) {
289 let wrapper = $tag('div');
290
291 for (let image of embed.images) {
292 let p = $tag('p');
293 p.append('[');
294
295 // TODO: load image
296 let a = $tag('a', { text: "Image" }, HTMLLinkElement);
297
298 if (image.fullsize) {
299 a.href = image.fullsize;
300 } else {
301 let cid = image.image.ref['$link'];
302 a.href = `https://cdn.bsky.app/img/feed_fullsize/plain/${this.post.author.did}/${cid}@jpeg`;
303 }
304
305 p.append(a);
306 p.append('] ');
307 wrapper.append(p);
308
309 if (image.alt) {
310 let details = $tag('details.image-alt');
311 details.append(
312 $tag('summary', { text: 'Show alt' }),
313 image.alt
314 );
315 wrapper.appendChild(details);
316 }
317 }
318
319 return wrapper;
320 }
321
322 /** @params {RawVideoEmbed | InlineVideoEmbed} embed, @returns {HTMLElement} */
323
324 buildVideoComponent(embed) {
325 let wrapper = $tag('div');
326
327 // TODO: load thumbnail
328 let a = $tag('a', { text: "Video" }, HTMLLinkElement);
329
330 if (embed.playlistURL) {
331 a.href = embed.playlistURL;
332 } else {
333 let cid = embed.video.ref['$link'];
334 a.href = `https://video.bsky.app/watch/${this.post.author.did}/${cid}/playlist.m3u8`;
335 }
336
337 let p = $tag('p');
338 p.append('[', a, ']');
339 wrapper.append(p);
340
341 if (embed.alt) {
342 let details = $tag('details.image-alt');
343 details.append(
344 $tag('summary', { text: 'Show alt' }),
345 embed.alt
346 );
347 wrapper.appendChild(details);
348 }
349
350 return wrapper;
351 }
352
353 /** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */
354
355 async loadQuotedPost(uri, div) {
356 let record = await api.loadPostIfExists(uri);
357
358 if (record) {
359 let post = new Post(record);
360 let postView = new PostComponent(post, 'quote').buildElement();
361 div.replaceChildren(postView);
362 } else {
363 let post = new MissingPost(this.embed.record);
364 let postView = new PostComponent(post, 'quote').buildElement();
365 div.replaceChildren(postView);
366 }
367 }
368}