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