Thread viewer for Bluesky
1import { api } from '../api.js';
2import { atURI, castToInt } from '../utils.js';
3import { ATProtoRecord, FeedGeneratorRecord, StarterPackRecord, UserListRecord } from './records.js';
4import { Embed } from './embeds.js';
5
6/**
7 * Thrown when parsing post JSON fails.
8 */
9
10export class PostDataError extends Error {
11
12 /** @param {string} message */
13 constructor(message) {
14 super(message);
15 }
16}
17
18
19/**
20 * Base class shared by the full Post and post stubs like BlockedPost, MissingPost etc.
21 */
22
23export class BasePost extends ATProtoRecord {
24
25 /** @returns {string} */
26 get didLinkToAuthor() {
27 let { repo } = atURI(this.uri);
28 return `https://bsky.app/profile/${repo}`;
29 }
30}
31
32
33/**
34 * View of a post as part of a thread, as returned from getPostThread.
35 * Expected to be #threadViewPost, but may be blocked or missing.
36 *
37 * @param {json} json
38 * @param {Post?} [pageRoot]
39 * @param {number} [level]
40 * @param {number} [absoluteLevel]
41 * @returns {AnyPost}
42 */
43
44export function parseThreadPost(json, pageRoot = null, level = 0, absoluteLevel = 0) {
45 switch (json.$type) {
46 case 'app.bsky.feed.defs#threadViewPost':
47 let post = new Post(json.post, { level: level, absoluteLevel: absoluteLevel });
48
49 post.pageRoot = pageRoot ?? post;
50
51 if (json.replies) {
52 let replies = json.replies.map(x => parseThreadPost(x, post.pageRoot, level + 1, absoluteLevel + 1));
53 post.setReplies(replies);
54 }
55
56 if (absoluteLevel <= 0 && json.parent) {
57 post.parent = parseThreadPost(json.parent, post.pageRoot, level - 1, absoluteLevel - 1);
58 }
59
60 return post;
61
62 case 'app.bsky.feed.defs#notFoundPost':
63 return new MissingPost(json);
64
65 case 'app.bsky.feed.defs#blockedPost':
66 return new BlockedPost(json);
67
68 default:
69 throw new PostDataError(`Unexpected record type: ${json.$type}`);
70 }
71}
72
73/**
74 * View of a post embedded as a quote.
75 * Expected to be app.bsky.embed.record#viewRecord, but may be blocked, missing or a different type of record
76 * (e.g. a list or a feed generator). For unknown record embeds, we fall back to generic ATProtoRecord.
77 *
78 * @param {json} json
79 * @returns {ATProtoRecord}
80 */
81
82export function parseViewRecord(json) {
83 switch (json.$type) {
84 case 'app.bsky.embed.record#viewRecord':
85 return new Post(json, { isEmbed: true });
86
87 case 'app.bsky.embed.record#viewNotFound':
88 return new MissingPost(json);
89
90 case 'app.bsky.embed.record#viewBlocked':
91 return new BlockedPost(json);
92
93 case 'app.bsky.embed.record#viewDetached':
94 return new DetachedQuotePost(json);
95
96 case 'app.bsky.feed.defs#generatorView':
97 return new FeedGeneratorRecord(json);
98
99 case 'app.bsky.graph.defs#listView':
100 return new UserListRecord(json);
101
102 case 'app.bsky.graph.defs#starterPackViewBasic':
103 return new StarterPackRecord(json);
104
105 default:
106 console.warn('Unknown record type:', json.$type);
107 return new ATProtoRecord(json);
108 }
109}
110
111/**
112 * View of a post as part of a feed (e.g. a profile feed, home timeline or a custom feed). It should be an
113 * app.bsky.feed.defs#feedViewPost - blocked or missing posts don't appear here, they just aren't included.
114 *
115 * @param {json} json
116 * @returns {Post}
117 */
118
119export function parseFeedPost(json) {
120 let post = new Post(json.post);
121
122 if (json.reply) {
123 post.parent = parsePostView(json.reply.parent);
124 post.threadRoot = parsePostView(json.reply.root);
125
126 if (json.reply.grandparentAuthor) {
127 post.grandparentAuthor = json.reply.grandparentAuthor;
128 }
129 }
130
131 if (json.reason) {
132 post.reason = json.reason;
133 }
134
135 return post;
136}
137
138/**
139 * Parses a #postView - the inner post object that includes the actual post - but still checks if it's not
140 * a blocked or missing post. The #postView must include a $type.
141 * (This is used for e.g. parent/root of a #feedViewPost.)
142 *
143 * @param {json} json, @returns {AnyPost}
144 */
145
146export function parsePostView(json) {
147 switch (json.$type) {
148 case 'app.bsky.feed.defs#postView':
149 return new Post(json);
150
151 case 'app.bsky.feed.defs#notFoundPost':
152 return new MissingPost(json);
153
154 case 'app.bsky.feed.defs#blockedPost':
155 return new BlockedPost(json);
156
157 default:
158 throw new PostDataError(`Unexpected record type: ${json.$type}`);
159 }
160}
161
162
163/**
164 * Standard Bluesky post record.
165 */
166
167export class Post extends BasePost {
168 /**
169 * Post object which is the direct parent of this post.
170 * @type {AnyPost | undefined}
171 */
172 parent;
173
174 /**
175 * Post object which is the root of the whole thread (as specified in the post record).
176 * @type {AnyPost | undefined}
177 */
178 threadRoot;
179
180 /**
181 * Post which is at the top of the (sub)thread currently loaded on the page (might not be the same as threadRoot).
182 * @type {Post | undefined}
183 */
184 pageRoot;
185
186 /**
187 * Post's direct replies (if it's displayed in a thread).
188 * @type {AnyPost[]}
189 */
190 replies;
191
192 /**
193 * Info about the author of the "grandparent" post. Included only in feedPost views, for the purposes
194 * of feed filtering algorithm.
195 * @type {json | undefined}
196 */
197 grandparentAuthor;
198
199 /**
200 * Depth of the post in the getPostThread response it was loaded from, starting from 0. May be negative.
201 * @type {number | undefined}
202 */
203 level;
204
205 /**
206 * Depth of the post in the whole tree visible on the page (pageRoot's absoluteLevel is 0). May be negative.
207 * @type {number | undefined}
208 */
209 absoluteLevel;
210
211 /**
212 * For posts in feeds and timelines - specifies e.g. that a post was reposted by someone.
213 * @type {object | undefined}
214 */
215 reason;
216
217 /**
218 * True if the post was extracted from inner embed of a quote, not from a #postView.
219 * @type {boolean | undefined}
220 */
221 isEmbed;
222
223
224 /** @param {json} data, @param {json} [extra] */
225
226 constructor(data, extra) {
227 super(data);
228 Object.assign(this, extra ?? {});
229
230 if (this.absoluteLevel === 0) {
231 this.pageRoot = this;
232 }
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 {Post} post */
256
257 updateDataFromPost(post) {
258 this.record = post.record;
259 this.embed = post.embed;
260 this.author = post.author;
261 this.viewerData = post.viewerData;
262 this.viewerLike = post.viewerLike;
263 this.level = post.level;
264 this.absoluteLevel = post.absoluteLevel;
265 this.setReplies(post.replies);
266 }
267
268 /** @param {AnyPost[]} replies */
269
270 setReplies(replies) {
271 this.replies = replies;
272 this.replies.sort(this.sortReplies.bind(this));
273 }
274
275 /** @param {AnyPost} a, @param {AnyPost} b, @returns {-1 | 0 | 1} */
276
277 sortReplies(a, b) {
278 if (a instanceof Post && b instanceof Post) {
279 if (a.author.did == this.author.did && b.author.did != this.author.did) {
280 return -1;
281 } else if (a.author.did != this.author.did && b.author.did == this.author.did) {
282 return 1;
283 } else if (a.text != "📌" && b.text == "📌") {
284 return -1;
285 } else if (a.text == "📌" && b.text != "📌") {
286 return 1;
287 } else if (a.createdAt.getTime() < b.createdAt.getTime()) {
288 return -1;
289 } else if (a.createdAt.getTime() > b.createdAt.getTime()) {
290 return 1;
291 } else {
292 return 0;
293 }
294 } else if (a instanceof Post) {
295 return -1;
296 } else if (b instanceof Post) {
297 return 1;
298 } else {
299 return 0;
300 }
301 }
302
303 /** @returns {boolean} */
304 get isPostView() {
305 return !this.isEmbed;
306 }
307
308 /** @returns {boolean} */
309 get isFediPost() {
310 return this.author?.handle.endsWith('.ap.brid.gy');
311 }
312
313 /** @returns {string | undefined} */
314 get originalFediContent() {
315 return this.record.bridgyOriginalText;
316 }
317
318 /** @returns {string | undefined} */
319 get originalFediURL() {
320 return this.record.bridgyOriginalUrl;
321 }
322
323 /** @returns {boolean} */
324 get isPageRoot() {
325 // I AM ROOOT
326 return (this.pageRoot === this);
327 }
328
329 /** @returns {string} */
330 get authorFediHandle() {
331 if (this.isFediPost) {
332 return this.author.handle.replace(/\.ap\.brid\.gy$/, '').replace('.', '@');
333 } else {
334 throw "Not a Fedi post";
335 }
336 }
337
338 /** @returns {boolean} */
339 get hasValidHandle() {
340 return this.author.handle != 'handle.invalid';
341 }
342
343 /** @returns {string} */
344 get authorDisplayName() {
345 if (this.author.displayName) {
346 return this.author.displayName.trim();
347 } else if (this.author.handle.endsWith('.bsky.social')) {
348 return this.author.handle.replace(/\.bsky\.social$/, '');
349 } else {
350 return this.author.handle;
351 }
352 }
353
354 /** @returns {string} */
355 get linkToAuthor() {
356 return 'https://bsky.app/profile/' + (this.hasValidHandle ? this.author.handle : this.author.did);
357 }
358
359 /** @returns {string} */
360 get linkToPost() {
361 return this.linkToAuthor + '/post/' + this.rkey;
362 }
363
364 /** @returns {string} */
365 get text() {
366 return this.record.text;
367 }
368
369 /** @returns {string} */
370 get lowercaseText() {
371 if (!this._lowercaseText) {
372 this._lowercaseText = this.record.text.toLowerCase();
373 }
374
375 return this._lowercaseText;
376 }
377
378 /** @returns {json} */
379 get facets() {
380 return this.record.facets;
381 }
382
383 /** @returns {string[] | undefined} */
384 get tags() {
385 return this.record.tags;
386 }
387
388 /** @returns {Date} */
389 get createdAt() {
390 return new Date(this.record.createdAt);
391 }
392
393 /** @returns {number} */
394 get likeCount() {
395 return castToInt(this.data.likeCount);
396 }
397
398 /** @returns {number} */
399 get replyCount() {
400 return castToInt(this.data.replyCount);
401 }
402
403 /** @returns {number} */
404 get quoteCount() {
405 return castToInt(this.data.quoteCount);
406 }
407
408 /** @returns {boolean} */
409 get hasMoreReplies() {
410 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length);
411
412 return shouldHaveMoreReplies && (this.replies.length === 0) && (this.level !== undefined && this.level > 4);
413 }
414
415 /** @returns {boolean} */
416 get hasHiddenReplies() {
417 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length);
418
419 return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4));
420 }
421
422 /** @returns {boolean} */
423 get isRestrictingReplies() {
424 return !!(this.data.threadgate && this.data.threadgate.record.allow);
425 }
426
427 /** @returns {number} */
428 get repostCount() {
429 return castToInt(this.data.repostCount);
430 }
431
432 /** @returns {boolean} */
433 get liked() {
434 return (this.viewerLike !== undefined);
435 }
436
437 /** @returns {boolean | undefined} */
438 get muted() {
439 return this.author.viewer?.muted;
440 }
441
442 /** @returns {string | undefined} */
443 get muteList() {
444 return this.author.viewer?.mutedByList?.name;
445 }
446
447 /** @returns {boolean} */
448 get hasViewerInfo() {
449 return (this.viewerData !== undefined);
450 }
451
452 /** @returns {ATProtoRecord | undefined} */
453 get parentReference() {
454 return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent);
455 }
456
457 /** @returns {ATProtoRecord | undefined} */
458 get rootReference() {
459 return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root);
460 }
461}
462
463
464/**
465 * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block
466 * between the post author and the parent author). It only includes a reference, but no post content.
467 */
468
469export class BlockedPost extends BasePost {
470
471 /** @param {json} data */
472 constructor(data) {
473 super(data);
474 this.author = data.author;
475 }
476
477 /** @returns {boolean} */
478 get blocksUser() {
479 return !!this.author.viewer?.blocking;
480 }
481
482 /** @returns {boolean} */
483 get blockedByUser() {
484 return this.author.viewer?.blockedBy;
485 }
486}
487
488
489/**
490 * Stub of a post which was deleted or hidden.
491 */
492
493export class MissingPost extends BasePost {}
494
495
496/**
497 * Stub of a quoted post which was un-quoted by the original author.
498 */
499
500export class DetachedQuotePost extends BasePost {}