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