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 lastDate = feedPostTime(last); 208 let daysBack = (startTime - lastDate) / 86400 / 1000; 209 210 this.progressBar.value = daysBack; 211 } 212 213 214 /** @param {string[]} dids */ 215 216 resetUserProgress(dids) { 217 this.userProgress = {}; 218 219 for (let did of dids) { 220 this.userProgress[did] = { pages: 0, progress: 0 }; 221 } 222 } 223 224 /** @param {string} did, @param {json[]} dataPage, @param {number} startTime, @param {number} requestedDays */ 225 226 updateUserProgress(did, dataPage, startTime, requestedDays) { 227 let last = dataPage.at(-1); 228 229 if (!last) { return } 230 231 let lastDate = feedPostTime(last); 232 let daysBack = (startTime - lastDate) / 86400 / 1000; 233 234 this.userProgress[did].pages += 1; 235 this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0); 236 237 let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress); 238 let known = expectedPages.filter(x => !isNaN(x)); 239 let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length; 240 let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b); 241 242 this.progressBar.value = Math.max(this.progressBar.value, (fetchedPages / expectedTotalPages) * requestedDays); 243 } 244 245 /** @param {json} a, @param {json} b, @returns {number} */ 246 247 sortUserRows(a, b) { 248 let asum = a.own + a.reposts; 249 let bsum = b.own + b.reposts; 250 251 if (asum < bsum) { 252 return 1; 253 } else if (asum > bsum) { 254 return -1; 255 } else { 256 return 0; 257 } 258 } 259 260 /** 261 * @param {json[]} items 262 * @param {number} startTime 263 * @param {number} requestedDays 264 * @param {{ showTotal?: boolean, showPercentages?: boolean, showReposts?: boolean, countFetchedDays?: boolean }} [options] 265 */ 266 267 updateResultsTable(items, startTime, requestedDays, options = {}) { 268 let users = {}; 269 let total = 0; 270 let allReposts = 0; 271 let allNormalPosts = 0; 272 273 let last = items.at(-1); 274 275 if (!last) { 276 this.stopScan(); 277 return; 278 } 279 280 let daysBack; 281 282 if (options.countFetchedDays !== false) { 283 let lastDate = feedPostTime(last); 284 let fetchedDays = (startTime - lastDate) / 86400 / 1000; 285 286 if (Math.ceil(fetchedDays) < requestedDays) { 287 let scanInfo = $(this.pageElement.querySelector('.scan-info')); 288 scanInfo.innerText = `🕓 Showing data from ${Math.round(fetchedDays)} days (the timeline only goes that far):`; 289 scanInfo.style.display = 'block'; 290 } 291 292 daysBack = Math.min(requestedDays, fetchedDays); 293 } else { 294 daysBack = requestedDays; 295 } 296 297 let timeLimit = startTime - requestedDays * 86400 * 1000; 298 items = items.filter(x => (feedPostTime(x) > timeLimit)); 299 300 for (let item of items) { 301 if (item.reply) { continue; } 302 303 let user = item.reason ? item.reason.by : item.post.author; 304 let handle = user.handle; 305 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar }; 306 total += 1; 307 308 if (item.reason) { 309 users[handle].reposts += 1; 310 allReposts += 1; 311 } else { 312 users[handle].own += 1; 313 allNormalPosts += 1; 314 } 315 } 316 317 let thead = $(this.table.querySelector('thead')); 318 let headRow = $tag('tr'); 319 320 if (options.showReposts !== false) { 321 headRow.append( 322 $tag('th', { text: '#' }), 323 $tag('th', { text: 'Handle' }), 324 $tag('th', { text: 'All posts /d' }), 325 $tag('th', { text: 'Own posts /d' }), 326 $tag('th', { text: 'Reposts /d' }) 327 ); 328 } else { 329 headRow.append( 330 $tag('th', { text: '#' }), 331 $tag('th', { text: 'Handle' }), 332 $tag('th', { text: 'Posts /d' }), 333 ); 334 } 335 336 if (options.showPercentages !== false) { 337 headRow.append($tag('th', { text: '% of all' })); 338 } 339 340 thead.append(headRow); 341 342 let tbody = $(this.table.querySelector('tbody')); 343 344 if (options.showTotal !== false) { 345 let tr = $tag('tr.total'); 346 347 tr.append( 348 $tag('td.no', { text: '' }), 349 $tag('td.handle', { text: 'Total:' }), 350 351 (options.showReposts !== false) ? 352 $tag('td', { text: (total / daysBack).toFixed(1) }) : '', 353 354 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }), 355 356 (options.showReposts !== false) ? 357 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : '' 358 ); 359 360 if (options.showPercentages !== false) { 361 tr.append($tag('td.percent', { text: '' })); 362 } 363 364 tbody.append(tr); 365 } 366 367 let sorted = Object.values(users).sort(this.sortUserRows); 368 369 for (let i = 0; i < sorted.length; i++) { 370 let user = sorted[i]; 371 let tr = $tag('tr'); 372 373 tr.append( 374 $tag('td.no', { text: i + 1 }), 375 $tag('td.handle', { 376 html: `<img class="avatar" src="${user.avatar}"> ` + 377 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 378 }), 379 380 (options.showReposts !== false) ? 381 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '', 382 383 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }), 384 385 (options.showReposts !== false) ? 386 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : '' 387 ); 388 389 if (options.showPercentages !== false) { 390 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' })); 391 } 392 393 tbody.append(tr); 394 } 395 396 this.table.style.display = 'table'; 397 this.submitButton.value = 'Start scan'; 398 this.progressBar.style.display = 'none'; 399 this.scanStartTime = undefined; 400 } 401 402 stopScan() { 403 this.submitButton.value = 'Start scan'; 404 this.scanStartTime = undefined; 405 this.progressBar.style.display = 'none'; 406 } 407}