Thread viewer for Bluesky
at master 12 kB view raw
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}