Thread viewer for Bluesky
1import { atURI, feedPostTime } from '../utils.js';
2import { BlueskyAPI, accountAPI } from '../api.js';
3
4export type LikeStatsResponse = { givenLikes: LikeStat[], receivedLikes: LikeStat[] }
5export type LikeStat = { handle?: string, did?: string, avatar?: string, count: number }
6export type LikeStatHash = Record<string, LikeStat>
7
8export class LikeStats {
9 scanStartTime: number | undefined;
10 appView: BlueskyAPI;
11 progressPosts: number;
12 progressLikeRecords: number;
13 progressPostLikes: number;
14 onProgress: ((days: number) => void) | undefined
15 abortController?: AbortController;
16
17 constructor() {
18 this.appView = new BlueskyAPI('public.api.bsky.app');
19
20 this.progressPosts = 0;
21 this.progressLikeRecords = 0;
22 this.progressPostLikes = 0;
23 }
24
25 async findLikes(requestedDays: number, onProgress: (days: number) => void): Promise<LikeStatsResponse> {
26 this.onProgress = onProgress;
27 this.resetProgress();
28 this.scanStartTime = new Date().getTime();
29 this.abortController = new AbortController();
30
31 let fetchGivenLikes = this.fetchGivenLikes(requestedDays);
32
33 let receivedLikes = await this.fetchReceivedLikes(requestedDays);
34 let receivedStats = this.sumUpReceivedLikes(receivedLikes);
35 let topReceived = this.getTopEntries(receivedStats);
36
37 let givenLikes = await fetchGivenLikes;
38 let givenStats = this.sumUpGivenLikes(givenLikes);
39 let topGiven = this.getTopEntries(givenStats);
40
41 let profileInfo = await this.appView.getRequest('app.bsky.actor.getProfiles',
42 { actors: topGiven.map(x => x.did) },
43 { abortSignal: this.abortController!.signal }
44 );
45
46 for (let profile of profileInfo.profiles) {
47 let user = topGiven.find(x => x.did == profile.did)!;
48 user.handle = profile.handle;
49 user.avatar = profile.avatar;
50 }
51
52 this.scanStartTime = undefined;
53
54 return { givenLikes: topGiven, receivedLikes: topReceived };
55 }
56
57 async fetchGivenLikes(requestedDays: number): Promise<json[]> {
58 let startTime = this.scanStartTime!
59
60 return await accountAPI.fetchAll('com.atproto.repo.listRecords', {
61 params: {
62 repo: accountAPI.user.did,
63 collection: 'app.bsky.feed.like',
64 limit: 100
65 },
66 field: 'records',
67 breakWhen: (x) => Date.parse(x['value']['createdAt']) < startTime - 86400 * requestedDays * 1000,
68 onPageLoad: (data) => {
69 let last = data.at(-1);
70
71 if (!last) { return }
72
73 let lastDate = Date.parse(last.value.createdAt);
74 let daysBack = (startTime - lastDate) / 86400 / 1000;
75
76 this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) });
77 },
78 abortSignal: this.abortController!.signal
79 });
80 }
81
82 async fetchReceivedLikes(requestedDays: number): Promise<json[]> {
83 let startTime = this.scanStartTime!
84
85 let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, {
86 filter: 'posts_with_replies',
87 onPageLoad: (data) => {
88 let last = data.at(-1);
89
90 if (!last) { return }
91
92 let lastDate = feedPostTime(last);
93 let daysBack = (startTime - lastDate) / 86400 / 1000;
94
95 this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) });
96 },
97 abortSignal: this.abortController!.signal
98 });
99
100 let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0);
101
102 let results: json[][] = [];
103
104 for (let i = 0; i < likedPosts.length; i += 10) {
105 let batch = likedPosts.slice(i, i + 10);
106 this.updateProgress({ postLikes: i / likedPosts.length });
107
108 let fetchBatch = batch.map(x => {
109 return this.appView.fetchAll('app.bsky.feed.getLikes', {
110 params: {
111 uri: x['post']['uri'],
112 limit: 100
113 },
114 field: 'likes',
115 abortSignal: this.abortController!.signal
116 });
117 });
118
119 let batchResults = await Promise.all(fetchBatch);
120 results = results.concat(batchResults);
121 }
122
123 this.updateProgress({ postLikes: 1.0 });
124
125 return results.flat();
126 }
127
128 sumUpReceivedLikes(likes: json[]): LikeStatHash {
129 let stats: LikeStatHash = {};
130
131 for (let like of likes) {
132 let handle = like.actor.handle;
133
134 if (!stats[handle]) {
135 stats[handle] = { handle: handle, count: 0, avatar: like.actor.avatar };
136 }
137
138 stats[handle].count += 1;
139 }
140
141 return stats;
142 }
143
144 sumUpGivenLikes(likes: json[]): LikeStatHash {
145 let stats: LikeStatHash = {};
146
147 for (let like of likes) {
148 let did = atURI(like.value.subject.uri).repo;
149
150 if (!stats[did]) {
151 stats[did] = { did: did, count: 0 };
152 }
153
154 stats[did].count += 1;
155 }
156
157 return stats;
158 }
159
160 getTopEntries(counts: LikeStatHash): LikeStat[] {
161 return Object.entries(counts).sort(this.sortResults).map(x => x[1]).slice(0, 25);
162 }
163
164 resetProgress() {
165 this.progressPosts = 0;
166 this.progressLikeRecords = 0;
167 this.progressPostLikes = 0;
168
169 this.onProgress?.(0);
170 }
171
172 updateProgress(data: { posts?: number, likeRecords?: number, postLikes?: number }) {
173 if (data.posts) {
174 this.progressPosts = data.posts;
175 }
176
177 if (data.likeRecords) {
178 this.progressLikeRecords = data.likeRecords;
179 }
180
181 if (data.postLikes) {
182 this.progressPostLikes = data.postLikes;
183 }
184
185 let totalProgress = (
186 0.1 * this.progressPosts +
187 0.65 * this.progressLikeRecords +
188 0.25 * this.progressPostLikes
189 );
190
191 this.onProgress?.(totalProgress);
192 }
193
194 sortResults(a: [string, LikeStat], b: [string, LikeStat]): -1 | 1 | 0 {
195 if (a[1].count < b[1].count) {
196 return 1;
197 } else if (a[1].count > b[1].count) {
198 return -1;
199 } else {
200 return 0;
201 }
202 }
203
204 abortScan() {
205 this.scanStartTime = undefined;
206 this.onProgress = undefined;
207 this.abortController?.abort();
208 delete this.abortController;
209 }
210}