Thread viewer for Bluesky

Merge branch 'posting_stats'

+14
api.js
··· 302 return { cursor: response.cursor, posts }; 303 } 304 305 /** @param {string} postURI, @returns {Promise<json>} */ 306 307 async loadPost(postURI) {
··· 302 return { cursor: response.cursor, posts }; 303 } 304 305 + async loadTimeline(days, options = {}) { 306 + let now = new Date(); 307 + let timeLimit = now.getTime() - days * 86400 * 1000; 308 + 309 + return await this.fetchAll('app.bsky.feed.getTimeline', { limit: 100 }, { 310 + field: 'feed', 311 + breakWhen: (x) => { 312 + let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt; 313 + return Date.parse(timestamp) < timeLimit; 314 + }, 315 + onPageLoad: options.onPageLoad 316 + }); 317 + } 318 + 319 /** @param {string} postURI, @returns {Promise<json>} */ 320 321 async loadPost(postURI) {
+37
index.html
··· 88 </form> 89 </div> 90 91 <script src="lib/purify.min.js"></script> 92 <script src="minisky.js"></script> 93 <script src="api.js"></script>
··· 88 </form> 89 </div> 90 91 + <div id="posting_stats_page"> 92 + <h2>Bluesky posting statistics</h2> 93 + 94 + <form> 95 + <p> 96 + Scan posts from: 97 + <input type="radio" name="scan_type" id="scan_type_timeline" value="timeline" checked> 98 + <label for="scan_type_timeline">Your timeline</label> 99 + <input type="radio" name="scan_type" id="scan_type_users" value="users" disabled> 100 + <label for="scan_type_users">Selected users (coming soon)</label> 101 + </p> 102 + 103 + <p> 104 + Time range: <input type="range" min="1" max="60" value="7"> <label>7 days</label> 105 + </p> 106 + 107 + <p> 108 + <input type="submit" value="Start scan"> <progress></progress> 109 + </p> 110 + </form> 111 + 112 + <table class="scan-result"> 113 + <thead> 114 + <tr> 115 + <th>#</th> 116 + <th>Handle</th> 117 + <th>All posts /d</th> 118 + <th>Own posts /d</th> 119 + <th>Reposts /d</th> 120 + <th>% of all</th> 121 + </tr> 122 + </thead> 123 + <tbody> 124 + </tbody> 125 + </table> 126 + </div> 127 + 128 <script src="lib/purify.min.js"></script> 129 <script src="minisky.js"></script> 130 <script src="api.js"></script>
+59 -1
minisky.js
··· 14 15 16 /** 17 * Thrown when authentication is needed, but access token is invalid or missing. 18 */ 19 ··· 97 let host = (this.host.includes('://')) ? this.host : `https://${this.host}`; 98 return host + '/xrpc'; 99 } else { 100 - throw new AuthError('Hostname not set'); 101 } 102 } 103 ··· 172 return await this.parseResponse(response); 173 } 174 175 /** @param {string | boolean} auth, @returns {Record<string, string>} */ 176 177 authHeaders(auth) { ··· 186 } else { 187 return {}; 188 } 189 } 190 191 /** @param {string} token, @returns {number} */
··· 14 15 16 /** 17 + * Thrown when passed arguments/options are invalid or missing. 18 + */ 19 + 20 + class RequestError extends Error {} 21 + 22 + 23 + /** 24 * Thrown when authentication is needed, but access token is invalid or missing. 25 */ 26 ··· 104 let host = (this.host.includes('://')) ? this.host : `https://${this.host}`; 105 return host + '/xrpc'; 106 } else { 107 + throw new RequestError('Hostname not set'); 108 } 109 } 110 ··· 179 return await this.parseResponse(response); 180 } 181 182 + async fetchAll(method, params, options) { 183 + if (!options || !options.field) { 184 + throw new RequestError("'field' option is required"); 185 + } 186 + 187 + let data = []; 188 + let reqParams = params ?? {}; 189 + let reqOptions = this.sliceOptions(options, ['auth', 'headers']); 190 + 191 + for (;;) { 192 + let response = await this.getRequest(method, reqParams, reqOptions); 193 + 194 + let items = response[options.field]; 195 + let cursor = response.cursor; 196 + 197 + if (options.breakWhen && items.some(x => options.breakWhen(x))) { 198 + items = items.filter(x => !options.breakWhen(x)); 199 + cursor = null; 200 + } 201 + 202 + data = data.concat(items); 203 + reqParams.cursor = cursor; 204 + 205 + if (options.onPageLoad) { 206 + let result = options.onPageLoad(items); 207 + 208 + if (result?.cancel) { 209 + break; 210 + } 211 + } 212 + 213 + if (items.length == 0 || !cursor) { 214 + break; 215 + } 216 + } 217 + 218 + return data; 219 + } 220 + 221 /** @param {string | boolean} auth, @returns {Record<string, string>} */ 222 223 authHeaders(auth) { ··· 232 } else { 233 return {}; 234 } 235 + } 236 + 237 + sliceOptions(options, list) { 238 + let newOptions = {}; 239 + 240 + for (let i of list) { 241 + if (i in options) { 242 + newOptions[i] = options[i]; 243 + } 244 + } 245 + 246 + return newOptions; 247 } 248 249 /** @param {string} token, @returns {number} */
+136
skythread.js
··· 7 8 window.loginDialog = $(document.querySelector('#login')); 9 window.accountMenu = $(document.querySelector('#account_menu')); 10 11 window.avatarPreloader = buildAvatarPreloader(); 12 ··· 123 $(accountMenu.querySelector('a[data-action=logout]')).addEventListener('click', (e) => { 124 e.preventDefault(); 125 logOut(); 126 }); 127 128 window.appView = new BlueskyAPI('api.bsky.app', false); ··· 447 if (page == 'notif') { 448 showLoader(); 449 showNotificationsPage(); 450 } 451 } 452 453 function showNotificationsPage() {
··· 7 8 window.loginDialog = $(document.querySelector('#login')); 9 window.accountMenu = $(document.querySelector('#account_menu')); 10 + window.postingStatsPage = $id('posting_stats_page'); 11 12 window.avatarPreloader = buildAvatarPreloader(); 13 ··· 124 $(accountMenu.querySelector('a[data-action=logout]')).addEventListener('click', (e) => { 125 e.preventDefault(); 126 logOut(); 127 + }); 128 + 129 + $(postingStatsPage.querySelector('form')).addEventListener('submit', (e) => { 130 + if (!window.scanStartTime) { 131 + scanPostingStats(); 132 + } else { 133 + stopScan(); 134 + } 135 + }); 136 + 137 + $(postingStatsPage.querySelector('input[type="range"]')).addEventListener('input', (e) => { 138 + let range = $(e.target, HTMLInputElement); 139 + configurePostingStats({ days: range.value }); 140 }); 141 142 window.appView = new BlueskyAPI('api.bsky.app', false); ··· 461 if (page == 'notif') { 462 showLoader(); 463 showNotificationsPage(); 464 + } else if (page == 'posting_stats') { 465 + showPostingStatsPage(); 466 } 467 + } 468 + 469 + function showPostingStatsPage() { 470 + $id('posting_stats_page').style.display = 'block'; 471 + } 472 + 473 + function configurePostingStats(args) { 474 + if (args.days) { 475 + let label = $(postingStatsPage.querySelector('input[type=range] + label')); 476 + label.innerText = (args.days == 1) ? '1 day' : `${args.days} days`; 477 + } 478 + } 479 + 480 + function scanPostingStats() { 481 + let submit = $(postingStatsPage.querySelector('input[type=submit]'), HTMLInputElement); 482 + submit.value = 'Cancel'; 483 + 484 + let range = $(postingStatsPage.querySelector('input[type=range]'), HTMLInputElement); 485 + let days = parseInt(range.value, 10); 486 + 487 + let progressBar = $(postingStatsPage.querySelector('input[type=submit] + progress'), HTMLProgressElement); 488 + progressBar.max = days; 489 + progressBar.value = 0; 490 + progressBar.style.display = 'inline'; 491 + 492 + let table = $(postingStatsPage.querySelector('table.scan-result')); 493 + table.style.display = 'none'; 494 + 495 + let tbody = $(table.querySelector('tbody')); 496 + tbody.innerHTML = ''; 497 + 498 + let now = new Date().getTime(); 499 + window.scanStartTime = now; 500 + 501 + accountAPI.loadTimeline(days, { 502 + onPageLoad: (data) => { 503 + let minTime = now; 504 + 505 + if (window.scanStartTime != now) { 506 + return { cancel: true }; 507 + } 508 + 509 + for (let item of data) { 510 + let timestamp = item.reason ? item.reason.indexedAt : item.post.record.createdAt; 511 + let date = Date.parse(timestamp); 512 + minTime = Math.min(minTime, date); 513 + } 514 + 515 + let daysBack = (now - minTime) / 86400 / 1000; 516 + progressBar.value = daysBack; 517 + } 518 + }).then(items => { 519 + if (window.scanStartTime != now) { 520 + return; 521 + } 522 + 523 + let users = {}; 524 + let total = 0; 525 + 526 + for (let item of items) { 527 + if (item.reply) { continue; } 528 + 529 + let user = item.reason ? item.reason.by : item.post.author; 530 + let handle = user.handle; 531 + users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar }; 532 + total += 1; 533 + 534 + if (item.reason) { 535 + users[handle].reposts += 1; 536 + } else { 537 + users[handle].own += 1; 538 + } 539 + } 540 + 541 + let sorted = Object.values(users).sort((a, b) => { 542 + let asum = a.own + a.reposts; 543 + let bsum = b.own + b.reposts; 544 + 545 + if (asum < bsum) { 546 + return 1; 547 + } else if (asum > bsum) { 548 + return -1; 549 + } else { 550 + return 0; 551 + } 552 + }); 553 + 554 + for (let i = 0; i < sorted.length; i++) { 555 + let user = sorted[i]; 556 + let tr = $tag('tr'); 557 + 558 + tr.append( 559 + $tag('td.no', { text: i + 1 }), 560 + $tag('td.handle', { 561 + html: `<img class="avatar" src="${user.avatar}"> ` + 562 + `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 563 + }), 564 + $tag('td', { text: ((user.own + user.reposts) / days).toFixed(1) }), 565 + $tag('td', { text: user.own > 0 ? (user.own / days).toFixed(1) : '–' }), 566 + $tag('td', { text: user.reposts > 0 ? (user.reposts / days).toFixed(1) : '–' }), 567 + $tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }) 568 + ); 569 + 570 + tbody.append(tr); 571 + } 572 + 573 + table.style.display = 'table'; 574 + submit.value = 'Start scan'; 575 + progressBar.style.display = 'none'; 576 + window.scanStartTime = undefined; 577 + }); 578 + } 579 + 580 + function stopScan() { 581 + let submit = $(postingStatsPage.querySelector('input[type=submit]'), HTMLInputElement); 582 + submit.value = 'Start scan'; 583 + window.scanStartTime = undefined; 584 + 585 + let progressBar = $(postingStatsPage.querySelector('input[type=submit] + progress'), HTMLProgressElement); 586 + progressBar.style.display = 'none'; 587 } 588 589 function showNotificationsPage() {
+89
style.css
··· 717 margin-top: 25px; 718 } 719 720 @media (prefers-color-scheme: dark) { 721 body { 722 background-color: rgb(39, 39, 37); ··· 883 884 .post .stats i.fa-heart.liked:hover { 885 color: #ff7070; 886 } 887 }
··· 717 margin-top: 25px; 718 } 719 720 + #posting_stats_page { 721 + display: none; 722 + } 723 + 724 + #posting_stats_page input[type="radio"] { 725 + position: relative; 726 + top: -1px; 727 + } 728 + 729 + #posting_stats_page label { 730 + user-select: none; 731 + -webkit-user-select: none; 732 + } 733 + 734 + #posting_stats_page input:disabled + label { 735 + color: #999; 736 + } 737 + 738 + #posting_stats_page input[type="range"] { 739 + width: 250px; 740 + vertical-align: middle; 741 + } 742 + 743 + #posting_stats_page input[type="submit"] { 744 + font-size: 12pt; 745 + margin: 5px 0px; 746 + padding: 5px 10px; 747 + } 748 + 749 + #posting_stats_page progress { 750 + width: 300px; 751 + margin-left: 10px; 752 + vertical-align: middle; 753 + display: none; 754 + } 755 + 756 + #posting_stats_page .scan-result { 757 + border: 1px solid #333; 758 + border-collapse: collapse; 759 + display: none; 760 + } 761 + 762 + #posting_stats_page .scan-result td, #posting_stats_page .scan-result th { 763 + border: 1px solid #333; 764 + padding: 5px 8px; 765 + } 766 + 767 + #posting_stats_page .scan-result td { 768 + text-align: right; 769 + } 770 + 771 + #posting_stats_page .scan-result th { 772 + text-align: center; 773 + background-color: hsl(207, 100%, 86%); 774 + padding: 7px 10px; 775 + } 776 + 777 + #posting_stats_page .scan-result td.handle { 778 + text-align: left; 779 + } 780 + 781 + #posting_stats_page .scan-result .avatar { 782 + width: 24px; 783 + border-radius: 14px; 784 + vertical-align: middle; 785 + margin-right: 2px; 786 + padding: 2px; 787 + } 788 + 789 + #posting_stats_page .scan-result td.no { 790 + font-weight: bold; 791 + } 792 + 793 + #posting_stats_page .scan-result td.percent { 794 + min-width: 70px; 795 + } 796 + 797 @media (prefers-color-scheme: dark) { 798 body { 799 background-color: rgb(39, 39, 37); ··· 960 961 .post .stats i.fa-heart.liked:hover { 962 color: #ff7070; 963 + } 964 + 965 + #posting_stats_page input:disabled + label { 966 + color: #777; 967 + } 968 + 969 + #posting_stats_page .scan-result, #posting_stats_page .scan-result td, #posting_stats_page .scan-result th { 970 + border-color: #888; 971 + } 972 + 973 + #posting_stats_page .scan-result th { 974 + background-color: hsl(207, 90%, 25%); 975 } 976 }
+2
types.d.ts
··· 11 declare var api: BlueskyAPI; 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 18 type json = Record<string, any>;
··· 11 declare var api: BlueskyAPI; 12 declare var isIncognito: boolean; 13 declare var biohazardEnabled: boolean; 14 + declare var scanStartTime: number | undefined; 15 declare var loginDialog: HTMLElement; 16 declare var accountMenu: HTMLElement; 17 + declare var postingStatsPage: HTMLElement; 18 declare var avatarPreloader: IntersectionObserver; 19 20 type json = Record<string, any>;