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