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