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}