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}