this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 615 lines 18 kB view raw
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 };