Thread viewer for Bluesky
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 321e30d75c2a7771ab6b91605115c6f781f4d5ca 315 lines 9.1 kB view raw
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 44 this.table.style.display = 'none'; 45 }); 46 }); 47 } 48 49 show() { 50 this.pageElement.style.display = 'block'; 51 this.fetchLists(); 52 } 53 54 /** @returns {number} */ 55 56 selectedDaysRange() { 57 return parseInt(this.rangeInput.value, 10); 58 } 59 60 /** @returns {Promise<void>} */ 61 62 async fetchLists() { 63 let select = $(this.pageElement.querySelector('.list-choice select')); 64 let lists = await accountAPI.loadUserLists(); 65 66 let sorted = lists.sort((a, b) => { 67 let aName = a.name.toLocaleLowerCase(); 68 let bName = b.name.toLocaleLowerCase(); 69 70 return aName.localeCompare(bName); 71 }); 72 73 for (let list of lists) { 74 let opt = $tag('option', { value: list.uri, text: list.name + ' ' }); 75 select.append(opt); 76 } 77 } 78 79 /** @param {{ days: number }} args */ 80 81 configurePostingStats(args) { 82 if (args.days) { 83 let label = $(this.pageElement.querySelector('input[type=range] + label')); 84 label.innerText = (args.days == 1) ? '1 day' : `${args.days} days`; 85 } 86 } 87 88 /** @returns {Promise<void>} */ 89 90 async scanPostingStats() { 91 this.submitButton.value = 'Cancel'; 92 93 let requestedDays = this.selectedDaysRange(); 94 95 this.progressBar.max = requestedDays; 96 this.progressBar.value = 0; 97 this.progressBar.style.display = 'inline'; 98 99 this.table.style.display = 'none'; 100 101 let tbody = $(this.table.querySelector('tbody')); 102 tbody.innerHTML = ''; 103 104 let thead = $(this.table.querySelector('thead')); 105 thead.innerHTML = ''; 106 107 let startTime = new Date().getTime(); 108 this.scanStartTime = startTime; 109 110 let scanInfo = $(this.pageElement.querySelector('.scan-info')); 111 scanInfo.style.display = 'none'; 112 113 /** @type {FetchAllOnPageLoad} */ 114 let onPageLoad = (data) => { 115 if (this.scanStartTime != startTime) { 116 return { cancel: true }; 117 } 118 119 this.updateProgress(data, startTime); 120 }; 121 122 let scanType = this.form.elements['scan_type'].value; 123 124 if (scanType == 'home') { 125 let items = await accountAPI.loadHomeTimeline(requestedDays, { onPageLoad }); 126 127 if (this.scanStartTime != startTime) { 128 return; 129 } 130 131 this.updateResultsTable(items, startTime, requestedDays); 132 } else if (scanType == 'list') { 133 let select = $(this.pageElement.querySelector('.list-choice select'), HTMLSelectElement); 134 let list = select.value; 135 let items = await accountAPI.loadListTimeline(list, requestedDays, { onPageLoad }); 136 137 if (this.scanStartTime != startTime) { 138 return; 139 } 140 141 this.updateResultsTable(items, startTime, requestedDays, { showReposts: false }); 142 } else { 143 let items = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, { 144 filter: 'posts_no_replies', 145 onPageLoad: onPageLoad 146 }); 147 148 if (this.scanStartTime != startTime) { 149 return; 150 } 151 152 this.updateResultsTable(items, startTime, requestedDays, { showTotal: false, showPercentages: false }); 153 } 154 } 155 156 /** @param {json[]} dataPage, @param {number} startTime */ 157 158 updateProgress(dataPage, startTime) { 159 if (dataPage.length == 0) { return } 160 161 let last = dataPage.at(-1); 162 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt; 163 let lastDate = Date.parse(lastTimestamp); 164 165 let daysBack = (startTime - lastDate) / 86400 / 1000; 166 this.progressBar.value = daysBack; 167 } 168 169 /** @param {json} a, @param {json} b, @returns {number} */ 170 171 sortUserRows(a, b) { 172 let asum = a.own + a.reposts; 173 let bsum = b.own + b.reposts; 174 175 if (asum < bsum) { 176 return 1; 177 } else if (asum > bsum) { 178 return -1; 179 } else { 180 return 0; 181 } 182 } 183 184 /** 185 * @param {json[]} items 186 * @param {number} startTime 187 * @param {number} requestedDays 188 * @param {{ showTotal?: boolean, showPercentages?: boolean, showReposts?: boolean }} [options] 189 */ 190 191 updateResultsTable(items, startTime, requestedDays, options = {}) { 192 let users = {}; 193 let total = 0; 194 let allReposts = 0; 195 let allNormalPosts = 0; 196 197 let last = items.at(-1); 198 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt; 199 let lastDate = Date.parse(lastTimestamp); 200 let daysBack = (startTime - lastDate) / 86400 / 1000; 201 202 for (let item of items) { 203 if (item.reply) { continue; } 204 205 let user = item.reason ? item.reason.by : item.post.author; 206 let handle = user.handle; 207 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar }; 208 total += 1; 209 210 if (item.reason) { 211 users[handle].reposts += 1; 212 allReposts += 1; 213 } else { 214 users[handle].own += 1; 215 allNormalPosts += 1; 216 } 217 } 218 219 let thead = $(this.table.querySelector('thead')); 220 let headRow = $tag('tr'); 221 222 if (options.showReposts !== false) { 223 headRow.append( 224 $tag('th', { text: '#' }), 225 $tag('th', { text: 'Handle' }), 226 $tag('th', { text: 'All posts /d' }), 227 $tag('th', { text: 'Own posts /d' }), 228 $tag('th', { text: 'Reposts /d' }) 229 ); 230 } else { 231 headRow.append( 232 $tag('th', { text: '#' }), 233 $tag('th', { text: 'Handle' }), 234 $tag('th', { text: 'Posts /d' }), 235 ); 236 } 237 238 if (options.showPercentages !== false) { 239 headRow.append($tag('th', { text: '% of all' })); 240 } 241 242 thead.append(headRow); 243 244 let tbody = $(this.table.querySelector('tbody')); 245 246 if (options.showTotal !== false) { 247 let tr = $tag('tr.total'); 248 249 tr.append( 250 $tag('td.no', { text: '' }), 251 $tag('td.handle', { text: 'Total:' }), 252 253 (options.showReposts !== false) ? 254 $tag('td', { text: (total / daysBack).toFixed(1) }) : '', 255 256 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }), 257 258 (options.showReposts !== false) ? 259 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : '' 260 ); 261 262 if (options.showPercentages !== false) { 263 tr.append($tag('td.percent', { text: '' })); 264 } 265 266 tbody.append(tr); 267 } 268 269 let sorted = Object.values(users).sort(this.sortUserRows); 270 271 for (let i = 0; i < sorted.length; i++) { 272 let user = sorted[i]; 273 let tr = $tag('tr'); 274 275 tr.append( 276 $tag('td.no', { text: i + 1 }), 277 $tag('td.handle', { 278 html: `<img class="avatar" src="${user.avatar}"> ` + 279 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 280 }), 281 282 (options.showReposts !== false) ? 283 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '', 284 285 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }), 286 287 (options.showReposts !== false) ? 288 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : '' 289 ); 290 291 if (options.showPercentages !== false) { 292 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' })); 293 } 294 295 tbody.append(tr); 296 } 297 298 if (Math.ceil(daysBack) < requestedDays) { 299 let scanInfo = $(this.pageElement.querySelector('.scan-info')); 300 scanInfo.innerText = `🕓 Showing data from ${Math.round(daysBack)} days (your timeline only goes that far):`; 301 scanInfo.style.display = 'block'; 302 } 303 304 this.table.style.display = 'table'; 305 this.submitButton.value = 'Start scan'; 306 this.progressBar.style.display = 'none'; 307 this.scanStartTime = undefined; 308 } 309 310 stopScan() { 311 this.submitButton.value = 'Start scan'; 312 this.scanStartTime = undefined; 313 this.progressBar.style.display = 'none'; 314 } 315}