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