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}