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}