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
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 let target = $(e.target);
56
57 hideDialog(target.closest('.dialog'));
58 });
59
60 $(document.querySelector('#biohazard_hide')).addEventListener('click', (e) => {
61 e.preventDefault();
62
63 window.biohazardEnabled = false;
64 localStorage.setItem('biohazard', 'false');
65 accountMenu.toggleMenuButtonCheck('biohazard', false);
66
67 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) {
68 $(p).style.display = 'none';
69 }
70
71 let target = $(e.target);
72
73 hideDialog(target.closest('.dialog'));
74 });
75
76 window.appView = new BlueskyAPI('api.bsky.app', false);
77 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false);
78 window.accountAPI = new BlueskyAPI(undefined, true);
79
80 if (accountAPI.isLoggedIn) {
81 accountAPI.host = accountAPI.user.pdsEndpoint;
82 accountMenu.hideMenuButton('login');
83
84 if (!isIncognito) {
85 window.api = accountAPI;
86 accountMenu.showLoggedInStatus(true, api.user.avatar);
87 } else {
88 window.api = appView;
89 accountMenu.showLoggedInStatus('incognito');
90 accountMenu.toggleMenuButtonCheck('incognito', true);
91 }
92 } else {
93 window.api = appView;
94 accountMenu.hideMenuButton('logout');
95 accountMenu.hideMenuButton('incognito');
96 }
97
98 accountMenu.toggleMenuButtonCheck('biohazard', window.biohazardEnabled !== false);
99
100 parseQueryParams();
101}
102
103function parseQueryParams() {
104 let params = new URLSearchParams(location.search);
105 let query = params.get('q');
106 let author = params.get('author');
107 let post = params.get('post');
108 let quotes = params.get('quotes');
109 let hash = params.get('hash');
110 let page = params.get('page');
111
112 if (quotes) {
113 showLoader();
114 loadQuotesPage(decodeURIComponent(quotes));
115 } else if (hash) {
116 showLoader();
117 loadHashtagPage(decodeURIComponent(hash));
118 } else if (query) {
119 showLoader();
120 threadPage.loadThreadByURL(decodeURIComponent(query));
121 } else if (author && post) {
122 showLoader();
123 threadPage.loadThreadById(decodeURIComponent(author), decodeURIComponent(post));
124 } else if (page) {
125 openPage(page);
126 } else {
127 showSearch();
128 }
129}
130
131/** @returns {IntersectionObserver} */
132
133function buildAvatarPreloader() {
134 return new IntersectionObserver((entries, observer) => {
135 for (const entry of entries) {
136 if (entry.isIntersecting) {
137 const img = entry.target;
138 img.removeAttribute('lazy');
139 observer.unobserve(img);
140 }
141 }
142 }, {
143 rootMargin: '1000px 0px'
144 });
145}
146
147function showLoader() {
148 $id('loader').style.display = 'block';
149}
150
151function hideLoader() {
152 $id('loader').style.display = 'none';
153}
154
155function showSearch() {
156 let search = $id('search');
157 let searchField = $(search.querySelector('input[type=text]'));
158
159 search.style.visibility = 'visible';
160 searchField.focus();
161}
162
163function hideSearch() {
164 $id('search').style.visibility = 'hidden';
165}
166
167function showDialog(dialog) {
168 dialog.style.visibility = 'visible';
169 $id('thread').classList.add('overlay');
170
171 dialog.querySelector('input[type=text]')?.focus();
172}
173
174function hideDialog(dialog) {
175 dialog.style.visibility = 'hidden';
176 dialog.classList.remove('expanded');
177 $id('thread').classList.remove('overlay');
178
179 for (let field of dialog.querySelectorAll('input[type=text]')) {
180 field.value = '';
181 }
182}
183
184function toggleDialog(dialog) {
185 if (dialog.style.visibility == 'visible') {
186 hideDialog(dialog);
187 } else {
188 showDialog(dialog);
189 }
190}
191
192function toggleLoginInfo(event) {
193 $id('login').classList.toggle('expanded');
194}
195
196function submitLogin() {
197 let handle = $id('login_handle', HTMLInputElement);
198 let password = $id('login_password', HTMLInputElement);
199 let submit = $id('login_submit');
200 let cloudy = $id('cloudy');
201
202 if (submit.style.display == 'none') { return }
203
204 handle.blur();
205 password.blur();
206
207 submit.style.display = 'none';
208 cloudy.style.display = 'inline-block';
209
210 logIn(handle.value, password.value).then((pds) => {
211 window.api = pds;
212 window.accountAPI = pds;
213
214 hideDialog(loginDialog);
215 submit.style.display = 'inline';
216 cloudy.style.display = 'none';
217
218 accountMenu.loadCurrentUserAvatar();
219
220 accountMenu.showMenuButton('logout');
221 accountMenu.showMenuButton('incognito');
222 accountMenu.hideMenuButton('login');
223
224 let params = new URLSearchParams(location.search);
225 let page = params.get('page');
226 if (page) {
227 openPage(page);
228 }
229 })
230 .catch((error) => {
231 submit.style.display = 'inline';
232 cloudy.style.display = 'none';
233 console.log(error);
234
235 if (error.code == 401 && error.json.error == 'AuthFactorTokenRequired') {
236 alert("Please log in using an \"app password\" if you have 2FA enabled.");
237 } else {
238 window.setTimeout(() => alert(error), 10);
239 }
240 });
241}
242
243/** @param {string} identifier, @param {string} password, @returns {Promise<BlueskyAPI>} */
244
245async function logIn(identifier, password) {
246 let pdsEndpoint;
247
248 if (identifier.match(/^did:/)) {
249 pdsEndpoint = await Minisky.pdsEndpointForDid(identifier);
250 } else if (identifier.match(/^[^@]+@[^@]+$/)) {
251 pdsEndpoint = 'bsky.social';
252 } else if (identifier.match(/^@?[\w\-]+(\.[\w\-]+)+$/)) {
253 identifier = identifier.replace(/^@/, '');
254 let did = await appView.resolveHandle(identifier);
255 pdsEndpoint = await Minisky.pdsEndpointForDid(did);
256 } else {
257 throw 'Please enter your handle or DID.';
258 }
259
260 let pds = new BlueskyAPI(pdsEndpoint, true);
261 await pds.logIn(identifier, password);
262 return pds;
263}
264
265function logOut() {
266 accountAPI.resetTokens();
267 localStorage.removeItem('incognito');
268 location.reload();
269}
270
271function submitSearch() {
272 let search = $id('search');
273 let searchField = $(search.querySelector('input[name=q]'), HTMLInputElement);
274 let url = searchField.value.trim();
275
276 if (!url) { return }
277
278 if (url.startsWith('at://')) {
279 let target = new URL(getLocation());
280 target.searchParams.set('q', url);
281 location.assign(target.toString());
282 return;
283 }
284
285 if (url.match(/^#?((\p{Letter}|\p{Number})+)$/u)) {
286 let target = new URL(getLocation());
287 target.searchParams.set('hash', encodeURIComponent(url.replace(/^#/, '')));
288 location.assign(target.toString());
289 return;
290 }
291
292 try {
293 let [handle, postId] = BlueskyAPI.parsePostURL(url);
294
295 let newURL = linkToPostById(handle, postId);
296 location.assign(newURL);
297 } catch (error) {
298 console.log(error);
299 alert(error.message || "This is not a valid URL or hashtag");
300 }
301}
302
303function openPage(page) {
304 if (!accountAPI.isLoggedIn) {
305 toggleDialog(loginDialog);
306 return;
307 }
308
309 if (page == 'notif') {
310 showLoader();
311 showNotificationsPage();
312 } else if (page == 'posting_stats') {
313 window.postingStatsPage.show();
314 } else if (page == 'like_stats') {
315 window.likeStatsPage.show();
316 }
317}
318
319function showNotificationsPage() {
320 document.title = `Notifications - Skythread`;
321
322 let isLoading = false;
323 let firstPageLoaded = false;
324 let finished = false;
325 let cursor;
326
327 loadInPages((next) => {
328 if (isLoading || finished) { return; }
329 isLoading = true;
330
331 accountAPI.loadMentions(cursor).then(data => {
332 let posts = data.posts.map(x => new Post(x));
333
334 if (posts.length > 0) {
335 if (!firstPageLoaded) {
336 hideLoader();
337 firstPageLoaded = true;
338
339 let header = $tag('header');
340 let h2 = $tag('h2', { text: "Replies & Mentions:" });
341 header.append(h2);
342 $id('thread').appendChild(header);
343 $id('thread').classList.add('notifications');
344 }
345
346 for (let post of posts) {
347 if (post.parentReference) {
348 let p = $tag('p.back');
349 p.innerHTML = `<i class="fa-solid fa-reply"></i> `;
350
351 let { repo, rkey } = atURI(post.parentReference.uri);
352 let url = linkToPostById(repo, rkey);
353 let parentLink = $tag('a', { href: url });
354 p.append(parentLink);
355
356 if (repo == api.user.did) {
357 parentLink.innerText = 'Reply to you';
358 } else {
359 parentLink.innerText = 'Reply';
360 api.fetchHandleForDid(repo).then(handle => {
361 parentLink.innerText = `Reply to @${handle}`;
362 });
363 }
364
365 $id('thread').appendChild(p);
366 }
367
368 let postView = new PostComponent(post, 'feed').buildElement();
369 $id('thread').appendChild(postView);
370 }
371 }
372
373 isLoading = false;
374 cursor = data.cursor;
375
376 if (!cursor) {
377 finished = true;
378 } else if (posts.length == 0) {
379 next();
380 }
381 }).catch(error => {
382 hideLoader();
383 console.log(error);
384 isLoading = false;
385 });
386 });
387}
388
389/** @param {Post} post */
390
391function setPageTitle(post) {
392 document.title = `${post.author.displayName}: "${post.text}" - Skythread`;
393}
394
395/** @param {string} hashtag */
396
397function loadHashtagPage(hashtag) {
398 hashtag = hashtag.replace(/^\#/, '');
399 document.title = `#${hashtag} - Skythread`;
400
401 let isLoading = false;
402 let firstPageLoaded = false;
403 let finished = false;
404 let cursor;
405
406 loadInPages(() => {
407 if (isLoading || finished) { return; }
408 isLoading = true;
409
410 api.getHashtagFeed(hashtag, cursor).then(data => {
411 let posts = data.posts.map(j => new Post(j));
412
413 if (!firstPageLoaded) {
414 hideLoader();
415
416 let header = $tag('header');
417 let h2 = $tag('h2', {
418 text: (posts.length > 0) ? `Posts tagged: #${hashtag}` : `No posts tagged #${hashtag}.`
419 });
420 header.append(h2);
421
422 $id('thread').appendChild(header);
423 $id('thread').classList.add('hashtag');
424 }
425
426 for (let post of posts) {
427 let postView = new PostComponent(post, 'feed').buildElement();
428 $id('thread').appendChild(postView);
429 }
430
431 isLoading = false;
432 firstPageLoaded = true;
433 cursor = data.cursor;
434
435 if (!cursor || posts.length == 0) {
436 finished = true;
437 }
438 }).catch(error => {
439 hideLoader();
440 console.log(error);
441 isLoading = false;
442 });
443 });
444}
445
446/** @param {string} url */
447
448function loadQuotesPage(url) {
449 let isLoading = false;
450 let firstPageLoaded = false;
451 let cursor;
452 let finished = false;
453
454 loadInPages(() => {
455 if (isLoading || finished) { return; }
456 isLoading = true;
457
458 blueAPI.getQuotes(url, cursor).then(data => {
459 api.loadPosts(data.posts).then(jsons => {
460 let posts = jsons.map(j => new Post(j));
461
462 if (!firstPageLoaded) {
463 hideLoader();
464
465 let header = $tag('header');
466 let h2;
467
468 if (data.quoteCount > 1) {
469 h2 = $tag('h2', { text: `${data.quoteCount} quotes:` });
470 } else if (data.quoteCount == 1) {
471 h2 = $tag('h2', { text: '1 quote:' });
472 } else {
473 h2 = $tag('h2', { text: 'No quotes found.' });
474 }
475
476 header.append(h2);
477 $id('thread').appendChild(header);
478 $id('thread').classList.add('quotes');
479 }
480
481 for (let post of posts) {
482 let postView = new PostComponent(post, 'quotes').buildElement();
483 $id('thread').appendChild(postView);
484 }
485
486 isLoading = false;
487 firstPageLoaded = true;
488 cursor = data.cursor;
489
490 if (!cursor || posts.length == 0) {
491 finished = true;
492 }
493 }).catch(error => {
494 hideLoader();
495 console.log(error);
496 isLoading = false;
497 })
498 }).catch(error => {
499 hideLoader();
500 console.log(error);
501 isLoading = false;
502 });
503 });
504}
505
506/** @param {Function} callback */
507
508function loadInPages(callback) {
509 let loadIfNeeded = () => {
510 if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) {
511 callback(loadIfNeeded);
512 }
513 };
514
515 callback(loadIfNeeded);
516
517 document.addEventListener('scroll', loadIfNeeded);
518 const resizeObserver = new ResizeObserver(loadIfNeeded);
519 resizeObserver.observe(document.body);
520}