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