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 /** @type {number | undefined} */
14 autocompleteTimer;
15
16 /** @type {number} */
17 autocompleteIndex = -1;
18
19 /** @type {json[]} */
20 autocompleteResults = [];
21
22 /** @type {Record<string, json>} */
23 selectedUsers = {};
24
25 constructor() {
26 this.pageElement = $id('posting_stats_page');
27 this.form = $(this.pageElement.querySelector('form'), HTMLFormElement);
28
29 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
30 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
31 this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement);
32 this.table = $(this.pageElement.querySelector('table.scan-result'));
33 this.tableHead = $(this.table.querySelector('thead'));
34 this.tableBody = $(this.table.querySelector('tbody'));
35 this.listSelect = $(this.pageElement.querySelector('.list-choice select'), HTMLSelectElement);
36 this.scanInfo = $(this.pageElement.querySelector('.scan-info'));
37 this.scanType = this.form.elements['scan_type'];
38
39 this.userField = $(this.pageElement.querySelector('.user-choice input'), HTMLInputElement);
40 this.userList = $(this.pageElement.querySelector('.selected-users'));
41 this.autocomplete = $(this.pageElement.querySelector('.autocomplete'));
42
43 this.userProgress = {};
44 this.appView = new BlueskyAPI('public.api.bsky.app', false);
45
46 this.setupEvents();
47 }
48
49 setupEvents() {
50 let html = $(document.body.parentNode);
51
52 html.addEventListener('click', (e) => {
53 this.hideAutocomplete();
54 });
55
56 this.form.addEventListener('submit', (e) => {
57 e.preventDefault();
58
59 if (!this.scanStartTime) {
60 this.scanPostingStats();
61 } else {
62 this.stopScan();
63 }
64 });
65
66 this.rangeInput.addEventListener('input', (e) => {
67 let days = parseInt(this.rangeInput.value, 10);
68 let label = $(this.pageElement.querySelector('input[type=range] + label'));
69 label.innerText = (days == 1) ? '1 day' : `${days} days`;
70 });
71
72 this.scanType.forEach(r => {
73 r.addEventListener('click', (e) => {
74 let value = $(r, HTMLInputElement).value;
75
76 $(this.pageElement.querySelector('.list-choice')).style.display = (value == 'list') ? 'block' : 'none';
77 $(this.pageElement.querySelector('.user-choice')).style.display = (value == 'users') ? 'block' : 'none';
78
79 if (value == 'users') {
80 this.userField.focus();
81 }
82
83 this.table.style.display = 'none';
84 });
85 });
86
87 this.userField.addEventListener('input', () => {
88 this.onUserInput();
89 });
90
91 this.userField.addEventListener('keydown', (e) => {
92 this.onUserKeyDown(e);
93 });
94 }
95
96 show() {
97 this.pageElement.style.display = 'block';
98 this.fetchLists();
99 }
100
101 /** @returns {number} */
102
103 selectedDaysRange() {
104 return parseInt(this.rangeInput.value, 10);
105 }
106
107 /** @returns {Promise<void>} */
108
109 async fetchLists() {
110 let lists = await accountAPI.loadUserLists();
111
112 let sorted = lists.sort((a, b) => {
113 let aName = a.name.toLocaleLowerCase();
114 let bName = b.name.toLocaleLowerCase();
115
116 return aName.localeCompare(bName);
117 });
118
119 for (let list of lists) {
120 this.listSelect.append(
121 $tag('option', { value: list.uri, text: list.name + ' ' })
122 );
123 }
124 }
125
126 onUserInput() {
127 if (this.autocompleteTimer) {
128 clearTimeout(this.autocompleteTimer);
129 }
130
131 let query = this.userField.value.trim();
132
133 if (query.length == 0) {
134 this.hideAutocomplete();
135 this.autocompleteTimer = undefined;
136 return;
137 }
138
139 this.autocompleteTimer = setTimeout(() => this.fetchAutocomplete(query), 100);
140 }
141
142 /** @param {KeyboardEvent} e */
143
144 onUserKeyDown(e) {
145 if (this.autocomplete.style.display != 'none') {
146 if (e.key == 'ArrowDown') {
147 e.preventDefault();
148 this.moveAutocomplete(1);
149 } else if (e.key == 'ArrowUp') {
150 e.preventDefault();
151 this.moveAutocomplete(-1);
152 } else if (e.key == 'Enter') {
153 e.preventDefault();
154
155 if (this.autocompleteIndex >= 0) {
156 this.selectUser(this.autocompleteIndex);
157 }
158 } else if (e.key == 'Escape') {
159 this.hideAutocomplete();
160 }
161 }
162 }
163
164 /** @param {string} query, @returns {Promise<void>} */
165
166 async fetchAutocomplete(query) {
167 let users = await accountAPI.autocompleteUsers(query);
168
169 let selectedDIDs = new Set(Object.keys(this.selectedUsers));
170 users = users.filter(u => !selectedDIDs.has(u.did));
171
172 this.autocompleteResults = users;
173 this.autocompleteIndex = -1;
174 this.showAutocomplete();
175 }
176
177 showAutocomplete() {
178 this.autocomplete.innerHTML = '';
179 this.autocomplete.scrollTop = 0;
180
181 if (this.autocompleteResults.length == 0) {
182 this.hideAutocomplete();
183 return;
184 }
185
186 for (let [i, user] of this.autocompleteResults.entries()) {
187 let row = this.makeUserRow(user);
188
189 row.addEventListener('mouseenter', () => {
190 this.highlightAutocomplete(i);
191 });
192
193 row.addEventListener('mousedown', (e) => {
194 e.preventDefault();
195 this.selectUser(i);
196 });
197
198 this.autocomplete.append(row);
199 };
200
201 this.autocomplete.style.top = this.userField.offsetHeight + 'px';
202 this.autocomplete.style.display = 'block';
203 this.highlightAutocomplete(0);
204 }
205
206 hideAutocomplete() {
207 this.autocomplete.style.display = 'none';
208 this.autocompleteResults = [];
209 this.autocompleteIndex = -1;
210 }
211
212 /** @param {number} change */
213
214 moveAutocomplete(change) {
215 if (this.autocompleteResults.length == 0) {
216 return;
217 }
218
219 let newIndex = this.autocompleteIndex + change;
220
221 if (newIndex < 0) {
222 newIndex = this.autocompleteResults.length - 1;
223 } else if (newIndex >= this.autocompleteResults.length) {
224 newIndex = 0;
225 }
226
227 this.highlightAutocomplete(newIndex);
228 }
229
230 /** @param {number} index */
231
232 highlightAutocomplete(index) {
233 this.autocompleteIndex = index;
234
235 let rows = this.autocomplete.querySelectorAll('.user-row');
236
237 rows.forEach((row, i) => {
238 row.classList.toggle('hover', i == index);
239 });
240 }
241
242 /** @param {number} index */
243
244 selectUser(index) {
245 let user = this.autocompleteResults[index];
246
247 if (!user) {
248 return;
249 }
250
251 this.selectedUsers[user.did] = user;
252
253 let row = this.makeUserRow(user, true);
254 this.userList.append(row);
255
256 this.userField.value = '';
257 this.hideAutocomplete();
258 }
259
260 /** @param {json} user, @param {boolean} [withRemove], @returns HTMLElement */
261
262 makeUserRow(user, withRemove = false) {
263 let row = $tag('div.user-row');
264 row.dataset.did = user.did;
265 row.append(
266 $tag('img.avatar', { src: user.avatar }),
267 $tag('span.name', { text: user.displayName || '–' }),
268 $tag('span.handle', { text: user.handle })
269 );
270
271 if (withRemove) {
272 let remove = $tag('a.remove', { href: '#', text: '✕' });
273
274 remove.addEventListener('click', (e) => {
275 e.preventDefault();
276 row.remove();
277 delete this.selectedUsers[user.did];
278 });
279
280 row.append(remove);
281 }
282
283 return row;
284 }
285
286 /** @returns {Promise<void>} */
287
288 async scanPostingStats() {
289 let startTime = new Date().getTime();
290 let requestedDays = this.selectedDaysRange();
291 let scanType = this.scanType.value;
292
293 /** @type {FetchAllOnPageLoad} */
294 let onPageLoad = (data) => {
295 if (this.scanStartTime != startTime) {
296 return { cancel: true };
297 }
298
299 this.updateProgress(data, startTime);
300 };
301
302 if (scanType == 'home') {
303 this.startScan(startTime, requestedDays);
304
305 let posts = await accountAPI.loadHomeTimeline(requestedDays, {
306 onPageLoad: onPageLoad,
307 keepLastPage: true
308 });
309
310 this.updateResultsTable(posts, startTime, requestedDays);
311 } else if (scanType == 'list') {
312 let list = this.listSelect.value;
313
314 if (!list) {
315 return;
316 }
317
318 this.startScan(startTime, requestedDays);
319
320 let posts = await accountAPI.loadListTimeline(list, requestedDays, {
321 onPageLoad: onPageLoad,
322 keepLastPage: true
323 });
324
325 this.updateResultsTable(posts, startTime, requestedDays, { showReposts: false });
326 } else if (scanType == 'users') {
327 let dids = Object.keys(this.selectedUsers);
328
329 if (dids.length == 0) {
330 return;
331 }
332
333 this.startScan(startTime, requestedDays);
334 this.resetUserProgress(dids);
335
336 let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, {
337 filter: 'posts_no_replies',
338 onPageLoad: (data) => {
339 if (this.scanStartTime != startTime) {
340 return { cancel: true };
341 }
342
343 this.updateUserProgress(did, data, startTime, requestedDays);
344 },
345 keepLastPage: true
346 }));
347
348 let datasets = await Promise.all(requests);
349 let posts = datasets.flat();
350
351 this.updateResultsTable(posts, startTime, requestedDays, {
352 showTotal: false,
353 showPercentages: false,
354 countFetchedDays: false,
355 users: Object.values(this.selectedUsers)
356 });
357 } else {
358 this.startScan(startTime, requestedDays);
359
360 let posts = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, {
361 filter: 'posts_no_replies',
362 onPageLoad: onPageLoad,
363 keepLastPage: true
364 });
365
366 this.updateResultsTable(posts, startTime, requestedDays, { showTotal: false, showPercentages: false });
367 }
368 }
369
370 /** @param {json[]} dataPage, @param {number} startTime */
371
372 updateProgress(dataPage, startTime) {
373 let last = dataPage.at(-1);
374
375 if (!last) { return }
376
377 let lastDate = feedPostTime(last);
378 let daysBack = (startTime - lastDate) / 86400 / 1000;
379
380 this.progressBar.value = daysBack;
381 }
382
383 /** @param {string[]} dids */
384
385 resetUserProgress(dids) {
386 this.userProgress = {};
387
388 for (let did of dids) {
389 this.userProgress[did] = { pages: 0, progress: 0 };
390 }
391 }
392
393 /** @param {string} did, @param {json[]} dataPage, @param {number} startTime, @param {number} requestedDays */
394
395 updateUserProgress(did, dataPage, startTime, requestedDays) {
396 let last = dataPage.at(-1);
397
398 if (!last) { return }
399
400 let lastDate = feedPostTime(last);
401 let daysBack = (startTime - lastDate) / 86400 / 1000;
402
403 this.userProgress[did].pages += 1;
404 this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0);
405
406 let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress);
407 let known = expectedPages.filter(x => !isNaN(x));
408 let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length;
409 let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b);
410
411 this.progressBar.value = Math.max(this.progressBar.value, (fetchedPages / expectedTotalPages) * requestedDays);
412 }
413
414 /** @param {json} a, @param {json} b, @returns {number} */
415
416 sortUserRows(a, b) {
417 let asum = a.own + a.reposts;
418 let bsum = b.own + b.reposts;
419
420 if (asum < bsum) {
421 return 1;
422 } else if (asum > bsum) {
423 return -1;
424 } else {
425 return 0;
426 }
427 }
428
429 /**
430 * @param {json[]} posts
431 * @param {number} startTime
432 * @param {number} requestedDays
433 * @param {{
434 * showTotal?: boolean,
435 * showPercentages?: boolean,
436 * showReposts?: boolean,
437 * countFetchedDays?: boolean,
438 * users?: json[]
439 * }} [options]
440 */
441
442 updateResultsTable(posts, startTime, requestedDays, options = {}) {
443 if (this.scanStartTime != startTime) {
444 return;
445 }
446
447 let users = {};
448 let total = 0;
449 let allReposts = 0;
450 let allNormalPosts = 0;
451
452 let last = posts.at(-1);
453
454 if (!last) {
455 this.stopScan();
456 return;
457 }
458
459 let daysBack;
460
461 if (options.countFetchedDays !== false) {
462 let lastDate = feedPostTime(last);
463 let fetchedDays = (startTime - lastDate) / 86400 / 1000;
464
465 if (Math.ceil(fetchedDays) < requestedDays) {
466 this.scanInfo.innerText = `🕓 Showing data from ${Math.round(fetchedDays)} days (the timeline only goes that far):`;
467 this.scanInfo.style.display = 'block';
468 }
469
470 daysBack = Math.min(requestedDays, fetchedDays);
471 } else {
472 daysBack = requestedDays;
473 }
474
475 let timeLimit = startTime - requestedDays * 86400 * 1000;
476 posts = posts.filter(x => (feedPostTime(x) > timeLimit));
477
478 if (options.users) {
479 for (let user of options.users) {
480 users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar };
481 }
482 }
483
484 for (let item of posts) {
485 if (item.reply) { continue; }
486
487 let user = item.reason ? item.reason.by : item.post.author;
488 let handle = user.handle;
489 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar };
490 total += 1;
491
492 if (item.reason) {
493 users[handle].reposts += 1;
494 allReposts += 1;
495 } else {
496 users[handle].own += 1;
497 allNormalPosts += 1;
498 }
499 }
500
501 let headRow = $tag('tr');
502
503 if (options.showReposts !== false) {
504 headRow.append(
505 $tag('th', { text: '#' }),
506 $tag('th', { text: 'Handle' }),
507 $tag('th', { text: 'All posts /d' }),
508 $tag('th', { text: 'Own posts /d' }),
509 $tag('th', { text: 'Reposts /d' })
510 );
511 } else {
512 headRow.append(
513 $tag('th', { text: '#' }),
514 $tag('th', { text: 'Handle' }),
515 $tag('th', { text: 'Posts /d' }),
516 );
517 }
518
519 if (options.showPercentages !== false) {
520 headRow.append($tag('th', { text: '% of timeline' }));
521 }
522
523 this.tableHead.append(headRow);
524
525 if (options.showTotal !== false) {
526 let tr = $tag('tr.total');
527
528 tr.append(
529 $tag('td.no', { text: '' }),
530 $tag('td.handle', { text: 'Total:' }),
531
532 (options.showReposts !== false) ?
533 $tag('td', { text: (total / daysBack).toFixed(1) }) : '',
534
535 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }),
536
537 (options.showReposts !== false) ?
538 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : ''
539 );
540
541 if (options.showPercentages !== false) {
542 tr.append($tag('td.percent', { text: '' }));
543 }
544
545 this.tableBody.append(tr);
546 }
547
548 let sorted = Object.values(users).sort(this.sortUserRows);
549
550 for (let i = 0; i < sorted.length; i++) {
551 let user = sorted[i];
552 let tr = $tag('tr');
553
554 tr.append(
555 $tag('td.no', { text: i + 1 }),
556 $tag('td.handle', {
557 html: `<img class="avatar" src="${user.avatar}"> ` +
558 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
559 }),
560
561 (options.showReposts !== false) ?
562 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '',
563
564 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }),
565
566 (options.showReposts !== false) ?
567 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : ''
568 );
569
570 if (options.showPercentages !== false) {
571 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }));
572 }
573
574 this.tableBody.append(tr);
575 }
576
577 this.table.style.display = 'table';
578 this.stopScan();
579 }
580
581 /** @param {number} startTime, @param {number} requestedDays */
582
583 startScan(startTime, requestedDays) {
584 this.submitButton.value = 'Cancel';
585
586 this.progressBar.max = requestedDays;
587 this.progressBar.value = 0;
588 this.progressBar.style.display = 'inline';
589
590 this.table.style.display = 'none';
591 this.tableHead.innerHTML = '';
592 this.tableBody.innerHTML = '';
593
594 this.scanStartTime = startTime;
595 this.scanInfo.style.display = 'none';
596 }
597
598 stopScan() {
599 this.submitButton.value = 'Start scan';
600 this.scanStartTime = undefined;
601 this.progressBar.style.display = 'none';
602 }
603}