Thread viewer for Bluesky
at master 12 kB view raw
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}