Thread viewer for Bluesky

use AbortController API in the statistical scans

+103 -96
+1 -1
src/api/authenticated_api.ts
··· 1 1 import { BlueskyAPI, type TimelineFetchOptions } from "./bluesky_api"; 2 - import { AuthError, type FetchAllOnPageLoad } from './minisky.js'; 2 + import { AuthError } from './minisky.js'; 3 3 import { Post } from '../models/posts.js'; 4 4 import { atURI, feedPostTime } from '../utils.js'; 5 5
+1
src/api/bluesky_api.ts
··· 46 46 export type TimelineFetchOptions = { 47 47 onPageLoad?: FetchAllOnPageLoad; 48 48 keepLastPage?: boolean; 49 + abortSignal?: AbortSignal; 49 50 } 50 51 51 52 /**
+19 -19
src/api/minisky.ts
··· 45 45 export type MiniskyRequestOptions = { 46 46 auth?: string | boolean; 47 47 headers?: Record<string, string>; 48 + abortSignal?: AbortSignal; 48 49 }; 49 50 50 - export type FetchAllOnPageLoad = (items: json[]) => { cancel: true } | undefined | void; 51 + export type FetchAllOnPageLoad = (items: json[]) => void; 51 52 52 53 export type FetchAllOptions = MiniskyOptions & MiniskyRequestOptions & { 53 54 field: string; ··· 91 92 return !!(this.user && this.user.accessToken && this.user.refreshToken && this.user.did && this.user.pdsEndpoint); 92 93 } 93 94 94 - async getRequest(method: string, params?: json | null, options?: MiniskyRequestOptions): Promise<json> { 95 + async getRequest(method: string, params?: json | null, options: MiniskyRequestOptions = {}): Promise<json> { 95 96 let url = new URL(`${this.baseURL}/${method}`); 96 97 let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders; 97 98 ··· 109 110 } 110 111 } 111 112 112 - let headers = this.authHeaders(auth); 113 + let headers: HeadersInit = this.authHeaders(auth); 113 114 114 - if (options && options.headers) { 115 + if (options.headers) { 115 116 Object.assign(headers, options.headers); 116 117 } 117 118 118 - let response = await fetch(url, { headers: headers }); 119 + let response = await fetch(url, { headers: headers, signal: options.abortSignal ?? null }); 119 120 return await this.parseResponse(response); 120 121 } 121 122 122 - async postRequest(method: string, data?: json | null, options?: MiniskyRequestOptions): Promise<json> { 123 + async postRequest(method: string, data?: json | null, options: MiniskyRequestOptions = {}): Promise<json> { 123 124 let url = `${this.baseURL}/${method}`; 124 125 let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders; 125 126 ··· 127 128 await this.checkAccess(); 128 129 } 129 130 130 - let request: Record<string, any> = { method: 'POST', headers: this.authHeaders(auth) }; 131 + let headers: HeadersInit = this.authHeaders(auth); 132 + let request: RequestInit = { method: 'POST' }; 131 133 132 134 if (data) { 133 135 request.body = JSON.stringify(data); 134 - request.headers['Content-Type'] = 'application/json'; 136 + headers['Content-Type'] = 'application/json'; 135 137 } 136 138 137 - if (options && options.headers) { 138 - Object.assign(request.headers, options.headers); 139 + if (options.headers) { 140 + Object.assign(headers, options.headers); 139 141 } 140 142 143 + if (options.abortSignal) { 144 + request.signal = options.abortSignal; 145 + } 146 + 147 + request.headers = headers; 141 148 let response = await fetch(url, request); 142 149 return await this.parseResponse(response); 143 150 } ··· 149 156 150 157 let data: json[] = []; 151 158 let reqParams: json = options.params ?? {}; 152 - let reqOptions = this.sliceOptions(options, ['auth', 'headers']); 159 + let reqOptions = this.sliceOptions(options, ['auth', 'headers', 'abortSignal']) as MiniskyRequestOptions; 153 160 154 161 for (;;) { 155 162 let response = await this.getRequest(method, reqParams, reqOptions); ··· 171 178 172 179 data = data.concat(items); 173 180 reqParams.cursor = cursor; 174 - 175 - if (options.onPageLoad) { 176 - let result = options.onPageLoad(items); 177 - 178 - if (result?.cancel) { 179 - break; 180 - } 181 - } 181 + options.onPageLoad?.(items); 182 182 183 183 if (!cursor) { 184 184 break;
+16 -10
src/pages/LikeStatsPage.svelte
··· 14 14 async function startScan(e: Event) { 15 15 e.preventDefault(); 16 16 17 - if (!scanInProgress) { 18 - givenLikesUsers = undefined; 19 - receivedLikesUsers = undefined; 17 + try { 18 + if (!scanInProgress) { 19 + givenLikesUsers = undefined; 20 + receivedLikesUsers = undefined; 20 21 21 - let result = await likeStats.findLikes(timeRangeDays, (p) => { progress = p }); 22 + let result = await likeStats.findLikes(timeRangeDays, (p) => { progress = p }); 22 23 23 - givenLikesUsers = result.givenLikes; 24 - receivedLikesUsers = result.receivedLikes; 25 - progress = undefined; 26 - } else { 27 - likeStats.stopScan(); 28 - progress = undefined; 24 + givenLikesUsers = result.givenLikes; 25 + receivedLikesUsers = result.receivedLikes; 26 + progress = undefined; 27 + } else { 28 + likeStats.abortScan(); 29 + progress = undefined; 30 + } 31 + } catch (error) { 32 + if (error.name !== 'AbortError') { 33 + throw error; 34 + } 29 35 } 30 36 } 31 37 </script>
+13 -7
src/pages/PostingStatsPage.svelte
··· 50 50 selectedList = lists[0]?.uri; 51 51 } 52 52 53 - function onsubmit(e: Event) { 53 + async function onsubmit(e: Event) { 54 54 e.preventDefault(); 55 55 56 - if (!scanInProgress) { 57 - startScan(); 58 - } else { 59 - scanInProgress = false; 60 - scanner.stopScan(); 56 + try { 57 + if (!scanInProgress) { 58 + await runScan(); 59 + } else { 60 + scanInProgress = false; 61 + scanner.abortScan(); 62 + } 63 + } catch (error) { 64 + if (error.name !== 'AbortError') { 65 + throw error; 66 + } 61 67 } 62 68 } 63 69 64 - async function startScan() { 70 + async function runScan() { 65 71 if ((selectedTab == 'list' && !selectedList) || (selectedTab == 'users' && selectedUsers.length == 0)) { 66 72 return; 67 73 }
+15 -9
src/pages/TimelineSearchPage.svelte
··· 18 18 async function startScan(e: Event) { 19 19 e.preventDefault(); 20 20 21 - if (!fetchInProgress) { 22 - progressMax = timeRangeDays; 23 - progress = 0; 21 + try { 22 + if (!fetchInProgress) { 23 + progressMax = timeRangeDays; 24 + progress = 0; 24 25 25 - await timelineSearch.fetchTimeline(timeRangeDays, (p) => { progress = p }); 26 + await timelineSearch.fetchTimeline(timeRangeDays, (p) => { progress = p }); 26 27 27 - daysFetched = progress; 28 - progress = undefined; 29 - } else { 30 - progress = undefined; 31 - timelineSearch.stopFetch(); 28 + daysFetched = progress; 29 + progress = undefined; 30 + } else { 31 + progress = undefined; 32 + timelineSearch.abortFetch(); 33 + } 34 + } catch (error) { 35 + if (error.name !== 'AbortError') { 36 + throw error; 37 + } 32 38 } 33 39 } 34 40
+15 -5
src/services/like_stats.ts
··· 12 12 progressLikeRecords: number; 13 13 progressPostLikes: number; 14 14 onProgress: ((days: number) => void) | undefined 15 + abortController?: AbortController; 15 16 16 17 constructor() { 17 18 this.appView = new BlueskyAPI('public.api.bsky.app'); ··· 25 26 this.onProgress = onProgress; 26 27 this.resetProgress(); 27 28 this.scanStartTime = new Date().getTime(); 29 + this.abortController = new AbortController(); 28 30 29 31 let fetchGivenLikes = this.fetchGivenLikes(requestedDays); 30 32 ··· 36 38 let givenStats = this.sumUpGivenLikes(givenLikes); 37 39 let topGiven = this.getTopEntries(givenStats); 38 40 39 - let profileInfo = await this.appView.getRequest('app.bsky.actor.getProfiles', { actors: topGiven.map(x => x.did) }); 41 + let profileInfo = await this.appView.getRequest('app.bsky.actor.getProfiles', 42 + { actors: topGiven.map(x => x.did) }, 43 + { abortSignal: this.abortController!.signal } 44 + ); 40 45 41 46 for (let profile of profileInfo.profiles) { 42 47 let user = topGiven.find(x => x.did == profile.did)!; ··· 69 74 let daysBack = (startTime - lastDate) / 86400 / 1000; 70 75 71 76 this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) }); 72 - } 77 + }, 78 + abortSignal: this.abortController!.signal 73 79 }); 74 80 } 75 81 ··· 87 93 let daysBack = (startTime - lastDate) / 86400 / 1000; 88 94 89 95 this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) }); 90 - } 96 + }, 97 + abortSignal: this.abortController!.signal 91 98 }); 92 99 93 100 let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0); ··· 104 111 uri: x['post']['uri'], 105 112 limit: 100 106 113 }, 107 - field: 'likes' 114 + field: 'likes', 115 + abortSignal: this.abortController!.signal 108 116 }); 109 117 }); 110 118 ··· 193 201 } 194 202 } 195 203 196 - stopScan() { 204 + abortScan() { 197 205 this.scanStartTime = undefined; 198 206 this.onProgress = undefined; 207 + this.abortController?.abort(); 208 + delete this.abortController; 199 209 } 200 210 }
+17 -32
src/services/posting_stats.ts
··· 35 35 36 36 export class PostingStats { 37 37 appView: BlueskyAPI; 38 - scanStartTime: number | undefined; 39 38 userProgress: Record<string, { pages: number, progress: number }>; 40 39 onProgress: OnProgress | undefined; 40 + abortController?: AbortController; 41 41 42 42 constructor(onProgress?: OnProgress) { 43 43 this.onProgress = onProgress; ··· 45 45 this.userProgress = {}; 46 46 } 47 47 48 - onPageLoad(data: json[], startTime: number): { cancel: true } | undefined { 49 - if (this.scanStartTime == startTime) { 50 - this.updateProgress(data, startTime); 51 - } else { 52 - return { cancel: true }; 53 - } 54 - } 55 - 56 48 async scanHomeTimeline(requestedDays: number): Promise<PostingStatsResult | null> { 57 49 let startTime = new Date().getTime(); 58 - this.scanStartTime = startTime; 50 + this.abortController = new AbortController(); 59 51 60 52 let posts = await accountAPI.loadHomeTimeline(requestedDays, { 61 - onPageLoad: (d) => this.onPageLoad(d, startTime), 53 + onPageLoad: (data) => this.updateProgress(data, startTime), 54 + abortSignal: this.abortController.signal, 62 55 keepLastPage: true 63 56 }); 64 57 ··· 67 60 68 61 async scanListTimeline(listURI: string, requestedDays: number): Promise<PostingStatsResult | null> { 69 62 let startTime = new Date().getTime(); 70 - this.scanStartTime = startTime; 63 + this.abortController = new AbortController(); 71 64 72 65 let posts = await accountAPI.loadListTimeline(listURI, requestedDays, { 73 - onPageLoad: (d) => this.onPageLoad(d, startTime), 66 + onPageLoad: (data) => this.updateProgress(data, startTime), 67 + abortSignal: this.abortController.signal, 74 68 keepLastPage: true 75 69 }); 76 70 ··· 79 73 80 74 async scanUserTimelines(users: UserWithHandle[], requestedDays: number): Promise<PostingStatsResult | null> { 81 75 let startTime = new Date().getTime(); 82 - this.scanStartTime = startTime; 83 - 84 76 let dids = users.map(u => u.did); 85 77 this.resetUserProgress(dids); 78 + this.abortController = new AbortController(); 86 79 80 + let abortSignal = this.abortController.signal; 87 81 let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, { 88 82 filter: 'posts_and_author_threads', 89 - onPageLoad: (data) => { 90 - if (this.scanStartTime != startTime) { 91 - return { cancel: true }; 92 - } 93 - 94 - this.updateUserProgress(did, data, startTime, requestedDays); 95 - }, 83 + onPageLoad: (data) => this.updateUserProgress(did, data, startTime, requestedDays), 84 + abortSignal: abortSignal, 96 85 keepLastPage: true 97 86 })); 98 87 ··· 104 93 105 94 async scanYourTimeline(requestedDays: number): Promise<PostingStatsResult | null> { 106 95 let startTime = new Date().getTime(); 107 - this.scanStartTime = startTime; 96 + this.abortController = new AbortController(); 108 97 109 98 let posts = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, { 110 99 filter: 'posts_no_replies', 111 - onPageLoad: (d) => this.onPageLoad(d, startTime), 100 + onPageLoad: (data) => this.updateProgress(data, startTime), 101 + abortSignal: this.abortController.signal, 112 102 keepLastPage: true 113 103 }); 114 104 ··· 119 109 let last = posts.at(-1); 120 110 121 111 if (!last) { 122 - this.stopScan(); 123 - return null; 124 - } 125 - 126 - if (this.scanStartTime != startTime) { 127 112 return null; 128 113 } 129 114 ··· 178 163 userRows.sort((a, b) => b.all - a.all); 179 164 180 165 sums.all = sums.own + sums.reposts; 181 - this.scanStartTime = undefined; 182 166 183 167 return { users: userRows, sums, fetchedDays, daysBack }; 184 168 } ··· 222 206 this.onProgress && this.onProgress(progress); 223 207 } 224 208 225 - stopScan() { 226 - this.scanStartTime = undefined; 209 + abortScan() { 210 + this.abortController?.abort(); 211 + delete this.abortController; 227 212 } 228 213 }
+6 -13
src/services/timeline_search.ts
··· 3 3 import { feedPostTime } from '../utils.js'; 4 4 5 5 export class TimelineSearch { 6 - fetchStartTime: number | undefined; 7 6 timelinePosts: json[]; 7 + abortController?: AbortController; 8 8 9 9 constructor() { 10 10 this.timelinePosts = []; ··· 12 12 13 13 async fetchTimeline(requestedDays: number, onProgress: (progress: number) => void) { 14 14 let startTime = new Date().getTime(); 15 - this.fetchStartTime = startTime; 15 + this.abortController = new AbortController(); 16 16 17 17 let timeline = await accountAPI.loadHomeTimeline(requestedDays, { 18 + abortSignal: this.abortController.signal, 18 19 onPageLoad: (data) => { 19 - if (this.fetchStartTime != startTime) { 20 - return { cancel: true }; 21 - } 22 - 23 20 let progress = this.calculateProgress(data, startTime); 24 21 if (progress) { 25 22 onProgress(progress); ··· 27 24 } 28 25 }); 29 26 30 - if (this.fetchStartTime != startTime) { 31 - return; 32 - } 33 - 34 27 this.timelinePosts = timeline; 35 - this.fetchStartTime = undefined; 36 28 } 37 29 38 30 calculateProgress(dataPage: json[], startTime: number) { ··· 57 49 return matching; 58 50 } 59 51 60 - stopFetch() { 61 - this.fetchStartTime = undefined; 52 + abortFetch() { 53 + this.abortController?.abort(); 54 + delete this.abortController; 62 55 } 63 56 }