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 (e.key == 'Enter') {
146 e.preventDefault();
147
148 if (this.autocompleteIndex >= 0) {
149 this.selectUser(this.autocompleteIndex);
150 }
151 } else if (e.key == 'Escape') {
152 this.hideAutocomplete();
153 } else if (e.key == 'ArrowDown' && this.autocompleteResults.length > 0) {
154 e.preventDefault();
155 this.moveAutocomplete(1);
156 } else if (e.key == 'ArrowUp' && this.autocompleteResults.length > 0) {
157 e.preventDefault();
158 this.moveAutocomplete(-1);
159 }
160 }
161
162 /** @param {string} query, @returns {Promise<void>} */
163
164 async fetchAutocomplete(query) {
165 let users = await accountAPI.autocompleteUsers(query);
166
167 let selectedDIDs = new Set(Object.keys(this.selectedUsers));
168 users = users.filter(u => !selectedDIDs.has(u.did));
169
170 this.autocompleteResults = users;
171 this.autocompleteIndex = -1;
172 this.showAutocomplete();
173 }
174
175 showAutocomplete() {
176 this.autocomplete.innerHTML = '';
177 this.autocomplete.scrollTop = 0;
178
179 if (this.autocompleteResults.length == 0) {
180 this.hideAutocomplete();
181 return;
182 }
183
184 for (let [i, user] of this.autocompleteResults.entries()) {
185 let row = this.makeUserRow(user);
186
187 row.addEventListener('mouseenter', () => {
188 this.highlightAutocomplete(i);
189 });
190
191 row.addEventListener('mousedown', (e) => {
192 e.preventDefault();
193 this.selectUser(i);
194 });
195
196 this.autocomplete.append(row);
197 };
198
199 this.autocomplete.style.top = this.userField.offsetHeight + 'px';
200 this.autocomplete.style.display = 'block';
201 this.highlightAutocomplete(0);
202 }
203
204 hideAutocomplete() {
205 this.autocomplete.style.display = 'none';
206 this.autocompleteResults = [];
207 this.autocompleteIndex = -1;
208 }
209
210 /** @param {number} change */
211
212 moveAutocomplete(change) {
213 if (this.autocompleteResults.length == 0) {
214 return;
215 }
216
217 let newIndex = this.autocompleteIndex + change;
218
219 if (newIndex < 0) {
220 newIndex = this.autocompleteResults.length - 1;
221 } else if (newIndex >= this.autocompleteResults.length) {
222 newIndex = 0;
223 }
224
225 this.highlightAutocomplete(newIndex);
226 }
227
228 /** @param {number} index */
229
230 highlightAutocomplete(index) {
231 this.autocompleteIndex = index;
232
233 let rows = this.autocomplete.querySelectorAll('.user-row');
234
235 rows.forEach((row, i) => {
236 row.classList.toggle('hover', i == index);
237 });
238 }
239
240 /** @param {number} index */
241
242 selectUser(index) {
243 let user = this.autocompleteResults[index];
244
245 if (!user) {
246 return;
247 }
248
249 this.selectedUsers[user.did] = user;
250
251 let row = this.makeUserRow(user, true);
252 this.userList.append(row);
253
254 this.userField.value = '';
255 this.hideAutocomplete();
256 }
257
258 /** @param {json} user, @param {boolean} [withRemove], @returns HTMLElement */
259
260 makeUserRow(user, withRemove = false) {
261 let row = $tag('div.user-row');
262 row.dataset.did = user.did;
263 row.append(
264 $tag('img.avatar', { src: user.avatar }),
265 $tag('span.name', { text: user.displayName || '–' }),
266 $tag('span.handle', { text: user.handle })
267 );
268
269 if (withRemove) {
270 let remove = $tag('a.remove', { href: '#', text: '✕' });
271
272 remove.addEventListener('click', (e) => {
273 e.preventDefault();
274 row.remove();
275 delete this.selectedUsers[user.did];
276 });
277
278 row.append(remove);
279 }
280
281 return row;
282 }
283
284 /** @returns {Promise<void>} */
285
286 async scanPostingStats() {
287 let startTime = new Date().getTime();
288 let requestedDays = this.selectedDaysRange();
289 let scanType = this.scanType.value;
290
291 /** @type {FetchAllOnPageLoad} */
292 let onPageLoad = (data) => {
293 if (this.scanStartTime != startTime) {
294 return { cancel: true };
295 }
296
297 this.updateProgress(data, startTime);
298 };
299
300 if (scanType == 'home') {
301 this.startScan(startTime, requestedDays);
302
303 let posts = await accountAPI.loadHomeTimeline(requestedDays, {
304 onPageLoad: onPageLoad,
305 keepLastPage: true
306 });
307
308 this.updateResultsTable(posts, startTime, requestedDays);
309 } else if (scanType == 'list') {
310 let list = this.listSelect.value;
311
312 if (!list) {
313 return;
314 }
315
316 this.startScan(startTime, requestedDays);
317
318 let posts = await accountAPI.loadListTimeline(list, requestedDays, {
319 onPageLoad: onPageLoad,
320 keepLastPage: true
321 });
322
323 this.updateResultsTable(posts, startTime, requestedDays, { showReposts: false });
324 } else if (scanType == 'users') {
325 let dids = Object.keys(this.selectedUsers);
326
327 if (dids.length == 0) {
328 return;
329 }
330
331 this.startScan(startTime, requestedDays);
332 this.resetUserProgress(dids);
333
334 let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, {
335 filter: 'posts_and_author_threads',
336 onPageLoad: (data) => {
337 if (this.scanStartTime != startTime) {
338 return { cancel: true };
339 }
340
341 this.updateUserProgress(did, data, startTime, requestedDays);
342 },
343 keepLastPage: true
344 }));
345
346 let datasets = await Promise.all(requests);
347 let posts = datasets.flat();
348
349 this.updateResultsTable(posts, startTime, requestedDays, {
350 showTotal: false,
351 showPercentages: false,
352 countFetchedDays: false,
353 users: Object.values(this.selectedUsers)
354 });
355 } else {
356 this.startScan(startTime, requestedDays);
357
358 let posts = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, {
359 filter: 'posts_no_replies',
360 onPageLoad: onPageLoad,
361 keepLastPage: true
362 });
363
364 this.updateResultsTable(posts, startTime, requestedDays, { showTotal: false, showPercentages: false });
365 }
366 }
367
368 /** @param {json[]} dataPage, @param {number} startTime */
369
370 updateProgress(dataPage, startTime) {
371 let last = dataPage.at(-1);
372
373 if (!last) { return }
374
375 let lastDate = feedPostTime(last);
376 let daysBack = (startTime - lastDate) / 86400 / 1000;
377
378 this.progressBar.value = daysBack;
379 }
380
381 /** @param {string[]} dids */
382
383 resetUserProgress(dids) {
384 this.userProgress = {};
385
386 for (let did of dids) {
387 this.userProgress[did] = { pages: 0, progress: 0 };
388 }
389 }
390
391 /** @param {string} did, @param {json[]} dataPage, @param {number} startTime, @param {number} requestedDays */
392
393 updateUserProgress(did, dataPage, startTime, requestedDays) {
394 let last = dataPage.at(-1);
395
396 if (!last) { return }
397
398 let lastDate = feedPostTime(last);
399 let daysBack = (startTime - lastDate) / 86400 / 1000;
400
401 this.userProgress[did].pages += 1;
402 this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0);
403
404 let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress);
405 let known = expectedPages.filter(x => !isNaN(x));
406 let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length;
407 let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b);
408
409 this.progressBar.value = Math.max(this.progressBar.value, (fetchedPages / expectedTotalPages) * requestedDays);
410 }
411
412 /** @param {json} a, @param {json} b, @returns {number} */
413
414 sortUserRows(a, b) {
415 let asum = a.own + a.reposts;
416 let bsum = b.own + b.reposts;
417
418 if (asum < bsum) {
419 return 1;
420 } else if (asum > bsum) {
421 return -1;
422 } else {
423 return 0;
424 }
425 }
426
427 /**
428 * @param {json[]} posts
429 * @param {number} startTime
430 * @param {number} requestedDays
431 * @param {{
432 * showTotal?: boolean,
433 * showPercentages?: boolean,
434 * showReposts?: boolean,
435 * countFetchedDays?: boolean,
436 * users?: json[]
437 * }} [options]
438 * @returns {Promise<void>}
439 */
440
441 async updateResultsTable(posts, startTime, requestedDays, options = {}) {
442 if (this.scanStartTime != startTime) {
443 return;
444 }
445
446 let now = new Date().getTime();
447
448 if (now - startTime < 100) {
449 // artificial UI delay in case scan finishes immediately
450 await new Promise(resolve => setTimeout(resolve, 100));
451 }
452
453 let users = {};
454 let total = 0;
455 let allReposts = 0;
456 let allNormalPosts = 0;
457
458 let last = posts.at(-1);
459
460 if (!last) {
461 this.stopScan();
462 return;
463 }
464
465 let daysBack;
466
467 if (options.countFetchedDays !== false) {
468 let lastDate = feedPostTime(last);
469 let fetchedDays = (startTime - lastDate) / 86400 / 1000;
470
471 if (Math.ceil(fetchedDays) < requestedDays) {
472 this.scanInfo.innerText = `🕓 Showing data from ${Math.round(fetchedDays)} days (the timeline only goes that far):`;
473 this.scanInfo.style.display = 'block';
474 }
475
476 daysBack = Math.min(requestedDays, fetchedDays);
477 } else {
478 daysBack = requestedDays;
479 }
480
481 let timeLimit = startTime - requestedDays * 86400 * 1000;
482 posts = posts.filter(x => (feedPostTime(x) > timeLimit));
483 posts.reverse();
484
485 if (options.users) {
486 for (let user of options.users) {
487 users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar };
488 }
489 }
490
491 let ownThreads = new Set();
492
493 for (let item of posts) {
494 if (item.reply) {
495 if (!ownThreads.has(item.reply.parent.uri)) {
496 continue;
497 }
498 }
499
500 let user = item.reason ? item.reason.by : item.post.author;
501 let handle = user.handle;
502 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar };
503 total += 1;
504
505 if (item.reason) {
506 users[handle].reposts += 1;
507 allReposts += 1;
508 } else {
509 users[handle].own += 1;
510 allNormalPosts += 1;
511 ownThreads.add(item.post.uri);
512 }
513 }
514
515 let headRow = $tag('tr');
516
517 if (options.showReposts !== false) {
518 headRow.append(
519 $tag('th', { text: '#' }),
520 $tag('th', { text: 'Handle' }),
521 $tag('th', { text: 'All posts /d' }),
522 $tag('th', { text: 'Own posts /d' }),
523 $tag('th', { text: 'Reposts /d' })
524 );
525 } else {
526 headRow.append(
527 $tag('th', { text: '#' }),
528 $tag('th', { text: 'Handle' }),
529 $tag('th', { text: 'Posts /d' }),
530 );
531 }
532
533 if (options.showPercentages !== false) {
534 headRow.append($tag('th', { text: '% of timeline' }));
535 }
536
537 this.tableHead.append(headRow);
538
539 if (options.showTotal !== false) {
540 let tr = $tag('tr.total');
541
542 tr.append(
543 $tag('td.no', { text: '' }),
544 $tag('td.handle', { text: 'Total:' }),
545
546 (options.showReposts !== false) ?
547 $tag('td', { text: (total / daysBack).toFixed(1) }) : '',
548
549 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }),
550
551 (options.showReposts !== false) ?
552 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : ''
553 );
554
555 if (options.showPercentages !== false) {
556 tr.append($tag('td.percent', { text: '' }));
557 }
558
559 this.tableBody.append(tr);
560 }
561
562 let sorted = Object.values(users).sort(this.sortUserRows);
563
564 for (let i = 0; i < sorted.length; i++) {
565 let user = sorted[i];
566 let tr = $tag('tr');
567
568 tr.append(
569 $tag('td.no', { text: i + 1 }),
570 $tag('td.handle', {
571 html: `<img class="avatar" src="${user.avatar}"> ` +
572 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
573 }),
574
575 (options.showReposts !== false) ?
576 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '',
577
578 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }),
579
580 (options.showReposts !== false) ?
581 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : ''
582 );
583
584 if (options.showPercentages !== false) {
585 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }));
586 }
587
588 this.tableBody.append(tr);
589 }
590
591 this.table.style.display = 'table';
592 this.stopScan();
593 }
594
595 /** @param {number} startTime, @param {number} requestedDays */
596
597 startScan(startTime, requestedDays) {
598 this.submitButton.value = 'Cancel';
599
600 this.progressBar.max = requestedDays;
601 this.progressBar.value = 0;
602 this.progressBar.style.display = 'inline';
603
604 this.table.style.display = 'none';
605 this.tableHead.innerHTML = '';
606 this.tableBody.innerHTML = '';
607
608 this.scanStartTime = startTime;
609 this.scanInfo.style.display = 'none';
610 }
611
612 stopScan() {
613 this.submitButton.value = 'Start scan';
614 this.scanStartTime = undefined;
615 this.progressBar.style.display = 'none';
616 }
617}