Thread viewer for Bluesky
1class LikeStatsPage {
2
3 /** @type {number | undefined} */
4 scanStartTime;
5
6 constructor() {
7 this.pageElement = $id('like_stats_page');
8
9 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
10 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
11 this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement);
12
13 this.receivedTable = $(this.pageElement.querySelector('.received-likes'), HTMLTableElement);
14 this.givenTable = $(this.pageElement.querySelector('.given-likes'), HTMLTableElement);
15
16 this.appView = new BlueskyAPI('public.api.bsky.app', false);
17
18 this.setupEvents();
19
20 this.progressPosts = 0;
21 this.progressLikeRecords = 0;
22 this.progressPostLikes = 0;
23 }
24
25 setupEvents() {
26 $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => {
27 e.preventDefault();
28
29 if (!this.scanStartTime) {
30 this.findLikes();
31 } else {
32 this.stopScan();
33 }
34 });
35
36 this.rangeInput.addEventListener('input', (e) => {
37 let days = parseInt(this.rangeInput.value, 10);
38 let label = $(this.pageElement.querySelector('input[type=range] + label'));
39 label.innerText = (days == 1) ? '1 day' : `${days} days`;
40 });
41 }
42
43 /** @returns {number} */
44
45 selectedDaysRange() {
46 return parseInt(this.rangeInput.value, 10);
47 }
48
49 show() {
50 this.pageElement.style.display = 'block';
51 }
52
53 /** @returns {Promise<void>} */
54
55 async findLikes() {
56 this.submitButton.value = 'Cancel';
57
58 let requestedDays = this.selectedDaysRange();
59
60 this.resetProgress();
61 this.progressBar.style.display = 'inline';
62
63 let startTime = new Date().getTime();
64 this.scanStartTime = startTime;
65
66 this.receivedTable.style.display = 'none';
67 this.givenTable.style.display = 'none';
68
69 let fetchGivenLikes = this.fetchGivenLikes(requestedDays);
70
71 let receivedLikes = await this.fetchReceivedLikes(requestedDays);
72 let receivedStats = this.sumUpReceivedLikes(receivedLikes);
73 let topReceived = this.getTopEntries(receivedStats);
74
75 await this.renderResults(topReceived, this.receivedTable);
76
77 let givenLikes = await fetchGivenLikes;
78 let givenStats = this.sumUpGivenLikes(givenLikes);
79 let topGiven = this.getTopEntries(givenStats);
80
81 let profileInfo = await appView.getRequest('app.bsky.actor.getProfiles', { actors: topGiven.map(x => x.did) });
82
83 for (let profile of profileInfo.profiles) {
84 let user = /** @type {LikeStat} */ (topGiven.find(x => x.did == profile.did));
85 user.handle = profile.handle;
86 user.avatar = profile.avatar;
87 }
88
89 await this.renderResults(topGiven, this.givenTable);
90
91 this.receivedTable.style.display = 'table';
92 this.givenTable.style.display = 'table';
93
94 this.submitButton.value = 'Start scan';
95 this.progressBar.style.display = 'none';
96 this.scanStartTime = undefined;
97 }
98
99 /** @param {number} requestedDays, @returns {Promise<json[]>} */
100
101 async fetchGivenLikes(requestedDays) {
102 let startTime = /** @type {number} */ (this.scanStartTime);
103
104 return await accountAPI.fetchAll('com.atproto.repo.listRecords', {
105 params: {
106 repo: accountAPI.user.did,
107 collection: 'app.bsky.feed.like',
108 limit: 100
109 },
110 field: 'records',
111 breakWhen: (x) => Date.parse(x['value']['createdAt']) < startTime - 86400 * requestedDays * 1000,
112 onPageLoad: (data) => {
113 if (data.length == 0) { return }
114
115 let last = data.at(-1);
116 let lastDate = Date.parse(last.value.createdAt);
117
118 let daysBack = (startTime - lastDate) / 86400 / 1000;
119 this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) });
120 }
121 });
122 }
123
124 /** @param {number} requestedDays, @returns {Promise<json[]>} */
125
126 async fetchReceivedLikes(requestedDays) {
127 let startTime = /** @type {number} */ (this.scanStartTime);
128
129 let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, {
130 filter: 'posts_no_replies',
131 onPageLoad: (data) => {
132 if (data.length == 0) { return }
133
134 let last = data.at(-1);
135 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
136 let lastDate = Date.parse(lastTimestamp);
137
138 let daysBack = (startTime - lastDate) / 86400 / 1000;
139 this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) });
140 }
141 });
142
143 let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0);
144
145 let results = [];
146
147 for (let i = 0; i < likedPosts.length; i += 10) {
148 let batch = likedPosts.slice(i, i + 10);
149 this.updateProgress({ postLikes: i / likedPosts.length });
150
151 let fetchBatch = batch.map(x => {
152 return this.appView.fetchAll('app.bsky.feed.getLikes', {
153 params: {
154 uri: x['post']['uri'],
155 limit: 100
156 },
157 field: 'likes'
158 });
159 });
160
161 let batchResults = await Promise.all(fetchBatch);
162 results = results.concat(batchResults);
163 }
164
165 this.updateProgress({ postLikes: 1.0 });
166
167 return results.flat();
168 }
169
170 /**
171 * @typedef {{ handle?: string, did?: string, avatar?: string, count: number }} LikeStat
172 * @typedef {Record<string, LikeStat>} LikeStatHash
173 */
174
175 /** @param {json[]} likes, @returns {LikeStatHash} */
176
177 sumUpReceivedLikes(likes) {
178 /** @type {LikeStatHash} */
179 let stats = {};
180
181 for (let like of likes) {
182 let handle = like.actor.handle;
183
184 if (!stats[handle]) {
185 stats[handle] = { handle: handle, count: 0, avatar: like.actor.avatar };
186 }
187
188 stats[handle].count += 1;
189 }
190
191 return stats;
192 }
193
194 /** @param {json[]} likes, @returns {LikeStatHash} */
195
196 sumUpGivenLikes(likes) {
197 /** @type {LikeStatHash} */
198 let stats = {};
199
200 for (let like of likes) {
201 let did = atURI(like.value.subject.uri).repo;
202
203 if (!stats[did]) {
204 stats[did] = { did: did, count: 0 };
205 }
206
207 stats[did].count += 1;
208 }
209
210 return stats;
211 }
212
213 /** @param {LikeStatHash} counts, @returns {LikeStat[]} */
214
215 getTopEntries(counts) {
216 return Object.entries(counts).sort(this.sortResults).map(x => x[1]).slice(0, 25);
217 }
218
219 /** @param {LikeStat[]} topUsers, @param {HTMLTableElement} table, @returns {Promise<void>} */
220
221 async renderResults(topUsers, table) {
222 let tableBody = $(table.querySelector('tbody'));
223 tableBody.innerHTML = '';
224
225 for (let [i, user] of topUsers.entries()) {
226 let tr = $tag('tr');
227 tr.append(
228 $tag('td.no', { text: i + 1 }),
229 $tag('td.handle', {
230 html: `<img class="avatar" src="${user.avatar}"> ` +
231 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
232 }),
233 $tag('td.count', { text: user.count })
234 );
235
236 tableBody.append(tr);
237 };
238 }
239
240 resetProgress() {
241 this.progressBar.value = 0;
242 this.progressPosts = 0;
243 this.progressLikeRecords = 0;
244 this.progressPostLikes = 0;
245 }
246
247 /** @param {{ posts?: number, likeRecords?: number, postLikes?: number }} data */
248
249 updateProgress(data) {
250 if (data.posts) {
251 this.progressPosts = data.posts;
252 }
253
254 if (data.likeRecords) {
255 this.progressLikeRecords = data.likeRecords;
256 }
257
258 if (data.postLikes) {
259 this.progressPostLikes = data.postLikes;
260 }
261
262 let totalProgress = (
263 0.1 * this.progressPosts +
264 0.65 * this.progressLikeRecords +
265 0.25 * this.progressPostLikes
266 );
267
268 this.progressBar.value = totalProgress;
269 }
270
271 /** @param {[string, LikeStat]} a, @param {[string, LikeStat]} b, @returns {-1|1|0} */
272
273 sortResults(a, b) {
274 if (a[1].count < b[1].count) {
275 return 1;
276 } else if (a[1].count > b[1].count) {
277 return -1;
278 } else {
279 return 0;
280 }
281 }
282
283 stopScan() {
284 this.submitButton.value = 'Start scan';
285 this.progressBar.style.display = 'none';
286 this.scanStartTime = undefined;
287 }
288}