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 }} [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 });
328 }
329
330 /**
331 @typedef
332 {'posts_with_replies' | 'posts_no_replies' | 'posts_and_author_threads' | 'posts_with_media' | 'posts_with_video'}
333 AuthorFeedFilter
334
335 Filters:
336 - posts_with_replies: posts, replies and reposts (default)
337 - posts_no_replies: posts and reposts (no replies)
338 - posts_and_author_threads: posts, reposts, and replies in your own threads
339 - posts_with_media: posts and replies, but only with images (no reposts)
340 - posts_with_video: posts and replies, but only with videos (no reposts)
341 */
342
343 /**
344 * @param {string} did
345 * @param {number} days
346 * @param {{ onPageLoad?: FetchAllOnPageLoad, filter: AuthorFeedFilter }} options
347 * @returns {Promise<json[]>}
348 */
349
350 async loadUserTimeline(did, days, options) {
351 let now = new Date();
352 let timeLimit = now.getTime() - days * 86400 * 1000;
353
354 return await this.fetchAll('app.bsky.feed.getAuthorFeed', {
355 params: {
356 actor: did,
357 filter: options.filter,
358 limit: 100
359 },
360 field: 'feed',
361 breakWhen: (x) => {
362 let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
363 return Date.parse(timestamp) < timeLimit;
364 },
365 onPageLoad: options.onPageLoad
366 });
367 }
368
369 /** @returns {Promise<json[]>} */
370
371 async loadUserLists() {
372 let lists = await this.fetchAll('app.bsky.graph.getLists', {
373 params: {
374 actor: this.user.did,
375 limit: 100
376 },
377 field: 'lists'
378 });
379
380 return lists.filter(x => x.purpose == "app.bsky.graph.defs#curatelist");
381 }
382
383 /**
384 * @param {string} list
385 * @param {number} days
386 * @param {{ onPageLoad?: FetchAllOnPageLoad }} [options]
387 * @returns {Promise<json[]>}
388 */
389
390 async loadListTimeline(list, days, options = {}) {
391 let now = new Date();
392 let timeLimit = now.getTime() - days * 86400 * 1000;
393
394 return await this.fetchAll('app.bsky.feed.getListFeed', {
395 params: {
396 list: list,
397 limit: 100
398 },
399 field: 'feed',
400 breakWhen: (x) => {
401 return Date.parse(x.post.record.createdAt) < timeLimit;
402 },
403 onPageLoad: options.onPageLoad
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}