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