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 {string} [cursor], @returns {Promise<json>} */
282
283 async loadNotifications(cursor) {
284 let params = { limit: 100 };
285
286 if (cursor) {
287 params.cursor = cursor;
288 }
289
290 return await this.getRequest('app.bsky.notification.listNotifications', params);
291 }
292
293 /**
294 * @param {string} [cursor]
295 * @returns {Promise<{ cursor: string | undefined, posts: json[] }>}
296 */
297
298 async loadMentions(cursor) {
299 let response = await this.loadNotifications(cursor);
300 let mentions = response.notifications.filter(x => ['reply', 'mention'].includes(x.reason));
301 let uris = mentions.map(x => x['uri']);
302 let posts = [];
303
304 for (let i = 0; i < uris.length; i += 25) {
305 let batch = await this.loadPosts(uris.slice(i, i + 25));
306 posts = posts.concat(batch);
307 }
308
309 return { cursor: response.cursor, posts };
310 }
311
312 /**
313 * @param {number} days
314 * @param {{ onPageLoad?: FetchAllOnPageLoad }} [options]
315 * @returns {Promise<json[]>}
316 */
317
318 async loadTimeline(days, options = {}) {
319 let now = new Date();
320 let timeLimit = now.getTime() - days * 86400 * 1000;
321
322 return await this.fetchAll('app.bsky.feed.getTimeline', {
323 params: {
324 limit: 100
325 },
326 field: 'feed',
327 breakWhen: (x) => {
328 let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
329 return Date.parse(timestamp) < timeLimit;
330 },
331 onPageLoad: options.onPageLoad
332 });
333 }
334
335 /**
336 * @param {string} did
337 * @param {number} days
338 * @param {{ onPageLoad?: FetchAllOnPageLoad }} [options]
339 * @returns {Promise<json[]>}
340 */
341
342 async loadUserTimeline(did, days, options = {}) {
343 let now = new Date();
344 let timeLimit = now.getTime() - days * 86400 * 1000;
345
346 return await this.fetchAll('app.bsky.feed.getAuthorFeed', {
347 params: {
348 actor: did,
349 filter: 'posts_no_replies',
350 limit: 100
351 },
352 field: 'feed',
353 breakWhen: (x) => {
354 let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
355 return Date.parse(timestamp) < timeLimit;
356 },
357 onPageLoad: options.onPageLoad
358 });
359 }
360
361 /** @param {string} postURI, @returns {Promise<json>} */
362
363 async loadPost(postURI) {
364 let posts = await this.loadPosts([postURI]);
365
366 if (posts.length == 1) {
367 return posts[0];
368 } else {
369 throw new ResponseDataError('Post not found');
370 }
371 }
372
373 /** @param {string} postURI, @returns {Promise<json | undefined>} */
374
375 async loadPostIfExists(postURI) {
376 let posts = await this.loadPosts([postURI]);
377 return posts[0];
378 }
379
380 /** @param {string[]} uris, @returns {Promise<object[]>} */
381
382 async loadPosts(uris) {
383 if (uris.length > 0) {
384 let response = await this.getRequest('app.bsky.feed.getPosts', { uris });
385 return response.posts;
386 } else {
387 return [];
388 }
389 }
390
391 /** @param {Post} post, @returns {Promise<json>} */
392
393 async likePost(post) {
394 return await this.postRequest('com.atproto.repo.createRecord', {
395 repo: this.user.did,
396 collection: 'app.bsky.feed.like',
397 record: {
398 subject: {
399 uri: post.uri,
400 cid: post.cid
401 },
402 createdAt: new Date().toISOString()
403 }
404 });
405 }
406
407 /** @param {string} uri, @returns {Promise<void>} */
408
409 async removeLike(uri) {
410 let { rkey } = atURI(uri);
411
412 await this.postRequest('com.atproto.repo.deleteRecord', {
413 repo: this.user.did,
414 collection: 'app.bsky.feed.like',
415 rkey: rkey
416 });
417 }
418
419 resetTokens() {
420 delete this.user.avatar;
421 super.resetTokens();
422 }
423}