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