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