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.text != "📌" && b.text == "📌") { 291 return -1; 292 } else if (a.text == "📌" && b.text != "📌") { 293 return 1; 294 } else if (a.createdAt.getTime() < b.createdAt.getTime()) { 295 return -1; 296 } else if (a.createdAt.getTime() > b.createdAt.getTime()) { 297 return 1; 298 } else { 299 return 0; 300 } 301 } else if (a instanceof Post) { 302 return -1; 303 } else if (b instanceof Post) { 304 return 1; 305 } else { 306 return 0; 307 } 308 } 309 310 /** @returns {boolean} */ 311 get isPostView() { 312 return !this.isEmbed; 313 } 314 315 /** @returns {boolean} */ 316 get isFediPost() { 317 return this.author?.handle.endsWith('.ap.brid.gy'); 318 } 319 320 /** @returns {string | undefined} */ 321 get originalFediContent() { 322 return this.record.bridgyOriginalText; 323 } 324 325 /** @returns {boolean} */ 326 get isRoot() { 327 // I AM ROOOT 328 return (this.pageRoot === this); 329 } 330 331 /** @returns {string} */ 332 get authorFediHandle() { 333 if (this.isFediPost) { 334 return this.author.handle.replace(/\.ap\.brid\.gy$/, '').replace('.', '@'); 335 } else { 336 throw "Not a Fedi post"; 337 } 338 } 339 340 /** @returns {string} */ 341 get text() { 342 return this.record.text; 343 } 344 345 /** @returns {string} */ 346 get lowercaseText() { 347 if (!this._lowercaseText) { 348 this._lowercaseText = this.record.text.toLowerCase(); 349 } 350 351 return this._lowercaseText; 352 } 353 354 /** @returns {json} */ 355 get facets() { 356 return this.record.facets; 357 } 358 359 /** @returns {string[] | undefined} */ 360 get tags() { 361 return this.record.tags; 362 } 363 364 /** @returns {Date} */ 365 get createdAt() { 366 return new Date(this.record.createdAt); 367 } 368 369 /** @returns {number} */ 370 get likeCount() { 371 return castToInt(this.data.likeCount); 372 } 373 374 /** @returns {number} */ 375 get replyCount() { 376 return castToInt(this.data.replyCount); 377 } 378 379 /** @returns {number} */ 380 get quoteCount() { 381 return castToInt(this.data.quoteCount); 382 } 383 384 /** @returns {boolean} */ 385 get hasMoreReplies() { 386 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 387 388 return shouldHaveMoreReplies && (this.replies.length === 0) && (this.level !== undefined && this.level > 4); 389 } 390 391 /** @returns {boolean} */ 392 get hasHiddenReplies() { 393 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 394 395 return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4)); 396 } 397 398 /** @returns {boolean} */ 399 get isRestrictingReplies() { 400 return !!(this.data.threadgate && this.data.threadgate.record.allow); 401 } 402 403 /** @returns {number} */ 404 get repostCount() { 405 return castToInt(this.data.repostCount); 406 } 407 408 /** @returns {boolean} */ 409 get liked() { 410 return (this.viewerLike !== undefined); 411 } 412 413 /** @returns {boolean | undefined} */ 414 get muted() { 415 return this.author.viewer?.muted; 416 } 417 418 /** @returns {string | undefined} */ 419 get muteList() { 420 return this.author.viewer?.mutedByList?.name; 421 } 422 423 /** @returns {boolean} */ 424 get hasViewerInfo() { 425 return (this.viewerData !== undefined); 426 } 427 428 /** @returns {ATProtoRecord | undefined} */ 429 get parentReference() { 430 return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent); 431 } 432 433 /** @returns {ATProtoRecord | undefined} */ 434 get rootReference() { 435 return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root); 436 } 437} 438 439 440/** 441 * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block 442 * between the post author and the parent author). It only includes a reference, but no post content. 443 */ 444 445class BlockedPost extends ATProtoRecord { 446 447 /** @param {json} data */ 448 constructor(data) { 449 super(data); 450 this.author = data.author; 451 } 452 453 /** @returns {boolean} */ 454 get blocksUser() { 455 return !!this.author.viewer?.blocking; 456 } 457 458 /** @returns {boolean} */ 459 get blockedByUser() { 460 return this.author.viewer?.blockedBy; 461 } 462} 463 464 465/** 466 * Stub of a post which was deleted or hidden. 467 */ 468 469class MissingPost extends ATProtoRecord {} 470 471 472/** 473 * Stub of a quoted post which was un-quoted by the original author. 474 */ 475 476class DetachedQuotePost extends ATProtoRecord {} 477 478 479/** 480 * Record representing a feed generator. 481 */ 482 483class FeedGeneratorRecord extends ATProtoRecord { 484 485 /** @param {json} data */ 486 constructor(data) { 487 super(data); 488 this.author = data.creator; 489 } 490 491 /** @returns {string | undefined} */ 492 get title() { 493 return this.data.displayName; 494 } 495 496 /** @returns {string | undefined} */ 497 get description() { 498 return this.data.description; 499 } 500 501 /** @returns {number} */ 502 get likeCount() { 503 return castToInt(this.data.likeCount); 504 } 505 506 /** @returns {string | undefined} */ 507 get avatar() { 508 return this.data.avatar; 509 } 510} 511 512 513/** 514 * Record representing a user list or moderation list. 515 */ 516 517class UserListRecord extends ATProtoRecord { 518 519 /** @param {json} data */ 520 constructor(data) { 521 super(data); 522 this.author = data.creator; 523 } 524 525 /** @returns {string | undefined} */ 526 get title() { 527 return this.data.name; 528 } 529 530 /** @returns {string | undefined} */ 531 get purpose() { 532 return this.data.purpose; 533 } 534 535 /** @returns {string | undefined} */ 536 get description() { 537 return this.data.description; 538 } 539 540 /** @returns {string | undefined} */ 541 get avatar() { 542 return this.data.avatar; 543 } 544} 545 546 547/** 548 * Record representing a starter pack. 549 */ 550 551class StarterPackRecord extends ATProtoRecord { 552 553 /** @param {json} data */ 554 constructor(data) { 555 super(data); 556 this.author = data.creator; 557 } 558 559 /** @returns {string | undefined} */ 560 get title() { 561 return this.data.record.name; 562 } 563 564 /** @returns {string | undefined} */ 565 get description() { 566 return this.data.record.description; 567 } 568} 569 570 571/** 572 * Base class for embed objects. 573 */ 574 575class Embed { 576 577 /** 578 * More hydrated view of an embed, taken from a full post view (#postView). 579 * 580 * @param {json} json, @returns {Embed} 581 */ 582 583 static parseInlineEmbed(json) { 584 switch (json.$type) { 585 case 'app.bsky.embed.record#view': 586 return new InlineRecordEmbed(json); 587 588 case 'app.bsky.embed.recordWithMedia#view': 589 return new InlineRecordWithMediaEmbed(json); 590 591 case 'app.bsky.embed.images#view': 592 return new InlineImageEmbed(json); 593 594 case 'app.bsky.embed.external#view': 595 return new InlineLinkEmbed(json); 596 597 case 'app.bsky.embed.video#view': 598 return new InlineVideoEmbed(json); 599 600 default: 601 if (location.protocol == 'file:') { 602 throw new PostDataError(`Unexpected embed type: ${json.$type}`); 603 } else { 604 console.warn('Unexpected embed type:', json.$type); 605 return new Embed(json); 606 } 607 } 608 } 609 610 /** 611 * Raw embed extracted from raw record data of a post. Does not include quoted post contents. 612 * 613 * @param {json} json, @returns {Embed} 614 */ 615 616 static parseRawEmbed(json) { 617 switch (json.$type) { 618 case 'app.bsky.embed.record': 619 return new RawRecordEmbed(json); 620 621 case 'app.bsky.embed.recordWithMedia': 622 return new RawRecordWithMediaEmbed(json); 623 624 case 'app.bsky.embed.images': 625 return new RawImageEmbed(json); 626 627 case 'app.bsky.embed.external': 628 return new RawLinkEmbed(json); 629 630 case 'app.bsky.embed.video': 631 return new RawVideoEmbed(json); 632 633 default: 634 if (location.protocol == 'file:') { 635 throw new PostDataError(`Unexpected embed type: ${json.$type}`); 636 } else { 637 console.warn('Unexpected embed type:', json.$type); 638 return new Embed(json); 639 } 640 } 641 } 642 643 /** @param {json} json */ 644 constructor(json) { 645 this.json = json; 646 } 647 648 /** @returns {string} */ 649 get type() { 650 return this.json.$type; 651 } 652} 653 654class RawImageEmbed extends Embed { 655 656 /** @param {json} json */ 657 constructor(json) { 658 super(json); 659 this.images = json.images; 660 } 661} 662 663class RawLinkEmbed extends Embed { 664 665 /** @param {json} json */ 666 constructor(json) { 667 super(json); 668 669 this.url = json.external.uri; 670 this.title = json.external.title; 671 this.thumb = json.external.thumb; 672 } 673} 674 675class RawVideoEmbed extends Embed { 676 677 /** @param {json} json */ 678 constructor(json) { 679 super(json); 680 this.video = json.video; 681 } 682} 683 684class RawRecordEmbed extends Embed { 685 686 /** @param {json} json */ 687 constructor(json) { 688 super(json); 689 this.record = new ATProtoRecord(json.record); 690 } 691} 692 693class RawRecordWithMediaEmbed extends Embed { 694 695 /** @param {json} json */ 696 constructor(json) { 697 super(json); 698 this.record = new ATProtoRecord(json.record.record); 699 this.media = Embed.parseRawEmbed(json.media); 700 } 701} 702 703class InlineRecordEmbed extends Embed { 704 705 /** 706 * app.bsky.embed.record#view 707 * @param {json} json 708 */ 709 constructor(json) { 710 super(json); 711 this.post = Post.parseViewRecord(json.record); 712 } 713} 714 715class InlineRecordWithMediaEmbed extends Embed { 716 717 /** 718 * app.bsky.embed.recordWithMedia#view 719 * @param {json} json 720 */ 721 constructor(json) { 722 super(json); 723 this.post = Post.parseViewRecord(json.record.record); 724 this.media = Embed.parseInlineEmbed(json.media); 725 } 726} 727 728class InlineLinkEmbed extends Embed { 729 730 /** 731 * app.bsky.embed.external#view 732 * @param {json} json 733 */ 734 constructor(json) { 735 super(json); 736 737 this.url = json.external.uri; 738 this.title = json.external.title; 739 this.description = json.external.description; 740 this.thumb = json.external.thumb; 741 } 742} 743 744class InlineImageEmbed extends Embed { 745 746 /** 747 * app.bsky.embed.images#view 748 * @param {json} json 749 */ 750 constructor(json) { 751 super(json); 752 this.images = json.images; 753 } 754} 755 756class InlineVideoEmbed extends Embed { 757 758 /** 759 * app.bsky.embed.video#view 760 * @param {json} json 761 */ 762 constructor(json) { 763 super(json); 764 this.playlistURL = json.playlist; 765 this.alt = json.alt; 766 } 767}