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