diff --git a/app/javascript/flavours/glitch/components/column_back_button.jsx b/app/javascript/flavours/glitch/components/column_back_button.jsx deleted file mode 100644 index 8d9f541049..0000000000 --- a/app/javascript/flavours/glitch/components/column_back_button.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; -import { createPortal } from 'react-dom'; - -import { FormattedMessage } from 'react-intl'; - -import { withRouter } from 'react-router-dom'; - -import { ReactComponent as ArrowBackIcon } from '@material-symbols/svg-600/outlined/arrow_back.svg'; - -import { Icon } from 'flavours/glitch/components/icon'; -import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; - -export class ColumnBackButton extends PureComponent { - - static propTypes = { - multiColumn: PropTypes.bool, - onClick: PropTypes.func, - ...WithRouterPropTypes, - }; - - handleClick = () => { - const { onClick, history } = this.props; - - if (onClick) { - onClick(); - } else if (history.location?.state?.fromMastodon) { - history.goBack(); - } else { - history.push('/'); - } - }; - - render () { - const { multiColumn } = this.props; - - const component = ( - - ); - - if (multiColumn) { - return component; - } else { - // The portal container and the component may be rendered to the DOM in - // the same React render pass, so the container might not be available at - // the time `render()` is called. - const container = document.getElementById('tabs-bar__portal'); - if (container === null) { - // The container wasn't available, force a re-render so that the - // component can eventually be inserted in the container and not scroll - // with the rest of the area. - this.forceUpdate(); - return component; - } else { - return createPortal(component, container); - } - } - } - -} - -export default withRouter(ColumnBackButton); diff --git a/app/javascript/flavours/glitch/components/column_back_button.tsx b/app/javascript/flavours/glitch/components/column_back_button.tsx new file mode 100644 index 0000000000..9f90f1dddc --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button.tsx @@ -0,0 +1,45 @@ +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { ReactComponent as ArrowBackIcon } from '@material-symbols/svg-600/outlined/arrow_back.svg'; + +import { Icon } from 'flavours/glitch/components/icon'; +import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context'; + +import { useAppHistory } from './router'; + +type OnClickCallback = () => void; + +function useHandleClick(onClick?: OnClickCallback) { + const history = useAppHistory(); + + return useCallback(() => { + if (onClick) { + onClick(); + } else if (history.location.state?.fromMastodon) { + history.goBack(); + } else { + history.push('/'); + } + }, [history, onClick]); +} + +export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({ + onClick, +}) => { + const handleClick = useHandleClick(onClick); + + const component = ( + + ); + + return {component}; +}; diff --git a/app/javascript/flavours/glitch/components/column_back_button_slim.jsx b/app/javascript/flavours/glitch/components/column_back_button_slim.jsx deleted file mode 100644 index 7cd20c2237..0000000000 --- a/app/javascript/flavours/glitch/components/column_back_button_slim.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import { withRouter } from 'react-router-dom'; - -import { ReactComponent as ArrowBackIcon } from '@material-symbols/svg-600/outlined/arrow_back.svg'; - -import { Icon } from 'flavours/glitch/components/icon'; -import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; - -class ColumnBackButtonSlim extends PureComponent { - - static propTypes = { - ...WithRouterPropTypes, - }; - - handleClick = () => { - const { location, history } = this.props; - - // Check if there is a previous page in the app to go back to per https://stackoverflow.com/a/70532858/9703201 - // When upgrading to V6, check `location.key !== 'default'` instead per https://github.com/remix-run/history/blob/main/docs/api-reference.md#location - if (location.key) { - history.goBack(); - } else { - history.push('/'); - } - }; - - render () { - return ( -
-
- - -
-
- ); - } -} - -export default withRouter(ColumnBackButtonSlim); diff --git a/app/javascript/flavours/glitch/components/column_header.jsx b/app/javascript/flavours/glitch/components/column_header.jsx index d44f284f9b..8cc9fde9d3 100644 --- a/app/javascript/flavours/glitch/components/column_header.jsx +++ b/app/javascript/flavours/glitch/components/column_header.jsx @@ -1,6 +1,5 @@ import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; -import { createPortal } from 'react-dom'; +import { PureComponent, useCallback } from 'react'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; @@ -15,8 +14,11 @@ import { ReactComponent as CloseIcon } from '@material-symbols/svg-600/outlined/ import { ReactComponent as TuneIcon } from '@material-symbols/svg-600/outlined/tune.svg'; import { Icon } from 'flavours/glitch/components/icon'; +import { ButtonInTabsBar, useColumnsContext } from 'flavours/glitch/features/ui/util/columns_context'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; +import { useAppHistory } from './router'; + const messages = defineMessages({ show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, @@ -24,6 +26,34 @@ const messages = defineMessages({ moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, }); +const BackButton = ({ pinned, show }) => { + const history = useAppHistory(); + const { multiColumn } = useColumnsContext(); + + const handleBackClick = useCallback(() => { + if (history.location?.state?.fromMastodon) { + history.goBack(); + } else { + history.push('/'); + } + }, [history]); + + const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show); + + if(!showButton) return null; + + return (); + +}; + +BackButton.propTypes = { + pinned: PropTypes.bool, + show: PropTypes.bool, +}; + class ColumnHeader extends PureComponent { static contextTypes = { @@ -72,16 +102,6 @@ class ColumnHeader extends PureComponent { this.props.onMove(1); }; - handleBackClick = () => { - const { history } = this.props; - - if (history.location?.state?.fromMastodon) { - history.goBack(); - } else { - history.push('/'); - } - }; - handleTransitionEnd = () => { this.setState({ animating: false }); }; @@ -95,7 +115,7 @@ class ColumnHeader extends PureComponent { }; render () { - const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = this.props; + const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -138,14 +158,7 @@ class ColumnHeader extends PureComponent { pinButton = ; } - if (!pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) { - backButton = ( - - ); - } + backButton = ; const collapsedContent = [ extraContent, @@ -203,22 +216,12 @@ class ColumnHeader extends PureComponent { ); - if (multiColumn || placeholder) { + if (placeholder) { return component; } else { - // The portal container and the component may be rendered to the DOM in - // the same React render pass, so the container might not be available at - // the time `render()` is called. - const container = document.getElementById('tabs-bar__portal'); - if (container === null) { - // The container wasn't available, force a re-render so that the - // component can eventually be inserted in the container and not scroll - // with the rest of the area. - this.forceUpdate(); - return component; - } else { - return createPortal(component, container); - } + return ( + {component} + ); } } diff --git a/app/javascript/flavours/glitch/components/router.tsx b/app/javascript/flavours/glitch/components/router.tsx index 96cc049b1b..22f22c65cc 100644 --- a/app/javascript/flavours/glitch/components/router.tsx +++ b/app/javascript/flavours/glitch/components/router.tsx @@ -1,7 +1,7 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; -import { Router as OriginalRouter } from 'react-router'; +import { Router as OriginalRouter, useHistory } from 'react-router'; import type { LocationDescriptor, @@ -16,18 +16,23 @@ interface MastodonLocationState { fromMastodon?: boolean; mastodonModalKey?: string; } -type HistoryPath = Path | LocationDescriptor; -const browserHistory = createBrowserHistory< - MastodonLocationState | undefined ->(); +type LocationState = MastodonLocationState | null | undefined; + +type HistoryPath = Path | LocationDescriptor; + +const browserHistory = createBrowserHistory(); const originalPush = browserHistory.push.bind(browserHistory); const originalReplace = browserHistory.replace.bind(browserHistory); +export function useAppHistory() { + return useHistory(); +} + function normalizePath( path: HistoryPath, - state?: MastodonLocationState, -): LocationDescriptorObject { + state?: LocationState, +): LocationDescriptorObject { const location = typeof path === 'string' ? { pathname: path } : { ...path }; if (location.state === undefined && state !== undefined) { diff --git a/app/javascript/flavours/glitch/features/blocks/index.jsx b/app/javascript/flavours/glitch/features/blocks/index.jsx index 210260c811..615e4c8be2 100644 --- a/app/javascript/flavours/glitch/features/blocks/index.jsx +++ b/app/javascript/flavours/glitch/features/blocks/index.jsx @@ -10,7 +10,6 @@ import { ReactComponent as BlockIcon } from '@material-symbols/svg-600/outlined/ import { debounce } from 'lodash'; import { fetchBlocks, expandBlocks } from '../../actions/blocks'; -import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import AccountContainer from '../../containers/account_container'; @@ -60,8 +59,7 @@ class Blocks extends ImmutablePureComponent { const emptyMessage = ; return ( - - + ; return ( - - - + - + - - +
{signedIn && ()} diff --git a/app/javascript/flavours/glitch/features/lists/index.jsx b/app/javascript/flavours/glitch/features/lists/index.jsx index b47a8c07ce..89aea03321 100644 --- a/app/javascript/flavours/glitch/features/lists/index.jsx +++ b/app/javascript/flavours/glitch/features/lists/index.jsx @@ -12,7 +12,6 @@ import { connect } from 'react-redux'; import { ReactComponent as ListAltIcon } from '@material-symbols/svg-600/outlined/list_alt.svg'; import { fetchLists } from 'flavours/glitch/actions/lists'; -import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; import Column from 'flavours/glitch/features/ui/components/column'; @@ -66,9 +65,7 @@ class Lists extends ImmutablePureComponent { const emptyMessage = ; return ( - - - + diff --git a/app/javascript/flavours/glitch/features/mutes/index.jsx b/app/javascript/flavours/glitch/features/mutes/index.jsx index da69bc547d..7f66edc03d 100644 --- a/app/javascript/flavours/glitch/features/mutes/index.jsx +++ b/app/javascript/flavours/glitch/features/mutes/index.jsx @@ -12,7 +12,6 @@ import { ReactComponent as VolumeOffIcon } from '@material-symbols/svg-600/outli import { debounce } from 'lodash'; import { fetchMutes, expandMutes } from '../../actions/mutes'; -import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import { LoadingIndicator } from '../../components/loading_indicator'; import ScrollableList from '../../components/scrollable_list'; import AccountContainer from '../../containers/account_container'; @@ -62,8 +61,7 @@ class Mutes extends ImmutablePureComponent { const emptyMessage = ; return ( - - + - +
diff --git a/app/javascript/flavours/glitch/features/onboarding/index.jsx b/app/javascript/flavours/glitch/features/onboarding/index.jsx index 129dd0da3e..2729e760f2 100644 --- a/app/javascript/flavours/glitch/features/onboarding/index.jsx +++ b/app/javascript/flavours/glitch/features/onboarding/index.jsx @@ -48,7 +48,6 @@ class Onboarding extends ImmutablePureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, account: ImmutablePropTypes.record, - multiColumn: PropTypes.bool, ...WithRouterPropTypes, }; @@ -101,14 +100,14 @@ class Onboarding extends ImmutablePureComponent { } render () { - const { account, multiColumn } = this.props; + const { account } = this.props; const { step, shareClicked } = this.state; switch(step) { case 'follows': - return ; + return ; case 'share': - return ; + return ; } return ( diff --git a/app/javascript/flavours/glitch/features/onboarding/share.jsx b/app/javascript/flavours/glitch/features/onboarding/share.jsx index 66c95b8c9b..7c35c9a492 100644 --- a/app/javascript/flavours/glitch/features/onboarding/share.jsx +++ b/app/javascript/flavours/glitch/features/onboarding/share.jsx @@ -14,7 +14,7 @@ import { ReactComponent as ContentCopyIcon } from '@material-symbols/svg-600/out import SwipeableViews from 'react-swipeable-views'; import Column from 'flavours/glitch/components/column'; -import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import { ColumnBackButton } from 'flavours/glitch/components/column_back_button'; import { Icon } from 'flavours/glitch/components/icon'; import { me, domain } from 'flavours/glitch/initial_state'; @@ -146,18 +146,17 @@ class Share extends PureComponent { static propTypes = { onBack: PropTypes.func, account: ImmutablePropTypes.record, - multiColumn: PropTypes.bool, intl: PropTypes.object, }; render () { - const { onBack, account, multiColumn, intl } = this.props; + const { onBack, account, intl } = this.props; const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href; return ( - +
diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx b/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx index fafcb78f1d..b888ab6850 100644 --- a/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx +++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx @@ -13,7 +13,6 @@ import { ReactComponent as PushPinIcon } from '@material-symbols/svg-600/outline import { getStatusList } from 'flavours/glitch/selectors'; import { fetchPinnedStatuses } from '../../actions/pin_statuses'; -import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import StatusList from '../../components/status_list'; import Column from '../ui/components/column'; @@ -52,8 +51,7 @@ class PinnedStatuses extends ImmutablePureComponent { const { intl, statusIds, hasMore, multiColumn } = this.props; return ( - - + + ); return (
{ - this.props.onClick(); - }; - - render () { - const { icon, iconComponent, type, active, columnHeaderId } = this.props; - let iconElement = ''; - - if (icon) { - iconElement = ; - } - - return ( -

- -

- ); - } - -} diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx index a37e3cadce..f3e1bfe492 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { Children, cloneElement } from 'react'; +import { Children, cloneElement, useCallback } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -21,6 +21,7 @@ import { ListTimeline, Directory, } from '../util/async-components'; +import { useColumnsContext } from '../util/columns_context'; import BundleColumnError from './bundle_column_error'; import { ColumnLoading } from './column_loading'; @@ -43,6 +44,17 @@ const componentMap = { 'DIRECTORY': Directory, }; +const TabsBarPortal = () => { + const {setTabsBarElement} = useColumnsContext(); + + const setRef = useCallback((element) => { + if(element) + setTabsBarElement(element); + }, [setTabsBarElement]); + + return
; +}; + export default class ColumnsArea extends ImmutablePureComponent { static propTypes = { columns: ImmutablePropTypes.list.isRequired, @@ -146,7 +158,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
-
+
{children}
diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index fa943f579f..dc3a7ce37b 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -67,8 +67,8 @@ import { About, PrivacyPolicy, } from './util/async-components'; +import { ColumnsContextProvider } from './util/columns_context'; import { WrappedSwitch, 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'; @@ -188,69 +188,71 @@ class SwitchingColumnsArea extends PureComponent { } return ( - - - {redirect} + + + + {redirect} - {singleColumn ? : null} - {singleColumn && pathName.startsWith('/deck/') ? : null} - {/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */} - {!singleColumn && pathName === '/getting-started' ? : null} - {!singleColumn && pathName === '/home' ? : null} + {singleColumn ? : null} + {singleColumn && pathName.startsWith('/deck/') ? : null} + {/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */} + {!singleColumn && pathName === '/getting-started' ? : null} + {!singleColumn && pathName === '/home' ? : null} - - - - + + + + - - - - - - - - - - - + + + + + + + + + + + - - + + - - - - + + + + - - - - - - - - - + + + + + + + + + - {/* Legacy routes, cannot be easily factored with other routes because they share a param name */} - - - - - + {/* Legacy routes, cannot be easily factored with other routes because they share a param name */} + + + + + - - - - - - - + + + + + + + - - - + + + + ); } diff --git a/app/javascript/flavours/glitch/features/ui/util/columns_context.tsx b/app/javascript/flavours/glitch/features/ui/util/columns_context.tsx new file mode 100644 index 0000000000..e02918deb0 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/util/columns_context.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from 'react'; +import { createContext, useContext, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; + +export const ColumnsContext = createContext<{ + tabsBarElement: HTMLElement | null; + setTabsBarElement: (element: HTMLElement) => void; + multiColumn: boolean; +}>({ + tabsBarElement: null, + multiColumn: false, + setTabsBarElement: () => undefined, // no-op +}); + +export function useColumnsContext() { + return useContext(ColumnsContext); +} + +export const ButtonInTabsBar: React.FC<{ + children: ReactElement | string | undefined; +}> = ({ children }) => { + const { multiColumn, tabsBarElement } = useColumnsContext(); + + if (multiColumn) { + return children; + } else if (!tabsBarElement) { + return children; + } else { + return createPortal(children, tabsBarElement); + } +}; + +type ContextValue = React.ContextType; + +export const ColumnsContextProvider: React.FC< + React.PropsWithChildren<{ multiColumn: boolean }> +> = ({ multiColumn, children }) => { + const [tabsBarElement, setTabsBarElement] = + useState(null); + + const contextValue = useMemo( + () => ({ multiColumn, tabsBarElement, setTabsBarElement }), + [multiColumn, tabsBarElement], + ); + + return ( + + {children} + + ); +}; diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index e210c553bb..5a2962e881 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -218,20 +218,6 @@ $ui-header-height: 55px; margin-inline-end: 5px; } -.column-back-button--slim { - position: relative; -} - -.column-back-button--slim-button { - cursor: pointer; - flex: 0 0 auto; - font-size: 16px; - padding: 15px; - position: absolute; - inset-inline-end: 0; - top: -48px; -} - .switch-to-advanced { color: $light-text-color; background-color: $ui-base-color; diff --git a/app/javascript/flavours/glitch/test_helpers.tsx b/app/javascript/flavours/glitch/test_helpers.tsx new file mode 100644 index 0000000000..6895895569 --- /dev/null +++ b/app/javascript/flavours/glitch/test_helpers.tsx @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import type { PropsWithChildren } from 'react'; +import { Component } from 'react'; + +import { IntlProvider } from 'react-intl'; + +import { MemoryRouter } from 'react-router'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { render as rtlRender } from '@testing-library/react'; + +class FakeIdentityWrapper extends Component< + PropsWithChildren<{ signedIn: boolean }> +> { + static childContextTypes = { + identity: PropTypes.shape({ + signedIn: PropTypes.bool.isRequired, + accountId: PropTypes.string, + disabledAccountId: PropTypes.string, + accessToken: PropTypes.string, + }).isRequired, + }; + + getChildContext() { + return { + identity: { + signedIn: this.props.signedIn, + accountId: '123', + accessToken: 'test-access-token', + }, + }; + } + + render() { + return this.props.children; + } +} + +function render( + ui: React.ReactElement, + { locale = 'en', signedIn = true, ...renderOptions } = {}, +) { + const Wrapper = (props: { children: React.ReactElement }) => { + return ( + + + + {props.children} + + + + ); + }; + return rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); +} + +// re-export everything +// eslint-disable-next-line import/no-extraneous-dependencies +export * from '@testing-library/react'; + +// override render method +export { render };