diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js deleted file mode 100644 index b06f509bf..000000000 --- a/app/soapbox/features/ui/index.js +++ /dev/null @@ -1,789 +0,0 @@ -'use strict'; - -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { HotKeys } from 'react-hotkeys'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { Switch, withRouter } from 'react-router-dom'; -import { Redirect } from 'react-router-dom'; - -import { fetchChats } from 'soapbox/actions/chats'; -import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis'; -import { fetchMarker } from 'soapbox/actions/markers'; -import { register as registerPushNotifications } from 'soapbox/actions/push_notifications'; -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import Icon from 'soapbox/components/icon'; -import ThumbNavigation from 'soapbox/components/thumb_navigation'; -import AdminPage from 'soapbox/pages/admin_page'; -import DefaultPage from 'soapbox/pages/default_page'; -// import GroupsPage from 'soapbox/pages/groups_page'; -// import GroupPage from 'soapbox/pages/group_page'; -import EmptyPage from 'soapbox/pages/default_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 { getAccessToken } from 'soapbox/utils/auth'; -import { getVapidKey } from 'soapbox/utils/auth'; -import { getFeatures } from 'soapbox/utils/features'; -import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; - -import { fetchFollowRequests } from '../../actions/accounts'; -import { fetchReports, fetchUsers, fetchConfig } from '../../actions/admin'; -import { uploadCompose, resetCompose } from '../../actions/compose'; -import { fetchFilters } from '../../actions/filters'; -import { openModal } from '../../actions/modals'; -import { expandNotifications } from '../../actions/notifications'; -import { fetchScheduledStatuses } from '../../actions/scheduled_statuses'; -import { connectUserStream } from '../../actions/streaming'; -import { expandHomeTimeline } from '../../actions/timelines'; -// import GroupSidebarPanel from '../groups/sidebar_panel'; - -import BackgroundShapes from './components/background_shapes'; -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, - // Groups, - // GroupTimeline, - ListTimeline, - Lists, - Bookmarks, - // GroupMembers, - // GroupRemovedAccounts, - // GroupCreate, - // GroupEdit, - ExternalLogin, - Settings, - MediaDisplay, - EditProfile, - EditEmail, - EditPassword, - EmailConfirmation, - DeleteAccount, - SoapboxConfig, - ExportData, - ImportData, - // Backups, - MfaForm, - ChatIndex, - ChatRoom, - ChatPanes, - ServerInfo, - Dashboard, - AwaitingApproval, - Reports, - ModerationLog, - CryptoDonate, - ScheduledStatuses, - UserIndex, - FederationRestrictions, - Aliases, - Migration, - FollowRecommendations, - Directory, - SidebarMenu, - UploadArea, - NotificationsContainer, - ModalContainer, - ProfileHoverCard, - RegisterInvite, - Share, - NewStatus, - IntentionalError, - Developers, - CreateApp, - SettingsStore, - TestTimeline, - LogoutPage, -} 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 '../../components/status'; - -const isMobile = width => width <= 1190; - -const messages = defineMessages({ - beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave.' }, - publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }, -}); - -const mapStateToProps = state => { - const me = state.get('me'); - const account = state.getIn(['accounts', me]); - const instance = state.get('instance'); - const soapbox = getSoapboxConfig(state); - const vapidKey = getVapidKey(state); - - return { - dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, - accessToken: getAccessToken(state), - streamingUrl: state.getIn(['instance', 'urls', 'streaming_api']), - me, - account, - features: getFeatures(instance), - soapbox, - vapidKey, - }; -}; - -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', -}; - -class SwitchingColumnsArea extends React.PureComponent { - - static propTypes = { - children: PropTypes.node, - location: PropTypes.object, - soapbox: ImmutablePropTypes.record.isRequired, - features: PropTypes.object.isRequired, - }; - - state = { - mobile: isMobile(window.innerWidth), - }; - - componentDidMount() { - window.addEventListener('resize', this.handleResize, { passive: true }); - } - - componentWillUnmount() { - window.removeEventListener('resize', this.handleResize); - } - - handleResize = debounce(() => { - this.setState({ mobile: isMobile(window.innerWidth) }); - }, 500, { - trailing: true, - }); - - setRef = c => { - this.node = c.getWrappedInstance(); - } - - render() { - const { children, soapbox, features } = this.props; - const authenticatedProfile = soapbox.get('authenticatedProfile'); - const hasCrypto = soapbox.get('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 && } - - - - - {/* Gab groups */} - {/* - - - - - - - - - */} - - {/* 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.chats && } - {features.chats && } - - - - {features.federating && } - - {features.filters && } - - - - - - - - - - - - - - {features.scheduledStatuses && } - - - - - - - {features.accountAliasesAPI && } - {features.accountMoving && } - - - - - - - {/* */} - - - - - - - - - - - - - - new Promise((resolve, reject) => reject())} content={children} /> - - - {hasCrypto && } - {features.federating && } - - - - - - ); - } - -} - -export default @connect(mapStateToProps) -@injectIntl -@withRouter -class UI extends React.PureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - children: PropTypes.node, - location: PropTypes.object, - history: PropTypes.object, - intl: PropTypes.object.isRequired, - dropdownMenuIsOpen: PropTypes.bool, - me: SoapboxPropTypes.me, - streamingUrl: PropTypes.string, - account: PropTypes.object, - features: PropTypes.object.isRequired, - soapbox: ImmutablePropTypes.record.isRequired, - vapidKey: PropTypes.string, - }; - - state = { - draggingOver: false, - mobile: isMobile(window.innerWidth), - }; - - handleDragEnter = (e) => { - e.preventDefault(); - - if (!this.dragTargets) { - this.dragTargets = []; - } - - if (this.dragTargets.indexOf(e.target) === -1) { - this.dragTargets.push(e.target); - } - - if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) { - this.setState({ draggingOver: true }); - } - } - - handleDragOver = (e) => { - if (this.dataTransferIsText(e.dataTransfer)) return false; - e.preventDefault(); - e.stopPropagation(); - - try { - e.dataTransfer.dropEffect = 'copy'; - } catch (err) { - // Do nothing - } - - return false; - } - - handleDrop = (e) => { - const { me } = this.props; - if (!me) return; - - if (this.dataTransferIsText(e.dataTransfer)) return; - e.preventDefault(); - - this.setState({ draggingOver: false }); - this.dragTargets = []; - - if (e.dataTransfer && e.dataTransfer.files.length >= 1) { - this.props.dispatch(uploadCompose(e.dataTransfer.files, this.props.intl)); - } - } - - handleDragLeave = (e) => { - e.preventDefault(); - e.stopPropagation(); - - this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el)); - - if (this.dragTargets.length > 0) { - return; - } - - this.setState({ draggingOver: false }); - } - - dataTransferIsText = (dataTransfer) => { - return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1); - } - - closeUploadModal = () => { - this.setState({ draggingOver: false }); - } - - handleServiceWorkerPostMessage = ({ data }) => { - if (data.type === 'navigate') { - this.props.history.push(data.path); - } else { - console.warn('Unknown message type:', data.type); - } - } - - connectStreaming = (prevProps) => { - const { dispatch } = this.props; - const keys = ['accessToken', 'streamingUrl']; - const credsSet = keys.every(p => this.props[p]); - if (!this.disconnect && credsSet) { - this.disconnect = dispatch(connectUserStream()); - } - } - - disconnectStreaming = () => { - if (this.disconnect) { - this.disconnect(); - this.disconnect = null; - } - } - - handleResize = debounce(() => { - this.setState({ mobile: isMobile(window.innerWidth) }); - }, 500, { - trailing: true, - }); - - // Load initial data when a user is logged in - loadAccountData = () => { - const { account, features, dispatch } = this.props; - - dispatch(expandHomeTimeline()); - - dispatch(expandNotifications()) - .then(() => dispatch(fetchMarker(['notifications']))) - .catch(console.error); - - if (features.chats) { - dispatch(fetchChats()); - } - - if (account.staff) { - dispatch(fetchReports({ state: 'open' })); - 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); - } - - componentDidMount() { - const { account, vapidKey, dispatch } = this.props; - - window.addEventListener('resize', this.handleResize, { passive: true }); - document.addEventListener('dragenter', this.handleDragEnter, false); - document.addEventListener('dragover', this.handleDragOver, false); - document.addEventListener('drop', this.handleDrop, false); - document.addEventListener('dragleave', this.handleDragLeave, false); - document.addEventListener('dragend', this.handleDragEnd, false); - - if ('serviceWorker' in navigator) { - navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); - } - - if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - window.setTimeout(() => Notification.requestPermission(), 120 * 1000); - } - - if (account) { - this.loadAccountData(); - dispatch(fetchCustomEmojis()); - } - - this.connectStreaming(); - - if (vapidKey) { - dispatch(registerPushNotifications()); - } - } - - componentDidUpdate(prevProps) { - this.connectStreaming(); - - const { dispatch, account, features, vapidKey } = this.props; - - // The user has logged in - if (account && !prevProps.account) { - this.loadAccountData(); - // The instance has loaded - } else if (account && features.chats && !prevProps.features.chats) { - dispatch(fetchChats()); - } - - if (vapidKey && !prevProps.vapidKey) { - dispatch(registerPushNotifications()); - } - } - - componentWillUnmount() { - window.removeEventListener('resize', this.handleResize); - document.removeEventListener('dragenter', this.handleDragEnter); - document.removeEventListener('dragover', this.handleDragOver); - document.removeEventListener('drop', this.handleDrop); - document.removeEventListener('dragleave', this.handleDragLeave); - document.removeEventListener('dragend', this.handleDragEnd); - this.disconnectStreaming(); - } - - setRef = c => { - this.node = c; - } - - handleHotkeyNew = e => { - e.preventDefault(); - if (!this.node) return; - - const element = this.node.querySelector('textarea#compose-textarea'); - - if (element) { - element.focus(); - } - } - - handleHotkeySearch = e => { - e.preventDefault(); - if (!this.node) return; - - const element = this.node.querySelector('input#search'); - - if (element) { - element.focus(); - } - } - - handleHotkeyForceNew = e => { - this.handleHotkeyNew(e); - this.props.dispatch(resetCompose()); - } - - handleHotkeyBack = () => { - if (window.history && window.history.length === 1) { - this.props.history.push('/'); - } else { - this.props.history.goBack(); - } - } - - setHotkeysRef = c => { - const { me } = this.props; - this.hotkeys = c; - - if (!me || !this.hotkeys) return; - this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); - }; - } - - handleHotkeyToggleHelp = () => { - this.props.dispatch(openModal('HOTKEYS')); - } - - handleHotkeyGoToHome = () => { - this.props.history.push('/'); - } - - handleHotkeyGoToNotifications = () => { - this.props.history.push('/notifications'); - } - - handleHotkeyGoToFavourites = () => { - const { account } = this.props; - if (!account) return; - - this.props.history.push(`/@${account.get('username')}/favorites`); - } - - handleHotkeyGoToPinned = () => { - const { account } = this.props; - if (!account) return; - - this.props.history.push(`/@${account.get('username')}/pins`); - } - - handleHotkeyGoToProfile = () => { - const { account } = this.props; - if (!account) return; - - this.props.history.push(`/@${account.get('username')}`); - } - - handleHotkeyGoToBlocked = () => { - this.props.history.push('/blocks'); - } - - handleHotkeyGoToMuted = () => { - this.props.history.push('/mutes'); - } - - handleHotkeyGoToRequests = () => { - this.props.history.push('/follow_requests'); - } - - handleOpenComposeModal = () => { - this.props.dispatch(openModal('COMPOSE')); - } - - shouldHideFAB = () => { - const path = this.props.location.pathname; - return path.match(/^\/posts\/|^\/search|^\/getting-started|^\/chats/); - } - - isChatRoomLocation = () => { - const path = this.props.location.pathname; - return path.match(/^\/chats\/(.*)/); - } - - render() { - const { features, soapbox } = this.props; - const { draggingOver, mobile } = this.state; - const { intl, children, location, dropdownMenuIsOpen, me } = this.props; - - // Wait for login to succeed or fail - if (me === null) return null; - - const handlers = me ? { - help: this.handleHotkeyToggleHelp, - new: this.handleHotkeyNew, - search: this.handleHotkeySearch, - forceNew: this.handleHotkeyForceNew, - back: this.handleHotkeyBack, - goToHome: this.handleHotkeyGoToHome, - goToNotifications: this.handleHotkeyGoToNotifications, - goToFavourites: this.handleHotkeyGoToFavourites, - goToPinned: this.handleHotkeyGoToPinned, - goToProfile: this.handleHotkeyGoToProfile, - goToBlocked: this.handleHotkeyGoToBlocked, - goToMuted: this.handleHotkeyGoToMuted, - goToRequests: this.handleHotkeyGoToRequests, - } : {}; - - const fabElem = ( - - ); - - const floatingActionButton = this.shouldHideFAB() ? null : fabElem; - - const style = { - pointerEvents: dropdownMenuIsOpen ? 'none' : null, - }; - - return ( - -
- - -
- - - - {children} - - - {me && floatingActionButton} - - - {Component => } - - - - {Component => } - - - - {Component => } - - - {me && ( - - {Component => } - - )} - {me && features.chats && !mobile && ( - - {Component => } - - )} - - - - {Component => } - -
-
-
- ); - } - -} diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx new file mode 100644 index 000000000..25c060bfe --- /dev/null +++ b/app/soapbox/features/ui/index.tsx @@ -0,0 +1,691 @@ +'use strict'; + +import { debounce } from 'lodash'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { HotKeys } from 'react-hotkeys'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { Switch, useHistory } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; + +import { fetchChats } from 'soapbox/actions/chats'; +import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis'; +import { fetchMarker } from 'soapbox/actions/markers'; +import { register as registerPushNotifications } from 'soapbox/actions/push_notifications'; +import Icon from 'soapbox/components/icon'; +import ThumbNavigation from 'soapbox/components/thumb_navigation'; +import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks'; +import AdminPage from 'soapbox/pages/admin_page'; +import DefaultPage from 'soapbox/pages/default_page'; +// import GroupsPage from 'soapbox/pages/groups_page'; +// import GroupPage from 'soapbox/pages/group_page'; +import EmptyPage from 'soapbox/pages/default_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 { getAccessToken } from 'soapbox/utils/auth'; +import { getVapidKey } from 'soapbox/utils/auth'; + +import { fetchFollowRequests } from '../../actions/accounts'; +import { fetchReports, fetchUsers, fetchConfig } from '../../actions/admin'; +import { uploadCompose, resetCompose } from '../../actions/compose'; +import { fetchFilters } from '../../actions/filters'; +import { openModal } from '../../actions/modals'; +import { expandNotifications } from '../../actions/notifications'; +import { fetchScheduledStatuses } from '../../actions/scheduled_statuses'; +import { connectUserStream } from '../../actions/streaming'; +import { expandHomeTimeline } from '../../actions/timelines'; +// import GroupSidebarPanel from '../groups/sidebar_panel'; + +import BackgroundShapes from './components/background_shapes'; +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, + // Groups, + // GroupTimeline, + ListTimeline, + Lists, + Bookmarks, + // GroupMembers, + // GroupRemovedAccounts, + // GroupCreate, + // GroupEdit, + ExternalLogin, + Settings, + MediaDisplay, + EditProfile, + EditEmail, + EditPassword, + EmailConfirmation, + DeleteAccount, + SoapboxConfig, + ExportData, + ImportData, + // Backups, + MfaForm, + ChatIndex, + ChatRoom, + ChatPanes, + ServerInfo, + Dashboard, + AwaitingApproval, + Reports, + ModerationLog, + CryptoDonate, + ScheduledStatuses, + UserIndex, + FederationRestrictions, + Aliases, + Migration, + FollowRecommendations, + Directory, + SidebarMenu, + UploadArea, + NotificationsContainer, + ModalContainer, + ProfileHoverCard, + RegisterInvite, + Share, + NewStatus, + IntentionalError, + Developers, + CreateApp, + SettingsStore, + TestTimeline, + LogoutPage, +} 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 '../../components/status'; + +const isMobile = (width: number): boolean => width <= 1190; + +const messages = defineMessages({ + beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave.' }, + publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }, +}); + +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', +}; + +const SwitchingColumnsArea: React.FC = ({ children }) => { + const history = useHistory(); + const features = useFeatures(); + + 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 && } + + + + + {/* Gab groups */} + {/* + + + + + + + + + */} + + {/* 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.chats && } + {features.chats && } + + + + {features.federating && } + + {features.filters && } + + + + + + + + + + + + + + {features.scheduledStatuses && } + + + + + + + {features.accountAliasesAPI && } + {features.accountMoving && } + + + + + + + {/* */} + + + + + + + + + + + + + + new Promise((_resolve, reject) => reject())} content={children} /> + + + {hasCrypto && } + {features.federating && } + + + + + + ); +}; + +const UI: React.FC = ({ children }) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useDispatch(); + + const [draggingOver, setDraggingOver] = useState(false); + const [mobile, setMobile] = useState(isMobile(window.innerWidth)); + + 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.get('openId') !== null); + const accessToken = useAppSelector(state => getAccessToken(state)); + const streamingUrl = useAppSelector(state => state.instance.urls.get('streaming_api')); + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + + if (e.target && dragTargets.current.indexOf(e.target) === -1) { + 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 = []; + + if (e.dataTransfer && e.dataTransfer.files.length >= 1) { + dispatch(uploadCompose(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()); + } + }; + + const disconnectStreaming = () => { + if (disconnect.current) { + disconnect.current(); + disconnect.current = null; + } + }; + + const handleResize = useCallback(debounce(() => { + setMobile(isMobile(window.innerWidth)); + }, 500, { + trailing: true, + }), [setMobile]); + + /** Load initial data when a user is logged in */ + const loadAccountData = () => { + if (!account) return; + + dispatch(expandHomeTimeline()); + + dispatch(expandNotifications()) + // @ts-ignore + .then(() => dispatch(fetchMarker(['notifications']))) + .catch(console.error); + + if (features.chats) { + dispatch(fetchChats()); + } + + if (account.staff) { + dispatch(fetchReports({ state: 'open' })); + 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(() => { + window.addEventListener('resize', handleResize, { passive: true }); + 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 (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { + window.setTimeout(() => Notification.requestPermission(), 120 * 1000); + } + + return () => { + window.removeEventListener('resize', handleResize); + 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]); + + 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 handleOpenComposeModal = () => { + dispatch(openModal('COMPOSE')); + }; + + 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 fabElem = ( + + ); + + const floatingActionButton = shouldHideFAB() ? null : fabElem; + + const style: React.CSSProperties = { + pointerEvents: dropdownMenuIsOpen ? 'none' : undefined, + }; + + return ( + +
+ + +
+ + + + {children} + + + {me && floatingActionButton} + + + {Component => } + + + + {Component => } + + + + {Component => } + + + {me && ( + + {Component => } + + )} + {me && features.chats && !mobile && ( + + {Component => } + + )} + + + + {Component => } + +
+
+
+ ); +}; + +export default UI; diff --git a/app/soapbox/features/ui/util/react_router_helpers.tsx b/app/soapbox/features/ui/util/react_router_helpers.tsx index 47cec40c4..05e183816 100644 --- a/app/soapbox/features/ui/util/react_router_helpers.tsx +++ b/app/soapbox/features/ui/util/react_router_helpers.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Redirect, Route, useHistory, RouteComponentProps, match as MatchType } from 'react-router-dom'; +import { Redirect, Route, useHistory, RouteProps, RouteComponentProps, match as MatchType } from 'react-router-dom'; import { useOwnAccount, useSettings } from 'soapbox/hooks'; @@ -14,12 +14,12 @@ type PageProps = { layout?: any, }; -interface IWrappedRoute { +interface IWrappedRoute extends RouteProps { component: (...args: any[]) => any, - page: React.ComponentType, - content: React.ReactNode, - componentParams: Record, - layout: any, + page?: React.ComponentType, + content?: React.ReactNode, + componentParams?: Record, + layout?: any, publicRoute?: boolean, staffOnly?: boolean, adminOnly?: boolean, diff --git a/app/soapbox/normalizers/instance.ts b/app/soapbox/normalizers/instance.ts index f43523d01..7d908b3dc 100644 --- a/app/soapbox/normalizers/instance.ts +++ b/app/soapbox/normalizers/instance.ts @@ -18,16 +18,16 @@ import { isNumber } from 'soapbox/utils/numbers'; // https://docs.joinmastodon.org/entities/instance/ export const InstanceRecord = ImmutableRecord({ approval_required: false, - contact_account: ImmutableMap(), - configuration: ImmutableMap({ - media_attachments: ImmutableMap(), - polls: ImmutableMap({ + contact_account: ImmutableMap(), + configuration: ImmutableMap({ + media_attachments: ImmutableMap(), + polls: ImmutableMap({ max_options: 4, max_characters_per_option: 25, min_expiration: 300, max_expiration: 2629746, }), - statuses: ImmutableMap({ + statuses: ImmutableMap({ max_characters: 500, max_media_attachments: 4, }), @@ -39,13 +39,13 @@ export const InstanceRecord = ImmutableRecord({ fedibird_capabilities: ImmutableList(), invites_enabled: false, languages: ImmutableList(), - pleroma: ImmutableMap({ - metadata: ImmutableMap({ + pleroma: ImmutableMap({ + metadata: ImmutableMap({ account_activation_required: false, birthday_min_age: 0, birthday_required: false, features: ImmutableList(), - federation: ImmutableMap({ + federation: ImmutableMap({ enabled: true, exclusions: false, }), @@ -55,7 +55,7 @@ export const InstanceRecord = ImmutableRecord({ registrations: false, rules: ImmutableList(), short_description: '', - stats: ImmutableMap({ + stats: ImmutableMap({ domain_count: 0, status_count: 0, user_count: 0, @@ -63,7 +63,7 @@ export const InstanceRecord = ImmutableRecord({ title: '', thumbnail: '', uri: '', - urls: ImmutableMap(), + urls: ImmutableMap(), version: '0.0.0', });