app.jsx
1 import './app.css'; 2 3 import { 4 useEffect, 5 useLayoutEffect, 6 useMemo, 7 useRef, 8 useState, 9 } from 'preact/hooks'; 10 import { matchPath, Route, Routes, useLocation } from 'react-router-dom'; 11 import 'swiped-events'; 12 import { useSnapshot } from 'valtio'; 13 14 import BackgroundService from './components/background-service'; 15 import ComposeButton from './components/compose-button'; 16 import { ICONS } from './components/icon'; 17 import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help'; 18 import Loader from './components/loader'; 19 import Modals from './components/modals'; 20 import NotificationService from './components/notification-service'; 21 import SearchCommand from './components/search-command'; 22 import Shortcuts from './components/shortcuts'; 23 import NotFound from './pages/404'; 24 import AccountStatuses from './pages/account-statuses'; 25 import Bookmarks from './pages/bookmarks'; 26 import Favourites from './pages/favourites'; 27 import FollowedHashtags from './pages/followed-hashtags'; 28 import Following from './pages/following'; 29 import Hashtag from './pages/hashtag'; 30 import Home from './pages/home'; 31 import Filters from './pages/filters'; 32 import HttpRoute from './pages/http-route'; 33 import List from './pages/list'; 34 import Lists from './pages/lists'; 35 import Login from './pages/login'; 36 import Mentions from './pages/mentions'; 37 import Notifications from './pages/notifications'; 38 import Public from './pages/public'; 39 import Search from './pages/search'; 40 import StatusRoute from './pages/status-route'; 41 import Trending from './pages/trending'; 42 import ForYou from './pages/forYou'; 43 import Topics from './pages/topics'; 44 import Welcome from './pages/welcome'; 45 import { 46 api, 47 initAccount, 48 initClient, 49 initInstance, 50 initPreferences, 51 } from './utils/api'; 52 import { getAccessToken } from './utils/auth'; 53 import focusDeck from './utils/focus-deck'; 54 import states, { initStates } from './utils/states'; 55 import store from './utils/store'; 56 import { getCurrentAccount } from './utils/store-utils'; 57 import './utils/toast-alert'; 58 import Link from './components/link'; 59 import Icon from './components/icon'; 60 import AsyncText from './components/AsyncText'; 61 import ImportFriends from './pages/importFriends'; 62 import ImportTwitter from './pages/importTwitter'; 63 import Modal from './components/modal'; 64 import ListManageMembers from './pages/list'; 65 // import {formattedShortcuts} from './utils/shortcuts'; 66 import {subscribeToProvocWordDict} from './utils/vibe-tag'; 67 68 window.__STATES__ = states; 69 70 // Preload icons 71 // There's probably a better way to do this 72 // Related: https://github.com/vitejs/vite/issues/10600 73 setTimeout(() => { 74 for (const icon in ICONS) { 75 if (Array.isArray(ICONS[icon])) { 76 ICONS[icon][0]?.(); 77 } else { 78 ICONS[icon]?.(); 79 } 80 } 81 }, 5000); 82 83 function App() { 84 subscribeToProvocWordDict(); 85 const snapStates = useSnapshot(states); 86 const [isLoggedIn, setIsLoggedIn] = useState(false); 87 const [uiState, setUIState] = useState('loading'); 88 store.local.set('provocContentWordDict', ''); 89 90 useLayoutEffect(() => { 91 const theme = store.local.get('theme'); 92 if (theme) { 93 document.documentElement.classList.add(`is-${theme}`); 94 document 95 .querySelector('meta[name="color-scheme"]') 96 .setAttribute('content', theme === 'auto' ? 'dark light' : theme); 97 } 98 const textSize = store.local.get('textSize'); 99 if (textSize) { 100 document.documentElement.style.setProperty( 101 '--text-size', 102 `${textSize}px`, 103 ); 104 } 105 }, []); 106 107 useEffect(() => { 108 const instanceURL = store.local.get('instanceURL'); 109 const code = decodeURIComponent( 110 (window.location.search.match(/code=([^&]+)/) || [, ''])[1], 111 ); 112 113 if (code) { 114 console.log({ code }); 115 // Clear the code from the URL 116 window.history.replaceState({}, document.title, location.pathname || '/'); 117 118 const clientID = store.session.get('clientID'); 119 const clientSecret = store.session.get('clientSecret'); 120 const vapidKey = store.session.get('vapidKey'); 121 122 (async () => { 123 setUIState('loading'); 124 const { access_token: accessToken } = await getAccessToken({ 125 instanceURL, 126 client_id: clientID, 127 client_secret: clientSecret, 128 code, 129 }); 130 131 const client = initClient({ instance: instanceURL, accessToken }); 132 await Promise.allSettled([ 133 initInstance(client, instanceURL), 134 initAccount(client, instanceURL, accessToken, vapidKey), 135 ]); 136 initStates(); 137 initPreferences(client); 138 139 setIsLoggedIn(true); 140 setUIState('default'); 141 })(); 142 } else { 143 window.__IGNORE_GET_ACCOUNT_ERROR__ = true; 144 const account = getCurrentAccount(); 145 const myCurrentInstance = api().instance; 146 store.local.set('instanceURL', myCurrentInstance); 147 const { instance } = api(); 148 const { masto } = api({ instance }); 149 150 if (account) { 151 store.session.set('currentAccount', account.info.id); 152 const { client } = api({ account }); 153 const { instance } = client; 154 155 // console.log('masto', masto); 156 initStates(); 157 initPreferences(client); 158 setUIState('loading'); 159 (async () => { 160 try { 161 await initInstance(client, instance); 162 } catch (e) { 163 } finally { 164 setIsLoggedIn(true); 165 setUIState('default'); 166 } 167 })(); 168 } else { 169 setUIState('default'); 170 } 171 } 172 }, []); 173 174 let location = useLocation(); 175 states.currentLocation = location.pathname; 176 177 useEffect(focusDeck, [location, isLoggedIn]); 178 179 const prevLocation = snapStates.prevLocation; 180 const backgroundLocation = useRef(prevLocation || null); 181 const isModalPage = useMemo(() => { 182 return ( 183 matchPath('/:instance/s/:id', location.pathname) || 184 matchPath('/s/:id', location.pathname) 185 ); 186 }, [location.pathname, matchPath]); 187 if (isModalPage) { 188 if (!backgroundLocation.current) backgroundLocation.current = prevLocation; 189 } else { 190 backgroundLocation.current = null; 191 } 192 console.debug({ 193 backgroundLocation: backgroundLocation.current, 194 location, 195 }); 196 197 if (/\/https?:/.test(location.pathname)) { 198 return <HttpRoute />; 199 } 200 201 const nonRootLocation = useMemo(() => { 202 const { pathname } = location; 203 return !/^\/(login|welcome)/.test(pathname); 204 }, [location]); 205 206 // Change #app dataset based on snapStates.settings.shortcutsViewMode 207 useEffect(() => { 208 const $app = document.getElementById('app'); 209 if ($app) { 210 $app.dataset.shortcutsViewMode = snapStates.shortcuts?.length 211 ? snapStates.settings.shortcutsViewMode 212 : ''; 213 } 214 }, [snapStates.shortcuts, snapStates.settings.shortcutsViewMode]); 215 216 // Add/Remove cloak class to body 217 useEffect(() => { 218 const $body = document.body; 219 $body.classList.toggle('cloak', snapStates.settings.cloakMode); 220 }, [snapStates.settings.cloakMode]); 221 222 const instanceUrl = store.local.get('instanceURL'); 223 224 let trending = { 225 id: 'trending', 226 title: 'Trending', 227 subtitle: '', 228 path: `/mastodon.social/trending`, 229 icon: 'chart', 230 } 231 232 if (instanceUrl?.indexOf('skybridge.fly.dev')) { 233 trending = { 234 id: 'trending', 235 title: 'Trending', 236 subtitle: '', 237 path: `/l/1860062187893555200`, 238 icon: 'chart', 239 } 240 } 241 242 const formattedShortcuts = [ 243 { 244 icon: "home", 245 id: "home", 246 path: "/", 247 subtitle: undefined, 248 title: "Home" 249 }, 250 trending, 251 { 252 id: 'foryou', 253 title: 'For You', 254 subtitle: '', 255 path: `/foryou`, 256 icon: 'algorithm', 257 }, 258 { 259 id: 'search', 260 title: 'Search', 261 path: '/search', 262 icon: 'search', 263 }, 264 { 265 icon: "notification", 266 id: "notifications", 267 path: "/notifications", 268 subtitle: undefined, 269 title: "Notifications" 270 }, 271 ] 272 273 return ( 274 <> 275 <Routes location={nonRootLocation || location}> 276 <Route 277 path="/" 278 element={ 279 isLoggedIn ? ( 280 <Home /> 281 ) : uiState === 'loading' ? ( 282 <Loader id="loader-root" /> 283 ) : ( 284 <Welcome /> 285 ) 286 } 287 /> 288 <Route path="/login" element={<Login />} /> 289 <Route path="/welcome" element={<Welcome />} /> 290 </Routes> 291 <Routes location={backgroundLocation.current || location}> 292 {isLoggedIn && ( 293 <Route path="/notifications" element={<Notifications />} /> 294 )} 295 {isLoggedIn && <Route path="/mentions" element={<Mentions />} />} 296 {isLoggedIn && <Route path="/following" element={<Following />} />} 297 {isLoggedIn && <Route path="/b" element={<Bookmarks />} />} 298 {isLoggedIn && <Route path="/f" element={<Favourites />} />} 299 {isLoggedIn && ( 300 <Route path="/l"> 301 <Route index element={<Lists />} /> 302 <Route path=":id" element={<List />} /> 303 </Route> 304 )} 305 {isLoggedIn && <Route path="/importfriends" element={<ImportFriends />} />} 306 {isLoggedIn && <Route path="/importtwitter" element={<ImportTwitter />} />} 307 {isLoggedIn && <Route path="/fh" element={<FollowedHashtags />} />} 308 {isLoggedIn && <Route path="/ft" element={<Filters />} />} 309 <Route path="/:instance?/t/:hashtag" element={<Hashtag />} /> 310 <Route path="/:instance?/a/:id" element={<AccountStatuses />} /> 311 <Route path="/:instance?/p"> 312 <Route index element={<Public />} /> 313 <Route path="l" element={<Public local />} /> 314 </Route> 315 <Route path="/:instance?/trending" element={<Trending />} /> 316 <Route path="/foryou" element={<ForYou />} /> 317 {isLoggedIn && <Route path="/topics" element={<Topics />} />} 318 <Route path="/:instance?/search" element={<Search />} /> 319 {/* <Route path="/:anything" element={<NotFound />} /> */} 320 </Routes> 321 {uiState === 'default' && ( 322 <Routes> 323 <Route path="/:instance?/s/:id" element={<StatusRoute />} /> 324 </Routes> 325 )} 326 {/* {true && ( 327 <Modal 328 class="light" 329 onClick={(e) => { 330 if (e.target === e.currentTarget) { 331 setShowManageMembersModal(false); 332 } 333 }} 334 > 335 <ListManageMembers 336 listID={"8133"} 337 onClose={() => setShowManageMembersModal(false)} 338 /> 339 </Modal> 340 )} */} 341 {isLoggedIn && <ComposeButton />} 342 {isLoggedIn && 343 !snapStates.settings.shortcutsColumnsMode && 344 snapStates.settings.shortcutsViewMode !== 'multi-column' && ( 345 <Shortcuts /> 346 )} 347 {isLoggedIn && <nav class="tab-bar"> 348 <ul> 349 {formattedShortcuts.map( 350 ({ id, path, title, subtitle, icon }, i) => { 351 return ( 352 <li key={i + title}> 353 <Link 354 class={title === 'Notifications' && snapStates.notificationsShowNew ? 'has-badge-tab-bar' : ''} 355 to={path} 356 onClick={(e) => { 357 if (e.target.classList.contains('is-active')) { 358 e.preventDefault(); 359 const page = document.getElementById(`${id}-page`); 360 console.log(id, page); 361 if (page) { 362 page.scrollTop = 0; 363 const updatesButton = 364 page.querySelector('.updates-button'); 365 if (updatesButton) { 366 updatesButton.click(); 367 } 368 } 369 } 370 }} 371 > 372 <Icon icon={icon} size="xl" alt={title} /> 373 <span> 374 <AsyncText>{title}</AsyncText> 375 {subtitle && ( 376 <> 377 <br /> 378 <small>{subtitle}</small> 379 </> 380 )} 381 </span> 382 </Link> 383 </li> 384 ); 385 }, 386 )} 387 </ul> 388 </nav>} 389 390 <Modals /> 391 {isLoggedIn && <NotificationService />} 392 <BackgroundService isLoggedIn={isLoggedIn} /> 393 {uiState !== 'loading' && <SearchCommand onClose={focusDeck} />} 394 <KeyboardShortcutsHelp /> 395 </> 396 ); 397 } 398 399 export { App };