Thread viewer for Bluesky
at mastodon 260 lines 7.1 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 {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}