diff --git a/Dockerfile b/Dockerfile index 6f44c4c3e1..0799a7b66f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -210,7 +210,7 @@ FROM media-build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.18.2 +ARG VIPS_VERSION=8.18.3 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb index 628418557c..46f9badb89 100644 --- a/app/controllers/collections_controller.rb +++ b/app/controllers/collections_controller.rb @@ -10,6 +10,7 @@ class CollectionsController < ApplicationController before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_collection + before_action :redirect_to_canonical_url skip_around_action :set_locale, if: -> { request.format == :json } skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode? @@ -18,7 +19,6 @@ class CollectionsController < ApplicationController respond_to do |format| format.html do expires_in expiration_duration, public: true unless user_signed_in? - render template: 'home/index' end format.json do @@ -46,6 +46,10 @@ class CollectionsController < ApplicationController not_found end + def redirect_to_canonical_url + redirect_to collection_path(@collection) if request.format.html? && request.path.starts_with?('/ap/') + end + def expiration_duration recently_updated = @collection.updated_at > 15.minutes.ago recently_updated ? 30.seconds : 5.minutes diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index b2fbc6e25f..f09db1327e 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -93,7 +93,7 @@ const messages = defineMessages({ export const ensureComposeIsVisible = (getState) => { if (!getState().getIn(['compose', 'mounted'])) { - browserHistory.push('/publish'); + browserHistory.push('/publish', { focusTarget: false }); } }; @@ -294,7 +294,10 @@ export function submitCompose(successCallback) { message: statusId === null ? messages.published : messages.saved, action: messages.open, dismissAfter: 10000, - onClick: () => browserHistory.push(`/@${response.data.account.username}/${response.data.id}`), + onClick: () => browserHistory.push( + `/@${response.data.account.username}/${response.data.id}`, + { focusTarget: 'detailed-status' } + ), })); }).catch(function (error) { dispatch(submitComposeFail(error)); @@ -341,11 +344,6 @@ export function uploadCompose(files) { return; } - if (getState().getIn(['compose', 'poll'])) { - dispatch(showAlert({ message: messages.uploadErrorPoll })); - return; - } - dispatch(uploadComposeRequest()); for (const [i, file] of Array.from(files).entries()) { diff --git a/app/javascript/mastodon/actions/importer/emoji.ts b/app/javascript/mastodon/actions/importer/emoji.ts index e9356ab621..36fb04b51e 100644 --- a/app/javascript/mastodon/actions/importer/emoji.ts +++ b/app/javascript/mastodon/actions/importer/emoji.ts @@ -18,7 +18,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { ); // If there's a mismatch, re-import all custom emojis. - if (existingEmojis.length < emojis.length) { + if (existingEmojis.length > 0 && existingEmojis.length < emojis.length) { await clearCache('custom'); await loadCustomEmoji(); diff --git a/app/javascript/mastodon/actions/modal.ts b/app/javascript/mastodon/actions/modal.ts index 49af176a11..0978c32658 100644 --- a/app/javascript/mastodon/actions/modal.ts +++ b/app/javascript/mastodon/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/mastodon/components/account_header/name.tsx b/app/javascript/mastodon/components/account_header/name.tsx index b46e849765..5bd2ed80db 100644 --- a/app/javascript/mastodon/components/account_header/name.tsx +++ b/app/javascript/mastodon/components/account_header/name.tsx @@ -19,6 +19,7 @@ import { FollowsYouBadge } from '../badge'; import { CopyButton } from '../copy_button'; import { DisplayName } from '../display_name'; import { Icon } from '../icon'; +import { NavigationFocusTarget } from '../navigation_focus_target'; import { AccountBadges } from './badges'; import classes from './styles.module.scss'; @@ -56,9 +57,9 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => { return (
-

+ -

+ {relationship?.followed_by && }
diff --git a/app/javascript/mastodon/components/callout/styles.module.css b/app/javascript/mastodon/components/callout/styles.module.css index dd2c753525..f049f25e98 100644 --- a/app/javascript/mastodon/components/callout/styles.module.css +++ b/app/javascript/mastodon/components/callout/styles.module.css @@ -62,6 +62,7 @@ background: none; border: none; color: inherit; + font: inherit; font-weight: 500; padding: 0; text-wrap: nowrap; diff --git a/app/javascript/mastodon/components/column_header.tsx b/app/javascript/mastodon/components/column_header.tsx index 36ed239a47..77cba09fbe 100644 --- a/app/javascript/mastodon/components/column_header.tsx +++ b/app/javascript/mastodon/components/column_header.tsx @@ -19,6 +19,7 @@ import { useIdentity } from 'mastodon/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({ @@ -272,7 +273,7 @@ export const ColumnHeader: React.FC = ({ {!backButton && hasIcon && ( )} - {title} + {title} ); @@ -285,7 +286,10 @@ export const ColumnHeader: React.FC = ({
{backButton} {hasTitle && ( -

+ {onClick ? (

+ )}
diff --git a/app/javascript/mastodon/components/modal_root.jsx b/app/javascript/mastodon/components/modal_root.jsx index 61ff19256f..c2f51a5673 100644 --- a/app/javascript/mastodon/components/modal_root.jsx +++ b/app/javascript/mastodon/components/modal_root.jsx @@ -7,6 +7,7 @@ import { multiply } from 'color-blend'; import { createBrowserHistory } from 'history'; import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router'; +import { IGNORE_FOCUS_ON_OPEN } from '../reducers/modal'; class ModalRoot extends PureComponent { @@ -21,7 +22,10 @@ class ModalRoot extends PureComponent { b: PropTypes.number, }), ]), - ignoreFocus: PropTypes.bool, + ignoreFocus: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, // 'on-open', see IGNORE_FOCUS_ON_OPEN + ]), ...WithOptionalRouterPropTypes, }; @@ -118,7 +122,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/mastodon/components/navigation_focus_target/index.tsx b/app/javascript/mastodon/components/navigation_focus_target/index.tsx new file mode 100644 index 0000000000..f4b42926a8 --- /dev/null +++ b/app/javascript/mastodon/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/mastodon/components/router.tsx b/app/javascript/mastodon/components/router.tsx index bd6e4b568f..2a53d6dff4 100644 --- a/app/javascript/mastodon/components/router.tsx +++ b/app/javascript/mastodon/components/router.tsx @@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history'; import { layoutFromWindow } from 'mastodon/is_mobile'; import { isDevelopment } from 'mastodon/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/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 808f4d73bf..2f66f15405 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -33,6 +33,7 @@ import StatusContent from './status_content'; import { StatusThreadLabel } from './status_thread_label'; 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(); @@ -311,9 +312,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/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index d6df09db49..4f87a1b823 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux'; import { hydrateStore } from 'mastodon/actions/store'; import { connectUserStream } from 'mastodon/actions/streaming'; import ErrorBoundary from 'mastodon/components/error_boundary'; +import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target'; import { Router } from 'mastodon/components/router'; import UI from 'mastodon/features/ui'; import { IdentityContext, createIdentityContext } from 'mastodon/identity_context'; @@ -49,7 +50,9 @@ export default class Mastodon extends PureComponent { - + + + diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 83f5bf6025..d60ef9e610 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/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 'mastodon/initial_state'; + import { injectIntl } from '@/mastodon/components/intl'; import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server'; import { Account } from 'mastodon/components/account'; import Column from 'mastodon/components/column'; +import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target'; import { ServerHeroImage } from 'mastodon/components/server_hero_image'; import { Skeleton } from 'mastodon/components/skeleton'; import { LinkFooter} from 'mastodon/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/mastodon/features/account_edit/components/field_actions.tsx b/app/javascript/mastodon/features/account_edit/components/field_actions.tsx index fecdcb8eff..028f4dc803 100644 --- a/app/javascript/mastodon/features/account_edit/components/field_actions.tsx +++ b/app/javascript/mastodon/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/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index d00473043e..6ef8997f7b 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/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/mastodon/features/account_timeline/modals/field_modal.tsx b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx index baf0c70f76..abb0a28ee3 100644 --- a/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx +++ b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx @@ -10,6 +10,7 @@ import { ModalShellActions, ModalShellBody, } from '@/mastodon/components/modal_shell'; +import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target'; import { useFieldHtml } from '../hooks/useFieldHtml'; @@ -24,12 +25,14 @@ export const AccountFieldModal: FC<{ return ( - + + + ', () => { const renderComponent = () => { return render( - - - , + + + + + + + , ); }; diff --git a/app/javascript/mastodon/features/alt_text_modal/index.tsx b/app/javascript/mastodon/features/alt_text_modal/index.tsx index fafd460947..3be514e1a4 100644 --- a/app/javascript/mastodon/features/alt_text_modal/index.tsx +++ b/app/javascript/mastodon/features/alt_text_modal/index.tsx @@ -22,6 +22,7 @@ import { changeUploadCompose } from 'mastodon/actions/compose_typed'; import { Button } from 'mastodon/components/button'; import { GIFV } from 'mastodon/components/gifv'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target'; import { Skeleton } from 'mastodon/components/skeleton'; import { Audio } from 'mastodon/features/audio'; import { CharacterCounter } from 'mastodon/features/compose/components/character_counter'; @@ -412,12 +413,15 @@ export const AltTextModal = forwardRef>( )} - + - +