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
8 document.addEventListener('click', (e) => {
9 $id('account_menu').style.visibility = 'hidden';
10 });
11
12 document.querySelector('#search form').addEventListener('submit', (e) => {
13 e.preventDefault();
14 submitSearch();
15 });
16
17 document.querySelector('#login').addEventListener('click', (e) => {
18 if (e.target === e.currentTarget) {
19 hideLogin();
20 } else {
21 e.stopPropagation();
22 }
23 });
24
25 document.querySelector('#login .info a').addEventListener('click', (e) => {
26 e.preventDefault();
27 toggleLoginInfo();
28 });
29
30 document.querySelector('#login form').addEventListener('submit', (e) => {
31 e.preventDefault();
32 submitLogin();
33 });
34
35 document.querySelector('#login .close').addEventListener('click', (e) => {
36 hideLogin();
37 });
38
39 document.querySelector('#account').addEventListener('click', (e) => {
40 if (accountAPI.isLoggedIn) {
41 toggleAccount();
42 } else {
43 toggleLogin();
44 }
45 e.stopPropagation();
46 });
47
48 document.querySelector('#account_menu').addEventListener('click', (e) => {
49 e.stopPropagation();
50 });
51
52 document.querySelector('#account_menu a[data-action=incognito]').addEventListener('click', (e) => {
53 e.preventDefault();
54
55 if (isIncognito) {
56 localStorage.removeItem('incognito');
57 } else {
58 localStorage.setItem('incognito', '1');
59 }
60
61 location.reload();
62 });
63
64 document.querySelector('#account_menu a[data-action=logout]').addEventListener('click', (e) => {
65 e.preventDefault();
66 logOut();
67 });
68
69 window.appView = new BlueskyAPI('api.bsky.app', false);
70 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false);
71 window.accountAPI = new BlueskyAPI(undefined, true);
72
73 if (accountAPI.isLoggedIn && !isIncognito) {
74 window.api = accountAPI;
75 accountAPI.host = accountAPI.user.pdsEndpoint;
76 showLoggedInStatus(true, api.user.avatar);
77 } else if (accountAPI.isLoggedIn && isIncognito) {
78 window.api = appView;
79 accountAPI.host = accountAPI.user.pdsEndpoint;
80 showLoggedInStatus('incognito');
81 document.querySelector('#account_menu a[data-action=incognito]').innerText = '✓ Incognito mode';
82 } else {
83 window.api = appView;
84 }
85
86 parseQueryParams();
87}
88
89function parseQueryParams() {
90 let params = new URLSearchParams(location.search);
91 let query = params.get('q');
92 let author = params.get('author');
93 let post = params.get('post');
94 let quotes = params.get('quotes');
95 let hash = params.get('hash');
96 let mastodon = params.get('masto');
97
98 if (quotes) {
99 showLoader();
100 loadQuotesPage(decodeURIComponent(quotes));
101 } else if (hash) {
102 showLoader();
103 loadHashtagPage(decodeURIComponent(hash));
104 } else if (query) {
105 showLoader();
106 loadThread(decodeURIComponent(query));
107 } else if (author && post) {
108 showLoader();
109 loadThread(decodeURIComponent(author), decodeURIComponent(post));
110 } else if (mastodon) {
111 showLoader();
112 loadMastodonThread(decodeURIComponent(mastodon));
113 } else {
114 showSearch();
115 }
116}
117
118/** @param {AnyPost} post, @returns {AnyElement} */
119
120function buildParentLink(post) {
121 let p = $tag('p.back');
122
123 if (post instanceof BlockedPost) {
124 let element = new PostComponent(post).buildElement('parent');
125 element.className = 'back';
126 element.querySelector('p.blocked-header span').innerText = 'Parent post blocked';
127 return element;
128 } else if (post instanceof MissingPost) {
129 p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`;
130 } else {
131 let url = linkToPostThread(post);
132 p.innerHTML = `<i class="fa-solid fa-reply"></i><a href="${url}">See parent post (@${post.author.handle})</a>`;
133 }
134
135 return p;
136}
137
138function showLoader() {
139 $id('loader').style.display = 'block';
140}
141
142function hideLoader() {
143 $id('loader').style.display = 'none';
144}
145
146function showSearch() {
147 $id('search').style.visibility = 'visible';
148 $id('search').querySelector('input[type=text]').focus();
149}
150
151function hideSearch() {
152 $id('search').style.visibility = 'hidden';
153}
154
155function showLogin() {
156 $id('login').style.visibility = 'visible';
157 $id('thread').classList.add('overlay');
158 $id('login_handle').focus();
159}
160
161function hideLogin() {
162 $id('login').style.visibility = 'hidden';
163 $id('login').classList.remove('expanded');
164 $id('thread').classList.remove('overlay');
165 $id('login_handle').value = '';
166 $id('login_password').value = '';
167}
168
169function toggleLogin() {
170 if ($id('login').style.visibility == 'visible') {
171 hideLogin();
172 } else {
173 showLogin();
174 }
175}
176
177function toggleLoginInfo(event) {
178 $id('login').classList.toggle('expanded');
179}
180
181function toggleAccount() {
182 let menu = $id('account_menu');
183 menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible';
184}
185
186/** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */
187
188function showLoggedInStatus(loggedIn, avatar) {
189 let account = $id('account');
190
191 if (loggedIn === true && avatar) {
192 let button = account.querySelector('i');
193
194 let img = $tag('img.avatar', { src: avatar });
195 img.style.display = 'none';
196 img.addEventListener('load', () => {
197 button.remove();
198 img.style.display = 'inline';
199 });
200 img.addEventListener('error', () => {
201 showLoggedInStatus(true, null);
202 })
203
204 account.append(img);
205 } else if (loggedIn === false) {
206 $id('account').innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`;
207 } else if (loggedIn === 'incognito') {
208 $id('account').innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`;
209 } else {
210 account.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`;
211 }
212}
213
214function submitLogin() {
215 let handle = $id('login_handle');
216 let password = $id('login_password');
217 let submit = $id('login_submit');
218 let cloudy = $id('cloudy');
219
220 if (submit.style.display == 'none') { return }
221
222 handle.blur();
223 password.blur();
224
225 submit.style.display = 'none';
226 cloudy.style.display = 'inline-block';
227
228 logIn(handle.value, password.value).then((pds) => {
229 window.api = pds;
230 window.accountAPI = pds;
231
232 hideLogin();
233 submit.style.display = 'inline';
234 cloudy.style.display = 'none';
235
236 loadCurrentUserAvatar();
237 })
238 .catch((error) => {
239 submit.style.display = 'inline';
240 cloudy.style.display = 'none';
241 console.log(error);
242
243 window.setTimeout(() => alert(error), 10);
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 loadCurrentUserAvatar() {
270 api.loadCurrentUserAvatar().then((url) => {
271 showLoggedInStatus(true, url);
272 }).catch((error) => {
273 console.log(error);
274 showLoggedInStatus(true, null);
275 });
276}
277
278function logOut() {
279 accountAPI.resetTokens();
280 location.reload();
281}
282
283function submitSearch() {
284 let url = $id('search').querySelector('input[name=q]').value.trim();
285
286 if (!url) { return }
287
288 if (url.match(/^#?((\p{Letter}|\p{Number})+)$/u)) {
289 let target = new URL(getLocation());
290 target.searchParams.set('hash', encodeURIComponent(url.replace(/^#/, '')));
291 location.assign(target.toString());
292 return;
293 }
294
295 try {
296 let [handle, postId] = BlueskyAPI.parsePostURL(url);
297
298 let newURL = linkToPostById(handle, postId);
299 location.assign(newURL);
300 } catch (error) {
301 console.log(error);
302 alert(error.message || "This is not a valid URL or hashtag");
303 }
304}
305
306/** @param {Post} post */
307
308function setPageTitle(post) {
309 document.title = `${post.author.displayName}: "${post.text}" - Skythread`;
310}
311
312/** @param {string} hashtag */
313
314function loadHashtagPage(hashtag) {
315 hashtag = hashtag.replace(/^\#/, '');
316 document.title = `#${hashtag} - Skythread`;
317
318 let isLoading = false;
319 let firstPageLoaded = false;
320 let finished = false;
321 let cursor;
322
323 loadInPages(() => {
324 if (isLoading || finished) { return; }
325 isLoading = true;
326
327 api.getHashtagFeed(hashtag, cursor).then(data => {
328 let posts = data.posts.map(j => new Post(j));
329
330 if (!firstPageLoaded) {
331 hideLoader();
332
333 let header = $tag('header');
334 let h2 = $tag('h2', {
335 text: (posts.length > 0) ? `Posts tagged: #${hashtag}` : `No posts tagged #${hashtag}.`
336 });
337 header.append(h2);
338
339 $id('thread').appendChild(header);
340 $id('thread').classList.add('hashtag');
341 }
342
343 for (let post of posts) {
344 let postView = new PostComponent(post).buildElement('feed');
345 $id('thread').appendChild(postView);
346 }
347
348 isLoading = false;
349 firstPageLoaded = true;
350 cursor = data.cursor;
351
352 if (!cursor || posts.length == 0) {
353 finished = true;
354 }
355 }).catch(error => {
356 hideLoader();
357 console.log(error);
358 isLoading = false;
359 });
360 });
361}
362
363/** @param {string} url */
364
365function loadQuotesPage(url) {
366 let isLoading = false;
367 let firstPageLoaded = false;
368 let cursor;
369 let finished = false;
370
371 loadInPages(() => {
372 if (isLoading || finished) { return; }
373 isLoading = true;
374
375 blueAPI.getQuotes(url, cursor).then(data => {
376 api.loadPosts(data.posts).then(jsons => {
377 let posts = jsons.map(j => new Post(j));
378
379 if (!firstPageLoaded) {
380 hideLoader();
381
382 let header = $tag('header');
383 let h2;
384
385 if (data.quoteCount > 1) {
386 h2 = $tag('h2', { text: `${data.quoteCount} quotes:` });
387 } else if (data.quoteCount == 1) {
388 h2 = $tag('h2', { text: '1 quote:' });
389 } else {
390 h2 = $tag('h2', { text: 'No quotes found.' });
391 }
392
393 header.append(h2);
394 $id('thread').appendChild(header);
395 $id('thread').classList.add('quotes');
396 }
397
398 for (let post of posts) {
399 let postView = new PostComponent(post).buildElement('quotes');
400 $id('thread').appendChild(postView);
401 }
402
403 isLoading = false;
404 firstPageLoaded = true;
405 cursor = data.cursor;
406
407 if (!cursor || posts.length == 0) {
408 finished = true;
409 }
410 }).catch(error => {
411 hideLoader();
412 console.log(error);
413 isLoading = false;
414 })
415 }).catch(error => {
416 hideLoader();
417 console.log(error);
418 isLoading = false;
419 });
420 });
421}
422
423/** @param {Function} callback */
424
425function loadInPages(callback) {
426 callback();
427
428 document.addEventListener('scroll', (e) => {
429 if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 200) {
430 callback();
431 }
432 });
433}
434
435/** @param {string} url, @param {string} [postId], @param {AnyElement} [nodeToUpdate] */
436
437function loadThread(url, postId, nodeToUpdate) {
438 let load = postId ? api.loadThreadById(url, postId) : api.loadThreadByURL(url);
439
440 load.then(json => {
441 let root = Post.parseThreadPost(json.thread);
442 window.root = root;
443
444 let loadQuoteCount;
445
446 if (!nodeToUpdate && root instanceof Post) {
447 setPageTitle(root);
448 loadQuoteCount = blueAPI.getQuoteCount(root.uri);
449
450 if (root.parent) {
451 let p = buildParentLink(root.parent);
452 $id('thread').appendChild(p);
453 }
454 }
455
456 let component = new PostComponent(root);
457 let list = component.buildElement('thread');
458 hideLoader();
459
460 if (nodeToUpdate) {
461 nodeToUpdate.querySelector('.content').replaceWith(list.querySelector('.content'));
462 } else {
463 $id('thread').appendChild(list);
464 }
465
466 loadQuoteCount?.then(count => {
467 if (count > 0) {
468 let stats = list.querySelector(':scope > .content > p.stats');
469 let q = new URL(getLocation());
470 q.searchParams.set('quotes', component.linkToPost);
471 stats.append($tag('i', { className: count > 1 ? 'fa-regular fa-comments' : 'fa-regular fa-comment' }));
472 stats.append(" ");
473 let quotes = $tag('a', {
474 html: count > 1 ? `${count} quotes` : '1 quote',
475 href: q.toString()
476 });
477 stats.append(quotes);
478 }
479 }).catch(error => {
480 console.warn("Couldn't load quote count: " + error);
481 });
482 }).catch(error => {
483 hideLoader();
484 console.log(error);
485 alert(error);
486 });
487}
488
489function loadMastodonThread(url, nodeToUpdate) {
490 let host = new URL(url).host;
491 let postId = url.replace(/\/$/, '').split('/').reverse()[0];
492 let statusURL = `https://${host}/api/v1/statuses/${postId}`;
493 let contextURL = `https://${host}/api/v1/statuses/${postId}/context`;
494
495 let load = async function() {
496 let post = await fetch(statusURL).then(x => x.json());
497 let context = await fetch(contextURL).then(x => x.json());
498 return [post, context];
499 }
500
501 load().then(json => {
502 console.log(json);
503
504 let root = Post.parseMastodonThread(json[0], json[1]);
505 window.root = root;
506
507 if (!nodeToUpdate && root instanceof Post) {
508 setPageTitle(root);
509
510 if (root.parent) {
511 let p = buildParentLink(root.parent);
512 $id('thread').appendChild(p);
513 }
514 }
515
516 let component = new PostComponent(root);
517 let list = component.buildElement('thread');
518 hideLoader();
519
520 if (nodeToUpdate) {
521 nodeToUpdate.querySelector('.content').replaceWith(list.querySelector('.content'));
522 } else {
523 $id('thread').appendChild(list);
524 }
525 }).catch(error => {
526 hideLoader();
527 console.log(error);
528 alert(error);
529 });
530}