Thread viewer for Bluesky
1import { BlueskyAPI, type TimelineFetchOptions } from "./bluesky_api";
2import { AuthError } from './minisky.js';
3import { Post } from '../models/posts.js';
4import { atURI, feedPostTime } from '../utils.js';
5
6/**
7 * Stores user's access tokens and data in local storage after they log in.
8 */
9
10class LocalStorageConfig {
11 user: json;
12
13 constructor() {
14 let data = localStorage.getItem('userData');
15 this.user = data ? JSON.parse(data) : {};
16 }
17
18 save() {
19 if (this.user) {
20 localStorage.setItem('userData', JSON.stringify(this.user));
21 } else {
22 localStorage.removeItem('userData');
23 }
24 }
25}
26
27export class AuthenticatedAPI extends BlueskyAPI {
28 override user: json;
29
30 constructor() {
31 let config = new LocalStorageConfig();
32 let pds: string | null = config.user.pdsEndpoint || null;
33 super(pds, config);
34 this.user = config.user;
35 }
36
37 async getCurrentUserAvatar(): Promise<json | undefined> {
38 let json = await this.getRequest('com.atproto.repo.getRecord', {
39 repo: this.user.did,
40 collection: 'app.bsky.actor.profile',
41 rkey: 'self'
42 });
43
44 return json.value.avatar;
45 }
46
47 async loadCurrentUserAvatar(): Promise<string | null> {
48 if (!this.config || !this.config.user) {
49 throw new AuthError("User isn't logged in");
50 }
51
52 let avatar = await this.getCurrentUserAvatar();
53
54 if (avatar) {
55 let url = `https://cdn.bsky.app/img/avatar/plain/${this.user.did}/${avatar.ref.$link}@jpeg`;
56 this.config.user.avatar = url;
57 this.config.save();
58 return url;
59 } else {
60 return null;
61 }
62 }
63
64 async loadNotifications(params?: json): Promise<json> {
65 return await this.getRequest('app.bsky.notification.listNotifications', params || {});
66 }
67
68 async loadMentions(cursor?: string): Promise<{ cursor: string | undefined, posts: json[] }> {
69 let response = await this.loadNotifications({ cursor: cursor ?? '', limit: 100, reasons: ['reply', 'mention'] });
70 let uris = response.notifications.map((x: any) => x.uri);
71 let batches: Promise<json[]>[] = [];
72
73 for (let i = 0; i < uris.length; i += 25) {
74 let batch = this.loadPosts(uris.slice(i, i + 25));
75 batches.push(batch);
76 }
77
78 let postGroups = await Promise.all(batches);
79
80 return { cursor: response.cursor, posts: postGroups.flat() };
81 }
82
83 async loadHomeTimeline(days: number, options: TimelineFetchOptions = {}): Promise<json[]> {
84 let now = new Date();
85 let timeLimit = now.getTime() - days * 86400 * 1000;
86
87 return await this.fetchAll('app.bsky.feed.getTimeline', {
88 params: { limit: 100 },
89 field: 'feed',
90 breakWhen: (x: json) => feedPostTime(x) < timeLimit,
91 ...options
92 });
93 }
94
95 async loadUserLists(): Promise<json[]> {
96 let lists = await this.fetchAll('app.bsky.graph.getLists', {
97 params: {
98 actor: this.user.did,
99 limit: 100
100 },
101 field: 'lists'
102 });
103
104 return lists.filter((x: json) => x.purpose == 'app.bsky.graph.defs#curatelist');
105 }
106
107 async likePost(post: Post): Promise<json> {
108 return await this.postRequest('com.atproto.repo.createRecord', {
109 repo: this.user.did,
110 collection: 'app.bsky.feed.like',
111 record: {
112 subject: {
113 uri: post.uri,
114 cid: post.cid
115 },
116 createdAt: new Date().toISOString()
117 }
118 });
119 }
120
121 async removeLike(uri: string) {
122 let { rkey } = atURI(uri);
123
124 await this.postRequest('com.atproto.repo.deleteRecord', {
125 repo: this.user.did,
126 collection: 'app.bsky.feed.like',
127 rkey: rkey
128 });
129 }
130
131 override resetTokens() {
132 delete this.user.avatar;
133 super.resetTokens();
134 }
135}