this repo has no description
1import './app.css';
2
3import { useLingui } from '@lingui/react';
4import debounce from 'just-debounce-it';
5import { lazy, memo, Suspense } from 'preact/compat';
6import {
7 useEffect,
8 useLayoutEffect,
9 useMemo,
10 useRef,
11 useState,
12} from 'preact/hooks';
13import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
14
15import 'swiped-events';
16
17import { subscribe } from 'valtio';
18
19import BackgroundService from './components/background-service';
20import ComposeButton from './components/compose-button';
21import { ICONS } from './components/ICONS';
22import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
23import Loader from './components/loader';
24import Modals from './components/modals';
25import NotificationService from './components/notification-service';
26import SearchCommand from './components/search-command';
27import Shortcuts from './components/shortcuts';
28import NotFound from './pages/404';
29import AccountStatuses from './pages/account-statuses';
30import AnnualReport from './pages/annual-report';
31import Bookmarks from './pages/bookmarks';
32import Catchup from './pages/catchup';
33import Favourites from './pages/favourites';
34import Filters from './pages/filters';
35import FollowedHashtags from './pages/followed-hashtags';
36import Following from './pages/following';
37import Hashtag from './pages/hashtag';
38import Home from './pages/home';
39import HttpRoute from './pages/http-route';
40import List from './pages/list';
41import Lists from './pages/lists';
42import Login from './pages/login';
43import Mentions from './pages/mentions';
44import Notifications from './pages/notifications';
45import Public from './pages/public';
46import ScheduledPosts from './pages/scheduled-posts';
47import Search from './pages/search';
48import StatusRoute from './pages/status-route';
49import Trending from './pages/trending';
50import Welcome from './pages/welcome';
51import {
52 api,
53 hasInstance,
54 hasPreferences,
55 initAccount,
56 initClient,
57 initInstance,
58 initPreferences,
59} from './utils/api';
60import { getAccessToken } from './utils/auth';
61import focusDeck from './utils/focus-deck';
62import states, { initStates, statusKey } from './utils/states';
63import store from './utils/store';
64import {
65 getAccount,
66 getCredentialApplication,
67 getCurrentAccount,
68 getVapidKey,
69 setCurrentAccountID,
70} from './utils/store-utils';
71
72import './utils/toast-alert';
73
74// Lazy load Sandbox component only in development
75const Sandbox =
76 import.meta.env.DEV || import.meta.env.PHANPY_DEV
77 ? lazy(() => import('./pages/sandbox'))
78 : () => null;
79
80window.__STATES__ = states;
81window.__STATES_STATS__ = () => {
82 const keys = [
83 'statuses',
84 'accounts',
85 'spoilers',
86 'unfurledLinks',
87 'statusQuotes',
88 ];
89 const counts = {};
90 keys.forEach((key) => {
91 counts[key] = Object.keys(states[key]).length;
92 });
93 console.warn('STATE stats', counts);
94
95 const { statuses } = states;
96 const unmountedPosts = [];
97 for (const key in statuses) {
98 const $post = document.querySelector(
99 `[data-state-post-id~="${key}"], [data-state-post-ids~="${key}"]`,
100 );
101 if (!$post) {
102 unmountedPosts.push(key);
103 }
104 }
105 console.warn('Unmounted posts', unmountedPosts.length, unmountedPosts);
106};
107
108// Experimental "garbage collection" for states
109// Every 15 minutes
110// Only posts for now
111setInterval(
112 () => {
113 if (!window.__IDLE__) return;
114 const { statuses, unfurledLinks, notifications } = states;
115 let keysCount = 0;
116 const { instance } = api();
117 for (const key in statuses) {
118 if (!window.__IDLE__) break;
119 try {
120 const $post = document.querySelector(
121 `[data-state-post-id~="${key}"], [data-state-post-ids~="${key}"]`,
122 );
123 const postInNotifications = notifications.some(
124 (n) => key === statusKey(n.status?.id, instance),
125 );
126 if (!$post && !postInNotifications) {
127 delete states.statuses[key];
128 delete states.statusQuotes[key];
129 for (const link in unfurledLinks) {
130 const unfurled = unfurledLinks[link];
131 const sKey = statusKey(unfurled.id, unfurled.instance);
132 if (sKey === key) {
133 delete states.unfurledLinks[link];
134 break;
135 }
136 }
137 keysCount++;
138 }
139 } catch (e) {}
140 }
141 if (keysCount) {
142 console.info(`GC: Removed ${keysCount} keys`);
143 }
144 },
145 15 * 60 * 1000,
146);
147
148// Preload icons
149// There's probably a better way to do this
150// Related: https://github.com/vitejs/vite/issues/10600
151setTimeout(() => {
152 for (const icon in ICONS) {
153 setTimeout(() => {
154 if (Array.isArray(ICONS[icon])) {
155 ICONS[icon][0]?.();
156 } else if (typeof ICONS[icon] === 'object') {
157 ICONS[icon].module?.();
158 } else {
159 ICONS[icon]?.();
160 }
161 }, 1);
162 }
163}, 5000);
164
165(() => {
166 window.__IDLE__ = true;
167 const nonIdleEvents = [
168 'mousemove',
169 'mousedown',
170 'resize',
171 'keydown',
172 'touchstart',
173 'pointerdown',
174 'pointermove',
175 'wheel',
176 ];
177 const setIdle = () => {
178 window.__IDLE__ = true;
179 };
180 const IDLE_TIME = 3_000; // 3 seconds
181 const debouncedSetIdle = debounce(setIdle, IDLE_TIME);
182 const onNonIdle = () => {
183 window.__IDLE__ = false;
184 debouncedSetIdle();
185 };
186 nonIdleEvents.forEach((event) => {
187 window.addEventListener(event, onNonIdle, {
188 passive: true,
189 capture: true,
190 });
191 });
192 window.addEventListener('blur', setIdle, {
193 passive: true,
194 });
195 // When cursor leaves the window, set idle
196 document.documentElement.addEventListener(
197 'mouseleave',
198 (e) => {
199 if (!e.relatedTarget && !e.toElement) {
200 setIdle();
201 }
202 },
203 {
204 passive: true,
205 },
206 );
207 // document.addEventListener(
208 // 'visibilitychange',
209 // () => {
210 // if (document.visibilityState === 'visible') {
211 // onNonIdle();
212 // }
213 // },
214 // {
215 // passive: true,
216 // },
217 // );
218})();
219
220// Possible fix for iOS PWA theme-color bug
221// It changes when loading web pages in "webview"
222const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
223if (isIOS) {
224 document.addEventListener('visibilitychange', () => {
225 if (document.visibilityState === 'visible') {
226 // Don't reset theme color if media modal is showing
227 // Media modal will set its own theme color based on the media's color
228 const showingMediaModal =
229 document.getElementsByClassName('media-modal-container').length > 0;
230 if (showingMediaModal) return;
231
232 const theme = store.local.get('theme');
233 let $meta;
234 if (theme) {
235 // Get current meta
236 $meta = document.querySelector(
237 `meta[name="theme-color"][data-theme-setting="manual"]`,
238 );
239 if ($meta) {
240 const color = $meta.content;
241 const tempColor =
242 theme === 'light'
243 ? $meta.dataset.themeLightColorTemp
244 : $meta.dataset.themeDarkColorTemp;
245 $meta.content = tempColor || '';
246 setTimeout(() => {
247 $meta.content = color;
248 }, 10);
249 }
250 } else {
251 // Get current color scheme
252 const colorScheme = window.matchMedia('(prefers-color-scheme: dark)')
253 .matches
254 ? 'dark'
255 : 'light';
256 // Get current theme-color
257 $meta = document.querySelector(
258 `meta[name="theme-color"][media*="${colorScheme}"]`,
259 );
260 if ($meta) {
261 const color = $meta.dataset.content;
262 const tempColor = $meta.dataset.contentTemp;
263 $meta.content = tempColor || '';
264 setTimeout(() => {
265 $meta.content = color;
266 }, 10);
267 }
268 }
269 }
270 });
271}
272
273{
274 const theme = store.local.get('theme');
275 // If there's a theme, it's NOT auto
276 if (theme) {
277 // dark | light
278 document.documentElement.classList.add(`is-${theme}`);
279 document
280 .querySelector('meta[name="color-scheme"]')
281 .setAttribute('content', theme || 'dark light');
282
283 // Enable manual theme <meta>
284 const $manualMeta = document.querySelector(
285 'meta[data-theme-setting="manual"]',
286 );
287 if ($manualMeta) {
288 $manualMeta.name = 'theme-color';
289 $manualMeta.content =
290 theme === 'light'
291 ? $manualMeta.dataset.themeLightColor
292 : $manualMeta.dataset.themeDarkColor;
293 }
294 // Disable auto theme <meta>s
295 const $autoMetas = document.querySelectorAll(
296 'meta[data-theme-setting="auto"]',
297 );
298 $autoMetas.forEach((m) => {
299 m.name = '';
300 });
301 }
302 const textSize = store.local.get('textSize');
303 if (textSize) {
304 document.documentElement.style.setProperty('--text-size', `${textSize}px`);
305 }
306}
307
308subscribe(states, (changes) => {
309 for (const [action, path, value, prevValue] of changes) {
310 // Change #app dataset based on settings.shortcutsViewMode
311 if (path.join('.') === 'settings.shortcutsViewMode') {
312 const $app = document.getElementById('app');
313 if ($app) {
314 $app.dataset.shortcutsViewMode = states.shortcuts?.length ? value : '';
315 }
316 }
317
318 // Add/Remove cloak class to body
319 if (path.join('.') === 'settings.cloakMode') {
320 const $body = document.body;
321 $body.classList.toggle('cloak', value);
322 }
323 }
324});
325
326const BENCHES = new Map();
327window.__BENCH_RESULTS = new Map();
328window.__BENCHMARK = {
329 start(name) {
330 if (!import.meta.env.DEV && !import.meta.env.PHANPY_DEV) return;
331 // If already started, ignore
332 if (BENCHES.has(name)) return;
333 const start = performance.now();
334 BENCHES.set(name, start);
335 },
336 end(name) {
337 if (!import.meta.env.DEV && !import.meta.env.PHANPY_DEV) return;
338 const start = BENCHES.get(name);
339 if (start) {
340 const end = performance.now();
341 const duration = end - start;
342 __BENCH_RESULTS.set(name, duration);
343 BENCHES.delete(name);
344 }
345 },
346};
347
348if (import.meta.env.DEV) {
349 // If press shift down, set --time-scale to 10 in root
350 document.addEventListener('keydown', (e) => {
351 if (e.key === 'Shift') {
352 document.documentElement.classList.add('slow-mo');
353 }
354 });
355 document.addEventListener('keyup', (e) => {
356 if (e.key === 'Shift') {
357 document.documentElement.classList.remove('slow-mo');
358 }
359 });
360}
361
362{
363 // Temporary Experiments
364 // May be removed in the future
365 document.body.classList.toggle(
366 'exp-tab-bar-v2',
367 store.local.get('experiments-tabBarV2') ?? false,
368 );
369}
370
371function App() {
372 const [isLoggedIn, setIsLoggedIn] = useState(false);
373 const [uiState, setUIState] = useState('loading');
374 __BENCHMARK.start('app-init');
375 __BENCHMARK.start('time-to-following');
376 __BENCHMARK.start('time-to-home');
377 __BENCHMARK.start('time-to-isLoggedIn');
378 useLingui();
379
380 useEffect(() => {
381 const instanceURL = store.local.get('instanceURL');
382 const code = decodeURIComponent(
383 (window.location.search.match(/code=([^&]+)/) || [, ''])[1],
384 );
385
386 if (code) {
387 console.log({ code });
388 // Clear the code from the URL
389 window.history.replaceState(
390 {},
391 document.title,
392 window.location.pathname || '/',
393 );
394
395 const {
396 client_id: clientID,
397 client_secret: clientSecret,
398 vapid_key,
399 } = getCredentialApplication(instanceURL) || {};
400 const vapidKey = getVapidKey(instanceURL) || vapid_key;
401 const verifier = store.sessionCookie.get('codeVerifier');
402
403 (async () => {
404 setUIState('loading');
405 const { access_token: accessToken } = await getAccessToken({
406 instanceURL,
407 client_id: clientID,
408 client_secret: clientSecret,
409 code,
410 code_verifier: verifier || undefined,
411 });
412
413 if (accessToken) {
414 const client = initClient({ instance: instanceURL, accessToken });
415 await Promise.allSettled([
416 initPreferences(client),
417 initInstance(client, instanceURL),
418 initAccount(client, instanceURL, accessToken, vapidKey),
419 ]);
420 initStates();
421 window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
422
423 setIsLoggedIn(true);
424 setUIState('default');
425 } else {
426 setUIState('error');
427 }
428 __BENCHMARK.end('app-init');
429 })();
430 } else {
431 window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
432 const searchAccount = decodeURIComponent(
433 (window.location.search.match(/account=([^&]+)/) || [, ''])[1],
434 );
435 let account;
436 if (searchAccount) {
437 account = getAccount(searchAccount);
438 console.log('searchAccount', searchAccount, account);
439 if (account) {
440 setCurrentAccountID(account.info.id);
441 window.history.replaceState(
442 {},
443 document.title,
444 window.location.pathname || '/',
445 );
446 }
447 }
448 if (!account) {
449 account = getCurrentAccount();
450 }
451 if (account) {
452 setCurrentAccountID(account.info.id);
453 const { client } = api({ account });
454 const { instance } = client;
455 // console.log('masto', masto);
456 initStates();
457 setUIState('loading');
458 (async () => {
459 try {
460 if (hasPreferences() && hasInstance(instance)) {
461 // Non-blocking
462 initPreferences(client);
463 initInstance(client, instance);
464 } else {
465 await Promise.allSettled([
466 initPreferences(client),
467 initInstance(client, instance),
468 ]);
469 }
470 } catch (e) {
471 } finally {
472 setIsLoggedIn(true);
473 setUIState('default');
474 __BENCHMARK.end('app-init');
475 }
476 })();
477 } else {
478 setUIState('default');
479 __BENCHMARK.end('app-init');
480 }
481 }
482
483 // Cleanup
484 store.sessionCookie.del('clientID');
485 store.sessionCookie.del('clientSecret');
486 store.sessionCookie.del('codeVerifier');
487 }, []);
488
489 let location = useLocation();
490 states.currentLocation = location.pathname;
491 // useLayoutEffect(() => {
492 // states.currentLocation = location.pathname;
493 // }, [location.pathname]);
494
495 useEffect(focusDeck, [location, isLoggedIn]);
496
497 if (/\/https?:/.test(location.pathname)) {
498 return <HttpRoute />;
499 }
500
501 if (uiState === 'loading') {
502 return <Loader id="loader-root" />;
503 }
504
505 return (
506 <>
507 <PrimaryRoutes isLoggedIn={isLoggedIn} />
508 <SecondaryRoutes isLoggedIn={isLoggedIn} />
509 <Routes>
510 <Route path="/:instance?/s/:id" element={<StatusRoute />} />
511 </Routes>
512 {isLoggedIn && <ComposeButton />}
513 {isLoggedIn && <Shortcuts />}
514 <Modals />
515 {isLoggedIn && <NotificationService />}
516 <BackgroundService isLoggedIn={isLoggedIn} />
517 <SearchCommand onClose={focusDeck} />
518 <KeyboardShortcutsHelp />
519 </>
520 );
521}
522
523function Root({ isLoggedIn }) {
524 if (isLoggedIn) {
525 __BENCHMARK.end('time-to-isLoggedIn');
526 }
527 return isLoggedIn ? <Home /> : <Welcome />;
528}
529
530const PrimaryRoutes = memo(({ isLoggedIn }) => {
531 const location = useLocation();
532 const nonRootLocation = useMemo(() => {
533 const { pathname } = location;
534 return !/^\/(login|welcome|_sandbox)/i.test(pathname);
535 }, [location]);
536
537 return (
538 <Routes location={nonRootLocation || location}>
539 <Route path="/" element={<Root isLoggedIn={isLoggedIn} />} />
540 <Route path="/login" element={<Login />} />
541 <Route path="/welcome" element={<Welcome />} />
542 {(import.meta.env.DEV || import.meta.env.PHANPY_DEV) && (
543 <Route
544 path="/_sandbox"
545 element={
546 <Suspense fallback={<Loader id="loader-sandbox" />}>
547 <Sandbox />
548 </Suspense>
549 }
550 />
551 )}
552 </Routes>
553 );
554});
555
556function getPrevLocation() {
557 return states.prevLocation || null;
558}
559function SecondaryRoutes({ isLoggedIn }) {
560 // const snapStates = useSnapshot(states);
561 const location = useLocation();
562 // const prevLocation = snapStates.prevLocation;
563 const backgroundLocation = useRef(getPrevLocation());
564
565 const isModalPage = useMemo(() => {
566 return (
567 matchPath('/:instance/s/:id', location.pathname) ||
568 matchPath('/s/:id', location.pathname)
569 );
570 }, [location.pathname, matchPath]);
571 if (isModalPage) {
572 if (!backgroundLocation.current)
573 backgroundLocation.current = getPrevLocation();
574 } else {
575 backgroundLocation.current = null;
576 }
577 console.debug({
578 backgroundLocation: backgroundLocation.current,
579 location,
580 });
581
582 return (
583 <Routes location={backgroundLocation.current || location}>
584 {isLoggedIn && (
585 <>
586 <Route path="/notifications" element={<Notifications />} />
587 <Route path="/mentions" element={<Mentions />} />
588 <Route path="/following" element={<Following />} />
589 <Route path="/b" element={<Bookmarks />} />
590 <Route path="/f" element={<Favourites />} />
591 <Route path="/l">
592 <Route index element={<Lists />} />
593 <Route path=":id" element={<List />} />
594 </Route>
595 <Route path="/fh" element={<FollowedHashtags />} />
596 <Route path="/sp" element={<ScheduledPosts />} />
597 <Route path="/ft" element={<Filters />} />
598 <Route path="/catchup" element={<Catchup />} />
599 <Route path="/annual_report/:year" element={<AnnualReport />} />
600 </>
601 )}
602 <Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
603 <Route path="/:instance?/a/:id" element={<AccountStatuses />} />
604 <Route path="/:instance?/p">
605 <Route index element={<Public />} />
606 <Route path="l" element={<Public local />} />
607 </Route>
608 <Route path="/:instance?/trending" element={<Trending />} />
609 <Route path="/:instance?/search" element={<Search />} />
610 {/* <Route path="/:anything" element={<NotFound />} /> */}
611 </Routes>
612 );
613}
614
615export { App };