Thread viewer for Bluesky
1class PrivateSearchPage { 2 3 /** @type {number | undefined} */ 4 fetchStartTime; 5 6 constructor() { 7 this.pageElement = $id('private_search_page'); 8 9 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement); 10 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement); 11 this.progressBar = $(this.pageElement.querySelector('input[type="submit"] + progress'), HTMLProgressElement); 12 this.archiveStatus = $(this.pageElement.querySelector('.archive-status')); 13 14 this.searchLine = $(this.pageElement.querySelector('.search')); 15 this.searchField = $(this.pageElement.querySelector('.search-query'), HTMLInputElement); 16 this.results = $(this.pageElement.querySelector('.results')); 17 18 this.timelinePosts = []; 19 20 this.setupEvents(); 21 22 let params = new URLSearchParams(location.search); 23 this.mode = params.get('mode'); 24 this.lycanMode = params.get('lycan'); 25 26 if (this.lycanMode == 'local') { 27 this.lycan = new BlueskyAPI('http://localhost:3000', false); 28 } 29 } 30 31 setupEvents() { 32 $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => { 33 e.preventDefault(); 34 35 if (!this.fetchStartTime) { 36 this.fetchTimeline(); 37 } else { 38 this.stopFetch(); 39 } 40 }); 41 42 this.rangeInput.addEventListener('input', (e) => { 43 let days = parseInt(this.rangeInput.value, 10); 44 let label = $(this.pageElement.querySelector('input[type=range] + label')); 45 label.innerText = (days == 1) ? '1 day' : `${days} days`; 46 }); 47 48 this.searchField.addEventListener('keydown', (e) => { 49 if (e.key == 'Enter') { 50 e.preventDefault(); 51 52 let query = this.searchField.value.trim().toLowerCase(); 53 54 if (this.mode == 'likes') { 55 this.searchInLycan(query); 56 } else { 57 this.searchInTimeline(query); 58 } 59 } 60 }); 61 } 62 63 /** @returns {number} */ 64 65 selectedDaysRange() { 66 return parseInt(this.rangeInput.value, 10); 67 } 68 69 show() { 70 this.pageElement.style.display = 'block'; 71 72 if (this.mode == 'likes') { 73 this.pageElement.querySelector('.timeline-search').style.display = 'none'; 74 this.searchLine.style.display = 'block'; 75 } else { 76 this.pageElement.querySelector('.timeline-search').style.display = 'block'; 77 } 78 } 79 80 /** @returns {Promise<void>} */ 81 82 async fetchTimeline() { 83 this.submitButton.value = 'Cancel'; 84 85 let requestedDays = this.selectedDaysRange(); 86 87 this.progressBar.max = requestedDays; 88 this.progressBar.value = 0; 89 this.progressBar.style.display = 'inline'; 90 91 let startTime = new Date().getTime(); 92 this.fetchStartTime = startTime; 93 94 let timeline = await accountAPI.loadHomeTimeline(requestedDays, { 95 onPageLoad: (data) => { 96 if (this.fetchStartTime != startTime) { 97 return { cancel: true }; 98 } 99 100 this.updateProgress(data, startTime); 101 } 102 }); 103 104 if (this.fetchStartTime != startTime) { 105 return; 106 } 107 108 let last = timeline.at(-1); 109 let daysBack; 110 111 if (last) { 112 let lastDate = feedPostTime(last); 113 daysBack = Math.round((startTime - lastDate) / 86400 / 1000); 114 } else { 115 daysBack = 0; 116 } 117 118 this.timelinePosts = timeline.map(x => Post.parseFeedPost(x)); 119 120 this.archiveStatus.innerText = "Timeline archive fetched: " + ((daysBack == 1) ? '1 day' : `${daysBack} days`); 121 this.searchLine.style.display = 'block'; 122 123 this.submitButton.value = 'Fetch timeline'; 124 this.progressBar.style.display = 'none'; 125 this.fetchStartTime = undefined; 126 } 127 128 /** @param {string} query */ 129 130 searchInTimeline(query) { 131 this.results.innerHTML = ''; 132 133 if (query.length == 0) { 134 return; 135 } 136 137 let matching = this.timelinePosts.filter(x => x.lowercaseText.includes(query)); 138 139 for (let post of matching) { 140 let postView = new PostComponent(post, 'feed').buildElement(); 141 this.results.appendChild(postView); 142 } 143 } 144 145 /** @param {string} query */ 146 147 searchInLycan(query) { 148 if (query.length == 0) { 149 this.results.innerHTML = ''; 150 return; 151 } 152 153 this.results.innerHTML = '...'; 154 155 let isLoading = false; 156 let firstPageLoaded = false; 157 let cursor; 158 let finished = false; 159 160 loadInPages(async () => { 161 if (isLoading || finished) { return; } 162 isLoading = true; 163 164 let response; 165 166 if (this.lycanMode == 'local') { 167 let params = { query: query, user: window.accountAPI.user.did }; 168 if (cursor) params.cursor = cursor; 169 170 response = await this.lycan.getRequest('blue.feeds.lycan.searchPosts', params); 171 } else { 172 let params = { query: query }; 173 if (cursor) params.cursor = cursor; 174 175 response = await accountAPI.getRequest('blue.feeds.lycan.searchPosts', params, { 176 headers: { 'atproto-proxy': 'did:web:lycan.feeds.blue#lycan' } 177 }); 178 } 179 180 if (response.posts.length == 0) { 181 this.results.append(firstPageLoaded ? "No more results." : "No results."); 182 isLoading = false; 183 finished = true; 184 return; 185 } 186 187 let records = await window.accountAPI.loadPosts(response.posts); 188 let posts = records.map(x => new Post(x)); 189 190 if (!firstPageLoaded) { 191 this.results.innerHTML = ''; 192 firstPageLoaded = true; 193 } 194 195 for (let post of posts) { 196 let postView = new PostComponent(post, 'feed').buildElement(); 197 this.results.appendChild(postView); 198 } 199 200 isLoading = false; 201 cursor = response.cursor; 202 203 if (!cursor) { 204 finished = true; 205 this.results.append("No more results."); 206 } 207 }); 208 } 209 210 /** @param {json[]} dataPage, @param {number} startTime */ 211 212 updateProgress(dataPage, startTime) { 213 let last = dataPage.at(-1); 214 215 if (!last) { return } 216 217 let lastDate = feedPostTime(last); 218 let daysBack = (startTime - lastDate) / 86400 / 1000; 219 220 this.progressBar.value = daysBack; 221 } 222 223 stopFetch() { 224 this.submitButton.value = 'Fetch timeline'; 225 this.progressBar.style.display = 'none'; 226 this.fetchStartTime = undefined; 227 } 228}