import './app.css'; import { useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'preact/hooks'; import { matchPath, Route, Routes, useLocation, useNavigate, useParams, } from 'react-router-dom'; import 'swiped-events'; import { useSnapshot } from 'valtio'; import AccountSheet from './components/account-sheet'; import Compose from './components/compose'; import Drafts from './components/drafts'; import Icon, { ICONS } 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 Notification from './components/notification'; import Shortcuts from './components/shortcuts'; import ShortcutsSettings from './components/shortcuts-settings'; import NotFound from './pages/404'; import AccountStatuses from './pages/account-statuses'; import Accounts from './pages/accounts'; 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 HttpRoute from './pages/HttpRoute'; import List from './pages/list'; import Lists from './pages/lists'; import Login from './pages/login'; import Mentions from './pages/mentions'; 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 Trending from './pages/trending'; import Welcome from './pages/welcome'; import { api, initAccount, initClient, initInstance, initPreferences, } from './utils/api'; import { getAccessToken } from './utils/auth'; import openCompose from './utils/open-compose'; import showToast from './utils/show-toast'; import states, { initStates, saveStatus } from './utils/states'; import store from './utils/store'; import { getAccountByAccessToken, getCurrentAccount, } from './utils/store-utils'; import './utils/toast-alert'; import useInterval from './utils/useInterval'; import usePageVisibility from './utils/usePageVisibility'; window.__STATES__ = states; // Preload icons // There's probably a better way to do this // Related: https://github.com/vitejs/vite/issues/10600 setTimeout(() => { for (const icon in ICONS) { if (Array.isArray(ICONS[icon])) { ICONS[icon][0]?.(); } else { ICONS[icon]?.(); } } }, 5000); 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); } const textSize = store.local.get('textSize'); if (textSize) { document.documentElement.style.setProperty( '--text-size', `${textSize}px`, ); } }, []); useEffect(() => { const instanceURL = store.local.get('instanceURL'); const code = decodeURIComponent( (window.location.search.match(/code=([^&]+)/) || [, ''])[1], ); if (code) { console.log({ code }); // Clear the code from the URL window.history.replaceState({}, document.title, location.pathname || '/'); const clientID = store.session.get('clientID'); const clientSecret = store.session.get('clientSecret'); const vapidKey = store.session.get('vapidKey'); (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, instanceURL), initAccount(masto, instanceURL, accessToken, vapidKey), ]); initStates(); initPreferences(masto); setIsLoggedIn(true); setUIState('default'); })(); } else { const account = getCurrentAccount(); if (account) { store.session.set('currentAccount', account.info.id); const { masto, instance } = api({ account }); console.log('masto', masto); initPreferences(masto); setUIState('loading'); (async () => { try { await initInstance(masto, instance); } catch (e) { } finally { setIsLoggedIn(true); setUIState('default'); } })(); } else { setUIState('default'); } } }, []); let location = useLocation(); states.currentLocation = location.pathname; const focusDeck = () => { let timer = setTimeout(() => { const columns = document.getElementById('columns'); if (columns) { // Focus first column // columns.querySelector('.deck-container')?.focus?.(); } else { const backDrop = document.querySelector('.deck-backdrop'); if (backDrop) return; // Focus last deck const pages = document.querySelectorAll('.deck-container'); const page = pages[pages.length - 1]; // last one if (page && page.tabIndex === -1) { console.log('FOCUS', page); page.focus(); } } }, 100); return () => clearTimeout(timer); }; useEffect(focusDeck, [location]); const showModal = snapStates.showCompose || snapStates.showSettings || snapStates.showAccounts || snapStates.showAccount || snapStates.showDrafts || snapStates.showMediaModal || snapStates.showShortcutsSettings; useEffect(() => { if (!showModal) focusDeck(); }, [showModal]); 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, }); if (/\/https?:/.test(location.pathname)) { return ; } const nonRootLocation = useMemo(() => { const { pathname } = location; return !/^\/(login|welcome)/.test(pathname); }, [location]); // Change #app dataset based on snapStates.settings.shortcutsViewMode useEffect(() => { const $app = document.getElementById('app'); if ($app) { $app.dataset.shortcutsViewMode = snapStates.settings.shortcutsViewMode; } }, [snapStates.settings.shortcutsViewMode]); // Add/Remove cloak class to body useEffect(() => { const $body = document.body; $body.classList.toggle('cloak', snapStates.settings.cloakMode); }, [snapStates.settings.cloakMode]); return ( <> ) : uiState === 'loading' ? ( ) : ( ) } /> } /> } /> {isLoggedIn && ( } /> )} {isLoggedIn && } />} {isLoggedIn && } />} {isLoggedIn && } />} {isLoggedIn && } />} {isLoggedIn && ( } /> } /> )} {isLoggedIn && } />} } /> } /> } /> } /> } /> } /> {/* } /> */} {uiState === 'default' && ( } /> )} {isLoggedIn && ( )} {isLoggedIn && !snapStates.settings.shortcutsColumnsMode && snapStates.settings.shortcutsViewMode !== 'multi-column' && ( )} {!!snapStates.showCompose && ( { const { newStatus, instance } = results || {}; states.showCompose = false; window.__COMPOSE__ = null; if (newStatus) { states.reloadStatusPage++; showToast({ text: 'Post published. Check it out.', delay: 1000, duration: 10_000, // 10 seconds onClick: (toast) => { toast.hideToast(); states.prevLocation = location; navigate( instance ? `/${instance}/s/${newStatus.id}` : `/s/${newStatus.id}`, ); }, }); } }} /> )} {!!snapStates.showSettings && ( { if (e.target === e.currentTarget) { states.showSettings = false; } }} > { states.showSettings = false; }} /> )} {!!snapStates.showAccounts && ( { if (e.target === e.currentTarget) { states.showAccounts = false; } }} > { states.showAccounts = false; }} /> )} {!!snapStates.showAccount && ( { if (e.target === e.currentTarget) { states.showAccount = false; } }} > { states.showAccount = false; if (destination) { states.showAccounts = false; } }} /> )} {!!snapStates.showDrafts && ( { if (e.target === e.currentTarget) { states.showDrafts = false; } }} > (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; } }} > (states.showShortcutsSettings = false)} /> )} ); } function BackgroundService({ 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, instance } = 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); if (notification.status) { saveStatus(notification.status, instance, { skipThreading: true, }); } 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]); // Check for updates service const lastCheckDate = useRef(); const checkForUpdates = () => { lastCheckDate.current = Date.now(); console.log('✨ Check app update'); fetch('./version.json') .then((r) => r.json()) .then((info) => { if (info) states.appVersion = info; }) .catch((e) => { console.error(e); }); }; useInterval(checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes usePageVisibility((visible) => { if (visible) { if (!lastCheckDate.current) { checkForUpdates(); } else { const diff = Date.now() - lastCheckDate.current; if (diff > 1000 * 60 * 60) { // 1 hour checkForUpdates(); } } } }); return null; } function NotificationService() { if (!('serviceWorker' in navigator)) return null; const snapStates = useSnapshot(states); const { routeNotification } = snapStates; console.log('🛎️ Notification service', routeNotification); const { id, accessToken } = routeNotification || {}; const [showNotificationSheet, setShowNotificationSheet] = useState(false); useLayoutEffect(() => { if (!id || !accessToken) return; const { instance: currentInstance } = api(); const { masto, instance } = api({ accessToken, }); console.log('API', { accessToken, currentInstance, instance }); const sameInstance = currentInstance === instance; const account = accessToken ? getAccountByAccessToken(accessToken) : getCurrentAccount(); (async () => { const notification = await masto.v1.notifications.fetch(id); if (notification && account) { console.log('🛎️ Notification', { id, notification, account }); const accountInstance = account.instanceURL; const { type, status, account: notificationAccount } = notification; const hasModal = !!document.querySelector('#modal-container > *'); const isFollow = type === 'follow' && !!notificationAccount?.id; const hasAccount = !!notificationAccount?.id; const hasStatus = !!status?.id; if (isFollow && sameInstance) { // Show account sheet, can handle different instances states.showAccount = { account: notificationAccount, instance: accountInstance, }; } else if (hasModal || !sameInstance || (hasAccount && hasStatus)) { // Show sheet of notification, if // - there is a modal open // - the notification is from another instance // - the notification has both account and status, gives choice for users to go to account or status setShowNotificationSheet({ id, account, notification, sameInstance, }); } else { if (hasStatus) { // Go to status page location.hash = `/${currentInstance}/s/${status.id}`; } else if (isFollow) { // Go to profile page location.hash = `/${currentInstance}/a/${notificationAccount.id}`; } else { // Go to notifications page location.hash = '/notifications'; } } } else { console.warn( '🛎️ Notification not found', notificationID, notificationAccessToken, ); } })(); }, [id, accessToken]); useLayoutEffect(() => { // Listen to message from service worker const handleMessage = (event) => { console.log('💥💥💥 Message event', event); const { type, id, accessToken } = event?.data || {}; if (type === 'notification') { states.routeNotification = { id, accessToken, }; } }; console.log('👂👂👂 Listen to message'); navigator.serviceWorker.addEventListener('message', handleMessage); return () => { console.log('👂👂👂 Remove listen to message'); navigator.serviceWorker.removeEventListener('message', handleMessage); }; }, []); const onClose = () => { setShowNotificationSheet(false); states.routeNotification = null; // If url is #/notifications?id=123, go to #/notifications if (/\/notifications\?id=/i.test(location.hash)) { location.hash = '/notifications'; } }; if (showNotificationSheet) { const { id, account, notification, sameInstance } = showNotificationSheet; return ( { if (e.target === e.currentTarget) { onClose(); } }} >
Notification
{!sameInstance && (

This notification is from your other account.

)}
{ const { target } = e; // If button or links if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') { onClose(); } }} >
View all notifications
); } return null; } function StatusRoute() { const params = useParams(); const { id, instance } = params; return ; } export { App };