Thread viewer for Bluesky
at svelte 500 lines 12 kB view raw
1import { api } from '../api.js'; 2import { atURI, castToInt } from '../utils.js'; 3import { ATProtoRecord, FeedGeneratorRecord, StarterPackRecord, UserListRecord } from './records.js'; 4import { Embed } from './embeds.js'; 5 6/** 7 * Thrown when parsing post JSON fails. 8 */ 9 10export class PostDataError extends Error { 11 12 /** @param {string} message */ 13 constructor(message) { 14 super(message); 15 } 16} 17 18 19/** 20 * Base class shared by the full Post and post stubs like BlockedPost, MissingPost etc. 21 */ 22 23export class BasePost extends ATProtoRecord { 24 25 /** @returns {string} */ 26 get didLinkToAuthor() { 27 let { repo } = atURI(this.uri); 28 return `https://bsky.app/profile/${repo}`; 29 } 30} 31 32 33/** 34 * View of a post as part of a thread, as returned from getPostThread. 35 * Expected to be #threadViewPost, but may be blocked or missing. 36 * 37 * @param {json} json 38 * @param {Post?} [pageRoot] 39 * @param {number} [level] 40 * @param {number} [absoluteLevel] 41 * @returns {AnyPost} 42 */ 43 44export function parseThreadPost(json, pageRoot = null, level = 0, absoluteLevel = 0) { 45 switch (json.$type) { 46 case 'app.bsky.feed.defs#threadViewPost': 47 let post = new Post(json.post, { level: level, absoluteLevel: absoluteLevel }); 48 49 post.pageRoot = pageRoot ?? post; 50 51 if (json.replies) { 52 let replies = json.replies.map(x => parseThreadPost(x, post.pageRoot, level + 1, absoluteLevel + 1)); 53 post.setReplies(replies); 54 } 55 56 if (absoluteLevel <= 0 && json.parent) { 57 post.parent = parseThreadPost(json.parent, post.pageRoot, level - 1, absoluteLevel - 1); 58 } 59 60 return post; 61 62 case 'app.bsky.feed.defs#notFoundPost': 63 return new MissingPost(json); 64 65 case 'app.bsky.feed.defs#blockedPost': 66 return new BlockedPost(json); 67 68 default: 69 throw new PostDataError(`Unexpected record type: ${json.$type}`); 70 } 71} 72 73/** 74 * View of a post embedded as a quote. 75 * Expected to be app.bsky.embed.record#viewRecord, but may be blocked, missing or a different type of record 76 * (e.g. a list or a feed generator). For unknown record embeds, we fall back to generic ATProtoRecord. 77 * 78 * @param {json} json 79 * @returns {ATProtoRecord} 80 */ 81 82export function parseViewRecord(json) { 83 switch (json.$type) { 84 case 'app.bsky.embed.record#viewRecord': 85 return new Post(json, { isEmbed: true }); 86 87 case 'app.bsky.embed.record#viewNotFound': 88 return new MissingPost(json); 89 90 case 'app.bsky.embed.record#viewBlocked': 91 return new BlockedPost(json); 92 93 case 'app.bsky.embed.record#viewDetached': 94 return new DetachedQuotePost(json); 95 96 case 'app.bsky.feed.defs#generatorView': 97 return new FeedGeneratorRecord(json); 98 99 case 'app.bsky.graph.defs#listView': 100 return new UserListRecord(json); 101 102 case 'app.bsky.graph.defs#starterPackViewBasic': 103 return new StarterPackRecord(json); 104 105 default: 106 console.warn('Unknown record type:', json.$type); 107 return new ATProtoRecord(json); 108 } 109} 110 111/** 112 * View of a post as part of a feed (e.g. a profile feed, home timeline or a custom feed). It should be an 113 * app.bsky.feed.defs#feedViewPost - blocked or missing posts don't appear here, they just aren't included. 114 * 115 * @param {json} json 116 * @returns {Post} 117 */ 118 119export function parseFeedPost(json) { 120 let post = new Post(json.post); 121 122 if (json.reply) { 123 post.parent = parsePostView(json.reply.parent); 124 post.threadRoot = parsePostView(json.reply.root); 125 126 if (json.reply.grandparentAuthor) { 127 post.grandparentAuthor = json.reply.grandparentAuthor; 128 } 129 } 130 131 if (json.reason) { 132 post.reason = json.reason; 133 } 134 135 return post; 136} 137 138/** 139 * Parses a #postView - the inner post object that includes the actual post - but still checks if it's not 140 * a blocked or missing post. The #postView must include a $type. 141 * (This is used for e.g. parent/root of a #feedViewPost.) 142 * 143 * @param {json} json, @returns {AnyPost} 144 */ 145 146export function parsePostView(json) { 147 switch (json.$type) { 148 case 'app.bsky.feed.defs#postView': 149 return new Post(json); 150 151 case 'app.bsky.feed.defs#notFoundPost': 152 return new MissingPost(json); 153 154 case 'app.bsky.feed.defs#blockedPost': 155 return new BlockedPost(json); 156 157 default: 158 throw new PostDataError(`Unexpected record type: ${json.$type}`); 159 } 160} 161 162 163/** 164 * Standard Bluesky post record. 165 */ 166 167export class Post extends BasePost { 168 /** 169 * Post object which is the direct parent of this post. 170 * @type {AnyPost | undefined} 171 */ 172 parent; 173 174 /** 175 * Post object which is the root of the whole thread (as specified in the post record). 176 * @type {AnyPost | undefined} 177 */ 178 threadRoot; 179 180 /** 181 * Post which is at the top of the (sub)thread currently loaded on the page (might not be the same as threadRoot). 182 * @type {Post | undefined} 183 */ 184 pageRoot; 185 186 /** 187 * Post's direct replies (if it's displayed in a thread). 188 * @type {AnyPost[]} 189 */ 190 replies; 191 192 /** 193 * Info about the author of the "grandparent" post. Included only in feedPost views, for the purposes 194 * of feed filtering algorithm. 195 * @type {json | undefined} 196 */ 197 grandparentAuthor; 198 199 /** 200 * Depth of the post in the getPostThread response it was loaded from, starting from 0. May be negative. 201 * @type {number | undefined} 202 */ 203 level; 204 205 /** 206 * Depth of the post in the whole tree visible on the page (pageRoot's absoluteLevel is 0). May be negative. 207 * @type {number | undefined} 208 */ 209 absoluteLevel; 210 211 /** 212 * For posts in feeds and timelines - specifies e.g. that a post was reposted by someone. 213 * @type {object | undefined} 214 */ 215 reason; 216 217 /** 218 * True if the post was extracted from inner embed of a quote, not from a #postView. 219 * @type {boolean | undefined} 220 */ 221 isEmbed; 222 223 224 /** @param {json} data, @param {json} [extra] */ 225 226 constructor(data, extra) { 227 super(data); 228 Object.assign(this, extra ?? {}); 229 230 if (this.absoluteLevel === 0) { 231 this.pageRoot = this; 232 } 233 234 this.record = this.isPostView ? data.record : data.value; 235 236 if (this.isPostView && data.embed) { 237 this.embed = Embed.parseInlineEmbed(data.embed); 238 } else if (this.isEmbed && data.embeds && data.embeds[0]) { 239 this.embed = Embed.parseInlineEmbed(data.embeds[0]); 240 } else if (this.record.embed) { 241 this.embed = Embed.parseRawEmbed(this.record.embed); 242 } 243 244 this.author = this.author ?? data.author; 245 this.replies = []; 246 247 this.viewerData = data.viewer; 248 this.viewerLike = data.viewer?.like; 249 250 if (this.author) { 251 api.cacheProfile(this.author); 252 } 253 } 254 255 /** @param {Post} post */ 256 257 updateDataFromPost(post) { 258 this.record = post.record; 259 this.embed = post.embed; 260 this.author = post.author; 261 this.viewerData = post.viewerData; 262 this.viewerLike = post.viewerLike; 263 this.level = post.level; 264 this.absoluteLevel = post.absoluteLevel; 265 this.setReplies(post.replies); 266 } 267 268 /** @param {AnyPost[]} replies */ 269 270 setReplies(replies) { 271 this.replies = replies; 272 this.replies.sort(this.sortReplies.bind(this)); 273 } 274 275 /** @param {AnyPost} a, @param {AnyPost} b, @returns {-1 | 0 | 1} */ 276 277 sortReplies(a, b) { 278 if (a instanceof Post && b instanceof Post) { 279 if (a.author.did == this.author.did && b.author.did != this.author.did) { 280 return -1; 281 } else if (a.author.did != this.author.did && b.author.did == this.author.did) { 282 return 1; 283 } else if (a.text != "📌" && b.text == "📌") { 284 return -1; 285 } else if (a.text == "📌" && b.text != "📌") { 286 return 1; 287 } else if (a.createdAt.getTime() < b.createdAt.getTime()) { 288 return -1; 289 } else if (a.createdAt.getTime() > b.createdAt.getTime()) { 290 return 1; 291 } else { 292 return 0; 293 } 294 } else if (a instanceof Post) { 295 return -1; 296 } else if (b instanceof Post) { 297 return 1; 298 } else { 299 return 0; 300 } 301 } 302 303 /** @returns {boolean} */ 304 get isPostView() { 305 return !this.isEmbed; 306 } 307 308 /** @returns {boolean} */ 309 get isFediPost() { 310 return this.author?.handle.endsWith('.ap.brid.gy'); 311 } 312 313 /** @returns {string | undefined} */ 314 get originalFediContent() { 315 return this.record.bridgyOriginalText; 316 } 317 318 /** @returns {string | undefined} */ 319 get originalFediURL() { 320 return this.record.bridgyOriginalUrl; 321 } 322 323 /** @returns {boolean} */ 324 get isPageRoot() { 325 // I AM ROOOT 326 return (this.pageRoot === this); 327 } 328 329 /** @returns {string} */ 330 get authorFediHandle() { 331 if (this.isFediPost) { 332 return this.author.handle.replace(/\.ap\.brid\.gy$/, '').replace('.', '@'); 333 } else { 334 throw "Not a Fedi post"; 335 } 336 } 337 338 /** @returns {boolean} */ 339 get hasValidHandle() { 340 return this.author.handle != 'handle.invalid'; 341 } 342 343 /** @returns {string} */ 344 get authorDisplayName() { 345 if (this.author.displayName) { 346 return this.author.displayName.trim(); 347 } else if (this.author.handle.endsWith('.bsky.social')) { 348 return this.author.handle.replace(/\.bsky\.social$/, ''); 349 } else { 350 return this.author.handle; 351 } 352 } 353 354 /** @returns {string} */ 355 get linkToAuthor() { 356 return 'https://bsky.app/profile/' + (this.hasValidHandle ? this.author.handle : this.author.did); 357 } 358 359 /** @returns {string} */ 360 get linkToPost() { 361 return this.linkToAuthor + '/post/' + this.rkey; 362 } 363 364 /** @returns {string} */ 365 get text() { 366 return this.record.text; 367 } 368 369 /** @returns {string} */ 370 get lowercaseText() { 371 if (!this._lowercaseText) { 372 this._lowercaseText = this.record.text.toLowerCase(); 373 } 374 375 return this._lowercaseText; 376 } 377 378 /** @returns {json} */ 379 get facets() { 380 return this.record.facets; 381 } 382 383 /** @returns {string[] | undefined} */ 384 get tags() { 385 return this.record.tags; 386 } 387 388 /** @returns {Date} */ 389 get createdAt() { 390 return new Date(this.record.createdAt); 391 } 392 393 /** @returns {number} */ 394 get likeCount() { 395 return castToInt(this.data.likeCount); 396 } 397 398 /** @returns {number} */ 399 get replyCount() { 400 return castToInt(this.data.replyCount); 401 } 402 403 /** @returns {number} */ 404 get quoteCount() { 405 return castToInt(this.data.quoteCount); 406 } 407 408 /** @returns {boolean} */ 409 get hasMoreReplies() { 410 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 411 412 return shouldHaveMoreReplies && (this.replies.length === 0) && (this.level !== undefined && this.level > 4); 413 } 414 415 /** @returns {boolean} */ 416 get hasHiddenReplies() { 417 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 418 419 return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4)); 420 } 421 422 /** @returns {boolean} */ 423 get isRestrictingReplies() { 424 return !!(this.data.threadgate && this.data.threadgate.record.allow); 425 } 426 427 /** @returns {number} */ 428 get repostCount() { 429 return castToInt(this.data.repostCount); 430 } 431 432 /** @returns {boolean} */ 433 get liked() { 434 return (this.viewerLike !== undefined); 435 } 436 437 /** @returns {boolean | undefined} */ 438 get muted() { 439 return this.author.viewer?.muted; 440 } 441 442 /** @returns {string | undefined} */ 443 get muteList() { 444 return this.author.viewer?.mutedByList?.name; 445 } 446 447 /** @returns {boolean} */ 448 get hasViewerInfo() { 449 return (this.viewerData !== undefined); 450 } 451 452 /** @returns {ATProtoRecord | undefined} */ 453 get parentReference() { 454 return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent); 455 } 456 457 /** @returns {ATProtoRecord | undefined} */ 458 get rootReference() { 459 return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root); 460 } 461} 462 463 464/** 465 * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block 466 * between the post author and the parent author). It only includes a reference, but no post content. 467 */ 468 469export class BlockedPost extends BasePost { 470 471 /** @param {json} data */ 472 constructor(data) { 473 super(data); 474 this.author = data.author; 475 } 476 477 /** @returns {boolean} */ 478 get blocksUser() { 479 return !!this.author.viewer?.blocking; 480 } 481 482 /** @returns {boolean} */ 483 get blockedByUser() { 484 return this.author.viewer?.blockedBy; 485 } 486} 487 488 489/** 490 * Stub of a post which was deleted or hidden. 491 */ 492 493export class MissingPost extends BasePost {} 494 495 496/** 497 * Stub of a quoted post which was un-quoted by the original author. 498 */ 499 500export class DetachedQuotePost extends BasePost {}