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 }
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_no_replies',
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 */
439
440 updateResultsTable(posts, startTime, requestedDays, options = {}) {
441 if (this.scanStartTime != startTime) {
442 return;
443 }
444
445 let users = {};
446 let total = 0;
447 let allReposts = 0;
448 let allNormalPosts = 0;
449
450 let last = posts.at(-1);
451
452 if (!last) {
453 this.stopScan();
454 return;
455 }
456
457 let daysBack;
458
459 if (options.countFetchedDays !== false) {
460 let lastDate = feedPostTime(last);
461 let fetchedDays = (startTime - lastDate) / 86400 / 1000;
462
463 if (Math.ceil(fetchedDays) < requestedDays) {
464 this.scanInfo.innerText = `🕓 Showing data from ${Math.round(fetchedDays)} days (the timeline only goes that far):`;
465 this.scanInfo.style.display = 'block';
466 }
467
468 daysBack = Math.min(requestedDays, fetchedDays);
469 } else {
470 daysBack = requestedDays;
471 }
472
473 let timeLimit = startTime - requestedDays * 86400 * 1000;
474 posts = posts.filter(x => (feedPostTime(x) > timeLimit));
475
476 if (options.users) {
477 for (let user of options.users) {
478 users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar };
479 }
480 }
481
482 for (let item of posts) {
483 if (item.reply) { continue; }
484
485 let user = item.reason ? item.reason.by : item.post.author;
486 let handle = user.handle;
487 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar };
488 total += 1;
489
490 if (item.reason) {
491 users[handle].reposts += 1;
492 allReposts += 1;
493 } else {
494 users[handle].own += 1;
495 allNormalPosts += 1;
496 }
497 }
498
499 let headRow = $tag('tr');
500
501 if (options.showReposts !== false) {
502 headRow.append(
503 $tag('th', { text: '#' }),
504 $tag('th', { text: 'Handle' }),
505 $tag('th', { text: 'All posts /d' }),
506 $tag('th', { text: 'Own posts /d' }),
507 $tag('th', { text: 'Reposts /d' })
508 );
509 } else {
510 headRow.append(
511 $tag('th', { text: '#' }),
512 $tag('th', { text: 'Handle' }),
513 $tag('th', { text: 'Posts /d' }),
514 );
515 }
516
517 if (options.showPercentages !== false) {
518 headRow.append($tag('th', { text: '% of timeline' }));
519 }
520
521 this.tableHead.append(headRow);
522
523 if (options.showTotal !== false) {
524 let tr = $tag('tr.total');
525
526 tr.append(
527 $tag('td.no', { text: '' }),
528 $tag('td.handle', { text: 'Total:' }),
529
530 (options.showReposts !== false) ?
531 $tag('td', { text: (total / daysBack).toFixed(1) }) : '',
532
533 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }),
534
535 (options.showReposts !== false) ?
536 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : ''
537 );
538
539 if (options.showPercentages !== false) {
540 tr.append($tag('td.percent', { text: '' }));
541 }
542
543 this.tableBody.append(tr);
544 }
545
546 let sorted = Object.values(users).sort(this.sortUserRows);
547
548 for (let i = 0; i < sorted.length; i++) {
549 let user = sorted[i];
550 let tr = $tag('tr');
551
552 tr.append(
553 $tag('td.no', { text: i + 1 }),
554 $tag('td.handle', {
555 html: `<img class="avatar" src="${user.avatar}"> ` +
556 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
557 }),
558
559 (options.showReposts !== false) ?
560 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '',
561
562 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }),
563
564 (options.showReposts !== false) ?
565 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : ''
566 );
567
568 if (options.showPercentages !== false) {
569 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }));
570 }
571
572 this.tableBody.append(tr);
573 }
574
575 this.table.style.display = 'table';
576 this.stopScan();
577 }
578
579 /** @param {number} startTime, @param {number} requestedDays */
580
581 startScan(startTime, requestedDays) {
582 this.submitButton.value = 'Cancel';
583
584 this.progressBar.max = requestedDays;
585 this.progressBar.value = 0;
586 this.progressBar.style.display = 'inline';
587
588 this.table.style.display = 'none';
589 this.tableHead.innerHTML = '';
590 this.tableBody.innerHTML = '';
591
592 this.scanStartTime = startTime;
593 this.scanInfo.style.display = 'none';
594 }
595
596 stopScan() {
597 this.submitButton.value = 'Start scan';
598 this.scanStartTime = undefined;
599 this.progressBar.style.display = 'none';
600 }
601}