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