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