Thread viewer for Bluesky
at mastodon 648 lines 14 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} AnyPost 52 */ 53 54class Post extends ATProtoRecord { 55 /** @type {ATProtoRecord | undefined} */ 56 parent; 57 58 /** @type {ATProtoRecord | undefined} */ 59 root; 60 61 /** @type {object | undefined} */ 62 reason; 63 64 /** @type {boolean | undefined} */ 65 isEmbed; 66 67 /** 68 * View of a post as part of a thread, as returned from getPostThread. 69 * Expected to be #threadViewPost, but may be blocked or missing. 70 * 71 * @param {json} json, @returns {AnyPost} 72 */ 73 74 static parseThreadPost(json) { 75 switch (json.$type) { 76 case 'app.bsky.feed.defs#threadViewPost': 77 let post = new Post(json.post); 78 79 if (json.replies) { 80 post.setReplies(json.replies.map(x => Post.parseThreadPost(x))); 81 } 82 83 if (json.parent) { 84 post.parent = Post.parseThreadPost(json.parent); 85 } 86 87 return post; 88 89 case 'app.bsky.feed.defs#notFoundPost': 90 return new MissingPost(json); 91 92 case 'app.bsky.feed.defs#blockedPost': 93 return new BlockedPost(json); 94 95 default: 96 throw new PostDataError(`Unexpected record type: ${json.$type}`); 97 } 98 } 99 100 /** 101 * View of a post embedded as a quote. 102 * Expected to be app.bsky.embed.record#viewRecord, but may be blocked, missing or a different type of record 103 * (e.g. a list or a feed generator). For unknown record embeds, we fall back to generic ATProtoRecord. 104 * 105 * @param {json} json, @returns {ATProtoRecord} 106 */ 107 108 static parseViewRecord(json) { 109 switch (json.$type) { 110 case 'app.bsky.embed.record#viewRecord': 111 return new Post(json, { isEmbed: true }); 112 113 case 'app.bsky.embed.record#viewNotFound': 114 return new MissingPost(json); 115 116 case 'app.bsky.embed.record#viewBlocked': 117 return new BlockedPost(json); 118 119 case 'app.bsky.feed.defs#generatorView': 120 return new FeedGeneratorRecord(json); 121 122 case 'app.bsky.graph.defs#listView': 123 return new UserListRecord(json); 124 125 default: 126 console.warn('Unknown record type:', json.$type); 127 return new ATProtoRecord(json); 128 } 129 } 130 131 /** 132 * View of a post as part of a feed (e.g. a profile feed, home timeline or a custom feed). It should be an 133 * app.bsky.feed.defs#feedViewPost - blocked or missing posts don't appear here, they just aren't included. 134 * 135 * @param {json} json, @returns {Post} 136 */ 137 138 static parseFeedPost(json) { 139 let post = new Post(json.post); 140 141 if (json.reply) { 142 post.parent = Post.parsePostView(json.reply.parent); 143 post.root = Post.parsePostView(json.reply.root); 144 } 145 146 if (json.reason) { 147 post.reason = json.reason; 148 } 149 150 return post; 151 } 152 153 /** 154 * Parses a #postView - the inner post object that includes the actual post - but still checks if it's not 155 * a blocked or missing post. The #postView must include a $type. 156 * (This is used for e.g. parent/root of a #feedViewPost.) 157 * 158 * @param {json} json, @returns {AnyPost} 159 */ 160 161 static parsePostView(json) { 162 switch (json.$type) { 163 case 'app.bsky.feed.defs#postView': 164 return new Post(json); 165 166 case 'app.bsky.feed.defs#notFoundPost': 167 return new MissingPost(json); 168 169 case 'app.bsky.feed.defs#blockedPost': 170 return new BlockedPost(json); 171 172 default: 173 throw new PostDataError(`Unexpected record type: ${json.$type}`); 174 } 175 } 176 177 static parseMastodonThread(post, context) { 178 let idMap = {}; 179 idMap[post.id] = post; 180 post.replies = []; 181 182 for (let p of context.descendants) { 183 p.replies = []; 184 idMap[p.id] = p; 185 186 if (idMap[p.in_reply_to_id]) { 187 idMap[p.in_reply_to_id].replies.push(p); 188 } 189 } 190 191 let root = Post.parseMastodonPost(post); 192 193 if (context.ancestors && context.ancestors[0]) { 194 post.parent = Post.parseMastodonPost(context.ancestors[0]); 195 } 196 197 return root; 198 } 199 200 static parseMastodonPost(post) { 201 let model = new Post({ 202 uri: `at://${post.account.id}/app.bsky.feed.post/${post.id}`, 203 author: { 204 handle: post.account.acct, 205 avatar: post.account.avatar_static, 206 displayName: post.account.display_name, 207 did: post.account.id 208 }, 209 record: { 210 mastodonContent: post.content, 211 createdAt: post.created_at 212 }, 213 likeCount: post.favourites_count, 214 repostCount: post.reblogs_count, 215 replyCount: post.replies_count 216 }, { 217 mastodonURL: post.url 218 }); 219 220 if (post.replies) { 221 model.setReplies(post.replies.map(r => Post.parseMastodonPost(r))); 222 } 223 224 return model; 225 } 226 227 228 /** @param {json} data, @param {json} [extra] */ 229 230 constructor(data, extra) { 231 super(data); 232 Object.assign(this, extra ?? {}); 233 234 this.record = this.isPostView ? data.record : data.value; 235 236 if (this.isPostView && data.embed) { 237 this.embed = Embed.parseInlineEmbed(data.embed); 238 } else if (this.isEmbed && data.embeds && data.embeds[0]) { 239 this.embed = Embed.parseInlineEmbed(data.embeds[0]); 240 } else if (this.record.embed) { 241 this.embed = Embed.parseRawEmbed(this.record.embed); 242 } 243 244 this.author = this.author ?? data.author; 245 this.replies = []; 246 247 this.viewerData = data.viewer; 248 this.viewerLike = data.viewer?.like; 249 250 if (this.author) { 251 api.cacheProfile(this.author); 252 } 253 } 254 255 /** @param {AnyPost[]} replies */ 256 257 setReplies(replies) { 258 this.replies = replies; 259 this.replies.sort(this.sortReplies.bind(this)); 260 } 261 262 /** @param {AnyPost} a, @param {AnyPost} b, @returns {-1 | 0 | 1} */ 263 264 sortReplies(a, b) { 265 if (a instanceof Post && b instanceof Post) { 266 if (a.author.did == this.author.did && b.author.did != this.author.did) { 267 return -1; 268 } else if (a.author.did != this.author.did && b.author.did == this.author.did) { 269 return 1; 270 } else if (a.createdAt.getTime() < b.createdAt.getTime()) { 271 return -1; 272 } else if (a.createdAt.getTime() > b.createdAt.getTime()) { 273 return 1; 274 } else { 275 return 0; 276 } 277 } else if (a instanceof Post) { 278 return -1; 279 } else if (b instanceof Post) { 280 return 1; 281 } else { 282 return 0; 283 } 284 } 285 286 /** @returns {boolean} */ 287 get isPostView() { 288 return !this.isEmbed; 289 } 290 291 /** @returns {boolean} */ 292 get isFediPost() { 293 return this.author?.handle.endsWith('.ap.brid.gy'); 294 } 295 296 /** @returns {boolean} */ 297 get isTruncatedFediPost() { 298 return this.isFediPost && (this.text.endsWith('…') || this.text.endsWith('[…]')); 299 } 300 301 /** @returns {string | undefined} */ 302 get originalFediContent() { 303 return this.record.bridgyOriginalText || this.record.mastodonContent; 304 } 305 306 /** @returns {string} */ 307 get authorFediHandle() { 308 if (this.isFediPost) { 309 return this.author.handle.replace(/\.ap\.brid\.gy$/, '').replace('.', '@'); 310 } else { 311 throw "Not a Fedi post"; 312 } 313 } 314 315 /** @returns {string} */ 316 get text() { 317 return this.record.text; 318 } 319 320 /** @returns {json} */ 321 get facets() { 322 return this.record.facets; 323 } 324 325 /** @returns {Date} */ 326 get createdAt() { 327 return new Date(this.record.createdAt); 328 } 329 330 /** @returns {number} */ 331 get likeCount() { 332 return castToInt(this.data.likeCount); 333 } 334 335 /** @returns {number} */ 336 get replyCount() { 337 return castToInt(this.data.replyCount); 338 } 339 340 /** @returns {boolean} */ 341 get hasMoreReplies() { 342 return this.replyCount !== undefined && this.replyCount !== this.replies.length; 343 } 344 345 /** @returns {number} */ 346 get repostCount() { 347 return castToInt(this.data.repostCount); 348 } 349 350 /** @returns {boolean} */ 351 get liked() { 352 return (this.viewerLike !== undefined); 353 } 354 355 /** @returns {boolean | undefined} */ 356 get muted() { 357 return this.author.viewer?.muted; 358 } 359 360 /** @returns {string | undefined} */ 361 get muteList() { 362 return this.author.viewer?.mutedByList?.name; 363 } 364 365 /** @returns {boolean} */ 366 get hasViewerInfo() { 367 return (this.viewerData !== undefined); 368 } 369 370 /** @returns {ATProtoRecord | undefined} */ 371 get parentReference() { 372 return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent); 373 } 374 375 /** @returns {ATProtoRecord | undefined} */ 376 get rootReference() { 377 return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root); 378 } 379} 380 381 382/** 383 * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block 384 * between the post author and the parent author). It only includes a reference, but no post content. 385 */ 386 387class BlockedPost extends ATProtoRecord { 388 389 /** @param {json} data */ 390 constructor(data) { 391 super(data); 392 this.author = data.author; 393 } 394 395 /** @returns {boolean} */ 396 get blocksUser() { 397 return !!this.author.viewer?.blocking; 398 } 399 400 /** @returns {boolean} */ 401 get blockedByUser() { 402 return this.author.viewer?.blockedBy; 403 } 404} 405 406 407/** 408 * Stub of a post which was deleted or hidden. 409 */ 410 411class MissingPost extends ATProtoRecord {} 412 413 414/** 415 * Record representing a feed generator. 416 */ 417 418class FeedGeneratorRecord extends ATProtoRecord { 419 420 /** @param {json} data */ 421 constructor(data) { 422 super(data); 423 this.author = data.creator; 424 } 425 426 /** @returns {string | undefined} */ 427 get title() { 428 return this.data.displayName; 429 } 430 431 /** @returns {string | undefined} */ 432 get description() { 433 return this.data.description; 434 } 435 436 /** @returns {number} */ 437 get likeCount() { 438 return castToInt(this.data.likeCount); 439 } 440 441 /** @returns {string | undefined} */ 442 get avatar() { 443 return this.data.avatar; 444 } 445} 446 447 448/** 449 * Record representing a user list or moderation list. 450 */ 451 452class UserListRecord extends ATProtoRecord { 453 454 /** @param {json} data */ 455 constructor(data) { 456 super(data); 457 this.author = data.creator; 458 } 459 460 /** @returns {string | undefined} */ 461 get title() { 462 return this.data.name; 463 } 464 465 /** @returns {string | undefined} */ 466 get purpose() { 467 return this.data.purpose; 468 } 469 470 /** @returns {string | undefined} */ 471 get description() { 472 return this.data.description; 473 } 474 475 /** @returns {string | undefined} */ 476 get avatar() { 477 return this.data.avatar; 478 } 479} 480 481 482/** 483 * Base class for embed objects. 484 */ 485 486class Embed { 487 488 /** 489 * More hydrated view of an embed, taken from a full post view (#postView). 490 * 491 * @param {json} json, @returns {Embed} 492 */ 493 494 static parseInlineEmbed(json) { 495 switch (json.$type) { 496 case 'app.bsky.embed.record#view': 497 return new InlineRecordEmbed(json); 498 499 case 'app.bsky.embed.recordWithMedia#view': 500 return new InlineRecordWithMediaEmbed(json); 501 502 case 'app.bsky.embed.images#view': 503 return new InlineImageEmbed(json); 504 505 case 'app.bsky.embed.external#view': 506 return new InlineLinkEmbed(json); 507 508 default: 509 if (location.protocol == 'file:') { 510 throw new PostDataError(`Unexpected embed type: ${json.$type}`); 511 } else { 512 console.warn('Unexpected embed type:', json.$type); 513 return new Embed(json); 514 } 515 } 516 } 517 518 /** 519 * Raw embed extracted from raw record data of a post. Does not include quoted post contents. 520 * 521 * @param {json} json, @returns {Embed} 522 */ 523 524 static parseRawEmbed(json) { 525 switch (json.$type) { 526 case 'app.bsky.embed.record': 527 return new RawRecordEmbed(json); 528 529 case 'app.bsky.embed.recordWithMedia': 530 return new RawRecordWithMediaEmbed(json); 531 532 case 'app.bsky.embed.images': 533 return new RawImageEmbed(json); 534 535 case 'app.bsky.embed.external': 536 return new RawLinkEmbed(json); 537 538 default: 539 if (location.protocol == 'file:') { 540 throw new PostDataError(`Unexpected embed type: ${json.$type}`); 541 } else { 542 console.warn('Unexpected embed type:', json.$type); 543 return new Embed(json); 544 } 545 } 546 } 547 548 /** @param {json} json */ 549 constructor(json) { 550 this.json = json; 551 } 552 553 /** @returns {string} */ 554 get type() { 555 return this.json.$type; 556 } 557} 558 559class RawImageEmbed extends Embed { 560 561 /** @param {json} json */ 562 constructor(json) { 563 super(json); 564 this.images = json.images; 565 } 566} 567 568class RawLinkEmbed extends Embed { 569 570 /** @param {json} json */ 571 constructor(json) { 572 super(json); 573 574 this.url = json.external.uri; 575 this.title = json.external.title; 576 } 577} 578 579class RawRecordEmbed extends Embed { 580 581 /** @param {json} json */ 582 constructor(json) { 583 super(json); 584 this.record = new ATProtoRecord(json.record); 585 } 586} 587 588class RawRecordWithMediaEmbed extends Embed { 589 590 /** @param {json} json */ 591 constructor(json) { 592 super(json); 593 this.record = new ATProtoRecord(json.record.record); 594 this.media = Embed.parseRawEmbed(json.media); 595 } 596} 597 598class InlineRecordEmbed extends Embed { 599 600 /** 601 * app.bsky.embed.record#view 602 * @param {json} json 603 */ 604 constructor(json) { 605 super(json); 606 this.post = Post.parseViewRecord(json.record); 607 } 608} 609 610class InlineRecordWithMediaEmbed extends Embed { 611 612 /** 613 * app.bsky.embed.recordWithMedia#view 614 * @param {json} json 615 */ 616 constructor(json) { 617 super(json); 618 this.post = Post.parseViewRecord(json.record.record); 619 this.media = Embed.parseInlineEmbed(json.media); 620 } 621} 622 623class InlineLinkEmbed extends Embed { 624 625 /** 626 * app.bsky.embed.external#view 627 * @param {json} json 628 */ 629 constructor(json) { 630 super(json); 631 632 this.url = json.external.uri; 633 this.title = json.external.title; 634 this.description = json.external.description; 635 } 636} 637 638class InlineImageEmbed extends Embed { 639 640 /** 641 * app.bsky.embed.images#view 642 * @param {json} json 643 */ 644 constructor(json) { 645 super(json); 646 this.images = json.images; 647 } 648}