Thread viewer for Bluesky
1function init() {
2 window.dateLocale = localStorage.getItem('locale') || undefined;
3 window.isIncognito = !!localStorage.getItem('incognito');
4 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null');
5
6 window.loginDialog = $(document.querySelector('#login'));
7
8 window.avatarPreloader = buildAvatarPreloader();
9
10 window.accountMenu = new Menu();
11 window.threadPage = new ThreadPage();
12 window.postingStatsPage = new PostingStatsPage();
13 window.likeStatsPage = new LikeStatsPage();
14 window.notificationsPage = new NotificationsPage();
15 window.privateSearchPage = new PrivateSearchPage();
16
17 $(document.querySelector('#search form')).addEventListener('submit', (e) => {
18 e.preventDefault();
19 submitSearch();
20 });
21
22 for (let dialog of document.querySelectorAll('.dialog')) {
23 let close = $(dialog.querySelector('.close'));
24
25 dialog.addEventListener('click', (e) => {
26 if (e.target === e.currentTarget && close && close.offsetHeight > 0) {
27 hideDialog(dialog);
28 } else {
29 e.stopPropagation();
30 }
31 });
32
33 close?.addEventListener('click', (e) => {
34 hideDialog(dialog);
35 });
36 }
37
38 $(document.querySelector('#login .info a')).addEventListener('click', (e) => {
39 e.preventDefault();
40 toggleLoginInfo();
41 });
42
43 $(document.querySelector('#login form')).addEventListener('submit', (e) => {
44 e.preventDefault();
45 submitLogin();
46 });
47
48 $(document.querySelector('#biohazard_show')).addEventListener('click', (e) => {
49 e.preventDefault();
50
51 window.biohazardEnabled = true;
52 localStorage.setItem('biohazard', 'true');
53
54 if (window.loadInfohazard) {
55 window.loadInfohazard();
56 window.loadInfohazard = undefined;
57 }
58
59 let target = $(e.target);
60
61 hideDialog(target.closest('.dialog'));
62 });
63
64 $(document.querySelector('#biohazard_hide')).addEventListener('click', (e) => {
65 e.preventDefault();
66
67 window.biohazardEnabled = false;
68 localStorage.setItem('biohazard', 'false');
69 accountMenu.toggleMenuButtonCheck('biohazard', false);
70
71 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) {
72 $(p).style.display = 'none';
73 }
74
75 let target = $(e.target);
76
77 hideDialog(target.closest('.dialog'));
78 });
79
80 window.appView = new BlueskyAPI('api.bsky.app', false);
81 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false);
82 window.accountAPI = new BlueskyAPI(undefined, true);
83
84 if (accountAPI.isLoggedIn) {
85 accountAPI.host = accountAPI.user.pdsEndpoint;
86 accountMenu.hideMenuButton('login');
87
88 if (!isIncognito) {
89 window.api = accountAPI;
90 accountMenu.showLoggedInStatus(true, api.user.avatar);
91 } else {
92 window.api = appView;
93 accountMenu.showLoggedInStatus('incognito');
94 accountMenu.toggleMenuButtonCheck('incognito', true);
95 }
96 } else {
97 window.api = appView;
98 accountMenu.hideMenuButton('logout');
99 accountMenu.hideMenuButton('incognito');
100 }
101
102 accountMenu.toggleMenuButtonCheck('biohazard', window.biohazardEnabled !== false);
103
104 parseQueryParams();
105}
106
107function parseQueryParams() {
108 let params = new URLSearchParams(location.search);
109 let { q, author, post, quotes, hash, page } = Object.fromEntries(params);
110
111 if (quotes) {
112 showLoader();
113 loadQuotesPage(decodeURIComponent(quotes));
114 } else if (hash) {
115 showLoader();
116 loadHashtagPage(decodeURIComponent(hash));
117 } else if (q) {
118 showLoader();
119 threadPage.loadThreadByURL(decodeURIComponent(q));
120 } else if (author && post) {
121 showLoader();
122 threadPage.loadThreadById(decodeURIComponent(author), decodeURIComponent(post));
123 } else if (page) {
124 openPage(page);
125 } else {
126 showSearch();
127 }
128}
129
130/** @returns {IntersectionObserver} */
131
132function buildAvatarPreloader() {
133 return new IntersectionObserver((entries, observer) => {
134 for (const entry of entries) {
135 if (entry.isIntersecting) {
136 const img = entry.target;
137 img.removeAttribute('lazy');
138 observer.unobserve(img);
139 }
140 }
141 }, {
142 rootMargin: '1000px 0px'
143 });
144}
145
146function showLoader() {
147 $id('loader').style.display = 'block';
148}
149
150function hideLoader() {
151 $id('loader').style.display = 'none';
152}
153
154function showSearch() {
155 let search = $id('search');
156 let searchField = $(search.querySelector('input[type=text]'));
157
158 search.style.visibility = 'visible';
159 searchField.focus();
160}
161
162function hideSearch() {
163 $id('search').style.visibility = 'hidden';
164}
165
166function showDialog(dialog) {
167 dialog.style.visibility = 'visible';
168 $id('thread').classList.add('overlay');
169
170 dialog.querySelector('input[type=text]')?.focus();
171}
172
173function hideDialog(dialog) {
174 dialog.style.visibility = 'hidden';
175 dialog.classList.remove('expanded');
176 $id('thread').classList.remove('overlay');
177
178 for (let field of dialog.querySelectorAll('input[type=text]')) {
179 field.value = '';
180 }
181}
182
183function toggleDialog(dialog) {
184 if (dialog.style.visibility == 'visible') {
185 hideDialog(dialog);
186 } else {
187 showDialog(dialog);
188 }
189}
190
191function toggleLoginInfo() {
192 $id('login').classList.toggle('expanded');
193}
194
195function submitLogin() {
196 let handleField = $id('login_handle', HTMLInputElement);
197 let passwordField = $id('login_password', HTMLInputElement);
198 let submit = $id('login_submit');
199 let cloudy = $id('cloudy');
200 let close = $(loginDialog.querySelector('.close'));
201
202 if (submit.style.display == 'none') { return }
203
204 handleField.blur();
205 passwordField.blur();
206
207 submit.style.display = 'none';
208 cloudy.style.display = 'inline-block';
209
210 let handle = handleField.value.trim();
211 let password = passwordField.value.trim();
212
213 logIn(handle, password).then((pds) => {
214 window.api = pds;
215 window.accountAPI = pds;
216
217 hideDialog(loginDialog);
218 submit.style.display = 'inline';
219 cloudy.style.display = 'none';
220 close.style.display = 'inline';
221
222 accountMenu.loadCurrentUserAvatar();
223
224 accountMenu.showMenuButton('logout');
225 accountMenu.showMenuButton('incognito');
226 accountMenu.hideMenuButton('login');
227
228 let params = new URLSearchParams(location.search);
229 let page = params.get('page');
230 if (page) {
231 openPage(page);
232 }
233 })
234 .catch((error) => {
235 submit.style.display = 'inline';
236 cloudy.style.display = 'none';
237 console.log(error);
238
239 if (error.code == 401 && error.json.error == 'AuthFactorTokenRequired') {
240 alert("Please log in using an \"app password\" if you have 2FA enabled.");
241 } else {
242 window.setTimeout(() => alert(error), 10);
243 }
244 });
245}
246
247/** @param {string} identifier, @param {string} password, @returns {Promise<BlueskyAPI>} */
248
249async function logIn(identifier, password) {
250 let pdsEndpoint;
251
252 if (identifier.match(/^did:/)) {
253 pdsEndpoint = await Minisky.pdsEndpointForDid(identifier);
254 } else if (identifier.match(/^[^@]+@[^@]+$/)) {
255 pdsEndpoint = 'bsky.social';
256 } else if (identifier.match(/^@?[\w\-]+(\.[\w\-]+)+$/)) {
257 identifier = identifier.replace(/^@/, '');
258 let did = await appView.resolveHandle(identifier);
259 pdsEndpoint = await Minisky.pdsEndpointForDid(did);
260 } else {
261 throw 'Please enter your handle or DID.';
262 }
263
264 let pds = new BlueskyAPI(pdsEndpoint, true);
265 await pds.logIn(identifier, password);
266 return pds;
267}
268
269function logOut() {
270 accountAPI.resetTokens();
271 localStorage.removeItem('incognito');
272 location.reload();
273}
274
275function submitSearch() {
276 let search = $id('search');
277 let searchField = $(search.querySelector('input[name=q]'), HTMLInputElement);
278 let url = searchField.value.trim();
279
280 if (!url) { return }
281
282 if (url.startsWith('at://')) {
283 let target = new URL(getLocation());
284 target.searchParams.set('q', url);
285 location.assign(target.toString());
286 return;
287 }
288
289 if (url.match(/^#?((\p{Letter}|\p{Number})+)$/u)) {
290 let target = new URL(getLocation());
291 target.searchParams.set('hash', encodeURIComponent(url.replace(/^#/, '')));
292 location.assign(target.toString());
293 return;
294 }
295
296 try {
297 let [handle, postId] = BlueskyAPI.parsePostURL(url);
298
299 let newURL = linkToPostById(handle, postId);
300 location.assign(newURL);
301 } catch (error) {
302 console.log(error);
303 alert(error.message || "This is not a valid URL or hashtag");
304 }
305}
306
307/** @param {string} page */
308
309function openPage(page) {
310 if (!accountAPI.isLoggedIn) {
311 showDialog(loginDialog);
312 $(loginDialog.querySelector('.close')).style.display = 'none';
313 return;
314 }
315
316 if (page == 'notif') {
317 window.notificationsPage.show();
318 } else if (page == 'posting_stats') {
319 window.postingStatsPage.show();
320 } else if (page == 'like_stats') {
321 window.likeStatsPage.show();
322 } else if (page == 'search') {
323 window.privateSearchPage.show();
324 }
325}
326
327/** @param {Post} post */
328
329function setPageTitle(post) {
330 document.title = `${post.author.displayName}: "${post.text}" - Skythread`;
331}
332
333/** @param {string} hashtag */
334
335function loadHashtagPage(hashtag) {
336 hashtag = hashtag.replace(/^\#/, '');
337 document.title = `#${hashtag} - Skythread`;
338
339 let isLoading = false;
340 let firstPageLoaded = false;
341 let finished = false;
342 let cursor;
343
344 Paginator.loadInPages(() => {
345 if (isLoading || finished) { return; }
346 isLoading = true;
347
348 api.getHashtagFeed(hashtag, cursor).then(data => {
349 let posts = data.posts.map(j => new Post(j));
350
351 if (!firstPageLoaded) {
352 hideLoader();
353
354 let header = $tag('header');
355 let h2 = $tag('h2', {
356 text: (posts.length > 0) ? `Posts tagged: #${hashtag}` : `No posts tagged #${hashtag}.`
357 });
358 header.append(h2);
359
360 $id('thread').appendChild(header);
361 $id('thread').classList.add('hashtag');
362 }
363
364 for (let post of posts) {
365 let postView = new PostComponent(post, 'feed').buildElement();
366 $id('thread').appendChild(postView);
367 }
368
369 isLoading = false;
370 firstPageLoaded = true;
371 cursor = data.cursor;
372
373 if (!cursor || posts.length == 0) {
374 finished = true;
375 }
376 }).catch(error => {
377 hideLoader();
378 console.log(error);
379 isLoading = false;
380 });
381 });
382}
383
384/** @param {string} url */
385
386function loadQuotesPage(url) {
387 let isLoading = false;
388 let firstPageLoaded = false;
389 let cursor;
390 let finished = false;
391
392 Paginator.loadInPages(() => {
393 if (isLoading || finished) { return; }
394 isLoading = true;
395
396 blueAPI.getQuotes(url, cursor).then(data => {
397 api.loadPosts(data.posts).then(jsons => {
398 let posts = jsons.map(j => new Post(j));
399
400 if (!firstPageLoaded) {
401 hideLoader();
402
403 let header = $tag('header');
404 let h2;
405
406 if (data.quoteCount > 1) {
407 h2 = $tag('h2', { text: `${data.quoteCount} quotes:` });
408 } else if (data.quoteCount == 1) {
409 h2 = $tag('h2', { text: '1 quote:' });
410 } else {
411 h2 = $tag('h2', { text: 'No quotes found.' });
412 }
413
414 header.append(h2);
415 $id('thread').appendChild(header);
416 $id('thread').classList.add('quotes');
417 }
418
419 for (let post of posts) {
420 let postView = new PostComponent(post, 'quotes').buildElement();
421 $id('thread').appendChild(postView);
422 }
423
424 isLoading = false;
425 firstPageLoaded = true;
426 cursor = data.cursor;
427
428 if (!cursor || posts.length == 0) {
429 finished = true;
430 }
431 }).catch(error => {
432 hideLoader();
433 console.log(error);
434 isLoading = false;
435 })
436 }).catch(error => {
437 hideLoader();
438 console.log(error);
439 isLoading = false;
440 });
441 });
442}