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