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