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 constructor() { 14 this.pageElement = $id('posting_stats_page'); 15 this.form = $(this.pageElement.querySelector('form'), HTMLFormElement); 16 17 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement); 18 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement); 19 this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement); 20 this.table = $(this.pageElement.querySelector('table.scan-result')); 21 22 this.setupEvents(); 23 24 this.userProgress = {}; 25 this.appView = new BlueskyAPI('public.api.bsky.app', false); 26 } 27 28 setupEvents() { 29 $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => { 30 e.preventDefault(); 31 32 if (!this.scanStartTime) { 33 this.scanPostingStats(); 34 } else { 35 this.stopScan(); 36 } 37 }); 38 39 this.rangeInput.addEventListener('input', (e) => { 40 let days = parseInt(this.rangeInput.value, 10); 41 this.configurePostingStats({ days }); 42 }); 43 44 this.pageElement.querySelectorAll('input[type="radio"]').forEach(r => { 45 r.addEventListener('click', (e) => { 46 let value = $(r, HTMLInputElement).value; 47 48 $(this.pageElement.querySelector('.list-choice')).style.display = (value == 'list') ? 'block' : 'none'; 49 $(this.pageElement.querySelector('.user-choice')).style.display = (value == 'users') ? 'block' : 'none'; 50 51 this.table.style.display = 'none'; 52 }); 53 }); 54 } 55 56 show() { 57 this.pageElement.style.display = 'block'; 58 this.fetchLists(); 59 } 60 61 /** @returns {number} */ 62 63 selectedDaysRange() { 64 return parseInt(this.rangeInput.value, 10); 65 } 66 67 /** @returns {Promise<void>} */ 68 69 async fetchLists() { 70 let select = $(this.pageElement.querySelector('.list-choice select')); 71 let lists = await accountAPI.loadUserLists(); 72 73 let sorted = lists.sort((a, b) => { 74 let aName = a.name.toLocaleLowerCase(); 75 let bName = b.name.toLocaleLowerCase(); 76 77 return aName.localeCompare(bName); 78 }); 79 80 for (let list of lists) { 81 let opt = $tag('option', { value: list.uri, text: list.name + ' ' }); 82 select.append(opt); 83 } 84 } 85 86 /** @param {{ days: number }} args */ 87 88 configurePostingStats(args) { 89 if (args.days) { 90 let label = $(this.pageElement.querySelector('input[type=range] + label')); 91 label.innerText = (args.days == 1) ? '1 day' : `${args.days} days`; 92 } 93 } 94 95 /** @returns {Promise<void>} */ 96 97 async scanPostingStats() { 98 this.submitButton.value = 'Cancel'; 99 100 let requestedDays = this.selectedDaysRange(); 101 102 this.progressBar.max = requestedDays; 103 this.progressBar.value = 0; 104 this.progressBar.style.display = 'inline'; 105 106 this.table.style.display = 'none'; 107 108 let tbody = $(this.table.querySelector('tbody')); 109 tbody.innerHTML = ''; 110 111 let thead = $(this.table.querySelector('thead')); 112 thead.innerHTML = ''; 113 114 let startTime = new Date().getTime(); 115 this.scanStartTime = startTime; 116 117 let scanInfo = $(this.pageElement.querySelector('.scan-info')); 118 scanInfo.style.display = 'none'; 119 120 /** @type {FetchAllOnPageLoad} */ 121 let onPageLoad = (data) => { 122 if (this.scanStartTime != startTime) { 123 return { cancel: true }; 124 } 125 126 this.updateProgress(data, startTime); 127 }; 128 129 let scanType = this.form.elements['scan_type'].value; 130 131 if (scanType == 'home') { 132 let items = await accountAPI.loadHomeTimeline(requestedDays, { 133 onPageLoad: onPageLoad, 134 keepLastPage: true 135 }); 136 137 if (this.scanStartTime != startTime) { 138 return; 139 } 140 141 this.updateResultsTable(items, startTime, requestedDays); 142 } else if (scanType == 'list') { 143 let select = $(this.pageElement.querySelector('.list-choice select'), HTMLSelectElement); 144 let list = select.value; 145 let items = await accountAPI.loadListTimeline(list, requestedDays, { 146 onPageLoad: onPageLoad, 147 keepLastPage: true 148 }); 149 150 if (this.scanStartTime != startTime) { 151 return; 152 } 153 154 this.updateResultsTable(items, startTime, requestedDays, { showReposts: false }); 155 } else if (scanType == 'users') { 156 let textarea = $(this.pageElement.querySelector('textarea'), HTMLTextAreaElement); 157 let users = textarea.value.split(/\n/).map(x => x.trim()).filter(x => x.length > 0); 158 let dids = await Promise.all(users.map(u => accountAPI.resolveHandle(u))); 159 160 this.resetUserProgress(dids); 161 162 let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, { 163 filter: 'posts_no_replies', 164 onPageLoad: (data) => { 165 if (this.scanStartTime != startTime) { 166 return { cancel: true }; 167 } 168 169 this.updateUserProgress(did, data, startTime, requestedDays); 170 }, 171 keepLastPage: true 172 })); 173 174 let datasets = await Promise.all(requests); 175 176 if (this.scanStartTime != startTime) { 177 return; 178 } 179 180 let items = datasets.flat(); 181 182 this.updateResultsTable(items, startTime, requestedDays, { 183 showTotal: false, showPercentages: false, countFetchedDays: false 184 }); 185 } else { 186 let items = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, { 187 filter: 'posts_no_replies', 188 onPageLoad: onPageLoad, 189 keepLastPage: true 190 }); 191 192 if (this.scanStartTime != startTime) { 193 return; 194 } 195 196 this.updateResultsTable(items, startTime, requestedDays, { showTotal: false, showPercentages: false }); 197 } 198 } 199 200 /** @param {json[]} dataPage, @param {number} startTime */ 201 202 updateProgress(dataPage, startTime) { 203 let last = dataPage.at(-1); 204 205 if (!last) { return } 206 207 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt; 208 let lastDate = Date.parse(lastTimestamp); 209 let daysBack = (startTime - lastDate) / 86400 / 1000; 210 211 this.progressBar.value = daysBack; 212 } 213 214 215 /** @param {string[]} dids */ 216 217 resetUserProgress(dids) { 218 this.userProgress = {}; 219 220 for (let did of dids) { 221 this.userProgress[did] = { pages: 0, progress: 0 }; 222 } 223 } 224 225 /** @param {string} did, @param {json[]} dataPage, @param {number} startTime, @param {number} requestedDays */ 226 227 updateUserProgress(did, dataPage, startTime, requestedDays) { 228 let last = dataPage.at(-1); 229 230 if (!last) { return } 231 232 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt; 233 let lastDate = Date.parse(lastTimestamp); 234 let daysBack = (startTime - lastDate) / 86400 / 1000; 235 236 this.userProgress[did].pages += 1; 237 this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0); 238 239 let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress); 240 let known = expectedPages.filter(x => !isNaN(x)); 241 let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length; 242 let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b); 243 244 this.progressBar.value = Math.max(this.progressBar.value, (fetchedPages / expectedTotalPages) * requestedDays); 245 } 246 247 /** @param {json} a, @param {json} b, @returns {number} */ 248 249 sortUserRows(a, b) { 250 let asum = a.own + a.reposts; 251 let bsum = b.own + b.reposts; 252 253 if (asum < bsum) { 254 return 1; 255 } else if (asum > bsum) { 256 return -1; 257 } else { 258 return 0; 259 } 260 } 261 262 /** 263 * @param {json[]} items 264 * @param {number} startTime 265 * @param {number} requestedDays 266 * @param {{ showTotal?: boolean, showPercentages?: boolean, showReposts?: boolean, countFetchedDays?: boolean }} [options] 267 */ 268 269 updateResultsTable(items, startTime, requestedDays, options = {}) { 270 let users = {}; 271 let total = 0; 272 let allReposts = 0; 273 let allNormalPosts = 0; 274 275 let last = items.at(-1); 276 277 if (!last) { 278 this.stopScan(); 279 return; 280 } 281 282 let daysBack; 283 284 if (options.countFetchedDays !== false) { 285 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt; 286 let lastDate = Date.parse(lastTimestamp); 287 let fetchedDays = (startTime - lastDate) / 86400 / 1000; 288 289 if (Math.ceil(fetchedDays) < requestedDays) { 290 let scanInfo = $(this.pageElement.querySelector('.scan-info')); 291 scanInfo.innerText = `🕓 Showing data from ${Math.round(fetchedDays)} days (the timeline only goes that far):`; 292 scanInfo.style.display = 'block'; 293 } 294 295 daysBack = Math.min(requestedDays, fetchedDays); 296 } else { 297 daysBack = requestedDays; 298 } 299 300 items = items.filter(x => { 301 let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt; 302 return Date.parse(timestamp) > startTime - requestedDays * 86400 * 1000; 303 }); 304 305 for (let item of items) { 306 if (item.reply) { continue; } 307 308 let user = item.reason ? item.reason.by : item.post.author; 309 let handle = user.handle; 310 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar }; 311 total += 1; 312 313 if (item.reason) { 314 users[handle].reposts += 1; 315 allReposts += 1; 316 } else { 317 users[handle].own += 1; 318 allNormalPosts += 1; 319 } 320 } 321 322 let thead = $(this.table.querySelector('thead')); 323 let headRow = $tag('tr'); 324 325 if (options.showReposts !== false) { 326 headRow.append( 327 $tag('th', { text: '#' }), 328 $tag('th', { text: 'Handle' }), 329 $tag('th', { text: 'All posts /d' }), 330 $tag('th', { text: 'Own posts /d' }), 331 $tag('th', { text: 'Reposts /d' }) 332 ); 333 } else { 334 headRow.append( 335 $tag('th', { text: '#' }), 336 $tag('th', { text: 'Handle' }), 337 $tag('th', { text: 'Posts /d' }), 338 ); 339 } 340 341 if (options.showPercentages !== false) { 342 headRow.append($tag('th', { text: '% of all' })); 343 } 344 345 thead.append(headRow); 346 347 let tbody = $(this.table.querySelector('tbody')); 348 349 if (options.showTotal !== false) { 350 let tr = $tag('tr.total'); 351 352 tr.append( 353 $tag('td.no', { text: '' }), 354 $tag('td.handle', { text: 'Total:' }), 355 356 (options.showReposts !== false) ? 357 $tag('td', { text: (total / daysBack).toFixed(1) }) : '', 358 359 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }), 360 361 (options.showReposts !== false) ? 362 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : '' 363 ); 364 365 if (options.showPercentages !== false) { 366 tr.append($tag('td.percent', { text: '' })); 367 } 368 369 tbody.append(tr); 370 } 371 372 let sorted = Object.values(users).sort(this.sortUserRows); 373 374 for (let i = 0; i < sorted.length; i++) { 375 let user = sorted[i]; 376 let tr = $tag('tr'); 377 378 tr.append( 379 $tag('td.no', { text: i + 1 }), 380 $tag('td.handle', { 381 html: `<img class="avatar" src="${user.avatar}"> ` + 382 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 383 }), 384 385 (options.showReposts !== false) ? 386 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '', 387 388 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }), 389 390 (options.showReposts !== false) ? 391 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : '' 392 ); 393 394 if (options.showPercentages !== false) { 395 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' })); 396 } 397 398 tbody.append(tr); 399 } 400 401 this.table.style.display = 'table'; 402 this.submitButton.value = 'Start scan'; 403 this.progressBar.style.display = 'none'; 404 this.scanStartTime = undefined; 405 } 406 407 stopScan() { 408 this.submitButton.value = 'Start scan'; 409 this.scanStartTime = undefined; 410 this.progressBar.style.display = 'none'; 411 } 412}