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