Thread viewer for Bluesky
1/** 2 * Thrown when parsing post JSON fails. 3 */ 4 5class PostDataError extends Error { 6 7 /** @param {string} message */ 8 constructor(message) { 9 super(message); 10 } 11} 12 13 14/** 15 * Generic record type, base class for all records or record view objects. 16 */ 17 18class ATProtoRecord { 19 20 /** @param {json} data, @param {json} [extra] */ 21 constructor(data, extra) { 22 this.data = data; 23 Object.assign(this, extra ?? {}); 24 } 25 26 /** @returns {string} */ 27 get uri() { 28 return this.data.uri; 29 } 30 31 /** @returns {string} */ 32 get cid() { 33 return this.data.cid; 34 } 35 36 /** @returns {string} */ 37 get rkey() { 38 return atURI(this.uri).rkey; 39 } 40 41 /** @returns {string} */ 42 get type() { 43 return this.data.$type; 44 } 45} 46 47 48/** 49 * Standard Bluesky post record. 50 * 51 * @typedef {Post | BlockedPost | MissingPost | DetachedQuotePost} AnyPost 52 */ 53 54class Post extends ATProtoRecord { 55 /** 56 * Post object which is the direct parent of this post. 57 * @type {ATProtoRecord | undefined} 58 */ 59 parent; 60 61 /** 62 * Post object which is the root of the whole thread (as specified in the post record). 63 * @type {ATProtoRecord | undefined} 64 */ 65 threadRoot; 66 67 /** 68 * Post which is at the top of the (sub)thread currently loaded on the page (might not be the same as threadRoot). 69 * @type {Post | undefined} 70 */ 71 pageRoot; 72 73 /** 74 * Info about the author of the "grandparent" post. Included only in feedPost views, for the purposes 75 * of feed filtering algorithm. 76 * @type {json | undefined} 77 */ 78 grandparentAuthor; 79 80 /** 81 * Depth of the post in the getPostThread response it was loaded from, starting from 0. May be negative. 82 * @type {number | undefined} 83 */ 84 level; 85 86 /** 87 * Depth of the post in the whole tree visible on the page (pageRoot's absoluteLevel is 0). May be negative. 88 * @type {number | undefined} 89 */ 90 absoluteLevel; 91 92 /** 93 * For posts in feeds and timelines - specifies e.g. that a post was reposted by someone. 94 * @type {object | undefined} 95 */ 96 reason; 97 98 /** 99 * True if the post was extracted from inner embed of a quote, not from a #postView. 100 * @type {boolean | undefined} 101 */ 102 isEmbed; 103 104 /** 105 * View of a post as part of a thread, as returned from getPostThread. 106 * Expected to be #threadViewPost, but may be blocked or missing. 107 * 108 * @param {json} json 109 * @param {Post?} [pageRoot] 110 * @param {number} [level] 111 * @param {number} [absoluteLevel] 112 * @returns {AnyPost} 113 */ 114 115 static parseThreadPost(json, pageRoot = null, level = 0, absoluteLevel = 0) { 116 switch (json.$type) { 117 case 'app.bsky.feed.defs#threadViewPost': 118 let post = new Post(json.post, { level: level, absoluteLevel: absoluteLevel }); 119 120 post.pageRoot = pageRoot ?? post; 121 122 if (json.replies) { 123 let replies = json.replies.map(x => Post.parseThreadPost(x, post.pageRoot, level + 1, absoluteLevel + 1)); 124 post.setReplies(replies); 125 } 126 127 if (absoluteLevel <= 0 && json.parent) { 128 post.parent = Post.parseThreadPost(json.parent, post.pageRoot, level - 1, absoluteLevel - 1); 129 } 130 131 return post; 132 133 case 'app.bsky.feed.defs#notFoundPost': 134 return new MissingPost(json); 135 136 case 'app.bsky.feed.defs#blockedPost': 137 return new BlockedPost(json); 138 139 default: 140 throw new PostDataError(`Unexpected record type: ${json.$type}`); 141 } 142 } 143 144 /** 145 * View of a post embedded as a quote. 146 * Expected to be app.bsky.embed.record#viewRecord, but may be blocked, missing or a different type of record 147 * (e.g. a list or a feed generator). For unknown record embeds, we fall back to generic ATProtoRecord. 148 * 149 * @param {json} json, @returns {ATProtoRecord} 150 */ 151 152 static parseViewRecord(json) { 153 switch (json.$type) { 154 case 'app.bsky.embed.record#viewRecord': 155 return new Post(json, { isEmbed: true }); 156 157 case 'app.bsky.embed.record#viewNotFound': 158 return new MissingPost(json); 159 160 case 'app.bsky.embed.record#viewBlocked': 161 return new BlockedPost(json); 162 163 case 'app.bsky.embed.record#viewDetached': 164 return new DetachedQuotePost(json); 165 166 case 'app.bsky.feed.defs#generatorView': 167 return new FeedGeneratorRecord(json); 168 169 case 'app.bsky.graph.defs#listView': 170 return new UserListRecord(json); 171 172 case 'app.bsky.graph.defs#starterPackViewBasic': 173 return new StarterPackRecord(json); 174 175 default: 176 console.warn('Unknown record type:', json.$type); 177 return new ATProtoRecord(json); 178 } 179 } 180 181 /** 182 * View of a post as part of a feed (e.g. a profile feed, home timeline or a custom feed). It should be an 183 * app.bsky.feed.defs#feedViewPost - blocked or missing posts don't appear here, they just aren't included. 184 * 185 * @param {json} json, @returns {Post} 186 */ 187 188 static parseFeedPost(json) { 189 let post = new Post(json.post); 190 191 if (json.reply) { 192 post.parent = Post.parsePostView(json.reply.parent); 193 post.threadRoot = Post.parsePostView(json.reply.root); 194 195 if (json.reply.grandparentAuthor) { 196 post.grandparentAuthor = json.reply.grandparentAuthor; 197 } 198 } 199 200 if (json.reason) { 201 post.reason = json.reason; 202 } 203 204 return post; 205 } 206 207 /** 208 * Parses a #postView - the inner post object that includes the actual post - but still checks if it's not 209 * a blocked or missing post. The #postView must include a $type. 210 * (This is used for e.g. parent/root of a #feedViewPost.) 211 * 212 * @param {json} json, @returns {AnyPost} 213 */ 214 215 static parsePostView(json) { 216 switch (json.$type) { 217 case 'app.bsky.feed.defs#postView': 218 return new Post(json); 219 220 case 'app.bsky.feed.defs#notFoundPost': 221 return new MissingPost(json); 222 223 case 'app.bsky.feed.defs#blockedPost': 224 return new BlockedPost(json); 225 226 default: 227 throw new PostDataError(`Unexpected record type: ${json.$type}`); 228 } 229 } 230 231 /** @param {json} data, @param {json} [extra] */ 232 233 constructor(data, extra) { 234 super(data); 235 Object.assign(this, extra ?? {}); 236 237 if (this.absoluteLevel === 0) { 238 this.pageRoot = this; 239 } 240 241 this.record = this.isPostView ? data.record : data.value; 242 243 if (this.isPostView && data.embed) { 244 this.embed = Embed.parseInlineEmbed(data.embed); 245 } else if (this.isEmbed && data.embeds && data.embeds[0]) { 246 this.embed = Embed.parseInlineEmbed(data.embeds[0]); 247 } else if (this.record.embed) { 248 this.embed = Embed.parseRawEmbed(this.record.embed); 249 } 250 251 this.author = this.author ?? data.author; 252 this.replies = []; 253 254 this.viewerData = data.viewer; 255 this.viewerLike = data.viewer?.like; 256 257 if (this.author) { 258 api.cacheProfile(this.author); 259 } 260 } 261 262 /** @param {Post} post */ 263 264 updateDataFromPost(post) { 265 this.record = post.record; 266 this.embed = post.embed; 267 this.author = post.author; 268 this.replies = post.replies; 269 this.viewerData = post.viewerData; 270 this.viewerLike = post.viewerLike; 271 this.level = post.level; 272 this.absoluteLevel = post.absoluteLevel; 273 } 274 275 /** @param {AnyPost[]} replies */ 276 277 setReplies(replies) { 278 this.replies = replies; 279 this.replies.sort(this.sortReplies.bind(this)); 280 } 281 282 /** @param {AnyPost} a, @param {AnyPost} b, @returns {-1 | 0 | 1} */ 283 284 sortReplies(a, b) { 285 if (a instanceof Post && b instanceof Post) { 286 if (a.author.did == this.author.did && b.author.did != this.author.did) { 287 return -1; 288 } else if (a.author.did != this.author.did && b.author.did == this.author.did) { 289 return 1; 290 } else if (a.createdAt.getTime() < b.createdAt.getTime()) { 291 return -1; 292 } else if (a.createdAt.getTime() > b.createdAt.getTime()) { 293 return 1; 294 } else { 295 return 0; 296 } 297 } else if (a instanceof Post) { 298 return -1; 299 } else if (b instanceof Post) { 300 return 1; 301 } else { 302 return 0; 303 } 304 } 305 306 /** @returns {boolean} */ 307 get isPostView() { 308 return !this.isEmbed; 309 } 310 311 /** @returns {boolean} */ 312 get isFediPost() { 313 return this.author?.handle.endsWith('.ap.brid.gy'); 314 } 315 316 /** @returns {string | undefined} */ 317 get originalFediContent() { 318 return this.record.bridgyOriginalText; 319 } 320 321 /** @returns {boolean} */ 322 get isRoot() { 323 // I AM ROOOT 324 return (this.pageRoot === this); 325 } 326 327 /** @returns {string} */ 328 get authorFediHandle() { 329 if (this.isFediPost) { 330 return this.author.handle.replace(/\.ap\.brid\.gy$/, '').replace('.', '@'); 331 } else { 332 throw "Not a Fedi post"; 333 } 334 } 335 336 /** @returns {string} */ 337 get text() { 338 return this.record.text; 339 } 340 341 /** @returns {json} */ 342 get facets() { 343 return this.record.facets; 344 } 345 346 /** @returns {string[] | undefined} */ 347 get tags() { 348 return this.record.tags; 349 } 350 351 /** @returns {Date} */ 352 get createdAt() { 353 return new Date(this.record.createdAt); 354 } 355 356 /** @returns {number} */ 357 get likeCount() { 358 return castToInt(this.data.likeCount); 359 } 360 361 /** @returns {number} */ 362 get replyCount() { 363 return castToInt(this.data.replyCount); 364 } 365 366 /** @returns {number} */ 367 get quoteCount() { 368 return castToInt(this.data.quoteCount); 369 } 370 371 /** @returns {boolean} */ 372 get hasMoreReplies() { 373 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 374 375 return shouldHaveMoreReplies && (this.replies.length === 0) && (this.level !== undefined && this.level > 4); 376 } 377 378 /** @returns {boolean} */ 379 get hasHiddenReplies() { 380 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 381 382 return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4)); 383 } 384 385 /** @returns {number} */ 386 get repostCount() { 387 return castToInt(this.data.repostCount); 388 } 389 390 /** @returns {boolean} */ 391 get liked() { 392 return (this.viewerLike !== undefined); 393 } 394 395 /** @returns {boolean | undefined} */ 396 get muted() { 397 return this.author.viewer?.muted; 398 } 399 400 /** @returns {string | undefined} */ 401 get muteList() { 402 return this.author.viewer?.mutedByList?.name; 403 } 404 405 /** @returns {boolean} */ 406 get hasViewerInfo() { 407 return (this.viewerData !== undefined); 408 } 409 410 /** @returns {ATProtoRecord | undefined} */ 411 get parentReference() { 412 return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent); 413 } 414 415 /** @returns {ATProtoRecord | undefined} */ 416 get rootReference() { 417 return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root); 418 } 419} 420 421 422/** 423 * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block 424 * between the post author and the parent author). It only includes a reference, but no post content. 425 */ 426 427class BlockedPost extends ATProtoRecord { 428 429 /** @param {json} data */ 430 constructor(data) { 431 super(data); 432 this.author = data.author; 433 } 434 435 /** @returns {boolean} */ 436 get blocksUser() { 437 return !!this.author.viewer?.blocking; 438 } 439 440 /** @returns {boolean} */ 441 get blockedByUser() { 442 return this.author.viewer?.blockedBy; 443 } 444} 445 446 447/** 448 * Stub of a post which was deleted or hidden. 449 */ 450 451class MissingPost extends ATProtoRecord {} 452 453 454/** 455 * Stub of a quoted post which was un-quoted by the original author. 456 */ 457 458class DetachedQuotePost extends ATProtoRecord {} 459 460 461/** 462 * Record representing a feed generator. 463 */ 464 465class FeedGeneratorRecord extends ATProtoRecord { 466 467 /** @param {json} data */ 468 constructor(data) { 469 super(data); 470 this.author = data.creator; 471 } 472 473 /** @returns {string | undefined} */ 474 get title() { 475 return this.data.displayName; 476 } 477 478 /** @returns {string | undefined} */ 479 get description() { 480 return this.data.description; 481 } 482 483 /** @returns {number} */ 484 get likeCount() { 485 return castToInt(this.data.likeCount); 486 } 487 488 /** @returns {string | undefined} */ 489 get avatar() { 490 return this.data.avatar; 491 } 492} 493 494 495/** 496 * Record representing a user list or moderation list. 497 */ 498 499class UserListRecord extends ATProtoRecord { 500 501 /** @param {json} data */ 502 constructor(data) { 503 super(data); 504 this.author = data.creator; 505 } 506 507 /** @returns {string | undefined} */ 508 get title() { 509 return this.data.name; 510 } 511 512 /** @returns {string | undefined} */ 513 get purpose() { 514 return this.data.purpose; 515 } 516 517 /** @returns {string | undefined} */ 518 get description() { 519 return this.data.description; 520 } 521 522 /** @returns {string | undefined} */ 523 get avatar() { 524 return this.data.avatar; 525 } 526} 527 528 529/** 530 * Record representing a starter pack. 531 */ 532 533class StarterPackRecord extends ATProtoRecord { 534 535 /** @param {json} data */ 536 constructor(data) { 537 super(data); 538 this.author = data.creator; 539 } 540 541 /** @returns {string | undefined} */ 542 get title() { 543 return this.data.record.name; 544 } 545 546 /** @returns {string | undefined} */ 547 get description() { 548 return this.data.record.description; 549 } 550} 551 552 553/** 554 * Base class for embed objects. 555 */ 556 557class Embed { 558 559 /** 560 * More hydrated view of an embed, taken from a full post view (#postView). 561 * 562 * @param {json} json, @returns {Embed} 563 */ 564 565 static parseInlineEmbed(json) { 566 switch (json.$type) { 567 case 'app.bsky.embed.record#view': 568 return new InlineRecordEmbed(json); 569 570 case 'app.bsky.embed.recordWithMedia#view': 571 return new InlineRecordWithMediaEmbed(json); 572 573 case 'app.bsky.embed.images#view': 574 return new InlineImageEmbed(json); 575 576 case 'app.bsky.embed.external#view': 577 return new InlineLinkEmbed(json); 578 579 case 'app.bsky.embed.video#view': 580 return new InlineVideoEmbed(json); 581 582 default: 583 if (location.protocol == 'file:') { 584 throw new PostDataError(`Unexpected embed type: ${json.$type}`); 585 } else { 586 console.warn('Unexpected embed type:', json.$type); 587 return new Embed(json); 588 } 589 } 590 } 591 592 /** 593 * Raw embed extracted from raw record data of a post. Does not include quoted post contents. 594 * 595 * @param {json} json, @returns {Embed} 596 */ 597 598 static parseRawEmbed(json) { 599 switch (json.$type) { 600 case 'app.bsky.embed.record': 601 return new RawRecordEmbed(json); 602 603 case 'app.bsky.embed.recordWithMedia': 604 return new RawRecordWithMediaEmbed(json); 605 606 case 'app.bsky.embed.images': 607 return new RawImageEmbed(json); 608 609 case 'app.bsky.embed.external': 610 return new RawLinkEmbed(json); 611 612 case 'app.bsky.embed.video': 613 return new RawVideoEmbed(json); 614 615 default: 616 if (location.protocol == 'file:') { 617 throw new PostDataError(`Unexpected embed type: ${json.$type}`); 618 } else { 619 console.warn('Unexpected embed type:', json.$type); 620 return new Embed(json); 621 } 622 } 623 } 624 625 /** @param {json} json */ 626 constructor(json) { 627 this.json = json; 628 } 629 630 /** @returns {string} */ 631 get type() { 632 return this.json.$type; 633 } 634} 635 636class RawImageEmbed extends Embed { 637 638 /** @param {json} json */ 639 constructor(json) { 640 super(json); 641 this.images = json.images; 642 } 643} 644 645class RawLinkEmbed extends Embed { 646 647 /** @param {json} json */ 648 constructor(json) { 649 super(json); 650 651 this.url = json.external.uri; 652 this.title = json.external.title; 653 this.thumb = json.external.thumb; 654 } 655} 656 657class RawVideoEmbed extends Embed { 658 659 /** @param {json} json */ 660 constructor(json) { 661 super(json); 662 this.video = json.video; 663 } 664} 665 666class RawRecordEmbed extends Embed { 667 668 /** @param {json} json */ 669 constructor(json) { 670 super(json); 671 this.record = new ATProtoRecord(json.record); 672 } 673} 674 675class RawRecordWithMediaEmbed extends Embed { 676 677 /** @param {json} json */ 678 constructor(json) { 679 super(json); 680 this.record = new ATProtoRecord(json.record.record); 681 this.media = Embed.parseRawEmbed(json.media); 682 } 683} 684 685class InlineRecordEmbed extends Embed { 686 687 /** 688 * app.bsky.embed.record#view 689 * @param {json} json 690 */ 691 constructor(json) { 692 super(json); 693 this.post = Post.parseViewRecord(json.record); 694 } 695} 696 697class InlineRecordWithMediaEmbed extends Embed { 698 699 /** 700 * app.bsky.embed.recordWithMedia#view 701 * @param {json} json 702 */ 703 constructor(json) { 704 super(json); 705 this.post = Post.parseViewRecord(json.record.record); 706 this.media = Embed.parseInlineEmbed(json.media); 707 } 708} 709 710class InlineLinkEmbed extends Embed { 711 712 /** 713 * app.bsky.embed.external#view 714 * @param {json} json 715 */ 716 constructor(json) { 717 super(json); 718 719 this.url = json.external.uri; 720 this.title = json.external.title; 721 this.description = json.external.description; 722 this.thumb = json.external.thumb; 723 } 724} 725 726class InlineImageEmbed extends Embed { 727 728 /** 729 * app.bsky.embed.images#view 730 * @param {json} json 731 */ 732 constructor(json) { 733 super(json); 734 this.images = json.images; 735 } 736} 737 738class InlineVideoEmbed extends Embed { 739 740 /** 741 * app.bsky.embed.video#view 742 * @param {json} json 743 */ 744 constructor(json) { 745 super(json); 746 this.playlistURL = json.playlist; 747 this.alt = json.alt; 748 } 749}