Thread viewer for Bluesky
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

at c2e1332b81833cc8cefe4e19f01303c8ef62523a 287 lines 7.9 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 ([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}