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