Thread viewer for Bluesky
at master 8.0 kB view raw
1class LikeStatsPage { 2 3 /** @type {number | undefined} */ 4 scanStartTime; 5 6 constructor() { 7 this.pageElement = $id('like_stats_page'); 8 9 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement); 10 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement); 11 this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement); 12 13 this.receivedTable = $(this.pageElement.querySelector('.received-likes'), HTMLTableElement); 14 this.givenTable = $(this.pageElement.querySelector('.given-likes'), HTMLTableElement); 15 16 this.appView = new BlueskyAPI('public.api.bsky.app', false); 17 18 this.setupEvents(); 19 20 this.progressPosts = 0; 21 this.progressLikeRecords = 0; 22 this.progressPostLikes = 0; 23 } 24 25 setupEvents() { 26 $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => { 27 e.preventDefault(); 28 29 if (!this.scanStartTime) { 30 this.findLikes(); 31 } else { 32 this.stopScan(); 33 } 34 }); 35 36 this.rangeInput.addEventListener('input', (e) => { 37 let days = parseInt(this.rangeInput.value, 10); 38 let label = $(this.pageElement.querySelector('input[type=range] + label')); 39 label.innerText = (days == 1) ? '1 day' : `${days} days`; 40 }); 41 } 42 43 /** @returns {number} */ 44 45 selectedDaysRange() { 46 return parseInt(this.rangeInput.value, 10); 47 } 48 49 show() { 50 this.pageElement.style.display = 'block'; 51 } 52 53 /** @returns {Promise<void>} */ 54 55 async findLikes() { 56 this.submitButton.value = 'Cancel'; 57 58 let requestedDays = this.selectedDaysRange(); 59 60 this.resetProgress(); 61 this.progressBar.style.display = 'inline'; 62 63 let startTime = new Date().getTime(); 64 this.scanStartTime = startTime; 65 66 this.receivedTable.style.display = 'none'; 67 this.givenTable.style.display = 'none'; 68 69 let fetchGivenLikes = this.fetchGivenLikes(requestedDays); 70 71 let receivedLikes = await this.fetchReceivedLikes(requestedDays); 72 let receivedStats = this.sumUpReceivedLikes(receivedLikes); 73 let topReceived = this.getTopEntries(receivedStats); 74 75 await this.renderResults(topReceived, this.receivedTable); 76 77 let givenLikes = await fetchGivenLikes; 78 let givenStats = this.sumUpGivenLikes(givenLikes); 79 let topGiven = this.getTopEntries(givenStats); 80 81 let profileInfo = await appView.getRequest('app.bsky.actor.getProfiles', { actors: topGiven.map(x => x.did) }); 82 83 for (let profile of profileInfo.profiles) { 84 let user = /** @type {LikeStat} */ (topGiven.find(x => x.did == profile.did)); 85 user.handle = profile.handle; 86 user.avatar = profile.avatar; 87 } 88 89 await this.renderResults(topGiven, this.givenTable); 90 91 this.receivedTable.style.display = 'table'; 92 this.givenTable.style.display = 'table'; 93 94 this.submitButton.value = 'Start scan'; 95 this.progressBar.style.display = 'none'; 96 this.scanStartTime = undefined; 97 } 98 99 /** @param {number} requestedDays, @returns {Promise<json[]>} */ 100 101 async fetchGivenLikes(requestedDays) { 102 let startTime = /** @type {number} */ (this.scanStartTime); 103 104 return await accountAPI.fetchAll('com.atproto.repo.listRecords', { 105 params: { 106 repo: accountAPI.user.did, 107 collection: 'app.bsky.feed.like', 108 limit: 100 109 }, 110 field: 'records', 111 breakWhen: (x) => Date.parse(x['value']['createdAt']) < startTime - 86400 * requestedDays * 1000, 112 onPageLoad: (data) => { 113 let last = data.at(-1); 114 115 if (!last) { return } 116 117 let lastDate = Date.parse(last.value.createdAt); 118 let daysBack = (startTime - lastDate) / 86400 / 1000; 119 120 this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) }); 121 } 122 }); 123 } 124 125 /** @param {number} requestedDays, @returns {Promise<json[]>} */ 126 127 async fetchReceivedLikes(requestedDays) { 128 let startTime = /** @type {number} */ (this.scanStartTime); 129 130 let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, { 131 filter: 'posts_with_replies', 132 onPageLoad: (data) => { 133 let last = data.at(-1); 134 135 if (!last) { return } 136 137 let lastDate = feedPostTime(last); 138 let daysBack = (startTime - lastDate) / 86400 / 1000; 139 140 this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) }); 141 } 142 }); 143 144 let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0); 145 146 let results = []; 147 148 for (let i = 0; i < likedPosts.length; i += 10) { 149 let batch = likedPosts.slice(i, i + 10); 150 this.updateProgress({ postLikes: i / likedPosts.length }); 151 152 let fetchBatch = batch.map(x => { 153 return this.appView.fetchAll('app.bsky.feed.getLikes', { 154 params: { 155 uri: x['post']['uri'], 156 limit: 100 157 }, 158 field: 'likes' 159 }); 160 }); 161 162 let batchResults = await Promise.all(fetchBatch); 163 results = results.concat(batchResults); 164 } 165 166 this.updateProgress({ postLikes: 1.0 }); 167 168 return results.flat(); 169 } 170 171 /** 172 * @typedef {{ handle?: string, did?: string, avatar?: string, count: number }} LikeStat 173 * @typedef {Record<string, LikeStat>} LikeStatHash 174 */ 175 176 /** @param {json[]} likes, @returns {LikeStatHash} */ 177 178 sumUpReceivedLikes(likes) { 179 /** @type {LikeStatHash} */ 180 let stats = {}; 181 182 for (let like of likes) { 183 let handle = like.actor.handle; 184 185 if (!stats[handle]) { 186 stats[handle] = { handle: handle, count: 0, avatar: like.actor.avatar }; 187 } 188 189 stats[handle].count += 1; 190 } 191 192 return stats; 193 } 194 195 /** @param {json[]} likes, @returns {LikeStatHash} */ 196 197 sumUpGivenLikes(likes) { 198 /** @type {LikeStatHash} */ 199 let stats = {}; 200 201 for (let like of likes) { 202 let did = atURI(like.value.subject.uri).repo; 203 204 if (!stats[did]) { 205 stats[did] = { did: did, count: 0 }; 206 } 207 208 stats[did].count += 1; 209 } 210 211 return stats; 212 } 213 214 /** @param {LikeStatHash} counts, @returns {LikeStat[]} */ 215 216 getTopEntries(counts) { 217 return Object.entries(counts).sort(this.sortResults).map(x => x[1]).slice(0, 25); 218 } 219 220 /** @param {LikeStat[]} topUsers, @param {HTMLTableElement} table, @returns {Promise<void>} */ 221 222 async renderResults(topUsers, table) { 223 let tableBody = $(table.querySelector('tbody')); 224 tableBody.innerHTML = ''; 225 226 for (let [i, user] of topUsers.entries()) { 227 let tr = $tag('tr'); 228 tr.append( 229 $tag('td.no', { text: i + 1 }), 230 $tag('td.handle', { 231 html: `<img class="avatar" src="${user.avatar}"> ` + 232 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 233 }), 234 $tag('td.count', { text: user.count }) 235 ); 236 237 tableBody.append(tr); 238 }; 239 } 240 241 resetProgress() { 242 this.progressBar.value = 0; 243 this.progressPosts = 0; 244 this.progressLikeRecords = 0; 245 this.progressPostLikes = 0; 246 } 247 248 /** @param {{ posts?: number, likeRecords?: number, postLikes?: number }} data */ 249 250 updateProgress(data) { 251 if (data.posts) { 252 this.progressPosts = data.posts; 253 } 254 255 if (data.likeRecords) { 256 this.progressLikeRecords = data.likeRecords; 257 } 258 259 if (data.postLikes) { 260 this.progressPostLikes = data.postLikes; 261 } 262 263 let totalProgress = ( 264 0.1 * this.progressPosts + 265 0.65 * this.progressLikeRecords + 266 0.25 * this.progressPostLikes 267 ); 268 269 this.progressBar.value = totalProgress; 270 } 271 272 /** @param {[string, LikeStat]} a, @param {[string, LikeStat]} b, @returns {-1|1|0} */ 273 274 sortResults(a, b) { 275 if (a[1].count < b[1].count) { 276 return 1; 277 } else if (a[1].count > b[1].count) { 278 return -1; 279 } else { 280 return 0; 281 } 282 } 283 284 stopScan() { 285 this.submitButton.value = 'Start scan'; 286 this.progressBar.style.display = 'none'; 287 this.scanStartTime = undefined; 288 } 289}