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}