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}