Thread viewer for Bluesky
1/**
2 * Manages the Posting Stats page.
3 */
4
5class PostingStatsPage {
6
7 /** @type {number | undefined} */
8 scanStartTime;
9
10 /** @type {Record<string, { pages: number, progress: number }>} */
11 userProgress;
12
13 constructor() {
14 this.pageElement = $id('posting_stats_page');
15 this.form = $(this.pageElement.querySelector('form'), HTMLFormElement);
16
17 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
18 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
19 this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement);
20 this.table = $(this.pageElement.querySelector('table.scan-result'));
21 this.tableHead = $(this.table.querySelector('thead'));
22 this.tableBody = $(this.table.querySelector('tbody'));
23 this.listSelect = $(this.pageElement.querySelector('.list-choice select'), HTMLSelectElement);
24 this.scanInfo = $(this.pageElement.querySelector('.scan-info'));
25 this.scanType = this.form.elements['scan_type'];
26
27 this.setupEvents();
28
29 this.userProgress = {};
30 this.appView = new BlueskyAPI('public.api.bsky.app', false);
31 }
32
33 setupEvents() {
34 this.form.addEventListener('submit', (e) => {
35 e.preventDefault();
36
37 if (!this.scanStartTime) {
38 this.scanPostingStats();
39 } else {
40 this.stopScan();
41 }
42 });
43
44 this.rangeInput.addEventListener('input', (e) => {
45 let days = parseInt(this.rangeInput.value, 10);
46 let label = $(this.pageElement.querySelector('input[type=range] + label'));
47 label.innerText = (days == 1) ? '1 day' : `${days} days`;
48 });
49
50 this.scanType.forEach(r => {
51 r.addEventListener('click', (e) => {
52 let value = $(r, HTMLInputElement).value;
53
54 $(this.pageElement.querySelector('.list-choice')).style.display = (value == 'list') ? 'block' : 'none';
55 $(this.pageElement.querySelector('.user-choice')).style.display = (value == 'users') ? 'block' : 'none';
56
57 this.table.style.display = 'none';
58 });
59 });
60 }
61
62 show() {
63 this.pageElement.style.display = 'block';
64 this.fetchLists();
65 }
66
67 /** @returns {number} */
68
69 selectedDaysRange() {
70 return parseInt(this.rangeInput.value, 10);
71 }
72
73 /** @returns {Promise<void>} */
74
75 async fetchLists() {
76 let lists = await accountAPI.loadUserLists();
77
78 let sorted = lists.sort((a, b) => {
79 let aName = a.name.toLocaleLowerCase();
80 let bName = b.name.toLocaleLowerCase();
81
82 return aName.localeCompare(bName);
83 });
84
85 for (let list of lists) {
86 this.listSelect.append(
87 $tag('option', { value: list.uri, text: list.name + ' ' })
88 );
89 }
90 }
91
92 /** @returns {Promise<void>} */
93
94 async scanPostingStats() {
95 let startTime = new Date().getTime();
96 let requestedDays = this.selectedDaysRange();
97 let scanType = this.scanType.value;
98
99 this.startScan(startTime, requestedDays);
100
101 /** @type {FetchAllOnPageLoad} */
102 let onPageLoad = (data) => {
103 if (this.scanStartTime != startTime) {
104 return { cancel: true };
105 }
106
107 this.updateProgress(data, startTime);
108 };
109
110 if (scanType == 'home') {
111 let items = await accountAPI.loadHomeTimeline(requestedDays, {
112 onPageLoad: onPageLoad,
113 keepLastPage: true
114 });
115
116 this.updateResultsTable(items, startTime, requestedDays);
117 } else if (scanType == 'list') {
118 let list = this.listSelect.value;
119 let items = await accountAPI.loadListTimeline(list, requestedDays, {
120 onPageLoad: onPageLoad,
121 keepLastPage: true
122 });
123
124 this.updateResultsTable(items, startTime, requestedDays, { showReposts: false });
125 } else if (scanType == 'users') {
126 let textarea = $(this.pageElement.querySelector('textarea'), HTMLTextAreaElement);
127 let users = textarea.value.split(/\n/).map(x => x.trim()).filter(x => x.length > 0);
128 let dids = await Promise.all(users.map(u => accountAPI.resolveHandle(u)));
129
130 this.resetUserProgress(dids);
131
132 let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, {
133 filter: 'posts_no_replies',
134 onPageLoad: (data) => {
135 if (this.scanStartTime != startTime) {
136 return { cancel: true };
137 }
138
139 this.updateUserProgress(did, data, startTime, requestedDays);
140 },
141 keepLastPage: true
142 }));
143
144 let datasets = await Promise.all(requests);
145 let items = datasets.flat();
146
147 this.updateResultsTable(items, startTime, requestedDays, {
148 showTotal: false, showPercentages: false, countFetchedDays: false
149 });
150 } else {
151 let items = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, {
152 filter: 'posts_no_replies',
153 onPageLoad: onPageLoad,
154 keepLastPage: true
155 });
156
157 this.updateResultsTable(items, startTime, requestedDays, { showTotal: false, showPercentages: false });
158 }
159 }
160
161 /** @param {json[]} dataPage, @param {number} startTime */
162
163 updateProgress(dataPage, startTime) {
164 let last = dataPage.at(-1);
165
166 if (!last) { return }
167
168 let lastDate = feedPostTime(last);
169 let daysBack = (startTime - lastDate) / 86400 / 1000;
170
171 this.progressBar.value = daysBack;
172 }
173
174 /** @param {string[]} dids */
175
176 resetUserProgress(dids) {
177 this.userProgress = {};
178
179 for (let did of dids) {
180 this.userProgress[did] = { pages: 0, progress: 0 };
181 }
182 }
183
184 /** @param {string} did, @param {json[]} dataPage, @param {number} startTime, @param {number} requestedDays */
185
186 updateUserProgress(did, dataPage, startTime, requestedDays) {
187 let last = dataPage.at(-1);
188
189 if (!last) { return }
190
191 let lastDate = feedPostTime(last);
192 let daysBack = (startTime - lastDate) / 86400 / 1000;
193
194 this.userProgress[did].pages += 1;
195 this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0);
196
197 let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress);
198 let known = expectedPages.filter(x => !isNaN(x));
199 let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length;
200 let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b);
201
202 this.progressBar.value = Math.max(this.progressBar.value, (fetchedPages / expectedTotalPages) * requestedDays);
203 }
204
205 /** @param {json} a, @param {json} b, @returns {number} */
206
207 sortUserRows(a, b) {
208 let asum = a.own + a.reposts;
209 let bsum = b.own + b.reposts;
210
211 if (asum < bsum) {
212 return 1;
213 } else if (asum > bsum) {
214 return -1;
215 } else {
216 return 0;
217 }
218 }
219
220 /**
221 * @param {json[]} items
222 * @param {number} startTime
223 * @param {number} requestedDays
224 * @param {{ showTotal?: boolean, showPercentages?: boolean, showReposts?: boolean, countFetchedDays?: boolean }} [options]
225 */
226
227 updateResultsTable(items, startTime, requestedDays, options = {}) {
228 if (this.scanStartTime != startTime) {
229 return;
230 }
231
232 let users = {};
233 let total = 0;
234 let allReposts = 0;
235 let allNormalPosts = 0;
236
237 let last = items.at(-1);
238
239 if (!last) {
240 this.stopScan();
241 return;
242 }
243
244 let daysBack;
245
246 if (options.countFetchedDays !== false) {
247 let lastDate = feedPostTime(last);
248 let fetchedDays = (startTime - lastDate) / 86400 / 1000;
249
250 if (Math.ceil(fetchedDays) < requestedDays) {
251 this.scanInfo.innerText = `🕓 Showing data from ${Math.round(fetchedDays)} days (the timeline only goes that far):`;
252 this.scanInfo.style.display = 'block';
253 }
254
255 daysBack = Math.min(requestedDays, fetchedDays);
256 } else {
257 daysBack = requestedDays;
258 }
259
260 let timeLimit = startTime - requestedDays * 86400 * 1000;
261 items = items.filter(x => (feedPostTime(x) > timeLimit));
262
263 for (let item of items) {
264 if (item.reply) { continue; }
265
266 let user = item.reason ? item.reason.by : item.post.author;
267 let handle = user.handle;
268 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar };
269 total += 1;
270
271 if (item.reason) {
272 users[handle].reposts += 1;
273 allReposts += 1;
274 } else {
275 users[handle].own += 1;
276 allNormalPosts += 1;
277 }
278 }
279
280 let headRow = $tag('tr');
281
282 if (options.showReposts !== false) {
283 headRow.append(
284 $tag('th', { text: '#' }),
285 $tag('th', { text: 'Handle' }),
286 $tag('th', { text: 'All posts /d' }),
287 $tag('th', { text: 'Own posts /d' }),
288 $tag('th', { text: 'Reposts /d' })
289 );
290 } else {
291 headRow.append(
292 $tag('th', { text: '#' }),
293 $tag('th', { text: 'Handle' }),
294 $tag('th', { text: 'Posts /d' }),
295 );
296 }
297
298 if (options.showPercentages !== false) {
299 headRow.append($tag('th', { text: '% of all' }));
300 }
301
302 this.tableHead.append(headRow);
303
304 if (options.showTotal !== false) {
305 let tr = $tag('tr.total');
306
307 tr.append(
308 $tag('td.no', { text: '' }),
309 $tag('td.handle', { text: 'Total:' }),
310
311 (options.showReposts !== false) ?
312 $tag('td', { text: (total / daysBack).toFixed(1) }) : '',
313
314 $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }),
315
316 (options.showReposts !== false) ?
317 $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : ''
318 );
319
320 if (options.showPercentages !== false) {
321 tr.append($tag('td.percent', { text: '' }));
322 }
323
324 this.tableBody.append(tr);
325 }
326
327 let sorted = Object.values(users).sort(this.sortUserRows);
328
329 for (let i = 0; i < sorted.length; i++) {
330 let user = sorted[i];
331 let tr = $tag('tr');
332
333 tr.append(
334 $tag('td.no', { text: i + 1 }),
335 $tag('td.handle', {
336 html: `<img class="avatar" src="${user.avatar}"> ` +
337 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
338 }),
339
340 (options.showReposts !== false) ?
341 $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '',
342
343 $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : '–' }),
344
345 (options.showReposts !== false) ?
346 $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : '–' }) : ''
347 );
348
349 if (options.showPercentages !== false) {
350 tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }));
351 }
352
353 this.tableBody.append(tr);
354 }
355
356 this.table.style.display = 'table';
357 this.stopScan();
358 }
359
360 startScan(startTime, requestedDays) {
361 this.submitButton.value = 'Cancel';
362
363 this.progressBar.max = requestedDays;
364 this.progressBar.value = 0;
365 this.progressBar.style.display = 'inline';
366
367 this.table.style.display = 'none';
368 this.tableHead.innerHTML = '';
369 this.tableBody.innerHTML = '';
370
371 this.scanStartTime = startTime;
372 this.scanInfo.style.display = 'none';
373 }
374
375 stopScan() {
376 this.submitButton.value = 'Start scan';
377 this.scanStartTime = undefined;
378 this.progressBar.style.display = 'none';
379 }
380}