+14
api.js
+14
api.js
···
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
+37
index.html
···
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
+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
+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
+89
style.css
···
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
+2
types.d.ts
···
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>;