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} 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}