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}