Thread viewer for Bluesky
1import { BlueskyAPI, accountAPI } from '../api.js';
2import { feedPostTime } from '../utils.js';
3
4/**
5 * Manages the Posting Stats page.
6 */
7
8type GenerateResultsOptions = {
9 countFetchedDays?: boolean
10 users?: UserWithHandle[]
11}
12
13export type OnProgress = ((progress: number) => void);
14
15export type UserWithHandle = {
16 did: string,
17 handle: string,
18 avatar?: string
19}
20
21export type PostingStatsResultRow = {
22 handle: string,
23 avatar: string | undefined,
24 own: number,
25 reposts: number,
26 all: number
27}
28
29export type PostingStatsResult = {
30 users: PostingStatsResultRow[],
31 sums: { own: number, reposts: number, all: number },
32 fetchedDays: number,
33 daysBack: number
34}
35
36export class PostingStats {
37 appView: BlueskyAPI;
38 userProgress: Record<string, { pages: number, progress: number }>;
39 onProgress: OnProgress | undefined;
40 abortController?: AbortController;
41
42 constructor(onProgress?: OnProgress) {
43 this.onProgress = onProgress;
44 this.appView = new BlueskyAPI('public.api.bsky.app');
45 this.userProgress = {};
46 }
47
48 async scanHomeTimeline(requestedDays: number): Promise<PostingStatsResult | null> {
49 let startTime = new Date().getTime();
50 this.abortController = new AbortController();
51
52 let posts = await accountAPI.loadHomeTimeline(requestedDays, {
53 onPageLoad: (data) => this.updateProgress(data, startTime),
54 abortSignal: this.abortController.signal,
55 keepLastPage: true
56 });
57
58 return this.generateResults(posts, requestedDays, startTime);
59 }
60
61 async scanListTimeline(listURI: string, requestedDays: number): Promise<PostingStatsResult | null> {
62 let startTime = new Date().getTime();
63 this.abortController = new AbortController();
64
65 let posts = await accountAPI.loadListTimeline(listURI, requestedDays, {
66 onPageLoad: (data) => this.updateProgress(data, startTime),
67 abortSignal: this.abortController.signal,
68 keepLastPage: true
69 });
70
71 return this.generateResults(posts, requestedDays, startTime);
72 }
73
74 async scanUserTimelines(users: UserWithHandle[], requestedDays: number): Promise<PostingStatsResult | null> {
75 let startTime = new Date().getTime();
76 let dids = users.map(u => u.did);
77 this.resetUserProgress(dids);
78 this.abortController = new AbortController();
79
80 let abortSignal = this.abortController.signal;
81 let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, {
82 filter: 'posts_and_author_threads',
83 onPageLoad: (data) => this.updateUserProgress(did, data, startTime, requestedDays),
84 abortSignal: abortSignal,
85 keepLastPage: true
86 }));
87
88 let datasets = await Promise.all(requests);
89 let posts = datasets.flat();
90
91 return this.generateResults(posts, requestedDays, startTime, { countFetchedDays: false, users: users });
92 }
93
94 async scanYourTimeline(requestedDays: number): Promise<PostingStatsResult | null> {
95 let startTime = new Date().getTime();
96 this.abortController = new AbortController();
97
98 let posts = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, {
99 filter: 'posts_no_replies',
100 onPageLoad: (data) => this.updateProgress(data, startTime),
101 abortSignal: this.abortController.signal,
102 keepLastPage: true
103 });
104
105 return this.generateResults(posts, requestedDays, startTime);
106 }
107
108 generateResults(posts: json[], requestedDays: number, startTime: number, options: GenerateResultsOptions = {}) {
109 let last = posts.at(-1);
110
111 if (!last) {
112 return null;
113 }
114
115 let users: Record<string, PostingStatsResultRow> = {};
116
117 let lastDate = feedPostTime(last);
118 let fetchedDays = (startTime - lastDate) / 86400 / 1000;
119 let daysBack: number;
120
121 if (options.countFetchedDays !== false) {
122 daysBack = Math.min(requestedDays, fetchedDays);
123 } else {
124 daysBack = requestedDays;
125 }
126
127 let timeLimit = startTime - requestedDays * 86400 * 1000;
128 posts = posts.filter(x => (feedPostTime(x) > timeLimit));
129 posts.reverse();
130
131 if (options.users) {
132 for (let user of options.users) {
133 users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar } as PostingStatsResultRow;
134 }
135 }
136
137 let ownThreads = new Set();
138 let sums = { own: 0, reposts: 0, all: 0 };
139
140 for (let item of posts) {
141 if (item.reply) {
142 if (!ownThreads.has(item.reply.parent.uri)) {
143 continue;
144 }
145 }
146
147 let user = item.reason ? item.reason.by : item.post.author;
148 let handle = user.handle;
149 users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar };
150
151 if (item.reason) {
152 users[handle].reposts += 1;
153 sums.reposts += 1;
154 } else {
155 users[handle].own += 1;
156 sums.own += 1;
157 ownThreads.add(item.post.uri);
158 }
159 }
160
161 let userRows = Object.values(users);
162 userRows.forEach((u) => { u.all = u.own + u.reposts });
163 userRows.sort((a, b) => b.all - a.all);
164
165 sums.all = sums.own + sums.reposts;
166
167 return { users: userRows, sums, fetchedDays, daysBack };
168 }
169
170 updateProgress(dataPage: json[], startTime: number) {
171 let last = dataPage.at(-1);
172
173 if (!last) { return }
174
175 let lastDate = feedPostTime(last);
176 let daysBack = (startTime - lastDate) / 86400 / 1000;
177
178 this.onProgress?.(daysBack);
179 }
180
181 resetUserProgress(dids: string[]) {
182 this.userProgress = {};
183
184 for (let did of dids) {
185 this.userProgress[did] = { pages: 0, progress: 0 };
186 }
187 }
188
189 updateUserProgress(did: string, dataPage: json[], startTime: number, requestedDays: number) {
190 let last = dataPage.at(-1);
191
192 if (!last) { return }
193
194 let lastDate = feedPostTime(last);
195 let daysBack = (startTime - lastDate) / 86400 / 1000;
196
197 this.userProgress[did].pages += 1;
198 this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0);
199
200 let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress);
201 let known = expectedPages.filter(x => !isNaN(x));
202 let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length;
203 let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b);
204
205 let progress = (fetchedPages / expectedTotalPages) * requestedDays;
206 this.onProgress?.(progress);
207 }
208
209 abortScan() {
210 this.abortController?.abort();
211 delete this.abortController;
212 }
213}