Thread viewer for Bluesky
at master 271 lines 8.0 kB view raw
1import { HandleCache } from './handle_cache.js'; 2import { appView, constellationAPI } from '../api.js'; 3import { APIError, Minisky, type FetchAllOnPageLoad, type MiniskyConfig, type MiniskyOptions } from './minisky.js'; 4import { atURI, feedPostTime } from '../utils.js'; 5import { Post } from '../models/posts.js'; 6import { parseBlueskyPostURL } from '../router.js'; 7 8export { APIError }; 9 10/** 11 * Thrown when the response is technically a "success" one, but the returned data is not what it should be. 12 */ 13 14export class ResponseDataError extends Error {} 15 16/** 17 * Thrown when the passed URL is not a supported post URL on bsky.app. 18 */ 19 20export class URLError extends Error { 21 constructor(message: string) { 22 super(message); 23 } 24} 25 26type AuthorFeedFilter = 27 | 'posts_with_replies' // posts, replies and reposts (default) 28 | 'posts_no_replies' // posts and reposts (no replies) 29 | 'posts_and_author_threads' // posts, reposts, and replies in your own threads 30 | 'posts_with_media' // posts and replies, but only with images (no reposts) 31 | 'posts_with_video'; // posts and replies, but only with videos (no reposts) 32 33export type TimelineFetchOptions = { 34 onPageLoad?: FetchAllOnPageLoad; 35 keepLastPage?: boolean; 36 abortSignal?: AbortSignal; 37} 38 39/** 40 * API client for connecting to the Bluesky XRPC API (authenticated or not). 41 */ 42 43export class BlueskyAPI extends Minisky { 44 handleCache: HandleCache; 45 profiles: Record<string, json>; 46 47 constructor(host: string | null, config?: MiniskyConfig | null, options?: MiniskyOptions | null) { 48 super(host, config, options); 49 50 this.handleCache = new HandleCache(); 51 this.profiles = {}; 52 } 53 54 cacheProfile(author: json) { 55 this.profiles[author.did] = author; 56 this.profiles[author.handle] = author; 57 this.handleCache.setHandleDid(author.handle, author.did); 58 } 59 60 async fetchHandleForDid(did: string): Promise<string> { 61 let cachedHandle = this.handleCache.findHandleByDid(did); 62 63 if (cachedHandle) { 64 return cachedHandle; 65 } else { 66 let author = await this.loadUserProfile(did); 67 return author.handle; 68 } 69 } 70 71 async resolveHandle(handle: string): Promise<string> { 72 let cachedDid = this.handleCache.getHandleDid(handle); 73 74 if (cachedDid) { 75 return cachedDid; 76 } else { 77 let json = await this.getRequest('com.atproto.identity.resolveHandle', { handle }, { auth: false }); 78 let did = json['did']; 79 80 if (did) { 81 this.handleCache.setHandleDid(handle, did); 82 return did; 83 } else { 84 throw new ResponseDataError('Missing DID in response: ' + JSON.stringify(json)); 85 } 86 } 87 } 88 89 async loadThreadByURL(url: string): Promise<json> { 90 let { user, post } = parseBlueskyPostURL(url); 91 return await this.loadThreadById(user, post); 92 } 93 94 async loadThreadById(author: string, postId: string): Promise<json> { 95 let did = author.startsWith('did:') ? author : await this.resolveHandle(author); 96 let postURI = `at://${did}/app.bsky.feed.post/${postId}`; 97 return await this.loadThreadByAtURI(postURI); 98 } 99 100 async loadThreadByAtURI(uri: string): Promise<json> { 101 return await this.getRequest('app.bsky.feed.getPostThread', { uri: uri, depth: 10 }); 102 } 103 104 async loadUserProfile(handle: string): Promise<json> { 105 if (this.profiles[handle]) { 106 return this.profiles[handle]; 107 } else { 108 let profile = await this.getRequest('app.bsky.actor.getProfile', { actor: handle }); 109 this.cacheProfile(profile); 110 return profile; 111 } 112 } 113 114 async autocompleteUsers(query: string): Promise<json[]> { 115 let json = await this.getRequest('app.bsky.actor.searchActorsTypeahead', { q: query }); 116 return json.actors; 117 } 118 119 async getReplies(uri: string): Promise<string[]> { 120 let results = await this.fetchAll('blue.microcosm.links.getBacklinks', { 121 field: 'records', 122 params: { 123 subject: uri, 124 source: 'app.bsky.feed.post:reply.parent.uri', 125 limit: 100 126 } 127 }); 128 129 return results.map((x: json) => `at://${x.did}/${x.collection}/${x.rkey}`); 130 } 131 132 async getQuoteCount(uri: string): Promise<number> { 133 let json = await this.getRequest('blue.feeds.post.getQuoteCount', { uri }); 134 return json.quoteCount; 135 } 136 137 async getQuotes(url: string, cursor?: string): Promise<json> { 138 let postURI: string; 139 140 if (url.startsWith('at://')) { 141 postURI = url; 142 } else { 143 let { user, post } = parseBlueskyPostURL(url); 144 let did = user.startsWith('did:') ? user : await appView.resolveHandle(user); 145 postURI = `at://${did}/app.bsky.feed.post/${post}`; 146 } 147 148 let params: Record<string, string> = { uri: postURI }; 149 150 if (cursor) { 151 params['cursor'] = cursor; 152 } 153 154 return await this.getRequest('blue.feeds.post.getQuotes', params); 155 } 156 157 async getHashtagFeed(hashtag: string, cursor?: string): Promise<json> { 158 let params: Record<string, any> = { q: '#' + hashtag, limit: 50, sort: 'latest' }; 159 160 if (cursor) { 161 params['cursor'] = cursor; 162 } 163 164 return await this.getRequest('app.bsky.feed.searchPosts', params); 165 } 166 167 async loadHiddenReplies(post: Post): Promise<(json | null)[]> { 168 let expectedReplyURIs = await constellationAPI.getReplies(post.uri); 169 let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r)); 170 171 missingReplyURIs.sort((a, b) => { 172 let arkey = a.split('/').at(-1)! 173 let brkey = b.split('/').at(-1)! 174 return arkey.localeCompare(brkey); 175 }); 176 177 let promises = missingReplyURIs.map(uri => this.loadThreadByAtURI(uri)); 178 let responses = await Promise.allSettled(promises); 179 180 return responses.map(r => (r.status == 'fulfilled') ? r.value : null); 181 } 182 183 async loadUserTimeline( 184 did: string, 185 days: number, 186 options: { filter: AuthorFeedFilter } & TimelineFetchOptions 187 ): Promise<json[]> { 188 let now = new Date(); 189 let timeLimit = now.getTime() - days * 86400 * 1000; 190 let { filter, ...fetchOptions } = options; 191 192 return await this.fetchAll('app.bsky.feed.getAuthorFeed', { 193 params: { 194 actor: did, 195 filter: filter, 196 limit: 100 197 }, 198 field: 'feed', 199 breakWhen: (x: json) => feedPostTime(x) < timeLimit, 200 ...fetchOptions 201 }); 202 } 203 204 async loadListTimeline(list: string, days: number, options: TimelineFetchOptions = {}): Promise<json[]> { 205 let now = new Date(); 206 let timeLimit = now.getTime() - days * 86400 * 1000; 207 208 return await this.fetchAll('app.bsky.feed.getListFeed', { 209 params: { 210 list: list, 211 limit: 100 212 }, 213 field: 'feed', 214 breakWhen: (x: json) => feedPostTime(x) < timeLimit, 215 ...options 216 }); 217 } 218 219 async loadPost(postURI: string): Promise<json> { 220 let posts = await this.loadPosts([postURI]); 221 222 if (posts.length == 1) { 223 return posts[0]; 224 } else { 225 throw new ResponseDataError('Post not found'); 226 } 227 } 228 229 async loadPostIfExists(postURI: string): Promise<json | undefined> { 230 let posts = await this.loadPosts([postURI]); 231 return posts[0]; 232 } 233 234 async loadPosts(uris: string[]): Promise<json[]> { 235 if (uris.length > 0) { 236 let response = await this.getRequest('app.bsky.feed.getPosts', { uris }); 237 return response.posts; 238 } else { 239 return []; 240 } 241 } 242 243 async loadPostViewerInfo(post: Post): Promise<json | undefined> { 244 let data = await this.loadPostIfExists(post.uri); 245 246 if (data) { 247 post.author = data.author; 248 post.viewerData = data.viewer; 249 post.viewerLike = data.viewer?.like; 250 } 251 252 return data; 253 } 254 255 async reloadBlockedPost(uri: string): Promise<Post | null> { 256 let { repo } = atURI(uri); 257 258 let loadPost = appView.loadPostIfExists(uri); 259 let loadProfile = this.getRequest('app.bsky.actor.getProfile', { actor: repo }); 260 261 let data = await loadPost; 262 263 if (!data) { 264 return null; 265 } 266 267 let profile = await loadProfile; 268 269 return new Post(data, { author: profile }); 270 } 271}