Thread viewer for Bluesky
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 if (data.length == 0) { return } 114 115 let last = data.at(-1); 116 let lastDate = Date.parse(last.value.createdAt); 117 118 let daysBack = (startTime - lastDate) / 86400 / 1000; 119 this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) }); 120 } 121 }); 122 } 123 124 /** @param {number} requestedDays, @returns {Promise<json[]>} */ 125 126 async fetchReceivedLikes(requestedDays) { 127 let startTime = /** @type {number} */ (this.scanStartTime); 128 129 let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, { 130 filter: 'posts_no_replies', 131 onPageLoad: (data) => { 132 if (data.length == 0) { return } 133 134 let last = data.at(-1); 135 let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt; 136 let lastDate = Date.parse(lastTimestamp); 137 138 let daysBack = (startTime - lastDate) / 86400 / 1000; 139 this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) }); 140 } 141 }); 142 143 let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0); 144 145 let results = []; 146 147 for (let i = 0; i < likedPosts.length; i += 10) { 148 let batch = likedPosts.slice(i, i + 10); 149 this.updateProgress({ postLikes: i / likedPosts.length }); 150 151 let fetchBatch = batch.map(x => { 152 return this.appView.fetchAll('app.bsky.feed.getLikes', { 153 params: { 154 uri: x['post']['uri'], 155 limit: 100 156 }, 157 field: 'likes' 158 }); 159 }); 160 161 let batchResults = await Promise.all(fetchBatch); 162 results = results.concat(batchResults); 163 } 164 165 this.updateProgress({ postLikes: 1.0 }); 166 167 return results.flat(); 168 } 169 170 /** 171 * @typedef {{ handle?: string, did?: string, avatar?: string, count: number }} LikeStat 172 * @typedef {Record<string, LikeStat>} LikeStatHash 173 */ 174 175 /** @param {json[]} likes, @returns {LikeStatHash} */ 176 177 sumUpReceivedLikes(likes) { 178 /** @type {LikeStatHash} */ 179 let stats = {}; 180 181 for (let like of likes) { 182 let handle = like.actor.handle; 183 184 if (!stats[handle]) { 185 stats[handle] = { handle: handle, count: 0, avatar: like.actor.avatar }; 186 } 187 188 stats[handle].count += 1; 189 } 190 191 return stats; 192 } 193 194 /** @param {json[]} likes, @returns {LikeStatHash} */ 195 196 sumUpGivenLikes(likes) { 197 /** @type {LikeStatHash} */ 198 let stats = {}; 199 200 for (let like of likes) { 201 let did = atURI(like.value.subject.uri).repo; 202 203 if (!stats[did]) { 204 stats[did] = { did: did, count: 0 }; 205 } 206 207 stats[did].count += 1; 208 } 209 210 return stats; 211 } 212 213 /** @param {LikeStatHash} counts, @returns {LikeStat[]} */ 214 215 getTopEntries(counts) { 216 return Object.entries(counts).sort(this.sortResults).map(x => x[1]).slice(0, 25); 217 } 218 219 /** @param {LikeStat[]} topUsers, @param {HTMLTableElement} table, @returns {Promise<void>} */ 220 221 async renderResults(topUsers, table) { 222 let tableBody = $(table.querySelector('tbody')); 223 tableBody.innerHTML = ''; 224 225 for (let [i, user] of topUsers.entries()) { 226 let tr = $tag('tr'); 227 tr.append( 228 $tag('td.no', { text: i + 1 }), 229 $tag('td.handle', { 230 html: `<img class="avatar" src="${user.avatar}"> ` + 231 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 232 }), 233 $tag('td.count', { text: user.count }) 234 ); 235 236 tableBody.append(tr); 237 }; 238 } 239 240 resetProgress() { 241 this.progressBar.value = 0; 242 this.progressPosts = 0; 243 this.progressLikeRecords = 0; 244 this.progressPostLikes = 0; 245 } 246 247 /** @param {{ posts?: number, likeRecords?: number, postLikes?: number }} data */ 248 249 updateProgress(data) { 250 if (data.posts) { 251 this.progressPosts = data.posts; 252 } 253 254 if (data.likeRecords) { 255 this.progressLikeRecords = data.likeRecords; 256 } 257 258 if (data.postLikes) { 259 this.progressPostLikes = data.postLikes; 260 } 261 262 let totalProgress = ( 263 0.1 * this.progressPosts + 264 0.65 * this.progressLikeRecords + 265 0.25 * this.progressPostLikes 266 ); 267 268 this.progressBar.value = totalProgress; 269 } 270 271 /** @param {[string, LikeStat]} a, @param {[string, LikeStat]} b, @returns {-1|1|0} */ 272 273 sortResults(a, b) { 274 if (a[1].count < b[1].count) { 275 return 1; 276 } else if (a[1].count > b[1].count) { 277 return -1; 278 } else { 279 return 0; 280 } 281 } 282 283 stopScan() { 284 this.submitButton.value = 'Start scan'; 285 this.progressBar.style.display = 'none'; 286 this.scanStartTime = undefined; 287 } 288}