Thread viewer for Bluesky
at mastodon 304 lines 7.6 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} string, @returns {[string, string]} */ 110 111 static parsePostURL(string) { 112 let url; 113 114 try { 115 url = new URL(string); 116 } catch (error) { 117 throw new URLError(`${error}`); 118 } 119 120 if (url.protocol != 'https:') { 121 throw new URLError('URL must start with https://'); 122 } 123 124 if (!(url.host == 'staging.bsky.app' || url.host == 'bsky.app' || url.host == 'main.bsky.dev')) { 125 throw new URLError('Only bsky.app URLs are supported'); 126 } 127 128 let parts = url.pathname.split('/'); 129 130 if (parts.length < 5 || parts[1] != 'profile' || parts[3] != 'post') { 131 throw new URLError('This is not a valid thread URL'); 132 } 133 134 let handle = parts[2]; 135 let postId = parts[4]; 136 137 return [handle, postId]; 138 } 139 140 /** @param {string} handle, @returns {Promise<string>} */ 141 142 async resolveHandle(handle) { 143 let cachedDid = this.handleCache.getHandleDid(handle); 144 145 if (cachedDid) { 146 return cachedDid; 147 } else { 148 let json = await this.getRequest('com.atproto.identity.resolveHandle', { handle }, { auth: false }); 149 let did = json['did']; 150 151 if (did) { 152 this.handleCache.setHandleDid(handle, did); 153 return did; 154 } else { 155 throw new ResponseDataError('Missing DID in response: ' + JSON.stringify(json)); 156 } 157 } 158 } 159 160 /** @param {string} url, @returns {Promise<json>} */ 161 162 async loadThreadByURL(url) { 163 let [handle, postId] = BlueskyAPI.parsePostURL(url); 164 return await this.loadThreadById(handle, postId); 165 } 166 167 /** @param {string} author, @param {string} postId, @returns {Promise<json>} */ 168 169 async loadThreadById(author, postId) { 170 let did = author.startsWith('did:') ? author : await this.resolveHandle(author); 171 let postURI = `at://${did}/app.bsky.feed.post/${postId}`; 172 let threadJSON = await this.getRequest('app.bsky.feed.getPostThread', { uri: postURI, depth: 10 }); 173 return threadJSON; 174 } 175 176 /** @param {string} handle, @returns {Promise<json>} */ 177 178 async loadUserProfile(handle) { 179 if (this.profiles[handle]) { 180 return this.profiles[handle]; 181 } else { 182 let profile = await this.getRequest('app.bsky.actor.getProfile', { actor: handle }); 183 this.cacheProfile(profile); 184 return profile; 185 } 186 } 187 188 /** @returns {Promise<json | undefined>} */ 189 190 async getCurrentUserAvatar() { 191 let json = await this.getRequest('com.atproto.repo.getRecord', { 192 repo: this.user.did, 193 collection: 'app.bsky.actor.profile', 194 rkey: 'self' 195 }); 196 197 return json.value.avatar; 198 } 199 200 /** @returns {Promise<string?>} */ 201 202 async loadCurrentUserAvatar() { 203 if (!this.config || !this.config.user) { 204 throw new AuthError("User isn't logged in"); 205 } 206 207 let avatar = await this.getCurrentUserAvatar(); 208 209 if (avatar) { 210 let url = `https://cdn.bsky.app/img/avatar/plain/${this.user.did}/${avatar.ref.$link}@jpeg`; 211 this.config.user.avatar = url; 212 this.config.save(); 213 return url; 214 } else { 215 return null; 216 } 217 } 218 219 /** @param {string} uri, @returns {Promise<number>} */ 220 221 async getQuoteCount(uri) { 222 let json = await this.getRequest('eu.mackuba.private.getQuoteCount', { uri }); 223 return json.quoteCount; 224 } 225 226 /** @param {string} url, @param {string | undefined} cursor, @returns {Promise<json>} */ 227 228 async getQuotes(url, cursor = undefined) { 229 let [handle, postId] = BlueskyAPI.parsePostURL(url); 230 let did = handle.startsWith('did:') ? handle : await appView.resolveHandle(handle); 231 let postURI = `at://${did}/app.bsky.feed.post/${postId}`; 232 233 let params = { uri: postURI }; 234 235 if (cursor) { 236 params['cursor'] = cursor; 237 } 238 239 return await this.getRequest('eu.mackuba.private.getPostQuotes', params); 240 } 241 242 /** @param {string} hashtag, @param {string | undefined} cursor, @returns {Promise<json>} */ 243 244 async getHashtagFeed(hashtag, cursor = undefined) { 245 let params = { q: '#' + hashtag, limit: 50, sort: 'latest' }; 246 247 if (cursor) { 248 params['cursor'] = cursor; 249 } 250 251 return await this.getRequest('app.bsky.feed.searchPosts', params); 252 } 253 254 /** @param {string} postURI, @returns {Promise<json>} */ 255 256 async loadPost(postURI) { 257 let posts = await this.loadPosts([postURI]); 258 return posts[0]; 259 } 260 261 /** @param {string[]} uris, @returns {Promise<object[]>} */ 262 263 async loadPosts(uris) { 264 if (uris.length > 0) { 265 let response = await this.getRequest('app.bsky.feed.getPosts', { uris }); 266 return response.posts; 267 } else { 268 return []; 269 } 270 } 271 272 /** @param {Post} post, @returns {Promise<json>} */ 273 274 async likePost(post) { 275 return await this.postRequest('com.atproto.repo.createRecord', { 276 repo: this.user.did, 277 collection: 'app.bsky.feed.like', 278 record: { 279 subject: { 280 uri: post.uri, 281 cid: post.cid 282 }, 283 createdAt: new Date().toISOString() 284 } 285 }); 286 } 287 288 /** @param {string} uri, @returns {Promise<void>} */ 289 290 async removeLike(uri) { 291 let { rkey } = atURI(uri); 292 293 await this.postRequest('com.atproto.repo.deleteRecord', { 294 repo: this.user.did, 295 collection: 'app.bsky.feed.like', 296 rkey: rkey 297 }); 298 } 299 300 resetTokens() { 301 delete this.user.avatar; 302 super.resetTokens(); 303 } 304}