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