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 {
50 return $tag('p', { text: `[${this.embed.type}]` });
51 }
52 }
53
54 /** @returns {AnyElement} */
55
56 quotedPostPlaceholder() {
57 return $tag('div.quote-embed', {
58 html: '<p class="post placeholder">Loading quoted post...</p>'
59 });
60 }
61
62 /** @param {InlineRecordEmbed | InlineRecordWithMediaEmbed} embed, @returns {AnyElement} */
63
64 buildQuotedPostElement(embed) {
65 let div = $tag('div.quote-embed');
66
67 if ([Post, BlockedPost, MissingPost, DetachedQuotePost].some(c => embed.post instanceof c)) {
68 let postView = new PostComponent(embed.post, 'quote').buildElement();
69 div.appendChild(postView);
70
71 } else if (embed.post instanceof FeedGeneratorRecord) {
72 return this.buildFeedGeneratorView(embed.post);
73
74 } else if (embed.post instanceof UserListRecord) {
75 return this.buildUserListView(embed.post);
76
77 } else if (embed.post instanceof StarterPackRecord) {
78 return this.buildStarterPackView(embed.post);
79
80 } else {
81 let p = $tag('p', { text: `[${embed.post.type}]` });
82 div.appendChild(p);
83 }
84
85 return div;
86 }
87
88 /** @params {RawLinkEmbed | InlineLinkEmbed} embed, @returns {AnyElement} */
89
90 buildLinkComponent(embed) {
91 let hostname;
92
93 try {
94 hostname = new URL(embed.url).hostname;
95 } catch (error) {
96 console.log("Invalid URL:" + error);
97
98 let a = $tag('a', { href: embed.url, text: embed.title || embed.url });
99 let p = $tag('p');
100 p.append('[Link: ', a, ']');
101 return p;
102 }
103
104 let a = $tag('a.link-card', { href: embed.url, target: '_blank' });
105 let box = $tag('div');
106
107 let domain = $tag('p.domain', { text: hostname });
108 let title = $tag('h2', { text: embed.title });
109 box.append(domain, title);
110
111 if (embed.description) {
112 let text;
113
114 if (embed.description.length <= 300) {
115 text = embed.description;
116 } else {
117 text = embed.description.slice(0, 300) + '…';
118 }
119
120 box.append($tag('p.description', { text: text }));
121 }
122
123 a.append(box);
124
125 return a;
126 }
127
128 /** @param {FeedGeneratorRecord} feedgen, @returns {AnyElement} */
129
130 buildFeedGeneratorView(feedgen) {
131 let link = this.linkToFeedGenerator(feedgen);
132
133 let a = $tag('a.link-card.record', { href: link, target: '_blank' });
134 let box = $tag('div');
135
136 if (feedgen.avatar) {
137 let avatar = $tag('img.avatar');
138 avatar.src = feedgen.avatar;
139 box.append(avatar);
140 }
141
142 let title = $tag('h2', { text: feedgen.title });
143 title.append($tag('span.handle', { text: `• Feed by @${feedgen.author.handle}` }));
144 box.append(title);
145
146 if (feedgen.description) {
147 let description = $tag('p.description', { text: feedgen.description });
148 box.append(description);
149 }
150
151 let stats = $tag('p.stats');
152 stats.append($tag('i', 'fa-solid fa-heart'), ' ');
153 stats.append($tag('output', { text: feedgen.likeCount }));
154 box.append(stats);
155
156 a.append(box);
157 return a;
158 }
159
160 /** @param {FeedGeneratorRecord} feedgen, @returns {string} */
161
162 linkToFeedGenerator(feedgen) {
163 let { repo, rkey } = atURI(feedgen.uri);
164 return `https://bsky.app/profile/${repo}/feed/${rkey}`;
165 }
166
167 /** @param {UserListRecord} list, @returns {AnyElement} */
168
169 buildUserListView(list) {
170 let link = this.linkToUserList(list);
171
172 let a = $tag('a.link-card.record', { href: link, target: '_blank' });
173 let box = $tag('div');
174
175 if (list.avatar) {
176 let avatar = $tag('img.avatar');
177 avatar.src = list.avatar;
178 box.append(avatar);
179 }
180
181 let listType;
182
183 switch (list.purpose) {
184 case 'app.bsky.graph.defs#curatelist':
185 listType = "User list";
186 break;
187 case 'app.bsky.graph.defs#modlist':
188 listType = "Mute list";
189 break;
190 default:
191 listType = "List";
192 }
193
194 let title = $tag('h2', { text: list.title });
195 title.append($tag('span.handle', { text: `• ${listType} by @${list.author.handle}` }));
196 box.append(title);
197
198 if (list.description) {
199 let description = $tag('p.description', { text: list.description });
200 box.append(description);
201 }
202
203 a.append(box);
204 return a;
205 }
206
207 /** @param {StarterPackRecord} pack, @returns {AnyElement} */
208
209 buildStarterPackView(pack) {
210 let { repo, rkey } = atURI(pack.uri);
211 let link = `https://bsky.app/starter-pack/${repo}/${rkey}`;
212
213 let a = $tag('a.link-card.record', { href: link, target: '_blank' });
214 let box = $tag('div');
215
216 let title = $tag('h2', { text: pack.title });
217 title.append($tag('span.handle', { text: `• Starter pack by @${pack.author.handle}` }));
218 box.append(title);
219
220 if (pack.description) {
221 let description = $tag('p.description', { text: pack.description });
222 box.append(description);
223 }
224
225 a.append(box);
226 return a;
227 }
228
229 /** @param {UserListRecord} list, @returns {string} */
230
231 linkToUserList(list) {
232 let { repo, rkey } = atURI(list.uri);
233 return `https://bsky.app/profile/${repo}/lists/${rkey}`;
234 }
235
236 /** @params {RawImageEmbed | InlineImageEmbed} embed, @returns {AnyElement} */
237
238 buildImagesComponent(embed) {
239 let wrapper = $tag('div');
240
241 for (let image of embed.images) {
242 let p = $tag('p');
243 p.append('[');
244
245 // TODO: load image
246 let a = $tag('a', { text: "Image" });
247
248 if (image.fullsize) {
249 a.href = image.fullsize;
250 } else {
251 let cid = image.image.ref['$link'];
252 a.href = `https://cdn.bsky.app/img/feed_fullsize/plain/${this.post.author.did}/${cid}@jpeg`;
253 }
254
255 p.append(a);
256 p.append('] ');
257 wrapper.append(p);
258
259 if (image.alt) {
260 let details = $tag('details.image-alt');
261 details.append(
262 $tag('summary', { text: 'Show alt' }),
263 image.alt
264 );
265 wrapper.appendChild(details);
266 }
267 }
268
269 return wrapper;
270 }
271
272 /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */
273
274 async loadQuotedPost(uri, div) {
275 let record = await api.loadPostIfExists(uri);
276
277 if (record) {
278 let post = new Post(record);
279 let postView = new PostComponent(post, 'quote').buildElement();
280 div.replaceChildren(postView);
281 } else {
282 let post = new MissingPost(this.embed.record);
283 let postView = new PostComponent(post, 'quote').buildElement();
284 div.replaceChildren(postView);
285 }
286 }
287}