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