Thread viewer for Bluesky

Merge branch 'like_stats'

+32 -2
api.js
··· 290 return await this.getRequest('app.bsky.notification.listNotifications', params); 291 } 292 293 - /** @param {string} [cursor], @returns {Promise<{ cursor: string | undefined, posts: json[] }>} */ 294 295 async loadMentions(cursor) { 296 let response = await this.loadNotifications(cursor); ··· 306 return { cursor: response.cursor, posts }; 307 } 308 309 - /** @param {number} days, @returns {Promise<json[]>} */ 310 311 async loadTimeline(days, options = {}) { 312 let now = new Date(); 313 let timeLimit = now.getTime() - days * 86400 * 1000; 314 315 return await this.fetchAll('app.bsky.feed.getTimeline', { limit: 100 }, { 316 field: 'feed', 317 breakWhen: (x) => { 318 let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
··· 290 return await this.getRequest('app.bsky.notification.listNotifications', params); 291 } 292 293 + /** 294 + * @param {string} [cursor] 295 + * @returns {Promise<{ cursor: string | undefined, posts: json[] }>} 296 + */ 297 298 async loadMentions(cursor) { 299 let response = await this.loadNotifications(cursor); ··· 309 return { cursor: response.cursor, posts }; 310 } 311 312 + /** 313 + * @param {number} days 314 + * @param {{ onPageLoad?: FetchAllOnPageLoad }} [options] 315 + * @returns {Promise<json[]>} 316 + */ 317 318 async loadTimeline(days, options = {}) { 319 let now = new Date(); 320 let timeLimit = now.getTime() - days * 86400 * 1000; 321 322 return await this.fetchAll('app.bsky.feed.getTimeline', { limit: 100 }, { 323 + field: 'feed', 324 + breakWhen: (x) => { 325 + let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt; 326 + return Date.parse(timestamp) < timeLimit; 327 + }, 328 + onPageLoad: options.onPageLoad 329 + }); 330 + } 331 + 332 + /** 333 + * @param {string} did 334 + * @param {number} days 335 + * @param {{ onPageLoad?: FetchAllOnPageLoad }} [options] 336 + * @returns {Promise<json[]>} 337 + */ 338 + 339 + async loadUserTimeline(did, days, options = {}) { 340 + let now = new Date(); 341 + let timeLimit = now.getTime() - days * 86400 * 1000; 342 + 343 + let params = { actor: did, filter: 'posts_no_replies', limit: 100 }; 344 + 345 + return await this.fetchAll('app.bsky.feed.getAuthorFeed', params, { 346 field: 'feed', 347 breakWhen: (x) => { 348 let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
+30
index.html
··· 127 </table> 128 </div> 129 130 <script src="lib/purify.min.js"></script> 131 <script src="minisky.js"></script> 132 <script src="api.js"></script> 133 <script src="utils.js"></script> 134 <script src="rich_text_lite.js"></script> 135 <script src="models.js"></script> 136 <script src="thread_page.js"></script> 137 <script src="posting_stats_page.js"></script> 138 <script src="embed_component.js"></script> 139 <script src="post_component.js"></script> 140 <script src="skythread.js"></script>
··· 127 </table> 128 </div> 129 130 + <div id="like_stats_page"> 131 + <h2>Like statistics</h2> 132 + 133 + <form> 134 + <p> 135 + Time range: <input type="range" min="1" max="60" value="7"> <label>7 days</label> 136 + </p> 137 + 138 + <p> 139 + <input type="submit" value="Start scan"> <progress></progress> 140 + </p> 141 + </form> 142 + 143 + <table class="scan-result given-likes"> 144 + <thead> 145 + <tr><th colspan="3">❤️ Likes from you:</th></tr> 146 + </thead> 147 + <tbody></tbody> 148 + </table> 149 + 150 + <table class="scan-result received-likes"> 151 + <thead> 152 + <tr><th colspan="3">💛 Likes on your posts:</th></tr> 153 + </thead> 154 + <tbody></tbody> 155 + </table> 156 + </div> 157 + 158 <script src="lib/purify.min.js"></script> 159 <script src="minisky.js"></script> 160 <script src="api.js"></script> 161 <script src="utils.js"></script> 162 <script src="rich_text_lite.js"></script> 163 <script src="models.js"></script> 164 + <script src="menu.js"></script> 165 <script src="thread_page.js"></script> 166 <script src="posting_stats_page.js"></script> 167 + <script src="like_stats_page.js"></script> 168 <script src="embed_component.js"></script> 169 <script src="post_component.js"></script> 170 <script src="skythread.js"></script>
+282
like_stats_page.js
···
··· 1 + class 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 + }
+133
menu.js
···
··· 1 + class Menu { 2 + constructor() { 3 + this.menuElement = $id('account_menu'); 4 + this.icon = $id('account'); 5 + 6 + this.setupEvents(); 7 + } 8 + 9 + setupEvents() { 10 + let html = $(document.body.parentNode); 11 + 12 + html.addEventListener('click', (e) => { 13 + this.menuElement.style.visibility = 'hidden'; 14 + }); 15 + 16 + this.icon.addEventListener('click', (e) => { 17 + e.stopPropagation(); 18 + this.toggleAccountMenu(); 19 + }); 20 + 21 + this.menuElement.addEventListener('click', (e) => { 22 + e.stopPropagation(); 23 + }); 24 + 25 + $(this.menuElement.querySelector('a[data-action=biohazard]')).addEventListener('click', (e) => { 26 + e.preventDefault(); 27 + 28 + let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post'); 29 + 30 + if (window.biohazardEnabled === false) { 31 + window.biohazardEnabled = true; 32 + localStorage.setItem('biohazard', 'true'); 33 + this.toggleMenuButtonCheck('biohazard', true); 34 + Array.from(hazards).forEach(p => { $(p).style.display = 'block' }); 35 + } else { 36 + window.biohazardEnabled = false; 37 + localStorage.setItem('biohazard', 'false'); 38 + this.toggleMenuButtonCheck('biohazard', false); 39 + Array.from(hazards).forEach(p => { $(p).style.display = 'none' }); 40 + } 41 + }); 42 + 43 + $(this.menuElement.querySelector('a[data-action=incognito]')).addEventListener('click', (e) => { 44 + e.preventDefault(); 45 + 46 + if (window.isIncognito) { 47 + localStorage.removeItem('incognito'); 48 + } else { 49 + localStorage.setItem('incognito', '1'); 50 + } 51 + 52 + location.reload(); 53 + }); 54 + 55 + $(this.menuElement.querySelector('a[data-action=login]')).addEventListener('click', (e) => { 56 + e.preventDefault(); 57 + 58 + toggleDialog(loginDialog); 59 + this.menuElement.style.visibility = 'hidden'; 60 + }); 61 + 62 + $(this.menuElement.querySelector('a[data-action=logout]')).addEventListener('click', (e) => { 63 + e.preventDefault(); 64 + logOut(); 65 + }); 66 + } 67 + 68 + toggleAccountMenu() { 69 + this.menuElement.style.visibility = (this.menuElement.style.visibility == 'visible') ? 'hidden' : 'visible'; 70 + } 71 + 72 + /** @param {string} buttonName */ 73 + 74 + showMenuButton(buttonName) { 75 + let button = $(this.menuElement.querySelector(`a[data-action=${buttonName}]`)); 76 + let item = $(button.parentNode); 77 + item.style.display = 'list-item'; 78 + } 79 + 80 + /** @param {string} buttonName */ 81 + 82 + hideMenuButton(buttonName) { 83 + let button = $(this.menuElement.querySelector(`a[data-action=${buttonName}]`)); 84 + let item = $(button.parentNode); 85 + item.style.display = 'none'; 86 + } 87 + 88 + /** @param {string} buttonName, @param {boolean} state */ 89 + 90 + toggleMenuButtonCheck(buttonName, state) { 91 + let button = $(this.menuElement.querySelector(`a[data-action=${buttonName}]`)); 92 + let check = $(button.querySelector('.check')); 93 + check.style.display = (state) ? 'inline' : 'none'; 94 + } 95 + 96 + /** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */ 97 + 98 + showLoggedInStatus(loggedIn, avatar) { 99 + if (loggedIn === true && avatar) { 100 + let button = $(this.icon.querySelector('i')); 101 + 102 + let img = $tag('img.avatar', { src: avatar }); 103 + img.style.display = 'none'; 104 + img.addEventListener('load', () => { 105 + button.remove(); 106 + img.style.display = 'inline'; 107 + }); 108 + img.addEventListener('error', () => { 109 + this.showLoggedInStatus(true, null); 110 + }) 111 + 112 + this.icon.append(img); 113 + } else if (loggedIn === false) { 114 + this.icon.innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`; 115 + } else if (loggedIn === 'incognito') { 116 + this.icon.innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`; 117 + } else { 118 + this.icon.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`; 119 + } 120 + } 121 + 122 + /** @returns {Promise<void>} */ 123 + 124 + async loadCurrentUserAvatar() { 125 + try { 126 + let url = await api.loadCurrentUserAvatar(); 127 + this.showLoggedInStatus(true, url); 128 + } catch (error) { 129 + console.log(error); 130 + this.showLoggedInStatus(true, null); 131 + } 132 + } 133 + }
+3 -1
minisky.js
··· 180 } 181 182 /** 183 * @typedef {MiniskyOptions & { 184 * field: string, 185 * breakWhen?: (obj: json) => boolean, 186 - * onPageLoad?: (obj: json[]) => { cancel: true }, 187 * }} FetchAllOptions 188 * 189 * @param {string} method
··· 180 } 181 182 /** 183 + * @typedef {(obj: json[]) => { cancel: true } | void} FetchAllOnPageLoad 184 + * 185 * @typedef {MiniskyOptions & { 186 * field: string, 187 * breakWhen?: (obj: json) => boolean, 188 + * onPageLoad?: FetchAllOnPageLoad | undefined 189 * }} FetchAllOptions 190 * 191 * @param {string} method
+17 -135
skythread.js
··· 1 function init() { 2 - let html = $(document.body.parentNode); 3 - 4 window.dateLocale = localStorage.getItem('locale') || undefined; 5 window.isIncognito = !!localStorage.getItem('incognito'); 6 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null'); 7 8 window.loginDialog = $(document.querySelector('#login')); 9 - window.accountMenu = $(document.querySelector('#account_menu')); 10 11 window.avatarPreloader = buildAvatarPreloader(); 12 13 window.threadPage = new ThreadPage(); 14 window.postingStatsPage = new PostingStatsPage(); 15 - 16 - html.addEventListener('click', (e) => { 17 - $id('account_menu').style.visibility = 'hidden'; 18 - }); 19 20 $(document.querySelector('#search form')).addEventListener('submit', (e) => { 21 e.preventDefault(); ··· 67 68 window.biohazardEnabled = false; 69 localStorage.setItem('biohazard', 'false'); 70 - toggleMenuButton('biohazard', false); 71 72 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) { 73 $(p).style.display = 'none'; ··· 78 hideDialog(target.closest('.dialog')); 79 }); 80 81 - $(document.querySelector('#account')).addEventListener('click', (e) => { 82 - toggleAccountMenu(); 83 - e.stopPropagation(); 84 - }); 85 - 86 - accountMenu.addEventListener('click', (e) => { 87 - e.stopPropagation(); 88 - }); 89 - 90 - $(accountMenu.querySelector('a[data-action=biohazard]')).addEventListener('click', (e) => { 91 - e.preventDefault(); 92 - 93 - let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post'); 94 - 95 - if (window.biohazardEnabled === false) { 96 - window.biohazardEnabled = true; 97 - localStorage.setItem('biohazard', 'true'); 98 - toggleMenuButton('biohazard', true); 99 - Array.from(hazards).forEach(p => { $(p).style.display = 'block' }); 100 - } else { 101 - window.biohazardEnabled = false; 102 - localStorage.setItem('biohazard', 'false'); 103 - toggleMenuButton('biohazard', false); 104 - Array.from(hazards).forEach(p => { $(p).style.display = 'none' }); 105 - } 106 - }); 107 - 108 - $(accountMenu.querySelector('a[data-action=incognito]')).addEventListener('click', (e) => { 109 - e.preventDefault(); 110 - 111 - if (isIncognito) { 112 - localStorage.removeItem('incognito'); 113 - } else { 114 - localStorage.setItem('incognito', '1'); 115 - } 116 - 117 - location.reload(); 118 - }); 119 - 120 - $(accountMenu.querySelector('a[data-action=login]')).addEventListener('click', (e) => { 121 - e.preventDefault(); 122 - toggleDialog(loginDialog); 123 - $id('account_menu').style.visibility = 'hidden'; 124 - }); 125 - 126 - $(accountMenu.querySelector('a[data-action=logout]')).addEventListener('click', (e) => { 127 - e.preventDefault(); 128 - logOut(); 129 - }); 130 - 131 window.appView = new BlueskyAPI('api.bsky.app', false); 132 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false); 133 window.accountAPI = new BlueskyAPI(undefined, true); 134 135 if (accountAPI.isLoggedIn) { 136 accountAPI.host = accountAPI.user.pdsEndpoint; 137 - hideMenuButton('login'); 138 139 if (!isIncognito) { 140 window.api = accountAPI; 141 - showLoggedInStatus(true, api.user.avatar); 142 } else { 143 window.api = appView; 144 - showLoggedInStatus('incognito'); 145 - toggleMenuButton('incognito', true); 146 } 147 } else { 148 window.api = appView; 149 - hideMenuButton('logout'); 150 - hideMenuButton('incognito'); 151 } 152 153 - toggleMenuButton('biohazard', window.biohazardEnabled !== false); 154 155 parseQueryParams(); 156 } ··· 248 $id('login').classList.toggle('expanded'); 249 } 250 251 - function toggleAccountMenu() { 252 - let menu = $id('account_menu'); 253 - menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible'; 254 - } 255 - 256 - /** @param {string} buttonName */ 257 - 258 - function showMenuButton(buttonName) { 259 - let button = $(accountMenu.querySelector(`a[data-action=${buttonName}]`)); 260 - let item = $(button.parentNode); 261 - item.style.display = 'list-item'; 262 - } 263 - 264 - /** @param {string} buttonName */ 265 - 266 - function hideMenuButton(buttonName) { 267 - let button = $(accountMenu.querySelector(`a[data-action=${buttonName}]`)); 268 - let item = $(button.parentNode); 269 - item.style.display = 'none'; 270 - } 271 - 272 - /** @param {string} buttonName, @param {boolean} state */ 273 - 274 - function toggleMenuButton(buttonName, state) { 275 - let button = $(accountMenu.querySelector(`a[data-action=${buttonName}]`)); 276 - let check = $(button.querySelector('.check')); 277 - check.style.display = (state) ? 'inline' : 'none'; 278 - } 279 - 280 - /** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */ 281 - 282 - function showLoggedInStatus(loggedIn, avatar) { 283 - let account = $id('account'); 284 - 285 - if (loggedIn === true && avatar) { 286 - let button = $(account.querySelector('i')); 287 - 288 - let img = $tag('img.avatar', { src: avatar }); 289 - img.style.display = 'none'; 290 - img.addEventListener('load', () => { 291 - button.remove(); 292 - img.style.display = 'inline'; 293 - }); 294 - img.addEventListener('error', () => { 295 - showLoggedInStatus(true, null); 296 - }) 297 - 298 - account.append(img); 299 - } else if (loggedIn === false) { 300 - $id('account').innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`; 301 - } else if (loggedIn === 'incognito') { 302 - $id('account').innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`; 303 - } else { 304 - account.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`; 305 - } 306 - } 307 - 308 function submitLogin() { 309 let handle = $id('login_handle', HTMLInputElement); 310 let password = $id('login_password', HTMLInputElement); ··· 327 submit.style.display = 'inline'; 328 cloudy.style.display = 'none'; 329 330 - loadCurrentUserAvatar(); 331 - showMenuButton('logout'); 332 - showMenuButton('incognito'); 333 - hideMenuButton('login'); 334 335 let params = new URLSearchParams(location.search); 336 let page = params.get('page'); ··· 373 return pds; 374 } 375 376 - function loadCurrentUserAvatar() { 377 - api.loadCurrentUserAvatar().then((url) => { 378 - showLoggedInStatus(true, url); 379 - }).catch((error) => { 380 - console.log(error); 381 - showLoggedInStatus(true, null); 382 - }); 383 - } 384 - 385 function logOut() { 386 accountAPI.resetTokens(); 387 localStorage.removeItem('incognito'); ··· 431 showNotificationsPage(); 432 } else if (page == 'posting_stats') { 433 window.postingStatsPage.show(); 434 } 435 } 436
··· 1 function init() { 2 window.dateLocale = localStorage.getItem('locale') || undefined; 3 window.isIncognito = !!localStorage.getItem('incognito'); 4 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null'); 5 6 window.loginDialog = $(document.querySelector('#login')); 7 8 window.avatarPreloader = buildAvatarPreloader(); 9 10 + window.accountMenu = new Menu(); 11 window.threadPage = new ThreadPage(); 12 window.postingStatsPage = new PostingStatsPage(); 13 + window.likeStatsPage = new LikeStatsPage(); 14 15 $(document.querySelector('#search form')).addEventListener('submit', (e) => { 16 e.preventDefault(); ··· 62 63 window.biohazardEnabled = false; 64 localStorage.setItem('biohazard', 'false'); 65 + accountMenu.toggleMenuButtonCheck('biohazard', false); 66 67 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) { 68 $(p).style.display = 'none'; ··· 73 hideDialog(target.closest('.dialog')); 74 }); 75 76 window.appView = new BlueskyAPI('api.bsky.app', false); 77 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false); 78 window.accountAPI = new BlueskyAPI(undefined, true); 79 80 if (accountAPI.isLoggedIn) { 81 accountAPI.host = accountAPI.user.pdsEndpoint; 82 + accountMenu.hideMenuButton('login'); 83 84 if (!isIncognito) { 85 window.api = accountAPI; 86 + accountMenu.showLoggedInStatus(true, api.user.avatar); 87 } else { 88 window.api = appView; 89 + accountMenu.showLoggedInStatus('incognito'); 90 + accountMenu.toggleMenuButtonCheck('incognito', true); 91 } 92 } else { 93 window.api = appView; 94 + accountMenu.hideMenuButton('logout'); 95 + accountMenu.hideMenuButton('incognito'); 96 } 97 98 + accountMenu.toggleMenuButtonCheck('biohazard', window.biohazardEnabled !== false); 99 100 parseQueryParams(); 101 } ··· 193 $id('login').classList.toggle('expanded'); 194 } 195 196 function submitLogin() { 197 let handle = $id('login_handle', HTMLInputElement); 198 let password = $id('login_password', HTMLInputElement); ··· 215 submit.style.display = 'inline'; 216 cloudy.style.display = 'none'; 217 218 + accountMenu.loadCurrentUserAvatar(); 219 + 220 + accountMenu.showMenuButton('logout'); 221 + accountMenu.showMenuButton('incognito'); 222 + accountMenu.hideMenuButton('login'); 223 224 let params = new URLSearchParams(location.search); 225 let page = params.get('page'); ··· 262 return pds; 263 } 264 265 function logOut() { 266 accountAPI.resetTokens(); 267 localStorage.removeItem('incognito'); ··· 311 showNotificationsPage(); 312 } else if (page == 'posting_stats') { 313 window.postingStatsPage.show(); 314 + } else if (page == 'like_stats') { 315 + window.likeStatsPage.show(); 316 } 317 } 318
+76 -1
style.css
··· 783 784 #posting_stats_page .scan-result td, #posting_stats_page .scan-result th { 785 border: 1px solid #333; 786 - padding: 5px 8px; 787 } 788 789 #posting_stats_page .scan-result td { 790 text-align: right; 791 } 792 793 #posting_stats_page .scan-result th { ··· 829 830 #posting_stats_page .scan-result td.percent { 831 min-width: 70px; 832 } 833 834 @media (prefers-color-scheme: dark) { ··· 1013 1014 #posting_stats_page .scan-result tr.total td { 1015 background-color: hsla(207, 90%, 25%, 0.4); 1016 } 1017 }
··· 783 784 #posting_stats_page .scan-result td, #posting_stats_page .scan-result th { 785 border: 1px solid #333; 786 } 787 788 #posting_stats_page .scan-result td { 789 text-align: right; 790 + padding: 5px 8px; 791 } 792 793 #posting_stats_page .scan-result th { ··· 829 830 #posting_stats_page .scan-result td.percent { 831 min-width: 70px; 832 + } 833 + 834 + #like_stats_page { 835 + display: none; 836 + } 837 + 838 + #like_stats_page input[type="range"] { 839 + width: 250px; 840 + vertical-align: middle; 841 + } 842 + 843 + #like_stats_page input[type="submit"] { 844 + font-size: 12pt; 845 + margin: 5px 0px; 846 + padding: 5px 10px; 847 + } 848 + 849 + #like_stats_page progress { 850 + width: 300px; 851 + margin-left: 10px; 852 + vertical-align: middle; 853 + display: none; 854 + } 855 + 856 + #like_stats_page .scan-result { 857 + border: 1px solid #333; 858 + border-collapse: collapse; 859 + display: none; 860 + float: left; 861 + margin-top: 20px; 862 + margin-bottom: 40px; 863 + } 864 + 865 + #like_stats_page .given-likes { 866 + margin-right: 100px; 867 + } 868 + 869 + #like_stats_page .scan-result td, #like_stats_page .scan-result th { 870 + border: 1px solid #333; 871 + padding: 5px 10px; 872 + } 873 + 874 + #like_stats_page .scan-result th { 875 + text-align: center; 876 + background-color: hsl(207, 100%, 86%); 877 + padding: 12px 10px; 878 + } 879 + 880 + #like_stats_page .scan-result td.no { 881 + font-weight: bold; 882 + text-align: right; 883 + } 884 + 885 + #like_stats_page .scan-result td.handle { 886 + width: 280px; 887 + } 888 + 889 + #like_stats_page .scan-result td.count { 890 + padding: 5px 15px; 891 + } 892 + 893 + #like_stats_page .scan-result .avatar { 894 + width: 24px; 895 + border-radius: 14px; 896 + vertical-align: middle; 897 + margin-right: 2px; 898 + padding: 2px; 899 } 900 901 @media (prefers-color-scheme: dark) { ··· 1080 1081 #posting_stats_page .scan-result tr.total td { 1082 background-color: hsla(207, 90%, 25%, 0.4); 1083 + } 1084 + 1085 + #like_stats_page .scan-result, #like_stats_page .scan-result td, #like_stats_page .scan-result th { 1086 + border-color: #888; 1087 + } 1088 + 1089 + #like_stats_page .scan-result th { 1090 + background-color: hsl(207, 90%, 25%); 1091 } 1092 }
+2 -1
types.d.ts
··· 12 declare var isIncognito: boolean; 13 declare var biohazardEnabled: boolean; 14 declare var loginDialog: HTMLElement; 15 - declare var accountMenu: HTMLElement; 16 declare var avatarPreloader: IntersectionObserver; 17 declare var threadPage: ThreadPage; 18 declare var postingStatsPage: PostingStatsPage; 19 20 type json = Record<string, any>; 21
··· 12 declare var isIncognito: boolean; 13 declare var biohazardEnabled: boolean; 14 declare var loginDialog: HTMLElement; 15 + declare var accountMenu: Menu; 16 declare var avatarPreloader: IntersectionObserver; 17 declare var threadPage: ThreadPage; 18 declare var postingStatsPage: PostingStatsPage; 19 + declare var likeStatsPage: LikeStatsPage; 20 21 type json = Record<string, any>; 22