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} AnyPost
52 */
53
54class Post extends ATProtoRecord {
55 /** @type {ATProtoRecord | undefined} */
56 parent;
57
58 /** @type {ATProtoRecord | undefined} */
59 root;
60
61 /** @type {object | undefined} */
62 reason;
63
64 /** @type {boolean | undefined} */
65 isEmbed;
66
67 /**
68 * View of a post as part of a thread, as returned from getPostThread.
69 * Expected to be #threadViewPost, but may be blocked or missing.
70 *
71 * @param {json} json, @returns {AnyPost}
72 */
73
74 static parseThreadPost(json) {
75 switch (json.$type) {
76 case 'app.bsky.feed.defs#threadViewPost':
77 let post = new Post(json.post);
78
79 if (json.replies) {
80 post.setReplies(json.replies.map(x => Post.parseThreadPost(x)));
81 }
82
83 if (json.parent) {
84 post.parent = Post.parseThreadPost(json.parent);
85 }
86
87 return post;
88
89 case 'app.bsky.feed.defs#notFoundPost':
90 return new MissingPost(json);
91
92 case 'app.bsky.feed.defs#blockedPost':
93 return new BlockedPost(json);
94
95 default:
96 throw new PostDataError(`Unexpected record type: ${json.$type}`);
97 }
98 }
99
100 /**
101 * View of a post embedded as a quote.
102 * Expected to be app.bsky.embed.record#viewRecord, but may be blocked, missing or a different type of record
103 * (e.g. a list or a feed generator). For unknown record embeds, we fall back to generic ATProtoRecord.
104 *
105 * @param {json} json, @returns {ATProtoRecord}
106 */
107
108 static parseViewRecord(json) {
109 switch (json.$type) {
110 case 'app.bsky.embed.record#viewRecord':
111 return new Post(json, { isEmbed: true });
112
113 case 'app.bsky.embed.record#viewNotFound':
114 return new MissingPost(json);
115
116 case 'app.bsky.embed.record#viewBlocked':
117 return new BlockedPost(json);
118
119 case 'app.bsky.feed.defs#generatorView':
120 return new FeedGeneratorRecord(json);
121
122 case 'app.bsky.graph.defs#listView':
123 return new UserListRecord(json);
124
125 default:
126 console.warn('Unknown record type:', json.$type);
127 return new ATProtoRecord(json);
128 }
129 }
130
131 /**
132 * View of a post as part of a feed (e.g. a profile feed, home timeline or a custom feed). It should be an
133 * app.bsky.feed.defs#feedViewPost - blocked or missing posts don't appear here, they just aren't included.
134 *
135 * @param {json} json, @returns {Post}
136 */
137
138 static parseFeedPost(json) {
139 let post = new Post(json.post);
140
141 if (json.reply) {
142 post.parent = Post.parsePostView(json.reply.parent);
143 post.root = Post.parsePostView(json.reply.root);
144 }
145
146 if (json.reason) {
147 post.reason = json.reason;
148 }
149
150 return post;
151 }
152
153 /**
154 * Parses a #postView - the inner post object that includes the actual post - but still checks if it's not
155 * a blocked or missing post. The #postView must include a $type.
156 * (This is used for e.g. parent/root of a #feedViewPost.)
157 *
158 * @param {json} json, @returns {AnyPost}
159 */
160
161 static parsePostView(json) {
162 switch (json.$type) {
163 case 'app.bsky.feed.defs#postView':
164 return new Post(json);
165
166 case 'app.bsky.feed.defs#notFoundPost':
167 return new MissingPost(json);
168
169 case 'app.bsky.feed.defs#blockedPost':
170 return new BlockedPost(json);
171
172 default:
173 throw new PostDataError(`Unexpected record type: ${json.$type}`);
174 }
175 }
176
177 static parseMastodonThread(post, context) {
178 let idMap = {};
179 idMap[post.id] = post;
180 post.replies = [];
181
182 for (let p of context.descendants) {
183 p.replies = [];
184 idMap[p.id] = p;
185
186 if (idMap[p.in_reply_to_id]) {
187 idMap[p.in_reply_to_id].replies.push(p);
188 }
189 }
190
191 let root = Post.parseMastodonPost(post);
192
193 if (context.ancestors && context.ancestors[0]) {
194 post.parent = Post.parseMastodonPost(context.ancestors[0]);
195 }
196
197 return root;
198 }
199
200 static parseMastodonPost(post) {
201 let model = new Post({
202 uri: `at://${post.account.id}/app.bsky.feed.post/${post.id}`,
203 author: {
204 handle: post.account.acct,
205 avatar: post.account.avatar_static,
206 displayName: post.account.display_name,
207 did: post.account.id
208 },
209 record: {
210 mastodonContent: post.content,
211 createdAt: post.created_at
212 },
213 likeCount: post.favourites_count,
214 repostCount: post.reblogs_count,
215 replyCount: post.replies_count
216 }, {
217 mastodonURL: post.url
218 });
219
220 if (post.replies) {
221 model.setReplies(post.replies.map(r => Post.parseMastodonPost(r)));
222 }
223
224 return model;
225 }
226
227
228 /** @param {json} data, @param {json} [extra] */
229
230 constructor(data, extra) {
231 super(data);
232 Object.assign(this, extra ?? {});
233
234 this.record = this.isPostView ? data.record : data.value;
235
236 if (this.isPostView && data.embed) {
237 this.embed = Embed.parseInlineEmbed(data.embed);
238 } else if (this.isEmbed && data.embeds && data.embeds[0]) {
239 this.embed = Embed.parseInlineEmbed(data.embeds[0]);
240 } else if (this.record.embed) {
241 this.embed = Embed.parseRawEmbed(this.record.embed);
242 }
243
244 this.author = this.author ?? data.author;
245 this.replies = [];
246
247 this.viewerData = data.viewer;
248 this.viewerLike = data.viewer?.like;
249
250 if (this.author) {
251 api.cacheProfile(this.author);
252 }
253 }
254
255 /** @param {AnyPost[]} replies */
256
257 setReplies(replies) {
258 this.replies = replies;
259 this.replies.sort(this.sortReplies.bind(this));
260 }
261
262 /** @param {AnyPost} a, @param {AnyPost} b, @returns {-1 | 0 | 1} */
263
264 sortReplies(a, b) {
265 if (a instanceof Post && b instanceof Post) {
266 if (a.author.did == this.author.did && b.author.did != this.author.did) {
267 return -1;
268 } else if (a.author.did != this.author.did && b.author.did == this.author.did) {
269 return 1;
270 } else if (a.createdAt.getTime() < b.createdAt.getTime()) {
271 return -1;
272 } else if (a.createdAt.getTime() > b.createdAt.getTime()) {
273 return 1;
274 } else {
275 return 0;
276 }
277 } else if (a instanceof Post) {
278 return -1;
279 } else if (b instanceof Post) {
280 return 1;
281 } else {
282 return 0;
283 }
284 }
285
286 /** @returns {boolean} */
287 get isPostView() {
288 return !this.isEmbed;
289 }
290
291 /** @returns {boolean} */
292 get isFediPost() {
293 return this.author?.handle.endsWith('.ap.brid.gy');
294 }
295
296 /** @returns {boolean} */
297 get isTruncatedFediPost() {
298 return this.isFediPost && (this.text.endsWith('…') || this.text.endsWith('[…]'));
299 }
300
301 /** @returns {string | undefined} */
302 get originalFediContent() {
303 return this.record.bridgyOriginalText || this.record.mastodonContent;
304 }
305
306 /** @returns {string} */
307 get authorFediHandle() {
308 if (this.isFediPost) {
309 return this.author.handle.replace(/\.ap\.brid\.gy$/, '').replace('.', '@');
310 } else {
311 throw "Not a Fedi post";
312 }
313 }
314
315 /** @returns {string} */
316 get text() {
317 return this.record.text;
318 }
319
320 /** @returns {json} */
321 get facets() {
322 return this.record.facets;
323 }
324
325 /** @returns {Date} */
326 get createdAt() {
327 return new Date(this.record.createdAt);
328 }
329
330 /** @returns {number} */
331 get likeCount() {
332 return castToInt(this.data.likeCount);
333 }
334
335 /** @returns {number} */
336 get replyCount() {
337 return castToInt(this.data.replyCount);
338 }
339
340 /** @returns {boolean} */
341 get hasMoreReplies() {
342 return this.replyCount !== undefined && this.replyCount !== this.replies.length;
343 }
344
345 /** @returns {number} */
346 get repostCount() {
347 return castToInt(this.data.repostCount);
348 }
349
350 /** @returns {boolean} */
351 get liked() {
352 return (this.viewerLike !== undefined);
353 }
354
355 /** @returns {boolean | undefined} */
356 get muted() {
357 return this.author.viewer?.muted;
358 }
359
360 /** @returns {string | undefined} */
361 get muteList() {
362 return this.author.viewer?.mutedByList?.name;
363 }
364
365 /** @returns {boolean} */
366 get hasViewerInfo() {
367 return (this.viewerData !== undefined);
368 }
369
370 /** @returns {ATProtoRecord | undefined} */
371 get parentReference() {
372 return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent);
373 }
374
375 /** @returns {ATProtoRecord | undefined} */
376 get rootReference() {
377 return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root);
378 }
379}
380
381
382/**
383 * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block
384 * between the post author and the parent author). It only includes a reference, but no post content.
385 */
386
387class BlockedPost extends ATProtoRecord {
388
389 /** @param {json} data */
390 constructor(data) {
391 super(data);
392 this.author = data.author;
393 }
394
395 /** @returns {boolean} */
396 get blocksUser() {
397 return !!this.author.viewer?.blocking;
398 }
399
400 /** @returns {boolean} */
401 get blockedByUser() {
402 return this.author.viewer?.blockedBy;
403 }
404}
405
406
407/**
408 * Stub of a post which was deleted or hidden.
409 */
410
411class MissingPost extends ATProtoRecord {}
412
413
414/**
415 * Record representing a feed generator.
416 */
417
418class FeedGeneratorRecord extends ATProtoRecord {
419
420 /** @param {json} data */
421 constructor(data) {
422 super(data);
423 this.author = data.creator;
424 }
425
426 /** @returns {string | undefined} */
427 get title() {
428 return this.data.displayName;
429 }
430
431 /** @returns {string | undefined} */
432 get description() {
433 return this.data.description;
434 }
435
436 /** @returns {number} */
437 get likeCount() {
438 return castToInt(this.data.likeCount);
439 }
440
441 /** @returns {string | undefined} */
442 get avatar() {
443 return this.data.avatar;
444 }
445}
446
447
448/**
449 * Record representing a user list or moderation list.
450 */
451
452class UserListRecord extends ATProtoRecord {
453
454 /** @param {json} data */
455 constructor(data) {
456 super(data);
457 this.author = data.creator;
458 }
459
460 /** @returns {string | undefined} */
461 get title() {
462 return this.data.name;
463 }
464
465 /** @returns {string | undefined} */
466 get purpose() {
467 return this.data.purpose;
468 }
469
470 /** @returns {string | undefined} */
471 get description() {
472 return this.data.description;
473 }
474
475 /** @returns {string | undefined} */
476 get avatar() {
477 return this.data.avatar;
478 }
479}
480
481
482/**
483 * Base class for embed objects.
484 */
485
486class Embed {
487
488 /**
489 * More hydrated view of an embed, taken from a full post view (#postView).
490 *
491 * @param {json} json, @returns {Embed}
492 */
493
494 static parseInlineEmbed(json) {
495 switch (json.$type) {
496 case 'app.bsky.embed.record#view':
497 return new InlineRecordEmbed(json);
498
499 case 'app.bsky.embed.recordWithMedia#view':
500 return new InlineRecordWithMediaEmbed(json);
501
502 case 'app.bsky.embed.images#view':
503 return new InlineImageEmbed(json);
504
505 case 'app.bsky.embed.external#view':
506 return new InlineLinkEmbed(json);
507
508 default:
509 if (location.protocol == 'file:') {
510 throw new PostDataError(`Unexpected embed type: ${json.$type}`);
511 } else {
512 console.warn('Unexpected embed type:', json.$type);
513 return new Embed(json);
514 }
515 }
516 }
517
518 /**
519 * Raw embed extracted from raw record data of a post. Does not include quoted post contents.
520 *
521 * @param {json} json, @returns {Embed}
522 */
523
524 static parseRawEmbed(json) {
525 switch (json.$type) {
526 case 'app.bsky.embed.record':
527 return new RawRecordEmbed(json);
528
529 case 'app.bsky.embed.recordWithMedia':
530 return new RawRecordWithMediaEmbed(json);
531
532 case 'app.bsky.embed.images':
533 return new RawImageEmbed(json);
534
535 case 'app.bsky.embed.external':
536 return new RawLinkEmbed(json);
537
538 default:
539 if (location.protocol == 'file:') {
540 throw new PostDataError(`Unexpected embed type: ${json.$type}`);
541 } else {
542 console.warn('Unexpected embed type:', json.$type);
543 return new Embed(json);
544 }
545 }
546 }
547
548 /** @param {json} json */
549 constructor(json) {
550 this.json = json;
551 }
552
553 /** @returns {string} */
554 get type() {
555 return this.json.$type;
556 }
557}
558
559class RawImageEmbed extends Embed {
560
561 /** @param {json} json */
562 constructor(json) {
563 super(json);
564 this.images = json.images;
565 }
566}
567
568class RawLinkEmbed extends Embed {
569
570 /** @param {json} json */
571 constructor(json) {
572 super(json);
573
574 this.url = json.external.uri;
575 this.title = json.external.title;
576 }
577}
578
579class RawRecordEmbed extends Embed {
580
581 /** @param {json} json */
582 constructor(json) {
583 super(json);
584 this.record = new ATProtoRecord(json.record);
585 }
586}
587
588class RawRecordWithMediaEmbed extends Embed {
589
590 /** @param {json} json */
591 constructor(json) {
592 super(json);
593 this.record = new ATProtoRecord(json.record.record);
594 this.media = Embed.parseRawEmbed(json.media);
595 }
596}
597
598class InlineRecordEmbed extends Embed {
599
600 /**
601 * app.bsky.embed.record#view
602 * @param {json} json
603 */
604 constructor(json) {
605 super(json);
606 this.post = Post.parseViewRecord(json.record);
607 }
608}
609
610class InlineRecordWithMediaEmbed extends Embed {
611
612 /**
613 * app.bsky.embed.recordWithMedia#view
614 * @param {json} json
615 */
616 constructor(json) {
617 super(json);
618 this.post = Post.parseViewRecord(json.record.record);
619 this.media = Embed.parseInlineEmbed(json.media);
620 }
621}
622
623class InlineLinkEmbed extends Embed {
624
625 /**
626 * app.bsky.embed.external#view
627 * @param {json} json
628 */
629 constructor(json) {
630 super(json);
631
632 this.url = json.external.uri;
633 this.title = json.external.title;
634 this.description = json.external.description;
635 }
636}
637
638class InlineImageEmbed extends Embed {
639
640 /**
641 * app.bsky.embed.images#view
642 * @param {json} json
643 */
644 constructor(json) {
645 super(json);
646 this.images = json.images;
647 }
648}