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}