Thread viewer for Bluesky
at master 10 kB view raw
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}