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:' && url.protocol != 'http:') {
134 throw new URLError('URL must start with http(s)://');
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 /** @param {string} query, @returns {Promise<json[]>} */
203
204 async autocompleteUsers(query) {
205 let json = await this.getRequest('app.bsky.actor.searchActorsTypeahead', { q: query });
206 return json.actors;
207 }
208
209 /** @returns {Promise<json | undefined>} */
210
211 async getCurrentUserAvatar() {
212 let json = await this.getRequest('com.atproto.repo.getRecord', {
213 repo: this.user.did,
214 collection: 'app.bsky.actor.profile',
215 rkey: 'self'
216 });
217
218 return json.value.avatar;
219 }
220
221 /** @returns {Promise<string?>} */
222
223 async loadCurrentUserAvatar() {
224 if (!this.config || !this.config.user) {
225 throw new AuthError("User isn't logged in");
226 }
227
228 let avatar = await this.getCurrentUserAvatar();
229
230 if (avatar) {
231 let url = `https://cdn.bsky.app/img/avatar/plain/${this.user.did}/${avatar.ref.$link}@jpeg`;
232 this.config.user.avatar = url;
233 this.config.save();
234 return url;
235 } else {
236 return null;
237 }
238 }
239
240 /** @param {string} uri, @returns {Promise<string[]>} */
241
242 async getReplies(uri) {
243 let json = await this.getRequest('blue.feeds.post.getReplies', { uri });
244 return json.replies;
245 }
246
247 /** @param {string} uri, @returns {Promise<number>} */
248
249 async getQuoteCount(uri) {
250 let json = await this.getRequest('blue.feeds.post.getQuoteCount', { uri });
251 return json.quoteCount;
252 }
253
254 /** @param {string} url, @param {string | undefined} cursor, @returns {Promise<json>} */
255
256 async getQuotes(url, cursor = undefined) {
257 let postURI;
258
259 if (url.startsWith('at://')) {
260 postURI = url;
261 } else {
262 let [handle, postId] = BlueskyAPI.parsePostURL(url);
263 let did = handle.startsWith('did:') ? handle : await appView.resolveHandle(handle);
264 postURI = `at://${did}/app.bsky.feed.post/${postId}`;
265 }
266
267 let params = { uri: postURI };
268
269 if (cursor) {
270 params['cursor'] = cursor;
271 }
272
273 return await this.getRequest('blue.feeds.post.getQuotes', params);
274 }
275
276 /** @param {string} hashtag, @param {string | undefined} cursor, @returns {Promise<json>} */
277
278 async getHashtagFeed(hashtag, cursor = undefined) {
279 let params = { q: '#' + hashtag, limit: 50, sort: 'latest' };
280
281 if (cursor) {
282 params['cursor'] = cursor;
283 }
284
285 return await this.getRequest('app.bsky.feed.searchPosts', params);
286 }
287
288 /** @param {json} [params], @returns {Promise<json>} */
289
290 async loadNotifications(params) {
291 return await this.getRequest('app.bsky.notification.listNotifications', params || {});
292 }
293
294 /**
295 * @param {string} [cursor]
296 * @returns {Promise<{ cursor: string | undefined, posts: json[] }>}
297 */
298
299 async loadMentions(cursor) {
300 let response = await this.loadNotifications({ cursor: cursor ?? '', limit: 100, reasons: ['reply', 'mention'] });
301 let uris = response.notifications.map(x => x.uri);
302 let batches = [];
303
304 for (let i = 0; i < uris.length; i += 25) {
305 let batch = this.loadPosts(uris.slice(i, i + 25));
306 batches.push(batch);
307 }
308
309 let postGroups = await Promise.all(batches);
310
311 return { cursor: response.cursor, posts: postGroups.flat() };
312 }
313
314 /**
315 * @param {number} days
316 * @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options]
317 * @returns {Promise<json[]>}
318 */
319
320 async loadHomeTimeline(days, options = {}) {
321 let now = new Date();
322 let timeLimit = now.getTime() - days * 86400 * 1000;
323
324 return await this.fetchAll('app.bsky.feed.getTimeline', {
325 params: { limit: 100 },
326 field: 'feed',
327 breakWhen: (x) => (feedPostTime(x) < timeLimit),
328 onPageLoad: options.onPageLoad,
329 keepLastPage: options.keepLastPage
330 });
331 }
332
333 /**
334 @typedef
335 {'posts_with_replies' | 'posts_no_replies' | 'posts_and_author_threads' | 'posts_with_media' | 'posts_with_video'}
336 AuthorFeedFilter
337
338 Filters:
339 - posts_with_replies: posts, replies and reposts (default)
340 - posts_no_replies: posts and reposts (no replies)
341 - posts_and_author_threads: posts, reposts, and replies in your own threads
342 - posts_with_media: posts and replies, but only with images (no reposts)
343 - posts_with_video: posts and replies, but only with videos (no reposts)
344 */
345
346 /**
347 * @param {string} did
348 * @param {number} days
349 * @param {{ filter: AuthorFeedFilter, onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} options
350 * @returns {Promise<json[]>}
351 */
352
353 async loadUserTimeline(did, days, options) {
354 let now = new Date();
355 let timeLimit = now.getTime() - days * 86400 * 1000;
356
357 return await this.fetchAll('app.bsky.feed.getAuthorFeed', {
358 params: {
359 actor: did,
360 filter: options.filter,
361 limit: 100
362 },
363 field: 'feed',
364 breakWhen: (x) => (feedPostTime(x) < timeLimit),
365 onPageLoad: options.onPageLoad,
366 keepLastPage: options.keepLastPage
367 });
368 }
369
370 /** @returns {Promise<json[]>} */
371
372 async loadUserLists() {
373 let lists = await this.fetchAll('app.bsky.graph.getLists', {
374 params: {
375 actor: this.user.did,
376 limit: 100
377 },
378 field: 'lists'
379 });
380
381 return lists.filter(x => x.purpose == "app.bsky.graph.defs#curatelist");
382 }
383
384 /**
385 * @param {string} list
386 * @param {number} days
387 * @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options]
388 * @returns {Promise<json[]>}
389 */
390
391 async loadListTimeline(list, days, options = {}) {
392 let now = new Date();
393 let timeLimit = now.getTime() - days * 86400 * 1000;
394
395 return await this.fetchAll('app.bsky.feed.getListFeed', {
396 params: {
397 list: list,
398 limit: 100
399 },
400 field: 'feed',
401 breakWhen: (x) => (feedPostTime(x) < timeLimit),
402 onPageLoad: options.onPageLoad,
403 keepLastPage: options.keepLastPage
404 });
405 }
406
407 /** @param {string} postURI, @returns {Promise<json>} */
408
409 async loadPost(postURI) {
410 let posts = await this.loadPosts([postURI]);
411
412 if (posts.length == 1) {
413 return posts[0];
414 } else {
415 throw new ResponseDataError('Post not found');
416 }
417 }
418
419 /** @param {string} postURI, @returns {Promise<json | undefined>} */
420
421 async loadPostIfExists(postURI) {
422 let posts = await this.loadPosts([postURI]);
423 return posts[0];
424 }
425
426 /** @param {string[]} uris, @returns {Promise<object[]>} */
427
428 async loadPosts(uris) {
429 if (uris.length > 0) {
430 let response = await this.getRequest('app.bsky.feed.getPosts', { uris });
431 return response.posts;
432 } else {
433 return [];
434 }
435 }
436
437 /** @param {Post} post, @returns {Promise<json>} */
438
439 async likePost(post) {
440 return await this.postRequest('com.atproto.repo.createRecord', {
441 repo: this.user.did,
442 collection: 'app.bsky.feed.like',
443 record: {
444 subject: {
445 uri: post.uri,
446 cid: post.cid
447 },
448 createdAt: new Date().toISOString()
449 }
450 });
451 }
452
453 /** @param {string} uri, @returns {Promise<void>} */
454
455 async removeLike(uri) {
456 let { rkey } = atURI(uri);
457
458 await this.postRequest('com.atproto.repo.deleteRecord', {
459 repo: this.user.did,
460 collection: 'app.bsky.feed.like',
461 rkey: rkey
462 });
463 }
464
465 resetTokens() {
466 delete this.user.avatar;
467 super.resetTokens();
468 }
469}