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 {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}