Thread viewer for Bluesky
1/**
2 * Manages the Posting Stats page.
3 */
4
5class PostingStatsPage {
6
7 /** @type {number | undefined} */
8 scanStartTime;
9
10 /** @type {Record<string, { pages: number, progress: number }>} */
11 userProgress;
12
13 constructor() {
14 this.pageElement = $id('posting_stats_page');
15 this.form = $(this.pageElement.querySelector('form'), HTMLFormElement);
16
17 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
18 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
19 this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement);
20 this.table = $(this.pageElement.querySelector('table.scan-result'));
21
22 this.setupEvents();
23
24 this.userProgress = {};
25 this.appView = new BlueskyAPI('public.api.bsky.app', false);
26 }
27
28 setupEvents() {
29 $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => {
30 e.preventDefault();
31
32 if (!this.scanStartTime) {
33 this.scanPostingStats();
34 } else {
35 this.stopScan();
36 }
37 });
38
39 this.rangeInput.addEventListener('input', (e) => {
40 let days = parseInt(this.rangeInput.value, 10);
41 this.configurePostingStats({ days });
42 });
43
44 this.pageElement.querySelectorAll('input[type="radio"]').forEach(r => {
45 r.addEventListener('click', (e) => {
46 let value = $(r, HTMLInputElement).value;
47
48 $(this.pageElement.querySelector('.list-choice')).style.display = (value == 'list') ? 'block' : 'none';
49 $(this.pageElement.querySelector('.user-choice')).style.display = (value == 'users') ? 'block' : 'none';
50
51 this.table.style.display = 'none';
52 });
53 });
54 }
55
56 show() {
57 this.pageElement.style.display = 'block';
58 this.fetchLists();
59 }
60
61 /** @returns {number} */
62
63 selectedDaysRange() {
64 return parseInt(this.rangeInput.value, 10);
65 }
66
67 /** @returns {Promise<void>} */
68
69 async fetchLists() {
70 let select = $(this.pageElement.querySelector('.list-choice select'));
71 let lists = await accountAPI.loadUserLists();
72
73 let sorted = lists.sort((a, b) => {
74 let aName = a.name.toLocaleLowerCase();
75 let bName = b.name.toLocaleLowerCase();
76
77 return aName.localeCompare(bName);
78 });
79
80 for (let list of lists) {
81 let opt = $tag('option', { value: list.uri, text: list.name + ' ' });
82 select.append(opt);
83 }
84 }
85
86 /** @param {{ days: number }} args */
87
88 configurePostingStats(args) {
89 if (args.days) {
90 let label = $(this.pageElement.querySelector('input[type=range] + label'));
91 label.innerText = (args.days == 1) ? '1 day' : `${args.days} days`;
92 }
93 }
94
95 /** @returns {Promise<void>} */
96
97 async scanPostingStats() {
98 this.submitButton.value = 'Cancel';
99
100 let requestedDays = this.selectedDaysRange();
101
102 this.progressBar.max = requestedDays;
103 this.progressBar.value = 0;
104 this.progressBar.style.display = 'inline';
105
106 this.table.style.display = 'none';
107
108 let tbody = $(this.table.querySelector('tbody'));
109 tbody.innerHTML = '';
110
111 let thead = $(this.table.querySelector('thead'));
112 thead.innerHTML = '';
113
114 let startTime = new Date().getTime();
115 this.scanStartTime = startTime;
116
117 let scanInfo = $(this.pageElement.querySelector('.scan-info'));
118 scanInfo.style.display = 'none';
119
120 /** @type {FetchAllOnPageLoad} */
121 let onPageLoad = (data) => {
122 if (this.scanStartTime != startTime) {
123 return { cancel: true };
124 }
125
126 this.updateProgress(data, startTime);
127 };
128
129 let scanType = this.form.elements['scan_type'].value;
130
131 if (scanType == 'home') {
132 let items = await accountAPI.loadHomeTimeline(requestedDays, {
133 onPageLoad: onPageLoad,
134 keepLastPage: true
135 });
136
137 if (this.scanStartTime != startTime) {
138 return;
139 }
140
141 this.updateResultsTable(items, startTime, requestedDays);
142 } else if (scanType == 'list') {
143 let select = $(this.pageElement.querySelector('.list-choice select'), HTMLSelectElement);
144 let list = select.value;
145 let items = await accountAPI.loadListTimeline(list, requestedDays, {
146 onPageLoad: onPageLoad,
147 keepLastPage: true
148 });
149
150 if (this.scanStartTime != startTime) {
151 return;
152 }
153
154 this.updateResultsTable(items, startTime, requestedDays, { showReposts: false });
155 } else if (scanType == 'users') {
156 let textarea = $(this.pageElement.querySelector('textarea'), HTMLTextAreaElement);
157 let users = textarea.value.split(/\n/).map(x => x.trim()).filter(x => x.length > 0);
158 let dids = await Promise.all(users.map(u => accountAPI.resolveHandle(u)));
159
160 this.resetUserProgress(dids);
161
162 let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, {
163 filter: 'posts_no_replies',
164 onPageLoad: (data) => {
165 if (this.scanStartTime != startTime) {
166 return { cancel: true };
167 }
168
169 this.updateUserProgress(did, data, startTime, requestedDays);
170 },
171 keepLastPage: true
172 }));
173
174 let datasets = await Promise.all(requests);
175
176 if (this.scanStartTime != startTime) {
177 return;
178 }
179
180 let items = datasets.flat();
181
182 this.updateResultsTable(items, startTime, requestedDays, {
183 showTotal: false, showPercentages: false, countFetchedDays: false
184 });
185 } else {
186 let items = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, {
187 filter: 'posts_no_replies',
188 onPageLoad: onPageLoad,
189 keepLastPage: true
190 });
191
192 if (this.scanStartTime != startTime) {
193 return;
194 }
195
196 this.updateResultsTable(items, startTime, requestedDays, { showTotal: false, showPercentages: false });
197 }
198 }
199
200 /** @param {json[]} dataPage, @param {number} startTime */
201
202 updateProgress(dataPage, startTime) {
203 let last = dataPage.at(-1);
204
205 if (!last) { return }
206
207 let lastDate = feedPostTime(last);
208 let daysBack = (startTime - lastDate) / 86400 / 1000;
209
210 this.progressBar.value = daysBack;
211 }
212
213
214 /** @param {string[]} dids */
215
216 resetUserProgress(dids) {
217 this.userProgress = {};
218
219 for (let did of dids) {
220 this.userProgress[did] = { pages: 0, progress: 0 };
221 }
222 }
223
224 /** @param {string} did, @param {json[]} dataPage, @param {number} startTime, @param {number} requestedDays */
225
226 updateUserProgress(did, dataPage, startTime, requestedDays) {
227 let last = dataPage.at(-1);
228
229 if (!last) { return }
230
231 let lastDate = feedPostTime(last);
232 let daysBack = (startTime - lastDate) / 86400 / 1000;
233
234 this.userProgress[did].pages += 1;
235 this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0);
236
237 let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress);
238 let known = expectedPages.filter(x => !isNaN(x));
239 let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length;
240 let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b);
241
242 this.progressBar.value = Math.max(this.progressBar.value, (fetchedPages / expectedTotalPages) * requestedDays);
243 }
244
245 /** @param {json} a, @param {json} b, @returns {number} */
246
247 sortUserRows(a, b) {
248 let asum = a.own + a.reposts;
249 let bsum = b.own + b.reposts;
250
251 if (asum < bsum) {
252 return 1;
253 } else if (asum > bsum) {
254 return -1;
255 } else {
256 return 0;
257 }
258 }
259
260 /**
261 * @param {json[]} items
262 * @param {number} startTime
263 * @param {number} requestedDays
264 * @param {{ showTotal?: boolean, showPercentages?: boolean, showReposts?: boolean, countFetchedDays?: boolean }} [options]
265 */
266
267 updateResultsTable(items, startTime, requestedDays, options = {}) {
268 let users = {};
269 let total = 0;
270 let allReposts = 0;
271 let allNormalPosts = 0;
272
273 let last = items.at(-1);
274
275 if (!last) {
276 this.stopScan();
277 return;
278 }
279
280 let daysBack;
281
282 if (options.countFetchedDays !== false) {
283 let lastDate = feedPostTime(last);
284 let fetchedDays = (startTime - lastDate) / 86400 / 1000;
285
286 if (Math.ceil(fetchedDays) < requestedDays) {
287 let scanInfo = $(this.pageElement.querySelector('.scan-info'));
288 scanInfo.innerText = `🕓 Showing data from ${Math.round(fetchedDays)} days (the timeline only goes that far):`;
289 scanInfo.style.display = 'block';
290 }
291
292 daysBack = Math.min(requestedDays, fetchedDays);
293 } else {
294 daysBack = requestedDays;
295 }
296
297 let timeLimit = startTime - requestedDays * 86400 * 1000;
298 items = items.filter(x => (feedPostTime(x) > timeLimit));
299
300 for (let item of items) {
301 if (item.reply) { continue; }
302
303 let user = item.reason ? item.reason.by : item.post.author;
304 let handle = user.handle;
305 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar };
306 total += 1;
307
308 if (item.reason) {
309 users[handle].reposts += 1;
310 allReposts += 1;
311 } else {
312 users[handle].own += 1;
313 allNormalPosts += 1;
314 }
315 }
316
317 let thead = $(this.table.querySelector('thead'));
318 let headRow = $tag('tr');
319
320 if (options.showReposts !== false) {
321 headRow.append(
322 $tag('th', { text: '#' }),
323 $tag('th', { text: 'Handle' }),
324 $tag('th', { text: 'All posts /d' }),
325 $tag('th', { text: 'Own posts /d' }),
326 $tag('th', { text: 'Reposts /d' })
327 );
328 } else {
329 headRow.append(
330 $tag('th', { text: '#' }),
331 $tag('th', { text: 'Handle' }),
332 $tag('th', { text: 'Posts /d' }),
333 );
334 }
335
336 if (options.showPercentages !== false) {
337 headRow.append($tag('th', { text: '% of all' }));
338 }
339
340 thead.append(headRow);
341
342 let tbody = $(this.table.querySelector('tbody'));
343
344 if (options.showTotal !== false) {
345 let tr = $tag('tr.total');
346
347 tr.append(
348 $tag('td.no', { text: '' }),
349 $tag('td.handle', { text: 'Total:' }),
350
351 (options.showReposts !== false) ?
352 $tag('td', { text: (total / daysBack).toFixed(1) }) : '',
353
354 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }),
355
356 (options.showReposts !== false) ?
357 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : ''
358 );
359
360 if (options.showPercentages !== false) {
361 tr.append($tag('td.percent', { text: '' }));
362 }
363
364 tbody.append(tr);
365 }
366
367 let sorted = Object.values(users).sort(this.sortUserRows);
368
369 for (let i = 0; i < sorted.length; i++) {
370 let user = sorted[i];
371 let tr = $tag('tr');
372
373 tr.append(
374 $tag('td.no', { text: i + 1 }),
375 $tag('td.handle', {
376 html: `<img class="avatar" src="${user.avatar}"> ` +
377 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
378 }),
379
380 (options.showReposts !== false) ?
381 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '',
382
383 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }),
384
385 (options.showReposts !== false) ?
386 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : ''
387 );
388
389 if (options.showPercentages !== false) {
390 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }));
391 }
392
393 tbody.append(tr);
394 }
395
396 this.table.style.display = 'table';
397 this.submitButton.value = 'Start scan';
398 this.progressBar.style.display = 'none';
399 this.scanStartTime = undefined;
400 }
401
402 stopScan() {
403 this.submitButton.value = 'Start scan';
404 this.scanStartTime = undefined;
405 this.progressBar.style.display = 'none';
406 }
407}