Thread viewer for Bluesky
1/**
2 * Thrown when the response is technically a "success" one, but the returned data is not what it should be.
3 */
4
5class ResponseDataError extends Error {}
6
7
8/**
9 * Thrown when the passed URL is not a supported post URL on bsky.app.
10 */
11
12class URLError extends Error {
13
14 /** @param {string} message */
15 constructor(message) {
16 super(message);
17 }
18}
19
20
21/**
22 * Caches the mapping of handles to DIDs to avoid unnecessary API calls to resolveHandle or getProfile.
23 */
24
25class HandleCache {
26 prepareCache() {
27 if (!this.cache) {
28 this.cache = JSON.parse(localStorage.getItem('handleCache') ?? '{}');
29 }
30 }
31
32 saveCache() {
33 localStorage.setItem('handleCache', JSON.stringify(this.cache));
34 }
35
36 /** @param {string} handle, @returns {string | undefined} */
37
38 getHandleDid(handle) {
39 this.prepareCache();
40 return this.cache[handle];
41 }
42
43 /** @param {string} handle, @param {string} did */
44
45 setHandleDid(handle, did) {
46 this.prepareCache();
47 this.cache[handle] = did;
48 this.saveCache();
49 }
50
51 /** @param {string} did, @returns {string | undefined} */
52
53 findHandleByDid(did) {
54 this.prepareCache();
55 let found = Object.entries(this.cache).find((e) => e[1] == did);
56 return found ? found[0] : undefined;
57 }
58}
59
60
61/**
62 * Stores user's access tokens and data in local storage after they log in.
63 */
64
65class LocalStorageConfig {
66 constructor() {
67 let data = localStorage.getItem('userData');
68 this.user = data ? JSON.parse(data) : {};
69 }
70
71 save() {
72 if (this.user) {
73 localStorage.setItem('userData', JSON.stringify(this.user));
74 } else {
75 localStorage.removeItem('userData');
76 }
77 }
78}
79
80
81/**
82 * API client for connecting to the Bluesky XRPC API (authenticated or not).
83 */
84
85class BlueskyAPI extends Minisky {
86
87 /** @param {string | undefined} host, @param {boolean} useAuthentication */
88 constructor(host, useAuthentication) {
89 super(host, useAuthentication ? new LocalStorageConfig() : undefined);
90
91 this.handleCache = new HandleCache();
92 this.profiles = {};
93 }
94
95 /** @param {json} author */
96
97 cacheProfile(author) {
98 this.profiles[author.did] = author;
99 this.profiles[author.handle] = author;
100 this.handleCache.setHandleDid(author.handle, author.did);
101 }
102
103 /** @param {string} did, @returns {string | undefined} */
104
105 findHandleByDid(did) {
106 return this.handleCache.findHandleByDid(did);
107 }
108
109 /** @param {string} did, @returns {Promise<string>} */
110
111 async fetchHandleForDid(did) {
112 let cachedHandle = this.handleCache.findHandleByDid(did);
113
114 if (cachedHandle) {
115 return cachedHandle;
116 } else {
117 let author = await this.loadUserProfile(did);
118 return author.handle;
119 }
120 }
121
122 /** @param {string} string, @returns {[string, string]} */
123
124 static parsePostURL(string) {
125 let url;
126
127 try {
128 url = new URL(string);
129 } catch (error) {
130 throw new URLError(`${error}`);
131 }
132
133 if (url.protocol != 'https:') {
134 throw new URLError('URL must start with https://');
135 }
136
137 let parts = url.pathname.split('/');
138
139 if (parts.length < 5 || parts[1] != 'profile' || parts[3] != 'post') {
140 throw new URLError('This is not a valid thread URL');
141 }
142
143 let handle = parts[2];
144 let postId = parts[4];
145
146 return [handle, postId];
147 }
148
149 /** @param {string} handle, @returns {Promise<string>} */
150
151 async resolveHandle(handle) {
152 let cachedDid = this.handleCache.getHandleDid(handle);
153
154 if (cachedDid) {
155 return cachedDid;
156 } else {
157 let json = await this.getRequest('com.atproto.identity.resolveHandle', { handle }, { auth: false });
158 let did = json['did'];
159
160 if (did) {
161 this.handleCache.setHandleDid(handle, did);
162 return did;
163 } else {
164 throw new ResponseDataError('Missing DID in response: ' + JSON.stringify(json));
165 }
166 }
167 }
168
169 /** @param {string} url, @returns {Promise<json>} */
170
171 async loadThreadByURL(url) {
172 let [handle, postId] = BlueskyAPI.parsePostURL(url);
173 return await this.loadThreadById(handle, postId);
174 }
175
176 /** @param {string} author, @param {string} postId, @returns {Promise<json>} */
177
178 async loadThreadById(author, postId) {
179 let did = author.startsWith('did:') ? author : await this.resolveHandle(author);
180 let postURI = `at://${did}/app.bsky.feed.post/${postId}`;
181 return await this.loadThreadByAtURI(postURI);
182 }
183
184 /** @param {string} uri, @returns {Promise<json>} */
185
186 async loadThreadByAtURI(uri) {
187 return await this.getRequest('app.bsky.feed.getPostThread', { uri: uri, depth: 10 });
188 }
189
190 /** @param {string} handle, @returns {Promise<json>} */
191
192 async loadUserProfile(handle) {
193 if (this.profiles[handle]) {
194 return this.profiles[handle];
195 } else {
196 let profile = await this.getRequest('app.bsky.actor.getProfile', { actor: handle });
197 this.cacheProfile(profile);
198 return profile;
199 }
200 }
201
202 /** @returns {Promise<json | undefined>} */
203
204 async getCurrentUserAvatar() {
205 let json = await this.getRequest('com.atproto.repo.getRecord', {
206 repo: this.user.did,
207 collection: 'app.bsky.actor.profile',
208 rkey: 'self'
209 });
210
211 return json.value.avatar;
212 }
213
214 /** @returns {Promise<string?>} */
215
216 async loadCurrentUserAvatar() {
217 if (!this.config || !this.config.user) {
218 throw new AuthError("User isn't logged in");
219 }
220
221 let avatar = await this.getCurrentUserAvatar();
222
223 if (avatar) {
224 let url = `https://cdn.bsky.app/img/avatar/plain/${this.user.did}/${avatar.ref.$link}@jpeg`;
225 this.config.user.avatar = url;
226 this.config.save();
227 return url;
228 } else {
229 return null;
230 }
231 }
232
233 /** @param {string} uri, @returns {Promise<string[]>} */
234
235 async getReplies(uri) {
236 let json = await this.getRequest('blue.feeds.post.getReplies', { uri });
237 return json.replies;
238 }
239
240 /** @param {string} uri, @returns {Promise<number>} */
241
242 async getQuoteCount(uri) {
243 let json = await this.getRequest('blue.feeds.post.getQuoteCount', { uri });
244 return json.quoteCount;
245 }
246
247 /** @param {string} url, @param {string | undefined} cursor, @returns {Promise<json>} */
248
249 async getQuotes(url, cursor = undefined) {
250 let postURI;
251
252 if (url.startsWith('at://')) {
253 postURI = url;
254 } else {
255 let [handle, postId] = BlueskyAPI.parsePostURL(url);
256 let did = handle.startsWith('did:') ? handle : await appView.resolveHandle(handle);
257 postURI = `at://${did}/app.bsky.feed.post/${postId}`;
258 }
259
260 let params = { uri: postURI };
261
262 if (cursor) {
263 params['cursor'] = cursor;
264 }
265
266 return await this.getRequest('blue.feeds.post.getQuotes', params);
267 }
268
269 /** @param {string} hashtag, @param {string | undefined} cursor, @returns {Promise<json>} */
270
271 async getHashtagFeed(hashtag, cursor = undefined) {
272 let params = { q: '#' + hashtag, limit: 50, sort: 'latest' };
273
274 if (cursor) {
275 params['cursor'] = cursor;
276 }
277
278 return await this.getRequest('app.bsky.feed.searchPosts', params);
279 }
280
281 /** @param {json} [params], @returns {Promise<json>} */
282
283 async loadNotifications(params) {
284 return await this.getRequest('app.bsky.notification.listNotifications', params || {});
285 }
286
287 /**
288 * @param {string} [cursor]
289 * @returns {Promise<{ cursor: string | undefined, posts: json[] }>}
290 */
291
292 async loadMentions(cursor) {
293 let response = await this.loadNotifications({ cursor: cursor ?? '', limit: 100, reasons: ['reply', 'mention'] });
294 let uris = response.notifications.map(x => x.uri);
295 let batches = [];
296
297 for (let i = 0; i < uris.length; i += 25) {
298 let batch = this.loadPosts(uris.slice(i, i + 25));
299 batches.push(batch);
300 }
301
302 let postGroups = await Promise.all(batches);
303
304 return { cursor: response.cursor, posts: postGroups.flat() };
305 }
306
307 /**
308 * @param {number} days
309 * @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options]
310 * @returns {Promise<json[]>}
311 */
312
313 async loadHomeTimeline(days, options = {}) {
314 let now = new Date();
315 let timeLimit = now.getTime() - days * 86400 * 1000;
316
317 return await this.fetchAll('app.bsky.feed.getTimeline', {
318 params: {
319 limit: 100
320 },
321 field: 'feed',
322 breakWhen: (x) => {
323 let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
324 return Date.parse(timestamp) < timeLimit;
325 },
326 onPageLoad: options.onPageLoad,
327 keepLastPage: options.keepLastPage
328 });
329 }
330
331 /**
332 @typedef
333 {'posts_with_replies' | 'posts_no_replies' | 'posts_and_author_threads' | 'posts_with_media' | 'posts_with_video'}
334 AuthorFeedFilter
335
336 Filters:
337 - posts_with_replies: posts, replies and reposts (default)
338 - posts_no_replies: posts and reposts (no replies)
339 - posts_and_author_threads: posts, reposts, and replies in your own threads
340 - posts_with_media: posts and replies, but only with images (no reposts)
341 - posts_with_video: posts and replies, but only with videos (no reposts)
342 */
343
344 /**
345 * @param {string} did
346 * @param {number} days
347 * @param {{ filter: AuthorFeedFilter, onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} options
348 * @returns {Promise<json[]>}
349 */
350
351 async loadUserTimeline(did, days, options) {
352 let now = new Date();
353 let timeLimit = now.getTime() - days * 86400 * 1000;
354
355 return await this.fetchAll('app.bsky.feed.getAuthorFeed', {
356 params: {
357 actor: did,
358 filter: options.filter,
359 limit: 100
360 },
361 field: 'feed',
362 breakWhen: (x) => {
363 let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
364 return Date.parse(timestamp) < timeLimit;
365 },
366 onPageLoad: options.onPageLoad,
367 keepLastPage: options.keepLastPage
368 });
369 }
370
371 /** @returns {Promise<json[]>} */
372
373 async loadUserLists() {
374 let lists = await this.fetchAll('app.bsky.graph.getLists', {
375 params: {
376 actor: this.user.did,
377 limit: 100
378 },
379 field: 'lists'
380 });
381
382 return lists.filter(x => x.purpose == "app.bsky.graph.defs#curatelist");
383 }
384
385 /**
386 * @param {string} list
387 * @param {number} days
388 * @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options]
389 * @returns {Promise<json[]>}
390 */
391
392 async loadListTimeline(list, days, options = {}) {
393 let now = new Date();
394 let timeLimit = now.getTime() - days * 86400 * 1000;
395
396 return await this.fetchAll('app.bsky.feed.getListFeed', {
397 params: {
398 list: list,
399 limit: 100
400 },
401 field: 'feed',
402 breakWhen: (x) => {
403 return Date.parse(x.post.record.createdAt) < timeLimit;
404 },
405 onPageLoad: options.onPageLoad,
406 keepLastPage: options.keepLastPage
407 });
408 }
409
410 /** @param {string} postURI, @returns {Promise<json>} */
411
412 async loadPost(postURI) {
413 let posts = await this.loadPosts([postURI]);
414
415 if (posts.length == 1) {
416 return posts[0];
417 } else {
418 throw new ResponseDataError('Post not found');
419 }
420 }
421
422 /** @param {string} postURI, @returns {Promise<json | undefined>} */
423
424 async loadPostIfExists(postURI) {
425 let posts = await this.loadPosts([postURI]);
426 return posts[0];
427 }
428
429 /** @param {string[]} uris, @returns {Promise<object[]>} */
430
431 async loadPosts(uris) {
432 if (uris.length > 0) {
433 let response = await this.getRequest('app.bsky.feed.getPosts', { uris });
434 return response.posts;
435 } else {
436 return [];
437 }
438 }
439
440 /** @param {Post} post, @returns {Promise<json>} */
441
442 async likePost(post) {
443 return await this.postRequest('com.atproto.repo.createRecord', {
444 repo: this.user.did,
445 collection: 'app.bsky.feed.like',
446 record: {
447 subject: {
448 uri: post.uri,
449 cid: post.cid
450 },
451 createdAt: new Date().toISOString()
452 }
453 });
454 }
455
456 /** @param {string} uri, @returns {Promise<void>} */
457
458 async removeLike(uri) {
459 let { rkey } = atURI(uri);
460
461 await this.postRequest('com.atproto.repo.deleteRecord', {
462 repo: this.user.did,
463 collection: 'app.bsky.feed.like',
464 rkey: rkey
465 });
466 }
467
468 resetTokens() {
469 delete this.user.avatar;
470 super.resetTokens();
471 }
472}