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}