Thread viewer for Bluesky
1<script lang="ts">
2 import UserAutocomplete, { type AutocompleteUser } from '../components/UserAutocomplete.svelte';
3 import PostingStatsTable, { type TableOptions } from '../components/PostingStatsTable.svelte';
4 import { accountAPI } from '../api.js';
5 import { PostingStats, type PostingStatsResult } from '../services/posting_stats.js';
6 import { numberOfDays } from '../utils.js';
7
8 const tabs = [
9 { id: 'home', title: 'Home timeline' },
10 { id: 'list', title: 'List feed' },
11 { id: 'users', title: 'Selected users' },
12 { id: 'you', title: 'Your profile' }
13 ] as const;
14
15 let lists: json[] = $state([]);
16
17 let timeRangeDays = $state(7);
18 let selectedTab: typeof tabs[number]['id'] = $state(tabs[0].id);
19 let selectedUsers: AutocompleteUser[] = $state([]);
20 let selectedList: string | undefined = $state();
21
22 let scanInProgress = $state(false);
23 let requestedDays: number | undefined = $state();
24 let progress: number | undefined = $state();
25 let scanInfo = $state();
26
27 let tableOptions: TableOptions = $state({});
28 let results: PostingStatsResult | null = $state(null);
29
30 let scanner = new PostingStats((p) => { progress = Math.max(progress || 0, p) });
31
32 $effect(() => {
33 fetchLists();
34 })
35
36 function onTabChange() {
37 results = null;
38 }
39
40 async function fetchLists() {
41 let result = await accountAPI.loadUserLists();
42
43 lists = result.sort((a, b) => {
44 let aName = a.name.toLocaleLowerCase();
45 let bName = b.name.toLocaleLowerCase();
46
47 return aName.localeCompare(bName);
48 });
49
50 selectedList = lists[0]?.uri;
51 }
52
53 async function onsubmit(e: Event) {
54 e.preventDefault();
55
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 }
67 }
68 }
69
70 async function runScan() {
71 if ((selectedTab == 'list' && !selectedList) || (selectedTab == 'users' && selectedUsers.length == 0)) {
72 return;
73 }
74
75 scanInfo = undefined;
76 results = null;
77 requestedDays = timeRangeDays;
78 progress = 0;
79 scanInProgress = true;
80
81 let startTime = new Date().getTime();
82 let data: PostingStatsResult | null;
83 let options: TableOptions;
84
85 if (selectedTab == 'home') {
86 options = {};
87 data = await scanner.scanHomeTimeline(requestedDays);
88 } else if (selectedTab == 'list') {
89 options = { showReposts: false };
90 data = await scanner.scanListTimeline(selectedList!, requestedDays);
91 } else if (selectedTab == 'users') {
92 options = { showTotal: false, showPercentages: false };
93 data = await scanner.scanUserTimelines(selectedUsers, requestedDays);
94 } else { // selectedTab == 'you'
95 options = { showTotal: false, showPercentages: false };
96 data = await scanner.scanYourTimeline(requestedDays);
97 }
98
99 let now = new Date().getTime();
100
101 if (now - startTime < 150) {
102 // artificial UI delay in case scan finishes immediately
103 await new Promise(resolve => setTimeout(resolve, 150));
104 }
105
106 tableOptions = options;
107 results = data;
108 scanInProgress = false;
109 }
110</script>
111
112<main>
113<h2>Bluesky posting statistics</h2>
114
115<form {onsubmit}>
116 <p>
117 Scan posts from:
118
119 {#each tabs as tab}
120 <input type="radio" name="scan_type" id="scan_type_{tab.id}" value="{tab.id}" bind:group={selectedTab} onclick={onTabChange}>
121 <label for="scan_type_{tab.id}">{tab.title}</label>
122 {/each}
123 </p>
124
125 <p>
126 Time range: <input id="posting_stats_range" type="range" min="1" max="60" bind:value={timeRangeDays}>
127 <label for="posting_stats_range">{numberOfDays(timeRangeDays)}</label>
128 </p>
129
130 {#if selectedTab == 'list'}
131 <p class="list-choice">
132 <label for="posting_stats_list">Select list:</label>
133 <select id="posting_stats_list" name="scan_list" bind:value={selectedList}>
134 {#each lists as list}
135 <option value={list.uri}>{list.name} </option>
136 {/each}
137 </select>
138 </p>
139 {/if}
140
141 {#if selectedTab == 'users'}
142 <UserAutocomplete bind:selectedUsers />
143 {/if}
144
145 <p>
146 <input type="submit" value="{!scanInProgress ? 'Start scan' : 'Cancel'}">
147
148 {#if scanInProgress}
149 <progress max={requestedDays} value={progress}></progress>
150 {/if}
151 </p>
152</form>
153
154{#if scanInfo}
155 <p class="scan-info">{scanInfo}</p>
156{/if}
157
158{#if results}
159 <PostingStatsTable {...tableOptions} {...results} />
160{/if}
161</main>
162
163<style>
164 input[type="radio"] {
165 position: relative;
166 top: -1px;
167 margin-left: 5px;
168 }
169
170 input[type="radio"] + label {
171 user-select: none;
172 -webkit-user-select: none;
173 margin-right: 4px;
174 }
175
176 input[type="range"] {
177 width: 250px;
178 vertical-align: middle;
179 }
180
181 input[type="submit"] {
182 font-size: 12pt;
183 margin: 5px 0px;
184 padding: 5px 10px;
185 }
186
187 select {
188 font-size: 12pt;
189 margin-left: 5px;
190 }
191
192 progress {
193 width: 300px;
194 margin-left: 10px;
195 vertical-align: middle;
196 }
197
198 .scan-info {
199 font-weight: 600;
200 line-height: 125%;
201 margin: 20px 0px;
202 }
203</style>