'use strict'; import clsx from 'clsx'; import React, { useEffect, useRef } from 'react'; import { HotKeys } from 'react-hotkeys'; 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 { resetCompose } from 'soapbox/actions/compose'; import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis'; 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 { connectNostrStream, connectUserStream } from 'soapbox/actions/streaming'; import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; import GroupLookupHoc from 'soapbox/components/hoc/group-lookup-hoc'; import withHoc from 'soapbox/components/hoc/with-hoc'; 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, useDraggedFiles } 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 GroupsPendingPage from 'soapbox/pages/groups-pending-page'; import HomePage from 'soapbox/pages/home-page'; import ManageGroupsPage from 'soapbox/pages/manage-groups-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, EditFilter, 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, ProfileHoverCard, StatusHoverCard, Share, NewStatus, IntentionalError, Developers, CreateApp, SettingsStore, TestTimeline, LogoutPage, AuthTokenList, ThemeEditor, Quotes, ServiceWorkerInfo, EventInformation, EventDiscussion, Events, GroupGallery, Groups, GroupsDiscover, GroupsPopular, GroupsSuggested, GroupsTag, GroupsTags, PendingGroupRequests, GroupMembers, GroupTags, GroupTagTimeline, GroupTimeline, ManageGroup, GroupBlockedMembers, GroupMembershipRequests, Announcements, EditGroup, } 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 GroupTagsSlug = withHoc(GroupTags as any, GroupLookupHoc); const GroupTagTimelineSlug = withHoc(GroupTagTimeline as any, GroupLookupHoc); const GroupTimelineSlug = withHoc(GroupTimeline as any, GroupLookupHoc); const GroupMembersSlug = withHoc(GroupMembers as any, GroupLookupHoc); const GroupGallerySlug = withHoc(GroupGallery as any, GroupLookupHoc); const ManageGroupSlug = withHoc(ManageGroup as any, GroupLookupHoc); const EditGroupSlug = withHoc(EditGroup as any, GroupLookupHoc); const GroupBlockedMembersSlug = withHoc(GroupBlockedMembers as any, GroupLookupHoc); const GroupMembershipRequestsSlug = withHoc(GroupMembershipRequests as any, GroupLookupHoc); 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.filtersV2) && } {(features.filters || features.filtersV2) && } {(features.filters || features.filtersV2) && } {features.events && } {features.events && } {features.groups && } {features.groupsDiscovery && } {features.groupsDiscovery && } {features.groupsDiscovery && } {features.groupsDiscovery && } {features.groupsDiscovery && } {features.groupsPending && } {features.groupsTags && } {features.groupsTags && } {features.groups && } {features.groups && } {features.groups && } {features.groups && } {features.groups && } {features.groups && } {features.groups && } {features.groups && } {features.groupsTags && } {features.groupsTags && } {features.groups && } {features.groups && } {features.groups && } {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 history = useHistory(); const dispatch = useAppDispatch(); const { data: pendingPolicy } = usePendingPolicy(); const instance = useInstance(); const statContext = useStatContext(); const userStream = useRef(null); const nostrStream = 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 { isDragging } = useDraggedFiles(node); const handleServiceWorkerPostMessage = ({ data }: MessageEvent) => { if (data.type === 'navigate') { history.push(data.path); } else { console.warn('Unknown message type:', data.type); } }; const connectStreaming = () => { if (accessToken && streamingUrl) { if (!userStream.current) { userStream.current = dispatch(connectUserStream({ statContext })); } if (!nostrStream.current && window.nostr) { nostrStream.current = dispatch(connectNostrStream()); } } }; const disconnectStreaming = () => { if (userStream.current) { userStream.current(); userStream.current = null; } if (nostrStream.current) { nostrStream.current(); nostrStream.current = null; } }; const handleDragEnter = (e: DragEvent) => e.preventDefault(); const handleDragLeave = (e: DragEvent) => e.preventDefault(); const handleDragOver = (e: DragEvent) => e.preventDefault(); const handleDrop = (e: DragEvent) => e.preventDefault(); /** 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(() => { if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', handleServiceWorkerPostMessage); } if (window.Notification?.permission === 'default') { window.setTimeout(() => Notification.requestPermission(), 120 * 1000); } return () => { disconnectStreaming(); }; }, []); useEffect(() => { document.addEventListener('dragenter', handleDragEnter); document.addEventListener('dragleave', handleDragLeave); document.addEventListener('dragover', handleDragOver); document.addEventListener('drop', handleDrop); return () => { document.removeEventListener('dragenter', handleDragEnter); document.removeEventListener('dragleave', handleDragLeave); document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDrop); }; }, []); 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', 'EM-EMOJI-PICKER'].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()) && (
)} {me && ( {Component => } )} {me && features.chats && ( {Component => (
)}
)} {Component => } {Component => }
); }; export default UI;