Thread viewer for Bluesky
1class PrivateSearchPage {
2
3 /** @type {number | undefined} */
4 fetchStartTime;
5
6 /** @type {number | undefined} */
7 importTimer;
8
9 /** @type {string | undefined} */
10 lycanImportStatus;
11
12 constructor() {
13 this.pageElement = $id('private_search_page');
14
15 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
16 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
17 this.progressBar = $(this.pageElement.querySelector('input[type="submit"] + progress'), HTMLProgressElement);
18 this.archiveStatus = $(this.pageElement.querySelector('.archive-status'));
19
20 this.searchLine = $(this.pageElement.querySelector('.search'));
21 this.searchField = $(this.pageElement.querySelector('.search-query'), HTMLInputElement);
22 this.searchForm = $(this.pageElement.querySelector('.search-form'), HTMLFormElement);
23 this.results = $(this.pageElement.querySelector('.results'));
24
25 this.timelineSearch = $(this.pageElement.querySelector('.timeline-search'));
26 this.timelineSearchForm = $(this.pageElement.querySelector('.timeline-search form'), HTMLFormElement);
27 this.searchCollections = $(this.pageElement.querySelector('.search-collections'));
28
29 this.lycanImportSection = $(this.pageElement.querySelector('.lycan-import'));
30 this.lycanImportForm = $(this.pageElement.querySelector('.lycan-import form'), HTMLFormElement);
31 this.importProgress = $(this.pageElement.querySelector('.import-progress'));
32 this.importProgressBar = $(this.pageElement.querySelector('.import-progress progress'), HTMLProgressElement);
33 this.importStatusLabel = $(this.pageElement.querySelector('.import-status'));
34 this.importStatusPosition = $(this.pageElement.querySelector('.import-progress output'));
35
36 this.isCheckingStatus = false;
37 this.timelinePosts = [];
38
39 this.setupEvents();
40
41 let params = new URLSearchParams(location.search);
42 this.mode = params.get('mode');
43 this.lycanMode = params.get('lycan');
44
45 if (this.lycanMode == 'local') {
46 this.localLycan = new BlueskyAPI('http://localhost:3000', false);
47 }
48 }
49
50 setupEvents() {
51 this.timelineSearchForm.addEventListener('submit', (e) => {
52 e.preventDefault();
53
54 if (!this.fetchStartTime) {
55 this.fetchTimeline();
56 } else {
57 this.stopFetch();
58 }
59 });
60
61 this.rangeInput.addEventListener('input', (e) => {
62 let days = parseInt(this.rangeInput.value, 10);
63 let label = $(this.pageElement.querySelector('input[type=range] + label'));
64 label.innerText = (days == 1) ? '1 day' : `${days} days`;
65 });
66
67 this.searchField.addEventListener('keydown', (e) => {
68 if (e.key == 'Enter') {
69 e.preventDefault();
70
71 let query = this.searchField.value.trim().toLowerCase();
72
73 if (this.mode == 'likes') {
74 this.searchInLycan(query);
75 } else {
76 this.searchInTimeline(query);
77 }
78 }
79 });
80
81 this.lycanImportForm.addEventListener('submit', (e) => {
82 e.preventDefault();
83 this.startLycanImport();
84 });
85 }
86
87 /** @returns {number} */
88
89 selectedDaysRange() {
90 return parseInt(this.rangeInput.value, 10);
91 }
92
93 show() {
94 this.pageElement.style.display = 'block';
95
96 if (this.mode == 'likes') {
97 this.timelineSearch.style.display = 'none';
98 this.searchCollections.style.display = 'block';
99 this.searchLine.style.display = 'block';
100 this.lycanImportSection.style.display = 'none';
101 this.checkLycanImportStatus();
102 } else {
103 this.timelineSearch.style.display = 'block';
104 this.searchCollections.style.display = 'none';
105 this.lycanImportSection.style.display = 'none';
106 }
107 }
108
109 /** @returns {Promise<void>} */
110
111 async checkLycanImportStatus() {
112 if (this.isCheckingStatus) {
113 return;
114 }
115
116 this.isCheckingStatus = true;
117
118 try {
119 let response = await this.getImportStatus();
120 this.showImportStatus(response);
121 } catch (error) {
122 this.showImportError(`Couldn't check import status: ${error}`);
123 } finally {
124 this.isCheckingStatus = false;
125 }
126 }
127
128 /** @returns {Promise<json>} */
129
130 async getImportStatus() {
131 if (this.localLycan) {
132 return await this.localLycan.getRequest('blue.feeds.lycan.getImportStatus', { user: accountAPI.user.did });
133 } else {
134 return await accountAPI.getRequest('blue.feeds.lycan.getImportStatus', null, {
135 headers: { 'atproto-proxy': 'did:web:lycan.feeds.blue#lycan' }
136 });
137 }
138 }
139
140 /** @param {json} info */
141
142 showImportStatus(info) {
143 console.log(info);
144
145 if (!info.status) {
146 this.showImportError("Error checking import status");
147 return;
148 }
149
150 this.lycanImportStatus = info.status;
151
152 if (info.status == 'not_started') {
153 this.lycanImportSection.style.display = 'block';
154 this.lycanImportForm.style.display = 'block';
155 this.importProgress.style.display = 'none';
156
157 this.stopImportTimer();
158 } else if (info.status == 'in_progress' || info.status == 'scheduled' || info.status == 'requested') {
159 this.lycanImportSection.style.display = 'block';
160 this.lycanImportForm.style.display = 'none';
161 this.importProgress.style.display = 'block';
162
163 this.showImportProgress(info);
164 this.startImportTimer();
165 } else if (info.status == 'finished') {
166 this.lycanImportForm.style.display = 'none';
167 this.importProgress.style.display = 'block';
168
169 this.showImportProgress({ status: 'finished', progress: 1.0 });
170 this.stopImportTimer();
171 } else {
172 this.showImportError("Error checking import status");
173 this.stopImportTimer();
174 }
175 }
176
177 /** @param {json} info */
178
179 showImportProgress(info) {
180 let progress = Math.max(0, Math.min(info.progress || 0));
181 this.importProgressBar.value = progress;
182 this.importProgressBar.style.display = 'inline';
183
184 let percent = Math.round(progress * 100);
185 this.importStatusPosition.innerText = `${percent}%`;
186
187 if (info.progress == 1.0) {
188 this.importStatusLabel.innerText = `Import complete ✓`;
189 } else if (info.position) {
190 let date = new Date(info.position).toLocaleString(window.dateLocale, { day: 'numeric', month: 'short', year: 'numeric' });
191 this.importStatusLabel.innerText = `Imported data until: ${date}`;
192 } else if (info.status == 'requested') {
193 this.importStatusLabel.innerText = 'Requesting import…';
194 } else {
195 this.importStatusLabel.innerText = 'Import started…';
196 }
197 }
198
199 /** @param {string} message */
200
201 showImportError(message) {
202 this.lycanImportSection.style.display = 'block';
203 this.lycanImportForm.style.display = 'none';
204 this.importProgress.style.display = 'block';
205
206 this.importStatusLabel.innerText = message;
207 this.stopImportTimer();
208 }
209
210 startImportTimer() {
211 if (this.importTimer) {
212 return;
213 }
214
215 this.importTimer = setInterval(() => {
216 this.checkLycanImportStatus();
217 }, 3000);
218 }
219
220 stopImportTimer() {
221 if (this.importTimer) {
222 clearInterval(this.importTimer);
223 this.importTimer = undefined;
224 }
225 }
226
227 /** @returns {Promise<void>} */
228
229 async startLycanImport() {
230 this.showImportStatus({ status: 'requested' });
231
232 try {
233 if (this.localLycan) {
234 await this.localLycan.postRequest('blue.feeds.lycan.startImport', {
235 user: accountAPI.user.did
236 });
237 } else {
238 await accountAPI.postRequest('blue.feeds.lycan.startImport', null, {
239 headers: { 'atproto-proxy': 'did:web:lycan.feeds.blue#lycan' }
240 });
241 }
242
243 this.startImportTimer();
244 } catch (err) {
245 console.error('Failed to start Lycan import', err);
246 this.showImportError(`Import failed: ${err}`);
247 }
248 }
249
250 /** @returns {Promise<void>} */
251
252 async fetchTimeline() {
253 this.submitButton.value = 'Cancel';
254
255 let requestedDays = this.selectedDaysRange();
256
257 this.progressBar.max = requestedDays;
258 this.progressBar.value = 0;
259 this.progressBar.style.display = 'inline';
260
261 let startTime = new Date().getTime();
262 this.fetchStartTime = startTime;
263
264 let timeline = await accountAPI.loadHomeTimeline(requestedDays, {
265 onPageLoad: (data) => {
266 if (this.fetchStartTime != startTime) {
267 return { cancel: true };
268 }
269
270 this.updateProgress(data, startTime);
271 }
272 });
273
274 if (this.fetchStartTime != startTime) {
275 return;
276 }
277
278 let last = timeline.at(-1);
279 let daysBack;
280
281 if (last) {
282 let lastDate = feedPostTime(last);
283 daysBack = Math.round((startTime - lastDate) / 86400 / 1000);
284 } else {
285 daysBack = 0;
286 }
287
288 this.timelinePosts = timeline.map(x => Post.parseFeedPost(x));
289
290 this.archiveStatus.innerText = "Timeline archive fetched: " + ((daysBack == 1) ? '1 day' : `${daysBack} days`);
291 this.searchLine.style.display = 'block';
292
293 this.submitButton.value = 'Fetch timeline';
294 this.progressBar.style.display = 'none';
295 this.fetchStartTime = undefined;
296 }
297
298 /** @param {string} query */
299
300 searchInTimeline(query) {
301 this.results.innerHTML = '';
302
303 if (query.length == 0) {
304 return;
305 }
306
307 let matching = this.timelinePosts.filter(x => x.lowercaseText.includes(query));
308
309 for (let post of matching) {
310 let postView = new PostComponent(post, 'feed').buildElement();
311 this.results.appendChild(postView);
312 }
313 }
314
315 /** @param {string} query */
316
317 searchInLycan(query) {
318 if (query.length == 0 || this.lycanImportStatus != 'finished') {
319 return;
320 }
321
322 this.results.innerHTML = '';
323 this.lycanImportSection.style.display = 'none';
324
325 let collection = this.searchForm.elements['collection'].value;
326
327 let loading = $tag('p', { text: "..." });
328 this.results.append(loading);
329
330 let isLoading = false;
331 let firstPageLoaded = false;
332 let cursor;
333 let finished = false;
334
335 Paginator.loadInPages(async () => {
336 if (isLoading || finished) { return; }
337 isLoading = true;
338
339 let response;
340
341 if (this.localLycan) {
342 let params = { collection, query, user: accountAPI.user.did };
343 if (cursor) params.cursor = cursor;
344
345 response = await this.localLycan.getRequest('blue.feeds.lycan.searchPosts', params);
346 } else {
347 let params = { collection, query };
348 if (cursor) params.cursor = cursor;
349
350 response = await accountAPI.getRequest('blue.feeds.lycan.searchPosts', params, {
351 headers: { 'atproto-proxy': 'did:web:lycan.feeds.blue#lycan' }
352 });
353 }
354
355 if (response.posts.length == 0) {
356 let p = $tag('p.results-end', { text: firstPageLoaded ? "No more results." : "No results." });
357 loading.remove();
358 this.results.append(p);
359
360 isLoading = false;
361 finished = true;
362 return;
363 }
364
365 let records = await accountAPI.loadPosts(response.posts);
366 let posts = records.map(x => new Post(x));
367
368 if (!firstPageLoaded) {
369 loading.remove();
370 firstPageLoaded = true;
371 }
372
373 for (let post of posts) {
374 let component = new PostComponent(post, 'feed');
375 let postView = component.buildElement();
376 this.results.appendChild(postView);
377
378 component.highlightSearchResults(response.terms);
379 }
380
381 isLoading = false;
382 cursor = response.cursor;
383
384 if (!cursor) {
385 finished = true;
386 this.results.append("No more results.");
387 }
388 });
389 }
390
391 /** @param {json[]} dataPage, @param {number} startTime */
392
393 updateProgress(dataPage, startTime) {
394 let last = dataPage.at(-1);
395
396 if (!last) { return }
397
398 let lastDate = feedPostTime(last);
399 let daysBack = (startTime - lastDate) / 86400 / 1000;
400
401 this.progressBar.value = daysBack;
402 }
403
404 stopFetch() {
405 this.submitButton.value = 'Fetch timeline';
406 this.progressBar.style.display = 'none';
407 this.fetchStartTime = undefined;
408 }
409}