import './app.css'; import { useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'preact/hooks'; import { matchPath, Route, Routes, useLocation, useNavigate, } from 'react-router-dom'; import Toastify from 'toastify-js'; import { useSnapshot } from 'valtio'; import Account from './components/account'; import Compose from './components/compose'; import Drafts from './components/drafts'; import Icon from './components/icon'; import Link from './components/link'; import Loader from './components/loader'; import MediaModal from './components/media-modal'; import Modal from './components/modal'; import Shortcuts from './components/shortcuts'; import ShortcutsSettings from './components/shortcuts-settings'; import NotFound from './pages/404'; import AccountStatuses from './pages/account-statuses'; import Bookmarks from './pages/bookmarks'; import Favourites from './pages/favourites'; import FollowedHashtags from './pages/followed-hashtags'; import Following from './pages/following'; import Hashtag from './pages/hashtag'; import Home from './pages/home'; import HomeV1 from './pages/home-v1'; import List from './pages/list'; import Lists from './pages/lists'; import Login from './pages/login'; import Notifications from './pages/notifications'; import Public from './pages/public'; import Search from './pages/search'; import Settings from './pages/settings'; import Status from './pages/status'; import Welcome from './pages/welcome'; import { api, initAccount, initClient, initInstance, initPreferences, } from './utils/api'; import { getAccessToken } from './utils/auth'; import states, { getStatus, saveStatus } from './utils/states'; import store from './utils/store'; import { getCurrentAccount } from './utils/store-utils'; import usePageVisibility from './utils/usePageVisibility'; window.__STATES__ = states; function App() { const snapStates = useSnapshot(states); const [isLoggedIn, setIsLoggedIn] = useState(false); const [uiState, setUIState] = useState('loading'); const navigate = useNavigate(); useLayoutEffect(() => { const theme = store.local.get('theme'); if (theme) { document.documentElement.classList.add(`is-${theme}`); document .querySelector('meta[name="color-scheme"]') .setAttribute('content', theme === 'auto' ? 'dark light' : theme); } }, []); useEffect(() => { const instanceURL = store.local.get('instanceURL'); const code = (window.location.search.match(/code=([^&]+)/) || [])[1]; if (code) { console.log({ code }); // Clear the code from the URL window.history.replaceState({}, document.title, '/'); const clientID = store.session.get('clientID'); const clientSecret = store.session.get('clientSecret'); (async () => { setUIState('loading'); const { access_token: accessToken } = await getAccessToken({ instanceURL, client_id: clientID, client_secret: clientSecret, code, }); const masto = initClient({ instance: instanceURL, accessToken }); await Promise.allSettled([ initInstance(masto), initAccount(masto, instanceURL, accessToken), ]); initPreferences(masto); setIsLoggedIn(true); setUIState('default'); })(); } else { const account = getCurrentAccount(); if (account) { store.session.set('currentAccount', account.info.id); const { masto } = api({ account }); console.log('masto', masto); initPreferences(masto); setUIState('loading'); (async () => { try { await initInstance(masto); } catch (e) { } finally { setIsLoggedIn(true); setUIState('default'); } })(); } else { setUIState('default'); } } }, []); let location = useLocation(); states.currentLocation = location.pathname; const locationDeckMap = { '/': 'home-page', '/notifications': 'notifications-page', }; const focusDeck = () => { let timer = setTimeout(() => { const page = document.getElementById(locationDeckMap[location.pathname]); console.debug('FOCUS', location.pathname, page); if (page) { page.focus(); } }, 100); return () => clearTimeout(timer); }; useEffect(focusDeck, [location]); const showModal = snapStates.showCompose || snapStates.showSettings || snapStates.showAccount || snapStates.showDrafts || snapStates.showMediaModal || snapStates.showShortcutsSettings; useEffect(() => { if (!showModal) focusDeck(); }, [showModal]); // useEffect(() => { // // HACK: prevent this from running again due to HMR // if (states.init) return; // if (isLoggedIn) { // requestAnimationFrame(startVisibility); // states.init = true; // } // }, [isLoggedIn]); // Notifications service // - WebSocket to receive notifications when page is visible const [visible, setVisible] = useState(true); usePageVisibility(setVisible); const notificationStream = useRef(); useEffect(() => { if (isLoggedIn && visible) { const { masto } = api(); (async () => { // 1. Get the latest notification if (states.notificationsLast) { const notificationsIterator = masto.v1.notifications.list({ limit: 1, since_id: states.notificationsLast.id, }); const { value: notifications } = await notificationsIterator.next(); if (notifications?.length) { states.notificationsShowNew = true; } } // 2. Start streaming notificationStream.current = await masto.ws.stream( '/api/v1/streaming', { stream: 'user:notification', }, ); console.log('🎏 Streaming notification', notificationStream.current); notificationStream.current.on('notification', (notification) => { console.log('🔔🔔 Notification', notification); states.notificationsShowNew = true; }); notificationStream.current.ws.onclose = () => { console.log('🔔🔔 Notification stream closed'); }; })(); } return () => { if (notificationStream.current) { notificationStream.current.ws.close(); notificationStream.current = null; } }; }, [visible, isLoggedIn]); const { prevLocation } = snapStates; const backgroundLocation = useRef(prevLocation || null); const isModalPage = matchPath('/:instance/s/:id', location.pathname) || matchPath('/s/:id', location.pathname); if (isModalPage) { if (!backgroundLocation.current) backgroundLocation.current = prevLocation; } else { backgroundLocation.current = null; } console.debug({ backgroundLocation: backgroundLocation.current, location, }); const nonRootLocation = useMemo(() => { const { pathname } = location; return !/^\/(login|welcome)/.test(pathname); }, [location]); return ( <> ) : uiState === 'loading' ? ( ) : ( ) } /> } /> } /> {isLoggedIn && ( } /> )} {isLoggedIn && } />} {isLoggedIn && } />} {isLoggedIn && } />} {isLoggedIn && } />} {isLoggedIn && ( } /> } /> )} {isLoggedIn && } />} } /> } /> } /> } /> } /> {/* } /> */} } /> {!snapStates.settings.shortcutsColumnsMode && } {!!snapStates.showCompose && ( { const { newStatus } = results || {}; states.showCompose = false; window.__COMPOSE__ = null; if (newStatus) { states.reloadStatusPage++; setTimeout(() => { const toast = Toastify({ className: 'shiny-pill', text: 'Status posted. Check it out.', duration: 10_000, // 10 seconds gravity: 'bottom', position: 'center', // destination: `/#/s/${newStatus.id}`, onClick: () => { toast.hideToast(); states.prevLocation = location; navigate(`/s/${newStatus.id}`); }, }); toast.showToast(); }, 1000); } }} /> )} {!!snapStates.showSettings && ( { if (e.target === e.currentTarget) { states.showSettings = false; } }} > { states.showSettings = false; }} /> )} {!!snapStates.showAccount && ( { if (e.target === e.currentTarget) { states.showAccount = false; } }} > { states.showAccount = false; }} /> )} {!!snapStates.showDrafts && ( { if (e.target === e.currentTarget) { states.showDrafts = false; } }} > )} {!!snapStates.showMediaModal && ( { if ( e.target === e.currentTarget || e.target.classList.contains('media') ) { states.showMediaModal = false; } }} > { states.showMediaModal = false; }} /> )} {!!snapStates.showShortcutsSettings && ( { if (e.target === e.currentTarget) { states.showShortcutsSettings = false; } }} > )} ); } // let ws; // async function startStream() { // const { masto, instance } = api(); // if ( // ws && // (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) // ) { // return; // } // const stream = await masto.v1.stream.streamUser(); // console.log('STREAM START', { stream }); // ws = stream.ws; // const handleNewStatus = debounce((status) => { // console.log('UPDATE', status); // if (document.visibilityState === 'hidden') return; // const inHomeNew = states.homeNew.find((s) => s.id === status.id); // const inHome = status.id === states.homeLast?.id; // if (!inHomeNew && !inHome) { // if (states.settings.boostsCarousel && status.reblog) { // // do nothing // } else { // states.homeNew.unshift({ // id: status.id, // reblog: status.reblog?.id, // reply: !!status.inReplyToAccountId, // }); // console.log('homeNew 1', [...states.homeNew]); // } // } // saveStatus(status, instance); // }, 5000); // stream.on('update', handleNewStatus); // stream.on('status.update', (status) => { // console.log('STATUS.UPDATE', status); // saveStatus(status, instance); // }); // stream.on('delete', (statusID) => { // console.log('DELETE', statusID); // // delete states.statuses[statusID]; // const s = getStatus(statusID); // if (s) s._deleted = true; // }); // stream.on('notification', (notification) => { // console.log('NOTIFICATION', notification); // const inNotificationsNew = states.notificationsNew.find( // (n) => n.id === notification.id, // ); // const inNotifications = notification.id === states.notificationsLast?.id; // if (!inNotificationsNew && !inNotifications) { // states.notificationsNew.unshift(notification); // } // saveStatus(notification.status, instance, { override: false }); // }); // stream.ws.onclose = () => { // console.log('STREAM CLOSED!'); // if (document.visibilityState !== 'hidden') { // startStream(); // } // }; // return { // stream, // stopStream: () => { // stream.ws.close(); // }, // }; // } // let lastHidden; // function startVisibility() { // const { masto, instance } = api(); // const handleVisible = (visible) => { // if (!visible) { // const timestamp = Date.now(); // lastHidden = timestamp; // } else { // const timestamp = Date.now(); // const diff = timestamp - lastHidden; // const diffMins = Math.round(diff / 1000 / 60); // console.log(`visible: ${visible}`, { lastHidden, diffMins }); // if (!lastHidden || diffMins > 1) { // (async () => { // try { // const firstStatusID = states.homeLast?.id; // const firstNotificationID = states.notificationsLast?.id; // console.log({ states, firstNotificationID, firstStatusID }); // const fetchHome = masto.v1.timelines.listHome({ // limit: 5, // ...(firstStatusID && { sinceId: firstStatusID }), // }); // const fetchNotifications = masto.v1.notifications.list({ // limit: 1, // ...(firstNotificationID && { sinceId: firstNotificationID }), // }); // const newStatuses = await fetchHome; // const hasOneAndReblog = // newStatuses.length === 1 && newStatuses?.[0]?.reblog; // if (newStatuses.length) { // if (states.settings.boostsCarousel && hasOneAndReblog) { // // do nothing // } else { // states.homeNew = newStatuses.map((status) => { // saveStatus(status, instance); // return { // id: status.id, // reblog: status.reblog?.id, // reply: !!status.inReplyToAccountId, // }; // }); // console.log('homeNew 2', [...states.homeNew]); // } // } // const newNotifications = await fetchNotifications; // if (newNotifications.length) { // const notification = newNotifications[0]; // const inNotificationsNew = states.notificationsNew.find( // (n) => n.id === notification.id, // ); // const inNotifications = // notification.id === states.notificationsLast?.id; // if (!inNotificationsNew && !inNotifications) { // states.notificationsNew.unshift(notification); // } // saveStatus(notification.status, instance, { override: false }); // } // } catch (e) { // // Silently fail // console.error(e); // } finally { // startStream(); // } // })(); // } // } // }; // const handleVisibilityChange = () => { // const hidden = document.visibilityState === 'hidden'; // handleVisible(!hidden); // console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible')); // }; // document.addEventListener('visibilitychange', handleVisibilityChange); // requestAnimationFrame(handleVisibilityChange); // return { // stop: () => { // document.removeEventListener('visibilitychange', handleVisibilityChange); // }, // }; // } export { App };