Thread viewer for Bluesky
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}