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