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