diff --git a/app/javascript/flavours/glitch/actions/modal.ts b/app/javascript/flavours/glitch/actions/modal.ts index 9e653c5f4c..947c3eac41 100644 --- a/app/javascript/flavours/glitch/actions/modal.ts +++ b/app/javascript/flavours/glitch/actions/modal.ts @@ -10,6 +10,7 @@ interface OpenModalPayload { modalType: ModalType; modalProps: ModalProps; previousModalProps?: ModalProps; + ignoreFocus?: boolean; } export const openModal = createAction('MODAL_OPEN'); diff --git a/app/javascript/flavours/glitch/components/column_header.tsx b/app/javascript/flavours/glitch/components/column_header.tsx index 3e28458185..45af80d032 100644 --- a/app/javascript/flavours/glitch/components/column_header.tsx +++ b/app/javascript/flavours/glitch/components/column_header.tsx @@ -19,6 +19,7 @@ import { useIdentity } from 'flavours/glitch/identity_context'; import { useColumnIndexContext } from '../features/ui/components/columns_area'; import { getColumnSkipLinkId } from '../features/ui/components/skip_links'; +import { NavigationFocusTarget } from './navigation_focus_target'; import { useAppHistory } from './router'; export const messages = defineMessages({ @@ -285,7 +286,10 @@ export const ColumnHeader: React.FC = ({
{backButton} {hasTitle && ( -

+ {onClick ? (

+ )}
diff --git a/app/javascript/flavours/glitch/components/modal_root.jsx b/app/javascript/flavours/glitch/components/modal_root.jsx index 86016706a6..b2335231c9 100644 --- a/app/javascript/flavours/glitch/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/components/modal_root.jsx @@ -7,6 +7,7 @@ import { multiply } from 'color-blend'; import { createBrowserHistory } from 'history'; import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router'; +import { IGNORE_FOCUS_ON_OPEN } from '../reducers/modal'; class ModalRoot extends PureComponent { @@ -22,7 +23,10 @@ class ModalRoot extends PureComponent { }), ]), noEsc: PropTypes.bool, - ignoreFocus: PropTypes.bool, + ignoreFocus: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, // 'on-open', see IGNORE_FOCUS_ON_OPEN + ]), ...WithOptionalRouterPropTypes, }; @@ -123,7 +127,11 @@ class ModalRoot extends PureComponent { _ensureHistoryBuffer () { const { pathname, search, hash, state } = this.history.location; if (!state || state.mastodonModalKey !== this._modalHistoryKey) { - this.history.push({ pathname, search, hash }, { ...state, mastodonModalKey: this._modalHistoryKey }); + this.history.push({ pathname, search, hash }, { + ...state, + focusTarget: this.props.ignoreFocus !== IGNORE_FOCUS_ON_OPEN, + mastodonModalKey: this._modalHistoryKey, + }); } } diff --git a/app/javascript/flavours/glitch/components/navigation_focus_target/index.tsx b/app/javascript/flavours/glitch/components/navigation_focus_target/index.tsx new file mode 100644 index 0000000000..f4b42926a8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/navigation_focus_target/index.tsx @@ -0,0 +1,145 @@ +import { + createContext, + useContext, + useRef, + useLayoutEffect, + useCallback, +} from 'react'; + +import { useLocation } from 'react-router-dom'; + +import { polymorphicForwardRef } from '@/types/polymorphic'; + +import type { MastodonLocation } from '../router'; + +export const FOCUS_TARGET = { + POST: 'detailed-status', +} as const; + +export type FocusTarget = + | boolean + | (typeof FOCUS_TARGET)[keyof typeof FOCUS_TARGET]; + +const FocusTargetContext = + createContext | null>(null); + +/** + * `FocusTargetProvider` keeps track of whether focus should be + * set after a navigation. By default, any navigation will set the + * current value of `focusTargetRef` to `true`, which will cause + * the `NavigationFocusTarget` component to focus itself when it mounts. + * + * To disable this behaviour for a navigation, the focus target can be + * set to `false` using location state, for example: + * ``` + * location.push(url, { focusTarget: false }); + * ``` + * + * If the target page contains multiple `NavigationFocusTarget` components + * (e.g. a main heading and a post that should be focused), give the more + * specific `NavigationFocusTarget` instance a name, and pass the same name + * via location state: + * ``` + * location.push(url, { focusTarget: 'detailed-status' }); + * ``` + */ + +export const FocusTargetProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const focusTargetRef = useRef(false); + const previousLocationRef = useRef< + | (Pick & { + focusTarget?: FocusTarget; + }) + | null + >(null); + + const { + pathname, + search, + state = {}, + } = useLocation<{ focusTarget?: FocusTarget } | undefined>(); + + const { focusTarget } = state; + + useLayoutEffect(() => { + // We never want to set focus on page load, so we keep + // track of whether a manual navigation has occurred by comparing + // our current with the previous location: + const previous = previousLocationRef.current; + + // Bail out on the first render, populate previousLocationRef + if (previous === null) { + previousLocationRef.current = { pathname, search, focusTarget }; + return; + } + + // Bail out if location hasn't changed + if ( + previous.pathname === pathname && + previous.search === search && + previous.focusTarget === focusTarget + ) { + return; + } + + // Location has changed: + // - Set focusTarget + // – Store current location as previous + // (We store `focusTarget` as `false` to allow overriding it.) + previousLocationRef.current = { pathname, search, focusTarget: false }; + focusTargetRef.current = focusTarget ?? true; + }, [pathname, search, focusTarget]); + + return ( + + {children} + + ); +}; + +export function useFocusOnNavigation(targetName?: string) { + const focusTargetRef = useContext(FocusTargetContext); + + if (focusTargetRef === null) { + throw Error( + 'useFocusTargetContext must be used inside of a FocusTargetProvider', + ); + } + + return useCallback( + (element: HTMLElement | null) => { + const focusTarget = focusTargetRef.current; + + // Bail out if focusTarget was set to `false` + if (!element || !focusTarget) { + return; + } + + if (focusTarget === true || focusTarget === targetName) { + setTimeout(() => { + element.focus({ preventScroll: true }); + }, 0); + } + }, + [focusTargetRef, targetName], + ); +} + +interface FocusTargetElementProps extends React.ComponentPropsWithoutRef<'h1'> { + focusTargetName?: string; +} + +export const NavigationFocusTarget = polymorphicForwardRef< + 'h1', + FocusTargetElementProps +>(({ as: Component = 'h1', focusTargetName, children, ...otherProps }) => { + const focusOnNavigation = useFocusOnNavigation(focusTargetName); + + return ( + + {children} + + ); +}); diff --git a/app/javascript/flavours/glitch/components/router.tsx b/app/javascript/flavours/glitch/components/router.tsx index ffe6fe461a..5802b04a00 100644 --- a/app/javascript/flavours/glitch/components/router.tsx +++ b/app/javascript/flavours/glitch/components/router.tsx @@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history'; import { layoutFromWindow } from 'flavours/glitch/is_mobile'; import { isDevelopment } from 'flavours/glitch/utils/environment'; +import type { FocusTarget } from './navigation_focus_target'; + interface MastodonLocationState { fromMastodon?: boolean; mastodonModalKey?: string; + // Controls which element is focused after a navigation. + // Set to `false` to prevent navigation focus. + focusTarget?: FocusTarget; // Prevent the rightmost column in advanced UI from scrolling // into view on location changes preventMultiColumnAutoScroll?: string; diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 45344e6638..3798f8b389 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -32,6 +32,7 @@ import StatusIcons from './status_icons'; import StatusPrepend from './status_prepend'; import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card'; import { compareUrls } from '../utils/compare_urls'; +import { FOCUS_TARGET } from './navigation_focus_target'; const domParser = new DOMParser(); @@ -392,9 +393,9 @@ class Status extends ImmutablePureComponent { window.open(path, '_blank', 'noopener'); } else { if (history.location.pathname.replace('/deck/', '/') === path) { - history.replace(path); + history.replace(path, {focusTarget: FOCUS_TARGET.POST}); } else { - history.push(path); + history.push(path, {focusTarget: FOCUS_TARGET.POST}); } } }; diff --git a/app/javascript/flavours/glitch/containers/mastodon.jsx b/app/javascript/flavours/glitch/containers/mastodon.jsx index 180024f438..da57ea1f1c 100644 --- a/app/javascript/flavours/glitch/containers/mastodon.jsx +++ b/app/javascript/flavours/glitch/containers/mastodon.jsx @@ -9,6 +9,7 @@ import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_sett import { hydrateStore } from 'flavours/glitch/actions/store'; import { connectUserStream } from 'flavours/glitch/actions/streaming'; import ErrorBoundary from 'flavours/glitch/components/error_boundary'; +import { FocusTargetProvider } from '@/flavours/glitch/components/navigation_focus_target'; import { Router } from 'flavours/glitch/components/router'; import UI from 'flavours/glitch/features/ui'; import { IdentityContext, createIdentityContext } from 'flavours/glitch/identity_context'; @@ -53,7 +54,9 @@ export default class Mastodon extends PureComponent { - + + + diff --git a/app/javascript/flavours/glitch/features/about/index.jsx b/app/javascript/flavours/glitch/features/about/index.jsx index 23bd19eb37..e0c641f9cf 100644 --- a/app/javascript/flavours/glitch/features/about/index.jsx +++ b/app/javascript/flavours/glitch/features/about/index.jsx @@ -8,10 +8,13 @@ import { Helmet } from '@unhead/react/helmet'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; +import { domain } from 'flavours/glitch/initial_state'; + import { injectIntl } from '@/flavours/glitch/components/intl'; import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server'; import { Account } from 'flavours/glitch/components/account'; import Column from 'flavours/glitch/components/column'; +import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target'; import { ServerHeroImage } from 'flavours/glitch/components/server_hero_image'; import { Skeleton } from 'flavours/glitch/components/skeleton'; import { LinkFooter} from 'flavours/glitch/features/ui/components/link_footer'; @@ -91,7 +94,9 @@ class About extends PureComponent { srcSet={Object.keys(server.item?.thumbnail.versions ?? {}).map((key) => `${server.item?.thumbnail.versions && server.item.thumbnail.versions[key]} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' /> -

{isLoading ? : server.domain}

+ + {isLoading ? : domain} +

Mastodon }} />

diff --git a/app/javascript/flavours/glitch/features/account_edit/components/field_actions.tsx b/app/javascript/flavours/glitch/features/account_edit/components/field_actions.tsx index 61743c51b1..680ae3e2c4 100644 --- a/app/javascript/flavours/glitch/features/account_edit/components/field_actions.tsx +++ b/app/javascript/flavours/glitch/features/account_edit/components/field_actions.tsx @@ -26,6 +26,7 @@ export const AccountFieldActions: FC<{ id: string }> = ({ id }) => { openModal({ modalType: 'ACCOUNT_EDIT_FIELD_EDIT', modalProps: { fieldKey: id }, + ignoreFocus: true, }), ); }, [dispatch, id]); diff --git a/app/javascript/flavours/glitch/features/account_edit/index.tsx b/app/javascript/flavours/glitch/features/account_edit/index.tsx index 1c0c3a7371..d492a1a6fd 100644 --- a/app/javascript/flavours/glitch/features/account_edit/index.tsx +++ b/app/javascript/flavours/glitch/features/account_edit/index.tsx @@ -137,16 +137,28 @@ export const AccountEdit: FC = () => { ); const handleOpenModal = useCallback( - (type: ModalType, props?: Record) => { - dispatch(openModal({ modalType: type, modalProps: props ?? {} })); + ( + type: ModalType, + { + modalProps = {}, + ignoreFocus = false, + }: { modalProps?: Record; ignoreFocus?: boolean } = {}, + ) => { + dispatch( + openModal({ + modalType: type, + modalProps, + ignoreFocus, + }), + ); }, [dispatch], ); const handleNameEdit = useCallback(() => { - handleOpenModal('ACCOUNT_EDIT_NAME'); + handleOpenModal('ACCOUNT_EDIT_NAME', { ignoreFocus: true }); }, [handleOpenModal]); const handleBioEdit = useCallback(() => { - handleOpenModal('ACCOUNT_EDIT_BIO'); + handleOpenModal('ACCOUNT_EDIT_BIO', { ignoreFocus: true }); }, [handleOpenModal]); const handleCustomFieldAdd = useCallback(() => { handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT'); diff --git a/app/javascript/flavours/glitch/features/account_timeline/modals/field_modal.tsx b/app/javascript/flavours/glitch/features/account_timeline/modals/field_modal.tsx index 06ac88bdf0..b5c9ae6d3e 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/modals/field_modal.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/modals/field_modal.tsx @@ -10,6 +10,7 @@ import { ModalShellActions, ModalShellBody, } from '@/flavours/glitch/components/modal_shell'; +import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target'; import { useFieldHtml } from '../hooks/useFieldHtml'; @@ -24,12 +25,14 @@ export const AccountFieldModal: FC<{ return ( - + + + ({ message: state.getIn(['server', 'server', 'item', 'registrations', 'message']), @@ -42,7 +43,9 @@ class ClosedRegistrationsModal extends ImmutablePureComponent { return (
-

+ + +

-

+

{closedRegistrationsMessage}
-

+

-

+ {isNew ? ( )} -

+
-

{pageTitleHtml}

+ + {pageTitleHtml} + {intl.formatMessage(createdByTabMessage, { diff --git a/app/javascript/flavours/glitch/features/interaction_modal/index.tsx b/app/javascript/flavours/glitch/features/interaction_modal/index.tsx index e7152ec982..4154c53f18 100644 --- a/app/javascript/flavours/glitch/features/interaction_modal/index.tsx +++ b/app/javascript/flavours/glitch/features/interaction_modal/index.tsx @@ -8,6 +8,7 @@ import { escapeRegExp } from 'lodash'; import { useDebouncedCallback } from 'use-debounce'; import { DisplayName } from '@/flavours/glitch/components/display_name'; +import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target'; import { openModal, closeModal } from 'flavours/glitch/actions/modal'; import { apiRequest } from 'flavours/glitch/api'; import { Button } from 'flavours/glitch/components/button'; @@ -474,12 +475,12 @@ const InteractionModal: React.FC<{ return (
-

+ -

+

{intent === 'follow' ? ( = ({ const path = `/@${account.acct}/${statusId}`; if (button === 0 && !(ctrlKey || metaKey)) { - history.push(path); + history.push(path, { focusTarget: FOCUS_TARGET.POST }); } else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) { window.open(path, '_blank', 'noopener'); } diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index 7bf1f9466e..79725b9c31 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -66,6 +66,7 @@ import ActionBar from './components/action_bar'; import { DetailedStatus } from './components/detailed_status'; import { RefreshController } from './components/refresh_controller'; import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed'; +import { FOCUS_TARGET, NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target'; const messages = defineMessages({ revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, @@ -630,7 +631,13 @@ class Status extends ImmutablePureComponent { {ancestors} -

+ -
+ {descendants} diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx index b79b0ff7cb..7d2cde8d3d 100644 --- a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { FormattedMessage, defineMessages } from 'react-intl'; +import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target'; import { Button } from 'flavours/glitch/components/button'; import { ModalShell, @@ -75,7 +76,13 @@ export const ConfirmationModal: React.FC< return ( -

{title}

+ {noFocusButton ? ( + + {title} + + ) : ( +

{title}

+ )} {message &&

{message}

} {extraContent ?? children} diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index 18d0af36e5..36ee0f5fae 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -195,7 +195,7 @@ class SwitchingColumnsArea extends PureComponent { - + {singleColumn ? : null} {singleColumn && pathName.startsWith('/deck/') ? : null} diff --git a/app/javascript/flavours/glitch/reducers/modal.ts b/app/javascript/flavours/glitch/reducers/modal.ts index 7144cb4d22..54e500c784 100644 --- a/app/javascript/flavours/glitch/reducers/modal.ts +++ b/app/javascript/flavours/glitch/reducers/modal.ts @@ -17,8 +17,10 @@ const Modal = ImmutableRecord({ modalProps: ImmutableRecord({})(), }); +export const IGNORE_FOCUS_ON_OPEN = 'on-open'; + interface ModalState { - ignoreFocus: boolean; + ignoreFocus: boolean | typeof IGNORE_FOCUS_ON_OPEN; stack: Stack>; } @@ -53,9 +55,10 @@ const pushModal = ( modalType: ModalType, modalProps: ModalProps, previousModalProps?: ModalProps, + ignoreFocusOnOpen = false, ): State => { return state.withMutations((record) => { - record.set('ignoreFocus', false); + record.set('ignoreFocus', ignoreFocusOnOpen ? IGNORE_FOCUS_ON_OPEN : false); record.update('stack', (stack) => { let tmp = stack; @@ -92,6 +95,7 @@ export const modalReducer: Reducer = (state = initialState, action) => { action.payload.modalType, action.payload.modalProps, action.payload.previousModalProps, + action.payload.ignoreFocus, ); else if (closeModal.match(action)) return popModal(state, action.payload); // TODO: type those actions diff --git a/app/javascript/flavours/glitch/styles/mastodon/components.scss b/app/javascript/flavours/glitch/styles/mastodon/components.scss index c1259f208c..229ab8ecc9 100644 --- a/app/javascript/flavours/glitch/styles/mastodon/components.scss +++ b/app/javascript/flavours/glitch/styles/mastodon/components.scss @@ -9487,6 +9487,8 @@ noscript { padding-bottom: 32px; } + h1, + h2, h3 { font-size: 22px; line-height: 33px; @@ -9517,6 +9519,8 @@ noscript { &__lead { margin-bottom: 20px; + h1, + h2, h3 { margin-bottom: 15px; } @@ -9592,6 +9596,8 @@ noscript { flex: 1; box-sizing: border-box; + h1, + h2, h3 { margin-bottom: 20px; }