'use strict'; import React, { useState, useEffect, useRef } from 'react'; import { HotKeys } from 'react-hotkeys'; import { useIntl } from 'react-intl'; import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom'; import { fetchFollowRequests } from 'soapbox/actions/accounts'; import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin'; import { fetchAnnouncements } from 'soapbox/actions/announcements'; import { uploadCompose, resetCompose } from 'soapbox/actions/compose'; import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis'; import { uploadEventBanner } from 'soapbox/actions/events'; import { fetchFilters } from 'soapbox/actions/filters'; import { fetchMarker } from 'soapbox/actions/markers'; import { openModal } from 'soapbox/actions/modals'; import { expandNotifications } from 'soapbox/actions/notifications'; import { register as registerPushNotifications } from 'soapbox/actions/push-notifications'; import { fetchScheduledStatuses } from 'soapbox/actions/scheduled-statuses'; import { connectUserStream } from 'soapbox/actions/streaming'; import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; import SidebarNavigation from 'soapbox/components/sidebar-navigation'; import ThumbNavigation from 'soapbox/components/thumb-navigation'; import { Layout } from 'soapbox/components/ui'; import { useStatContext } from 'soapbox/contexts/stat-context'; import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useInstance } from 'soapbox/hooks'; import AdminPage from 'soapbox/pages/admin-page'; import ChatsPage from 'soapbox/pages/chats-page'; import DefaultPage from 'soapbox/pages/default-page'; import EventPage from 'soapbox/pages/event-page'; import EventsPage from 'soapbox/pages/events-page'; import GroupPage from 'soapbox/pages/group-page'; import GroupsPage from 'soapbox/pages/groups-page'; import HomePage from 'soapbox/pages/home-page'; import ProfilePage from 'soapbox/pages/profile-page'; import RemoteInstancePage from 'soapbox/pages/remote-instance-page'; import StatusPage from 'soapbox/pages/status-page'; import { usePendingPolicy } from 'soapbox/queries/policies'; import { getAccessToken, getVapidKey } from 'soapbox/utils/auth'; import { isStandalone } from 'soapbox/utils/state'; import BackgroundShapes from './components/background-shapes'; import FloatingActionButton from './components/floating-action-button'; import { supportedPolicyIds } from './components/modals/policy-modal'; import Navbar from './components/navbar'; import BundleContainer from './containers/bundle-container'; import { Status, CommunityTimeline, PublicTimeline, RemoteTimeline, AccountTimeline, AccountGallery, HomeTimeline, Followers, Following, DirectTimeline, Conversations, HashtagTimeline, Notifications, FollowRequests, GenericNotFound, FavouritedStatuses, Blocks, DomainBlocks, Mutes, Filters, PinnedStatuses, Search, ListTimeline, Lists, Bookmarks, Settings, EditProfile, EditEmail, EditPassword, EmailConfirmation, DeleteAccount, SoapboxConfig, ExportData, ImportData, Backups, MfaForm, ChatIndex, ChatWidget, ServerInfo, Dashboard, ModerationLog, CryptoDonate, ScheduledStatuses, UserIndex, FederationRestrictions, Aliases, Migration, FollowRecommendations, Directory, SidebarMenu, UploadArea, ProfileHoverCard, StatusHoverCard, Share, NewStatus, IntentionalError, Developers, CreateApp, SettingsStore, TestTimeline, LogoutPage, AuthTokenList, ThemeEditor, Quotes, ServiceWorkerInfo, EventInformation, EventDiscussion, Events, Groups, GroupsDiscover, GroupMembers, GroupTimeline, ManageGroup, GroupBlockedMembers, GroupMembershipRequests, Announcements, } from './util/async-components'; import { WrappedRoute } from './util/react-router-helpers'; // Dummy import, to make sure that ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. import 'soapbox/components/status'; const EmptyPage = HomePage; const keyMap = { help: '?', new: 'n', search: 's', forceNew: 'option+n', reply: 'r', favourite: 'f', react: 'e', boost: 'b', mention: 'm', open: ['enter', 'o'], openProfile: 'p', moveDown: ['down', 'j'], moveUp: ['up', 'k'], back: 'backspace', goToHome: 'g h', goToNotifications: 'g n', goToFavourites: 'g f', goToPinned: 'g p', goToProfile: 'g u', goToBlocked: 'g b', goToMuted: 'g m', goToRequests: 'g r', toggleHidden: 'x', toggleSensitive: 'h', openMedia: 'a', }; interface ISwitchingColumnsArea { children: React.ReactNode } const SwitchingColumnsArea: React.FC = ({ children }) => { const features = useFeatures(); const { search } = useLocation(); const { authenticatedProfile, cryptoAddresses } = useSoapboxConfig(); const hasCrypto = cryptoAddresses.size > 0; // NOTE: Mastodon and Pleroma route some basenames to the backend. // When adding new routes, use a basename that does NOT conflict // with a known backend route, but DO redirect the backend route // to the corresponding component as a fallback. // Ex: use /login instead of /auth, but redirect /auth to /login return ( {/* NOTE: we cannot nest routes in a fragment https://stackoverflow.com/a/68637108 */} {features.federating && } {features.federating && } {features.federating && } {features.conversations && } {features.directTimeline && } {(features.conversations && !features.directTimeline) && ( )} {/* Mastodon web routes */} {/* Pleroma FE web routes */} {/* Gab */} {/* Mastodon rendered pages */} {/* Pleroma hard-coded email URLs */} {/* Soapbox Legacy redirects */} {features.lists && } {features.lists && } {features.bookmarks && } {features.suggestions && } {features.profileDirectory && } {features.events && } {features.chats && } {features.chats && } {features.chats && } {features.chats && } {features.federating && } {features.filters && } {features.events && } {features.events && } {features.groups && } {features.groupsDiscovery && } {features.groups && } {features.groups && } {features.groups && } {features.groups && } {features.groups && } {features.scheduledStatuses && } {features.exportData && } {features.importData && } {features.accountAliases && } {features.accountMoving && } {features.backups && } new Promise((_resolve, reject) => reject())} content={children} /> {hasCrypto && } {features.federating && } ); }; interface IUI { children?: React.ReactNode } const UI: React.FC = ({ children }) => { const intl = useIntl(); const history = useHistory(); const dispatch = useAppDispatch(); const { data: pendingPolicy } = usePendingPolicy(); const instance = useInstance(); const statContext = useStatContext(); const [draggingOver, setDraggingOver] = useState(false); const dragTargets = useRef([]); const disconnect = useRef(null); const node = useRef(null); const hotkeys = useRef(null); const me = useAppSelector(state => state.me); const account = useOwnAccount(); const features = useFeatures(); const vapidKey = useAppSelector(state => getVapidKey(state)); const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.isOpen); const accessToken = useAppSelector(state => getAccessToken(state)); const streamingUrl = instance.urls.get('streaming_api'); const standalone = useAppSelector(isStandalone); const handleDragEnter = (e: DragEvent) => { e.preventDefault(); if (e.target && !dragTargets.current.includes(e.target)) { dragTargets.current.push(e.target); } if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) { setDraggingOver(true); } }; const handleDragOver = (e: DragEvent) => { if (dataTransferIsText(e.dataTransfer)) return false; e.preventDefault(); e.stopPropagation(); try { if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy'; } } catch (err) { // Do nothing } return false; }; const handleDrop = (e: DragEvent) => { if (!me) return; if (dataTransferIsText(e.dataTransfer)) return; e.preventDefault(); setDraggingOver(false); dragTargets.current = []; dispatch((_, getState) => { if (e.dataTransfer && e.dataTransfer.files.length >= 1) { const modals = getState().modals; const isModalOpen = modals.last()?.modalType === 'COMPOSE'; const isEventsModalOpen = modals.last()?.modalType === 'COMPOSE_EVENT'; if (isEventsModalOpen) dispatch(uploadEventBanner(e.dataTransfer.files[0], intl)); else dispatch(uploadCompose(isModalOpen ? 'compose-modal' : 'home', e.dataTransfer.files, intl)); } }); }; const handleDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); dragTargets.current = dragTargets.current.filter(el => el !== e.target && node.current?.contains(el as Node)); if (dragTargets.current.length > 0) { return; } setDraggingOver(false); }; const dataTransferIsText = (dataTransfer: DataTransfer | null) => { return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1); }; const closeUploadModal = () => { setDraggingOver(false); }; const handleServiceWorkerPostMessage = ({ data }: MessageEvent) => { if (data.type === 'navigate') { history.push(data.path); } else { console.warn('Unknown message type:', data.type); } }; const connectStreaming = () => { if (!disconnect.current && accessToken && streamingUrl) { disconnect.current = dispatch(connectUserStream({ statContext })); } }; const disconnectStreaming = () => { if (disconnect.current) { disconnect.current(); disconnect.current = null; } }; /** Load initial data when a user is logged in */ const loadAccountData = () => { if (!account) return; dispatch(expandHomeTimeline({}, () => { dispatch(fetchSuggestionsForTimeline()); })); dispatch(expandNotifications()) // @ts-ignore .then(() => dispatch(fetchMarker(['notifications']))) .catch(console.error); dispatch(fetchAnnouncements()); if (account.staff) { dispatch(fetchReports({ resolved: false })); dispatch(fetchUsers(['local', 'need_approval'])); } if (account.admin) { dispatch(fetchConfig()); } setTimeout(() => dispatch(fetchFilters()), 500); if (account.locked) { setTimeout(() => dispatch(fetchFollowRequests()), 700); } setTimeout(() => dispatch(fetchScheduledStatuses()), 900); }; useEffect(() => { document.addEventListener('dragenter', handleDragEnter, false); document.addEventListener('dragover', handleDragOver, false); document.addEventListener('drop', handleDrop, false); document.addEventListener('dragleave', handleDragLeave, false); if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', handleServiceWorkerPostMessage); } if (window.Notification?.permission === 'default') { window.setTimeout(() => Notification.requestPermission(), 120 * 1000); } return () => { document.removeEventListener('dragenter', handleDragEnter); document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDrop); document.removeEventListener('dragleave', handleDragLeave); disconnectStreaming(); }; }, []); useEffect(() => { connectStreaming(); }, [accessToken, streamingUrl]); // The user has logged in useEffect(() => { loadAccountData(); dispatch(fetchCustomEmojis()); }, [!!account]); useEffect(() => { dispatch(registerPushNotifications()); }, [vapidKey]); useEffect(() => { if (account && pendingPolicy && supportedPolicyIds.includes(pendingPolicy.pending_policy_id)) { setTimeout(() => { dispatch(openModal('POLICY')); }, 500); } }, [pendingPolicy, !!account]); const handleHotkeyNew = (e?: KeyboardEvent) => { e?.preventDefault(); if (!node.current) return; const element = node.current.querySelector('textarea#compose-textarea') as HTMLTextAreaElement; if (element) { element.focus(); } }; const handleHotkeySearch = (e?: KeyboardEvent) => { e?.preventDefault(); if (!node.current) return; const element = node.current.querySelector('input#search') as HTMLInputElement; if (element) { element.focus(); } }; const handleHotkeyForceNew = (e?: KeyboardEvent) => { handleHotkeyNew(e); dispatch(resetCompose()); }; const handleHotkeyBack = () => { if (window.history && window.history.length === 1) { history.push('/'); } else { history.goBack(); } }; const setHotkeysRef: React.LegacyRef = (c: any) => { hotkeys.current = c; if (!me || !hotkeys.current) return; // @ts-ignore hotkeys.current.__mousetrap__.stopCallback = (_e, element) => { return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); }; }; const handleHotkeyToggleHelp = () => { dispatch(openModal('HOTKEYS')); }; const handleHotkeyGoToHome = () => { history.push('/'); }; const handleHotkeyGoToNotifications = () => { history.push('/notifications'); }; const handleHotkeyGoToFavourites = () => { if (!account) return; history.push(`/@${account.username}/favorites`); }; const handleHotkeyGoToPinned = () => { if (!account) return; history.push(`/@${account.username}/pins`); }; const handleHotkeyGoToProfile = () => { if (!account) return; history.push(`/@${account.username}`); }; const handleHotkeyGoToBlocked = () => { history.push('/blocks'); }; const handleHotkeyGoToMuted = () => { history.push('/mutes'); }; const handleHotkeyGoToRequests = () => { history.push('/follow_requests'); }; const shouldHideFAB = (): boolean => { const path = location.pathname; return Boolean(path.match(/^\/posts\/|^\/search|^\/getting-started|^\/chats/)); }; // Wait for login to succeed or fail if (me === null) return null; type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; const handlers: HotkeyHandlers = { help: handleHotkeyToggleHelp, new: handleHotkeyNew, search: handleHotkeySearch, forceNew: handleHotkeyForceNew, back: handleHotkeyBack, goToHome: handleHotkeyGoToHome, goToNotifications: handleHotkeyGoToNotifications, goToFavourites: handleHotkeyGoToFavourites, goToPinned: handleHotkeyGoToPinned, goToProfile: handleHotkeyGoToProfile, goToBlocked: handleHotkeyGoToBlocked, goToMuted: handleHotkeyGoToMuted, goToRequests: handleHotkeyGoToRequests, }; const style: React.CSSProperties = { pointerEvents: dropdownMenuIsOpen ? 'none' : undefined, }; return (
{!standalone && } {children} {(me && !shouldHideFAB()) && (
)} {Component => } {me && ( {Component => } )} {me && features.chats && ( {Component => (
)}
)} {Component => } {Component => }
); }; export default UI;