Thread viewer for Bluesky
1function init() {
2 let document = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document));
3 let html = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document.body.parentNode));
4
5 window.dateLocale = localStorage.getItem('locale') || undefined;
6 window.isIncognito = !!localStorage.getItem('incognito');
7 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null');
8
9 window.loginDialog = document.querySelector('#login');
10 window.accountMenu = document.querySelector('#account_menu');
11
12 window.avatarPreloader = buildAvatarPreloader();
13
14 html.addEventListener('click', (e) => {
15 $id('account_menu').style.visibility = 'hidden';
16 });
17
18 document.querySelector('#search form').addEventListener('submit', (e) => {
19 e.preventDefault();
20 submitSearch();
21 });
22
23 for (let dialog of document.querySelectorAll('.dialog')) {
24 dialog.addEventListener('click', (e) => {
25 if (e.target === e.currentTarget) {
26 hideDialog(dialog);
27 } else {
28 e.stopPropagation();
29 }
30 });
31
32 dialog.querySelector('.close')?.addEventListener('click', (e) => {
33 hideDialog(dialog);
34 });
35 }
36
37 document.querySelector('#login .info a').addEventListener('click', (e) => {
38 e.preventDefault();
39 toggleLoginInfo();
40 });
41
42 document.querySelector('#login form').addEventListener('submit', (e) => {
43 e.preventDefault();
44 submitLogin();
45 });
46
47 document.querySelector('#biohazard_show').addEventListener('click', (e) => {
48 e.preventDefault();
49
50 window.biohazardEnabled = true;
51 localStorage.setItem('biohazard', 'true');
52
53 if (window.loadInfohazard) {
54 window.loadInfohazard();
55 window.loadInfohazard = undefined;
56 }
57
58 let target = /** @type {AnyElement} */ (/** @type {unknown} */ (e.target));
59
60 hideDialog(target.closest('.dialog'));
61 });
62
63 document.querySelector('#biohazard_hide').addEventListener('click', (e) => {
64 e.preventDefault();
65
66 window.biohazardEnabled = false;
67 localStorage.setItem('biohazard', 'false');
68 toggleMenuButton('biohazard', false);
69
70 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) {
71 p.style.display = 'none';
72 }
73
74 let target = /** @type {AnyElement} */ (/** @type {unknown} */ (e.target));
75
76 hideDialog(target.closest('.dialog'));
77 });
78
79 document.querySelector('#account').addEventListener('click', (e) => {
80 toggleAccountMenu();
81 e.stopPropagation();
82 });
83
84 accountMenu.addEventListener('click', (e) => {
85 e.stopPropagation();
86 });
87
88 accountMenu.querySelector('a[data-action=biohazard]').addEventListener('click', (e) => {
89 e.preventDefault();
90
91 let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post');
92
93 if (window.biohazardEnabled === false) {
94 window.biohazardEnabled = true;
95 localStorage.setItem('biohazard', 'true');
96 toggleMenuButton('biohazard', true);
97 Array.from(hazards).forEach(p => { p.style.display = 'block' });
98 } else {
99 window.biohazardEnabled = false;
100 localStorage.setItem('biohazard', 'false');
101 toggleMenuButton('biohazard', false);
102 Array.from(hazards).forEach(p => { p.style.display = 'none' });
103 }
104 });
105
106 accountMenu.querySelector('a[data-action=incognito]').addEventListener('click', (e) => {
107 e.preventDefault();
108
109 if (isIncognito) {
110 localStorage.removeItem('incognito');
111 } else {
112 localStorage.setItem('incognito', '1');
113 }
114
115 location.reload();
116 });
117
118 accountMenu.querySelector('a[data-action=login]').addEventListener('click', (e) => {
119 e.preventDefault();
120 toggleDialog(loginDialog);
121 $id('account_menu').style.visibility = 'hidden';
122 });
123
124 accountMenu.querySelector('a[data-action=logout]').addEventListener('click', (e) => {
125 e.preventDefault();
126 logOut();
127 });
128
129 window.appView = new BlueskyAPI('api.bsky.app', false);
130 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false);
131 window.accountAPI = new BlueskyAPI(undefined, true);
132
133 if (accountAPI.isLoggedIn) {
134 accountAPI.host = accountAPI.user.pdsEndpoint;
135 hideMenuButton('login');
136
137 if (!isIncognito) {
138 window.api = accountAPI;
139 showLoggedInStatus(true, api.user.avatar);
140 } else {
141 window.api = appView;
142 showLoggedInStatus('incognito');
143 toggleMenuButton('incognito', true);
144 }
145 } else {
146 window.api = appView;
147 hideMenuButton('logout');
148 hideMenuButton('incognito');
149 }
150
151 toggleMenuButton('biohazard', window.biohazardEnabled !== false);
152
153 parseQueryParams();
154}
155
156function parseQueryParams() {
157 let params = new URLSearchParams(location.search);
158 let query = params.get('q');
159 let author = params.get('author');
160 let post = params.get('post');
161 let quotes = params.get('quotes');
162 let hash = params.get('hash');
163 let page = params.get('page');
164
165 if (quotes) {
166 showLoader();
167 loadQuotesPage(decodeURIComponent(quotes));
168 } else if (hash) {
169 showLoader();
170 loadHashtagPage(decodeURIComponent(hash));
171 } else if (query) {
172 showLoader();
173 loadThreadByURL(decodeURIComponent(query));
174 } else if (author && post) {
175 showLoader();
176 loadThreadById(decodeURIComponent(author), decodeURIComponent(post));
177 } else if (page) {
178 openPage(page);
179 } else {
180 showSearch();
181 }
182}
183
184/** @param {AnyPost} post, @returns {AnyElement} */
185
186function buildParentLink(post) {
187 let p = $tag('p.back');
188
189 if (post instanceof BlockedPost) {
190 let element = new PostComponent(post, 'parent').buildElement();
191 element.className = 'back';
192 element.querySelector('p.blocked-header span').innerText = 'Parent post blocked';
193 return element;
194 } else if (post instanceof MissingPost) {
195 p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`;
196 } else {
197 let url = linkToPostThread(post);
198 p.innerHTML = `<i class="fa-solid fa-reply"></i><a href="${url}">See parent post (@${post.author.handle})</a>`;
199 }
200
201 return p;
202}
203
204/** @returns {IntersectionObserver} */
205
206function buildAvatarPreloader() {
207 return new IntersectionObserver((entries, observer) => {
208 for (const entry of entries) {
209 if (entry.isIntersecting) {
210 const img = entry.target;
211 img.removeAttribute('lazy');
212 observer.unobserve(img);
213 }
214 }
215 }, {
216 rootMargin: '1000px 0px'
217 });
218}
219
220function showLoader() {
221 $id('loader').style.display = 'block';
222}
223
224function hideLoader() {
225 $id('loader').style.display = 'none';
226}
227
228function showSearch() {
229 $id('search').style.visibility = 'visible';
230 $id('search').querySelector('input[type=text]').focus();
231}
232
233function hideSearch() {
234 $id('search').style.visibility = 'hidden';
235}
236
237function showDialog(dialog) {
238 dialog.style.visibility = 'visible';
239 $id('thread').classList.add('overlay');
240
241 dialog.querySelector('input[type=text]')?.focus();
242}
243
244function hideDialog(dialog) {
245 dialog.style.visibility = 'hidden';
246 dialog.classList.remove('expanded');
247 $id('thread').classList.remove('overlay');
248
249 for (let field of dialog.querySelectorAll('input[type=text]')) {
250 field.value = '';
251 }
252}
253
254function toggleDialog(dialog) {
255 if (dialog.style.visibility == 'visible') {
256 hideDialog(dialog);
257 } else {
258 showDialog(dialog);
259 }
260}
261
262function toggleLoginInfo(event) {
263 $id('login').classList.toggle('expanded');
264}
265
266function toggleAccountMenu() {
267 let menu = $id('account_menu');
268 menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible';
269}
270
271/** @param {string} buttonName */
272
273function showMenuButton(buttonName) {
274 let button = accountMenu.querySelector(`a[data-action=${buttonName}]`);
275 button.parentNode.style.display = 'list-item';
276}
277
278/** @param {string} buttonName */
279
280function hideMenuButton(buttonName) {
281 let button = accountMenu.querySelector(`a[data-action=${buttonName}]`);
282 button.parentNode.style.display = 'none';
283}
284
285/** @param {string} buttonName, @param {boolean} state */
286
287function toggleMenuButton(buttonName, state) {
288 let button = accountMenu.querySelector(`a[data-action=${buttonName}]`);
289 button.querySelector('.check').style.display = (state) ? 'inline' : 'none';
290}
291
292/** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */
293
294function showLoggedInStatus(loggedIn, avatar) {
295 let account = $id('account');
296
297 if (loggedIn === true && avatar) {
298 let button = account.querySelector('i');
299
300 let img = $tag('img.avatar', { src: avatar });
301 img.style.display = 'none';
302 img.addEventListener('load', () => {
303 button.remove();
304 img.style.display = 'inline';
305 });
306 img.addEventListener('error', () => {
307 showLoggedInStatus(true, null);
308 })
309
310 account.append(img);
311 } else if (loggedIn === false) {
312 $id('account').innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`;
313 } else if (loggedIn === 'incognito') {
314 $id('account').innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`;
315 } else {
316 account.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`;
317 }
318}
319
320function submitLogin() {
321 let handle = $id('login_handle');
322 let password = $id('login_password');
323 let submit = $id('login_submit');
324 let cloudy = $id('cloudy');
325
326 if (submit.style.display == 'none') { return }
327
328 handle.blur();
329 password.blur();
330
331 submit.style.display = 'none';
332 cloudy.style.display = 'inline-block';
333
334 logIn(handle.value, password.value).then((pds) => {
335 window.api = pds;
336 window.accountAPI = pds;
337
338 hideDialog(loginDialog);
339 submit.style.display = 'inline';
340 cloudy.style.display = 'none';
341
342 loadCurrentUserAvatar();
343 showMenuButton('logout');
344 showMenuButton('incognito');
345 hideMenuButton('login');
346
347 let params = new URLSearchParams(location.search);
348 let page = params.get('page');
349 if (page) {
350 openPage(page);
351 }
352 })
353 .catch((error) => {
354 submit.style.display = 'inline';
355 cloudy.style.display = 'none';
356 console.log(error);
357
358 if (error.code == 401 && error.json.error == 'AuthFactorTokenRequired') {
359 alert("Please log in using an \"app password\" if you have 2FA enabled.");
360 } else {
361 window.setTimeout(() => alert(error), 10);
362 }
363 });
364}
365
366/** @param {string} identifier, @param {string} password, @returns {Promise<BlueskyAPI>} */
367
368async function logIn(identifier, password) {
369 let pdsEndpoint;
370
371 if (identifier.match(/^did:/)) {
372 pdsEndpoint = await Minisky.pdsEndpointForDid(identifier);
373 } else if (identifier.match(/^[^@]+@[^@]+$/)) {
374 pdsEndpoint = 'bsky.social';
375 } else if (identifier.match(/^@?[\w\-]+(\.[\w\-]+)+$/)) {
376 identifier = identifier.replace(/^@/, '');
377 let did = await appView.resolveHandle(identifier);
378 pdsEndpoint = await Minisky.pdsEndpointForDid(did);
379 } else {
380 throw 'Please enter your handle or DID.';
381 }
382
383 let pds = new BlueskyAPI(pdsEndpoint, true);
384 await pds.logIn(identifier, password);
385 return pds;
386}
387
388function loadCurrentUserAvatar() {
389 api.loadCurrentUserAvatar().then((url) => {
390 showLoggedInStatus(true, url);
391 }).catch((error) => {
392 console.log(error);
393 showLoggedInStatus(true, null);
394 });
395}
396
397function logOut() {
398 accountAPI.resetTokens();
399 localStorage.removeItem('incognito');
400 location.reload();
401}
402
403function submitSearch() {
404 let url = $id('search').querySelector('input[name=q]').value.trim();
405
406 if (!url) { return }
407
408 if (url.startsWith('at://')) {
409 let target = new URL(getLocation());
410 target.searchParams.set('q', url);
411 location.assign(target.toString());
412 return;
413 }
414
415 if (url.match(/^#?((\p{Letter}|\p{Number})+)$/u)) {
416 let target = new URL(getLocation());
417 target.searchParams.set('hash', encodeURIComponent(url.replace(/^#/, '')));
418 location.assign(target.toString());
419 return;
420 }
421
422 try {
423 let [handle, postId] = BlueskyAPI.parsePostURL(url);
424
425 let newURL = linkToPostById(handle, postId);
426 location.assign(newURL);
427 } catch (error) {
428 console.log(error);
429 alert(error.message || "This is not a valid URL or hashtag");
430 }
431}
432
433function openPage(page) {
434 if (!accountAPI.isLoggedIn) {
435 toggleDialog(loginDialog);
436 return;
437 }
438
439 if (page == 'notif') {
440 showLoader();
441 showNotificationsPage();
442 }
443}
444
445function showNotificationsPage() {
446 document.title = `Notifications - Skythread`;
447
448 let isLoading = false;
449 let firstPageLoaded = false;
450 let finished = false;
451 let cursor;
452
453 loadInPages((next) => {
454 if (isLoading || finished) { return; }
455 isLoading = true;
456
457 accountAPI.loadMentions(cursor).then(data => {
458 let posts = data.posts.map(x => new Post(x));
459
460 if (posts.length > 0) {
461 if (!firstPageLoaded) {
462 hideLoader();
463 firstPageLoaded = true;
464
465 let header = $tag('header');
466 let h2 = $tag('h2', { text: "Replies & Mentions:" });
467 header.append(h2);
468 $id('thread').appendChild(header);
469 $id('thread').classList.add('notifications');
470 }
471
472 for (let post of posts) {
473 if (post.parentReference) {
474 let p = $tag('p.back');
475 p.innerHTML = `<i class="fa-solid fa-reply"></i> `;
476
477 let { repo, rkey } = atURI(post.parentReference.uri);
478 let url = linkToPostById(repo, rkey);
479 let parentLink = $tag('a', { href: url });
480 p.append(parentLink);
481
482 if (repo == api.user.did) {
483 parentLink.innerText = 'Reply to you';
484 } else {
485 parentLink.innerText = 'Reply';
486 api.fetchHandleForDid(repo).then(handle => {
487 parentLink.innerText = `Reply to @${handle}`;
488 });
489 }
490
491 $id('thread').appendChild(p);
492 }
493
494 let postView = new PostComponent(post, 'feed').buildElement();
495 $id('thread').appendChild(postView);
496 }
497 }
498
499 isLoading = false;
500 cursor = data.cursor;
501
502 if (!cursor) {
503 finished = true;
504 } else if (posts.length == 0) {
505 next();
506 }
507 }).catch(error => {
508 hideLoader();
509 console.log(error);
510 isLoading = false;
511 });
512 });
513}
514
515/** @param {Post} post */
516
517function setPageTitle(post) {
518 document.title = `${post.author.displayName}: "${post.text}" - Skythread`;
519}
520
521/** @param {string} hashtag */
522
523function loadHashtagPage(hashtag) {
524 hashtag = hashtag.replace(/^\#/, '');
525 document.title = `#${hashtag} - Skythread`;
526
527 let isLoading = false;
528 let firstPageLoaded = false;
529 let finished = false;
530 let cursor;
531
532 loadInPages(() => {
533 if (isLoading || finished) { return; }
534 isLoading = true;
535
536 api.getHashtagFeed(hashtag, cursor).then(data => {
537 let posts = data.posts.map(j => new Post(j));
538
539 if (!firstPageLoaded) {
540 hideLoader();
541
542 let header = $tag('header');
543 let h2 = $tag('h2', {
544 text: (posts.length > 0) ? `Posts tagged: #${hashtag}` : `No posts tagged #${hashtag}.`
545 });
546 header.append(h2);
547
548 $id('thread').appendChild(header);
549 $id('thread').classList.add('hashtag');
550 }
551
552 for (let post of posts) {
553 let postView = new PostComponent(post, 'feed').buildElement();
554 $id('thread').appendChild(postView);
555 }
556
557 isLoading = false;
558 firstPageLoaded = true;
559 cursor = data.cursor;
560
561 if (!cursor || posts.length == 0) {
562 finished = true;
563 }
564 }).catch(error => {
565 hideLoader();
566 console.log(error);
567 isLoading = false;
568 });
569 });
570}
571
572/** @param {string} url */
573
574function loadQuotesPage(url) {
575 let isLoading = false;
576 let firstPageLoaded = false;
577 let cursor;
578 let finished = false;
579
580 loadInPages(() => {
581 if (isLoading || finished) { return; }
582 isLoading = true;
583
584 blueAPI.getQuotes(url, cursor).then(data => {
585 api.loadPosts(data.posts).then(jsons => {
586 let posts = jsons.map(j => new Post(j));
587
588 if (!firstPageLoaded) {
589 hideLoader();
590
591 let header = $tag('header');
592 let h2;
593
594 if (data.quoteCount > 1) {
595 h2 = $tag('h2', { text: `${data.quoteCount} quotes:` });
596 } else if (data.quoteCount == 1) {
597 h2 = $tag('h2', { text: '1 quote:' });
598 } else {
599 h2 = $tag('h2', { text: 'No quotes found.' });
600 }
601
602 header.append(h2);
603 $id('thread').appendChild(header);
604 $id('thread').classList.add('quotes');
605 }
606
607 for (let post of posts) {
608 let postView = new PostComponent(post, 'quotes').buildElement();
609 $id('thread').appendChild(postView);
610 }
611
612 isLoading = false;
613 firstPageLoaded = true;
614 cursor = data.cursor;
615
616 if (!cursor || posts.length == 0) {
617 finished = true;
618 }
619 }).catch(error => {
620 hideLoader();
621 console.log(error);
622 isLoading = false;
623 })
624 }).catch(error => {
625 hideLoader();
626 console.log(error);
627 isLoading = false;
628 });
629 });
630}
631
632/** @param {Function} callback */
633
634function loadInPages(callback) {
635 let loadIfNeeded = () => {
636 if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) {
637 callback(loadIfNeeded);
638 }
639 };
640
641 callback(loadIfNeeded);
642
643 document.addEventListener('scroll', loadIfNeeded);
644 const resizeObserver = new ResizeObserver(loadIfNeeded);
645 resizeObserver.observe(document.body);
646}
647
648/** @param {string} url */
649
650function loadThreadByURL(url) {
651 let loadThread = url.startsWith('at://') ? api.loadThreadByAtURI(url) : api.loadThreadByURL(url);
652
653 loadThread.then(json => {
654 displayThread(json);
655 }).catch(error => {
656 hideLoader();
657 showError(error);
658 });
659}
660
661/** @param {string} author, @param {string} rkey */
662
663function loadThreadById(author, rkey) {
664 api.loadThreadById(author, rkey).then(json => {
665 displayThread(json);
666 }).catch(error => {
667 hideLoader();
668 showError(error);
669 });
670}
671
672/** @param {json} json */
673
674function displayThread(json) {
675 let root = Post.parseThreadPost(json.thread);
676 window.root = root;
677 window.subtreeRoot = root;
678
679 let loadQuoteCount;
680
681 if (root instanceof Post) {
682 setPageTitle(root);
683 loadQuoteCount = blueAPI.getQuoteCount(root.uri);
684
685 if (root.parent) {
686 let p = buildParentLink(root.parent);
687 $id('thread').appendChild(p);
688 }
689 }
690
691 let component = new PostComponent(root, 'thread');
692 let view = component.buildElement();
693 hideLoader();
694 $id('thread').appendChild(view);
695
696 loadQuoteCount?.then(count => {
697 if (count > 0) {
698 component.appendQuotesIconLink(count, true);
699 }
700 }).catch(error => {
701 console.warn("Couldn't load quote count: " + error);
702 });
703}
704
705/** @param {Post} post, @param {AnyElement} nodeToUpdate */
706
707function loadSubtree(post, nodeToUpdate) {
708 api.loadThreadByAtURI(post.uri).then(json => {
709 let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel);
710 post.updateDataFromPost(root);
711 window.subtreeRoot = post;
712
713 let component = new PostComponent(post, 'thread');
714 component.installIntoElement(nodeToUpdate);
715 }).catch(showError);
716}
717
718/** @param {Post} post, @param {AnyElement} nodeToUpdate */
719
720function loadHiddenSubtree(post, nodeToUpdate) {
721 let content = nodeToUpdate.querySelector('.content');
722 let hiddenRepliesDiv = content.querySelector(':scope > .hidden-replies');
723
724 blueAPI.getReplies(post.uri).then(replies => {
725 let missingReplies = replies.filter(r => !post.replies.some(x => x.uri === r));
726
727 Promise.allSettled(missingReplies.map(uri => api.loadThreadByAtURI(uri))).then(responses => {
728 let replies = responses
729 .map(r => r.status == 'fulfilled' ? r.value : undefined)
730 .filter(v => v)
731 .map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1));
732
733 post.setReplies(replies);
734 hiddenRepliesDiv.remove();
735
736 for (let reply of post.replies) {
737 let component = new PostComponent(reply, 'thread');
738 let view = component.buildElement();
739 content.append(view);
740 }
741
742 if (replies.length < responses.length) {
743 let notFoundCount = responses.length - replies.length;
744 let pluralizedCount = notFoundCount + ' ' + ((notFoundCount > 1) ? 'replies are' : 'reply is');
745
746 let info = $tag('p.missing-replies-info', {
747 html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)`
748 });
749 content.append(info);
750 }
751 }).catch(error => {
752 hiddenRepliesDiv.remove();
753 setTimeout(() => showError(error), 1);
754 });
755 }).catch(error => {
756 hiddenRepliesDiv.remove();
757
758 if (error instanceof APIError && error.code == 404) {
759 let info = $tag('p.missing-replies-info', {
760 html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)`
761 });
762 content.append(info);
763 } else {
764 setTimeout(() => showError(error), 1);
765 }
766 });
767}