Thread viewer for Bluesky
at master 17 kB view raw
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}