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