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 * @param {string} did
332 * @param {number} days
333 * @param {{ onPageLoad?: FetchAllOnPageLoad }} [options]
334 * @returns {Promise<json[]>}
335 */
336
337 async loadUserTimeline(did, days, options = {}) {
338 let now = new Date();
339 let timeLimit = now.getTime() - days * 86400 * 1000;
340
341 return await this.fetchAll('app.bsky.feed.getAuthorFeed', {
342 params: {
343 actor: did,
344 filter: 'posts_no_replies',
345 limit: 100
346 },
347 field: 'feed',
348 breakWhen: (x) => {
349 let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
350 return Date.parse(timestamp) < timeLimit;
351 },
352 onPageLoad: options.onPageLoad
353 });
354 }
355
356 /** @param {string} postURI, @returns {Promise<json>} */
357
358 async loadPost(postURI) {
359 let posts = await this.loadPosts([postURI]);
360
361 if (posts.length == 1) {
362 return posts[0];
363 } else {
364 throw new ResponseDataError('Post not found');
365 }
366 }
367
368 /** @param {string} postURI, @returns {Promise<json | undefined>} */
369
370 async loadPostIfExists(postURI) {
371 let posts = await this.loadPosts([postURI]);
372 return posts[0];
373 }
374
375 /** @param {string[]} uris, @returns {Promise<object[]>} */
376
377 async loadPosts(uris) {
378 if (uris.length > 0) {
379 let response = await this.getRequest('app.bsky.feed.getPosts', { uris });
380 return response.posts;
381 } else {
382 return [];
383 }
384 }
385
386 /** @param {Post} post, @returns {Promise<json>} */
387
388 async likePost(post) {
389 return await this.postRequest('com.atproto.repo.createRecord', {
390 repo: this.user.did,
391 collection: 'app.bsky.feed.like',
392 record: {
393 subject: {
394 uri: post.uri,
395 cid: post.cid
396 },
397 createdAt: new Date().toISOString()
398 }
399 });
400 }
401
402 /** @param {string} uri, @returns {Promise<void>} */
403
404 async removeLike(uri) {
405 let { rkey } = atURI(uri);
406
407 await this.postRequest('com.atproto.repo.deleteRecord', {
408 repo: this.user.did,
409 collection: 'app.bsky.feed.like',
410 rkey: rkey
411 });
412 }
413
414 resetTokens() {
415 delete this.user.avatar;
416 super.resetTokens();
417 }
418}