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