Merge pull request #3527 from glitch-soc/glitch-soc/merge-upstream
Merge upstream changes up to 9e0a3aaf08e7d4fc700f7f19d61f5f8fee346b6a
This commit is contained in:
commit
42bdcc70d5
@ -210,7 +210,7 @@ FROM media-build AS libvips
|
|||||||
|
|
||||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
# 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"]
|
# 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
|
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ class CollectionsController < ApplicationController
|
|||||||
|
|
||||||
before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
|
before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :set_collection
|
before_action :set_collection
|
||||||
|
before_action :redirect_to_canonical_url
|
||||||
|
|
||||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||||
skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode?
|
skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode?
|
||||||
@ -18,7 +19,6 @@ class CollectionsController < ApplicationController
|
|||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html do
|
format.html do
|
||||||
expires_in expiration_duration, public: true unless user_signed_in?
|
expires_in expiration_duration, public: true unless user_signed_in?
|
||||||
render template: 'home/index'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
format.json do
|
format.json do
|
||||||
@ -46,6 +46,10 @@ class CollectionsController < ApplicationController
|
|||||||
not_found
|
not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def redirect_to_canonical_url
|
||||||
|
redirect_to collection_path(@collection) if request.format.html? && request.path.starts_with?('/ap/')
|
||||||
|
end
|
||||||
|
|
||||||
def expiration_duration
|
def expiration_duration
|
||||||
recently_updated = @collection.updated_at > 15.minutes.ago
|
recently_updated = @collection.updated_at > 15.minutes.ago
|
||||||
recently_updated ? 30.seconds : 5.minutes
|
recently_updated ? 30.seconds : 5.minutes
|
||||||
|
|||||||
@ -98,7 +98,7 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
export const ensureComposeIsVisible = (getState) => {
|
export const ensureComposeIsVisible = (getState) => {
|
||||||
if (!getState().getIn(['compose', 'mounted'])) {
|
if (!getState().getIn(['compose', 'mounted'])) {
|
||||||
browserHistory.push('/publish');
|
browserHistory.push('/publish', { focusTarget: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -318,7 +318,10 @@ export function submitCompose(overridePrivacy = null, successCallback = undefine
|
|||||||
message: statusId === null ? messages.published : messages.saved,
|
message: statusId === null ? messages.published : messages.saved,
|
||||||
action: messages.open,
|
action: messages.open,
|
||||||
dismissAfter: 10000,
|
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) {
|
}).catch(function (error) {
|
||||||
@ -373,11 +376,6 @@ export function uploadCompose(files) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getState().getIn(['compose', 'poll'])) {
|
|
||||||
dispatch(showAlert({ message: messages.uploadErrorPoll }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(uploadComposeRequest());
|
dispatch(uploadComposeRequest());
|
||||||
|
|
||||||
for (const [i, file] of Array.from(files).entries()) {
|
for (const [i, file] of Array.from(files).entries()) {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// If there's a mismatch, re-import all custom emojis.
|
// 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 clearCache('custom');
|
||||||
await loadCustomEmoji();
|
await loadCustomEmoji();
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ interface OpenModalPayload {
|
|||||||
modalType: ModalType;
|
modalType: ModalType;
|
||||||
modalProps: ModalProps;
|
modalProps: ModalProps;
|
||||||
previousModalProps?: ModalProps;
|
previousModalProps?: ModalProps;
|
||||||
|
ignoreFocus?: boolean;
|
||||||
}
|
}
|
||||||
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { FollowsYouBadge } from '../badge';
|
|||||||
import { CopyButton } from '../copy_button';
|
import { CopyButton } from '../copy_button';
|
||||||
import { DisplayName } from '../display_name';
|
import { DisplayName } from '../display_name';
|
||||||
import { Icon } from '../icon';
|
import { Icon } from '../icon';
|
||||||
|
import { NavigationFocusTarget } from '../navigation_focus_target';
|
||||||
|
|
||||||
import { AccountBadges } from './badges';
|
import { AccountBadges } from './badges';
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
@ -56,9 +57,9 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
return (
|
return (
|
||||||
<div className={classes.nameWrapper}>
|
<div className={classes.nameWrapper}>
|
||||||
<div className={classes.name}>
|
<div className={classes.name}>
|
||||||
<h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
<DisplayName account={account} variant='simple' />
|
<DisplayName account={account} variant='simple' />
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
{relationship?.followed_by && <FollowsYouBadge />}
|
{relationship?.followed_by && <FollowsYouBadge />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-wrap: nowrap;
|
text-wrap: nowrap;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { useIdentity } from 'flavours/glitch/identity_context';
|
|||||||
import { useColumnIndexContext } from '../features/ui/components/columns_area';
|
import { useColumnIndexContext } from '../features/ui/components/columns_area';
|
||||||
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
|
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from './navigation_focus_target';
|
||||||
import { useAppHistory } from './router';
|
import { useAppHistory } from './router';
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
@ -272,7 +273,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
{!backButton && hasIcon && (
|
{!backButton && hasIcon && (
|
||||||
<Icon id={icon} icon={iconComponent} className='column-header__icon' />
|
<Icon id={icon} icon={iconComponent} className='column-header__icon' />
|
||||||
)}
|
)}
|
||||||
{title}
|
<span className='column-header__text'>{title}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -285,7 +286,10 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
<div className={headingClassName}>
|
<div className={headingClassName}>
|
||||||
{backButton}
|
{backButton}
|
||||||
{hasTitle && (
|
{hasTitle && (
|
||||||
<h1 className='column-header__title-wrapper'>
|
<NavigationFocusTarget
|
||||||
|
as='h1'
|
||||||
|
className='column-header__title-wrapper'
|
||||||
|
>
|
||||||
{onClick ? (
|
{onClick ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleTitleClick}
|
onClick={handleTitleClick}
|
||||||
@ -304,7 +308,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
{titleContents}
|
{titleContents}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='column-header__buttons'>
|
<div className='column-header__buttons'>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { multiply } from 'color-blend';
|
|||||||
import { createBrowserHistory } from 'history';
|
import { createBrowserHistory } from 'history';
|
||||||
|
|
||||||
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
|
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
|
||||||
|
import { IGNORE_FOCUS_ON_OPEN } from '../reducers/modal';
|
||||||
|
|
||||||
class ModalRoot extends PureComponent {
|
class ModalRoot extends PureComponent {
|
||||||
|
|
||||||
@ -22,7 +23,10 @@ class ModalRoot extends PureComponent {
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
noEsc: PropTypes.bool,
|
noEsc: PropTypes.bool,
|
||||||
ignoreFocus: PropTypes.bool,
|
ignoreFocus: PropTypes.oneOfType([
|
||||||
|
PropTypes.bool,
|
||||||
|
PropTypes.string, // 'on-open', see IGNORE_FOCUS_ON_OPEN
|
||||||
|
]),
|
||||||
...WithOptionalRouterPropTypes,
|
...WithOptionalRouterPropTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -123,7 +127,11 @@ class ModalRoot extends PureComponent {
|
|||||||
_ensureHistoryBuffer () {
|
_ensureHistoryBuffer () {
|
||||||
const { pathname, search, hash, state } = this.history.location;
|
const { pathname, search, hash, state } = this.history.location;
|
||||||
if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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<React.MutableRefObject<FocusTarget> | 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<FocusTarget>(false);
|
||||||
|
const previousLocationRef = useRef<
|
||||||
|
| (Pick<MastodonLocation, 'pathname' | 'search'> & {
|
||||||
|
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 (
|
||||||
|
<FocusTargetContext.Provider value={focusTargetRef}>
|
||||||
|
{children}
|
||||||
|
</FocusTargetContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Component ref={focusOnNavigation} tabIndex={-1} {...otherProps}>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history';
|
|||||||
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
|
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
|
||||||
import { isDevelopment } from 'flavours/glitch/utils/environment';
|
import { isDevelopment } from 'flavours/glitch/utils/environment';
|
||||||
|
|
||||||
|
import type { FocusTarget } from './navigation_focus_target';
|
||||||
|
|
||||||
interface MastodonLocationState {
|
interface MastodonLocationState {
|
||||||
fromMastodon?: boolean;
|
fromMastodon?: boolean;
|
||||||
mastodonModalKey?: string;
|
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
|
// Prevent the rightmost column in advanced UI from scrolling
|
||||||
// into view on location changes
|
// into view on location changes
|
||||||
preventMultiColumnAutoScroll?: string;
|
preventMultiColumnAutoScroll?: string;
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import StatusIcons from './status_icons';
|
|||||||
import StatusPrepend from './status_prepend';
|
import StatusPrepend from './status_prepend';
|
||||||
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
||||||
import { compareUrls } from '../utils/compare_urls';
|
import { compareUrls } from '../utils/compare_urls';
|
||||||
|
import { FOCUS_TARGET } from './navigation_focus_target';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
@ -392,9 +393,9 @@ class Status extends ImmutablePureComponent {
|
|||||||
window.open(path, '_blank', 'noopener');
|
window.open(path, '_blank', 'noopener');
|
||||||
} else {
|
} else {
|
||||||
if (history.location.pathname.replace('/deck/', '/') === path) {
|
if (history.location.pathname.replace('/deck/', '/') === path) {
|
||||||
history.replace(path);
|
history.replace(path, {focusTarget: FOCUS_TARGET.POST});
|
||||||
} else {
|
} else {
|
||||||
history.push(path);
|
history.push(path, {focusTarget: FOCUS_TARGET.POST});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_sett
|
|||||||
import { hydrateStore } from 'flavours/glitch/actions/store';
|
import { hydrateStore } from 'flavours/glitch/actions/store';
|
||||||
import { connectUserStream } from 'flavours/glitch/actions/streaming';
|
import { connectUserStream } from 'flavours/glitch/actions/streaming';
|
||||||
import ErrorBoundary from 'flavours/glitch/components/error_boundary';
|
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 { Router } from 'flavours/glitch/components/router';
|
||||||
import UI from 'flavours/glitch/features/ui';
|
import UI from 'flavours/glitch/features/ui';
|
||||||
import { IdentityContext, createIdentityContext } from 'flavours/glitch/identity_context';
|
import { IdentityContext, createIdentityContext } from 'flavours/glitch/identity_context';
|
||||||
@ -53,7 +54,9 @@ export default class Mastodon extends PureComponent {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollContext>
|
<ScrollContext>
|
||||||
<Route path='/' component={UI} />
|
<FocusTargetProvider>
|
||||||
|
<Route path='/' component={UI} />
|
||||||
|
</FocusTargetProvider>
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
<BodyScrollLock />
|
<BodyScrollLock />
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@ -8,10 +8,13 @@ import { Helmet } from '@unhead/react/helmet';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { domain } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
import { injectIntl } from '@/flavours/glitch/components/intl';
|
import { injectIntl } from '@/flavours/glitch/components/intl';
|
||||||
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server';
|
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server';
|
||||||
import { Account } from 'flavours/glitch/components/account';
|
import { Account } from 'flavours/glitch/components/account';
|
||||||
import Column from 'flavours/glitch/components/column';
|
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 { ServerHeroImage } from 'flavours/glitch/components/server_hero_image';
|
||||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||||
import { LinkFooter} from 'flavours/glitch/features/ui/components/link_footer';
|
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(', ')}
|
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'
|
className='about__header__hero'
|
||||||
/>
|
/>
|
||||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.domain}</h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
{isLoading ? <Skeleton width='10ch' /> : domain}
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
|
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const AccountFieldActions: FC<{ id: string }> = ({ id }) => {
|
|||||||
openModal({
|
openModal({
|
||||||
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
||||||
modalProps: { fieldKey: id },
|
modalProps: { fieldKey: id },
|
||||||
|
ignoreFocus: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|||||||
@ -137,16 +137,28 @@ export const AccountEdit: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenModal = useCallback(
|
const handleOpenModal = useCallback(
|
||||||
(type: ModalType, props?: Record<string, unknown>) => {
|
(
|
||||||
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
|
type: ModalType,
|
||||||
|
{
|
||||||
|
modalProps = {},
|
||||||
|
ignoreFocus = false,
|
||||||
|
}: { modalProps?: Record<string, unknown>; ignoreFocus?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: type,
|
||||||
|
modalProps,
|
||||||
|
ignoreFocus,
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
const handleNameEdit = useCallback(() => {
|
const handleNameEdit = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_NAME');
|
handleOpenModal('ACCOUNT_EDIT_NAME', { ignoreFocus: true });
|
||||||
}, [handleOpenModal]);
|
}, [handleOpenModal]);
|
||||||
const handleBioEdit = useCallback(() => {
|
const handleBioEdit = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
handleOpenModal('ACCOUNT_EDIT_BIO', { ignoreFocus: true });
|
||||||
}, [handleOpenModal]);
|
}, [handleOpenModal]);
|
||||||
const handleCustomFieldAdd = useCallback(() => {
|
const handleCustomFieldAdd = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT');
|
handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT');
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
ModalShellActions,
|
ModalShellActions,
|
||||||
ModalShellBody,
|
ModalShellBody,
|
||||||
} from '@/flavours/glitch/components/modal_shell';
|
} from '@/flavours/glitch/components/modal_shell';
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
|
|
||||||
import { useFieldHtml } from '../hooks/useFieldHtml';
|
import { useFieldHtml } from '../hooks/useFieldHtml';
|
||||||
|
|
||||||
@ -24,12 +25,14 @@ export const AccountFieldModal: FC<{
|
|||||||
return (
|
return (
|
||||||
<ModalShell>
|
<ModalShell>
|
||||||
<ModalShellBody>
|
<ModalShellBody>
|
||||||
<EmojiHTML
|
<NavigationFocusTarget as='h1'>
|
||||||
as='h2'
|
<EmojiHTML
|
||||||
htmlString={field.name_emojified}
|
as='span'
|
||||||
onElement={handleLabelElement}
|
htmlString={field.name_emojified}
|
||||||
className={classes.fieldName}
|
onElement={handleLabelElement}
|
||||||
/>
|
className={classes.fieldName}
|
||||||
|
/>
|
||||||
|
</NavigationFocusTarget>
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
as='p'
|
as='p'
|
||||||
htmlString={field.value_emojified}
|
htmlString={field.value_emojified}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { List, Map } from 'immutable';
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { FocusTargetProvider } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
|
import { Router } from '@/flavours/glitch/components/router';
|
||||||
import type { RootState } from 'flavours/glitch/store';
|
import type { RootState } from 'flavours/glitch/store';
|
||||||
import { useAppSelector } from 'flavours/glitch/store';
|
import { useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
@ -27,9 +29,13 @@ describe('<AltTextModal />', () => {
|
|||||||
|
|
||||||
const renderComponent = () => {
|
const renderComponent = () => {
|
||||||
return render(
|
return render(
|
||||||
<IntlProvider locale='en' messages={{}}>
|
<Router>
|
||||||
<AltTextModal mediaId={mediaId} onClose={handleClose} />
|
<FocusTargetProvider>
|
||||||
</IntlProvider>,
|
<IntlProvider locale='en' messages={{}}>
|
||||||
|
<AltTextModal mediaId={mediaId} onClose={handleClose} />
|
||||||
|
</IntlProvider>
|
||||||
|
</FocusTargetProvider>
|
||||||
|
</Router>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { changeUploadCompose } from 'flavours/glitch/actions/compose_typed';
|
|||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { GIFV } from 'flavours/glitch/components/gifv';
|
import { GIFV } from 'flavours/glitch/components/gifv';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||||
import { Audio } from 'flavours/glitch/features/audio';
|
import { Audio } from 'flavours/glitch/features/audio';
|
||||||
import { CharacterCounter } from 'flavours/glitch/features/compose/components/character_counter';
|
import { CharacterCounter } from 'flavours/glitch/features/compose/components/character_counter';
|
||||||
@ -412,12 +413,15 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className='dialog-modal__header__title'>
|
<NavigationFocusTarget
|
||||||
|
as='h1'
|
||||||
|
className='dialog-modal__header__title'
|
||||||
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='alt_text_modal.add_alt_text'
|
id='alt_text_modal.add_alt_text'
|
||||||
defaultMessage='Add alt text'
|
defaultMessage='Add alt text'
|
||||||
/>
|
/>
|
||||||
</span>
|
</NavigationFocusTarget>
|
||||||
|
|
||||||
<Button secondary onClick={onClose}>
|
<Button secondary onClick={onClose}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import classNames from 'classnames/bind';
|
|||||||
import { closeModal } from '@/flavours/glitch/actions/modal';
|
import { closeModal } from '@/flavours/glitch/actions/modal';
|
||||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { getReport } from '@/flavours/glitch/reducers/slices/annual_report';
|
import { getReport } from '@/flavours/glitch/reducers/slices/annual_report';
|
||||||
import {
|
import {
|
||||||
createAppSelector,
|
createAppSelector,
|
||||||
@ -83,7 +84,9 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1>Wrapstodon {report.year}</h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
Wrapstodon {report.year}
|
||||||
|
</NavigationFocusTarget>
|
||||||
{account && <p>@{account.acct}</p>}
|
{account && <p>@{account.acct}</p>}
|
||||||
{context === 'modal' && (
|
{context === 'modal' && (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
|||||||
|
|
||||||
import { fetchServer } from 'flavours/glitch/actions/server';
|
import { fetchServer } from 'flavours/glitch/actions/server';
|
||||||
import { domain } from 'flavours/glitch/initial_state';
|
import { domain } from 'flavours/glitch/initial_state';
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
message: state.getIn(['server', 'server', 'item', 'registrations', 'message']),
|
message: state.getIn(['server', 'server', 'item', 'registrations', 'message']),
|
||||||
@ -42,7 +43,9 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<div className='modal-root__modal interaction-modal'>
|
<div className='modal-root__modal interaction-modal'>
|
||||||
<div className='interaction-modal__lead'>
|
<div className='interaction-modal__lead'>
|
||||||
<h3><FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' /></h3>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
<FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' />
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p>
|
<p>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='closed_registrations_modal.preamble'
|
id='closed_registrations_modal.preamble'
|
||||||
@ -53,12 +56,12 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
|
|||||||
|
|
||||||
<div className='interaction-modal__choices'>
|
<div className='interaction-modal__choices'>
|
||||||
<div className='interaction-modal__choices__choice'>
|
<div className='interaction-modal__choices__choice'>
|
||||||
<h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
|
<h2><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h2>
|
||||||
{closedRegistrationsMessage}
|
{closedRegistrationsMessage}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='interaction-modal__choices__choice'>
|
<div className='interaction-modal__choices__choice'>
|
||||||
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
|
<h2><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h2>
|
||||||
<p className='prose'>
|
<p className='prose'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='closed_registrations.other_server_instructions'
|
id='closed_registrations.other_server_instructions'
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
|||||||
|
|
||||||
import type { ApiCollectionJSON } from '@/flavours/glitch/api_types/collections';
|
import type { ApiCollectionJSON } from '@/flavours/glitch/api_types/collections';
|
||||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
import type { Account } from '@/flavours/glitch/models/account';
|
import type { Account } from '@/flavours/glitch/models/account';
|
||||||
import {
|
import {
|
||||||
@ -114,13 +115,17 @@ export const CollectionAdder: React.FC<{
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className='dialog-modal__header__title' id={titleId}>
|
<NavigationFocusTarget
|
||||||
|
as='h1'
|
||||||
|
id={titleId}
|
||||||
|
className='dialog-modal__header__title'
|
||||||
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='collections.add_to_collection'
|
id='collections.add_to_collection'
|
||||||
defaultMessage='Add {name} to collections'
|
defaultMessage='Add {name} to collections'
|
||||||
values={{ name: <strong>@{account?.acct}</strong> }}
|
values={{ name: <strong>@{account?.acct}</strong> }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</NavigationFocusTarget>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='dialog-modal__content'>
|
<div className='dialog-modal__content'>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||||||
|
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { me } from '@/flavours/glitch/initial_state';
|
import { me } from '@/flavours/glitch/initial_state';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { changeCompose, focusCompose } from 'flavours/glitch/actions/compose';
|
import { changeCompose, focusCompose } from 'flavours/glitch/actions/compose';
|
||||||
@ -66,7 +67,7 @@ export const CollectionShareModal: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<ModalShell>
|
<ModalShell>
|
||||||
<ModalShellBody>
|
<ModalShellBody>
|
||||||
<h1 className={classes.heading}>
|
<NavigationFocusTarget as='h1' className={classes.heading}>
|
||||||
{isNew ? (
|
{isNew ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='collection.share_modal.title_new'
|
id='collection.share_modal.title_new'
|
||||||
@ -78,7 +79,7 @@ export const CollectionShareModal: React.FC<{
|
|||||||
defaultMessage='Share collection'
|
defaultMessage='Share collection'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Helmet } from '@unhead/react/helmet';
|
import { Helmet } from '@unhead/react/helmet';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { Column } from 'flavours/glitch/components/column';
|
import { Column } from 'flavours/glitch/components/column';
|
||||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||||
import { DisplayNameSimple } from 'flavours/glitch/components/display_name/simple';
|
import { DisplayNameSimple } from 'flavours/glitch/components/display_name/simple';
|
||||||
@ -74,7 +75,9 @@ export const Collections: React.FC<{
|
|||||||
|
|
||||||
<Scrollable>
|
<Scrollable>
|
||||||
<header className={classes.header}>
|
<header className={classes.header}>
|
||||||
<h1 className={classes.heading}>{pageTitleHtml}</h1>
|
<NavigationFocusTarget as='h1' className={classes.heading}>
|
||||||
|
{pageTitleHtml}
|
||||||
|
</NavigationFocusTarget>
|
||||||
<TabList plain>
|
<TabList plain>
|
||||||
<TabLink exact to={`/@${account?.acct}/collections`}>
|
<TabLink exact to={`/@${account?.acct}/collections`}>
|
||||||
{intl.formatMessage(createdByTabMessage, {
|
{intl.formatMessage(createdByTabMessage, {
|
||||||
|
|||||||
@ -242,7 +242,9 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
||||||
this.textareaRef.current.focus();
|
this.textareaRef.current.focus();
|
||||||
} else if (this.props.spoiler !== prevProps.spoiler) {
|
} else if (this.props.spoiler !== prevProps.spoiler) {
|
||||||
if (this.props.spoiler) {
|
const mediaJustAdded = this.props.anyMedia && !prevProps.anyMedia;
|
||||||
|
|
||||||
|
if (this.props.spoiler && !mediaJustAdded) {
|
||||||
this.spoilerText.input.focus();
|
this.spoilerText.input.focus();
|
||||||
} else if (prevProps.spoiler) {
|
} else if (prevProps.spoiler) {
|
||||||
this.textareaRef.current.focus();
|
this.textareaRef.current.focus();
|
||||||
|
|||||||
@ -4,12 +4,10 @@ import { addPoll, removePoll } from '../../../actions/compose';
|
|||||||
import PollButton from '../components/poll_button';
|
import PollButton from '../components/poll_button';
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = state => {
|
||||||
const readyAttachmentsSize = state.compose.get('media_attachments').size ?? 0;
|
|
||||||
const hasAttachments = readyAttachmentsSize > 0 || !!state.compose.get('is_uploading');
|
|
||||||
const hasQuote = !!state.compose.get('quoted_status_id');
|
const hasQuote = !!state.compose.get('quoted_status_id');
|
||||||
|
|
||||||
return ({
|
return ({
|
||||||
disabled: hasAttachments || hasQuote,
|
disabled: hasQuote,
|
||||||
active: state.getIn(['compose', 'poll']) !== null,
|
active: state.getIn(['compose', 'poll']) !== null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { openModal } from '../../../actions/modal';
|
|||||||
import UploadButton from '../components/upload_button';
|
import UploadButton from '../components/upload_button';
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = state => {
|
||||||
const isPoll = state.getIn(['compose', 'poll']) !== null;
|
|
||||||
const isUploading = state.getIn(['compose', 'is_uploading']);
|
const isUploading = state.getIn(['compose', 'is_uploading']);
|
||||||
const readyAttachmentsSize = state.getIn(['compose', 'media_attachments']).size ?? 0;
|
const readyAttachmentsSize = state.getIn(['compose', 'media_attachments']).size ?? 0;
|
||||||
const pendingAttachmentsSize = state.getIn(['compose', 'pending_media_attachments']).size ?? 0;
|
const pendingAttachmentsSize = state.getIn(['compose', 'pending_media_attachments']).size ?? 0;
|
||||||
@ -15,7 +14,7 @@ const mapStateToProps = state => {
|
|||||||
const hasQuote = !!state.compose.get('quoted_status_id');
|
const hasQuote = !!state.compose.get('quoted_status_id');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disabled: isPoll || isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
|
disabled: isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
|
||||||
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Helmet } from '@unhead/react/helmet';
|
import { Helmet } from '@unhead/react/helmet';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { fetchServer } from 'flavours/glitch/actions/server';
|
import { fetchServer } from 'flavours/glitch/actions/server';
|
||||||
import { ServerHeroImage } from 'flavours/glitch/components/server_hero_image';
|
import { ServerHeroImage } from 'flavours/glitch/components/server_hero_image';
|
||||||
import { TabLink, TabList } from 'flavours/glitch/components/tab_list';
|
import { TabLink, TabList } from 'flavours/glitch/components/tab_list';
|
||||||
@ -40,7 +41,9 @@ export const CustomHomepage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={classes.topSection}>
|
<div className={classes.topSection}>
|
||||||
<h1>{server.item?.domain}</h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
{server.item?.domain}
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p>{server.item?.description}</p>
|
<p>{server.item?.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,10 @@ function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('emoji database', () => {
|
describe('emoji database', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testGet(); // Loads the database schema.
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
testClear();
|
testClear();
|
||||||
indexedDB = new IDBFactory();
|
indexedDB = new IDBFactory();
|
||||||
|
|||||||
@ -143,7 +143,28 @@ export async function search({
|
|||||||
return intersection;
|
return intersection;
|
||||||
})
|
})
|
||||||
.values(),
|
.values(),
|
||||||
).toSorted((a, b) => a.score - b.score);
|
);
|
||||||
|
|
||||||
|
// If there are no results, try a cursor-based custom emoji search instead.
|
||||||
|
if (results.length === 0) {
|
||||||
|
const trx = db.transaction('custom', 'readonly');
|
||||||
|
const foundEmojis = new Set<string>();
|
||||||
|
for await (const cursor of trx.store) {
|
||||||
|
const emoji = cursor.value;
|
||||||
|
const score = getScoreForEmoji(emoji, query, false);
|
||||||
|
if (score === null || foundEmojis.has(emoji.shortcode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ ...emoji, score });
|
||||||
|
foundEmojis.add(emoji.shortcode);
|
||||||
|
}
|
||||||
|
log('cursor search found %d results for "%s"', foundEmojis.size, query);
|
||||||
|
await trx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score, descending.
|
||||||
|
results.sort((a, b) => a.score - b.score);
|
||||||
|
|
||||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||||
log(
|
log(
|
||||||
@ -159,14 +180,19 @@ export async function search({
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScoreForEmoji(emoji: AnyEmojiData, query: string) {
|
function getScoreForEmoji(
|
||||||
|
emoji: AnyEmojiData,
|
||||||
|
query: string,
|
||||||
|
checkTokens = true,
|
||||||
|
) {
|
||||||
const id = 'shortcode' in emoji ? emoji.shortcode : emoji.label;
|
const id = 'shortcode' in emoji ? emoji.shortcode : emoji.label;
|
||||||
if (id === query) {
|
if (id === query) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let index = 1;
|
let index = 1;
|
||||||
for (const token of [id, ...emoji.tokens]) {
|
const searchTokens = checkTokens ? [id, ...emoji.tokens] : [id];
|
||||||
|
for (const token of searchTokens) {
|
||||||
const tokenIndex = token.indexOf(query);
|
const tokenIndex = token.indexOf(query);
|
||||||
if (tokenIndex !== -1) {
|
if (tokenIndex !== -1) {
|
||||||
return index + tokenIndex / token.length;
|
return index + tokenIndex / token.length;
|
||||||
@ -246,6 +272,13 @@ export async function clearCache(key: CacheKey) {
|
|||||||
log('Cleared cache for %s', key);
|
log('Cleared cache for %s', key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetDatabase() {
|
||||||
|
const db = await loadDB();
|
||||||
|
const storeNames = [...db.objectStoreNames];
|
||||||
|
await Promise.all(storeNames.map((storeName) => db.clear(storeName)));
|
||||||
|
log(storeNames, 'Reset emoji database stores:');
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadEmojiByHexcode(
|
export async function loadEmojiByHexcode(
|
||||||
hexcode: string,
|
hexcode: string,
|
||||||
localeString: string,
|
localeString: string,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
StoreNames,
|
StoreNames,
|
||||||
} from 'idb';
|
} from 'idb';
|
||||||
|
|
||||||
|
import { resetDatabase } from './database';
|
||||||
import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types';
|
import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types';
|
||||||
import { emojiLogger } from './utils';
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
@ -57,7 +58,7 @@ type Transaction<Mode extends IDBTransactionMode = 'versionchange'> =
|
|||||||
|
|
||||||
export type Database = IDBPDatabase<EmojiDB>;
|
export type Database = IDBPDatabase<EmojiDB>;
|
||||||
|
|
||||||
const SCHEMA_VERSION = 3;
|
const SCHEMA_VERSION = 4;
|
||||||
|
|
||||||
export async function openEmojiDB() {
|
export async function openEmojiDB() {
|
||||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||||
@ -98,6 +99,8 @@ export async function openEmojiDB() {
|
|||||||
});
|
});
|
||||||
deleteOldIndexes(shortcodeTable, ['hexcode']);
|
deleteOldIndexes(shortcodeTable, ['hexcode']);
|
||||||
|
|
||||||
|
void resetDatabase();
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'Upgraded emoji database from version %d to %d',
|
'Upgraded emoji database from version %d to %d',
|
||||||
oldVersion,
|
oldVersion,
|
||||||
|
|||||||
@ -57,6 +57,8 @@ export const Emoji: FC<EmojiProps> = ({
|
|||||||
const { mode } = useEmojiAppState();
|
const { mode } = useEmojiAppState();
|
||||||
return (
|
return (
|
||||||
<EmojiRaw
|
<EmojiRaw
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- In React props are frozen, but the library we use is old so doesn't respect that.
|
||||||
|
{...(EmojiRaw.defaultProps ?? {})}
|
||||||
data={EmojiData}
|
data={EmojiData}
|
||||||
set={set}
|
set={set}
|
||||||
sheetSize={sheetSize}
|
sheetSize={sheetSize}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { initialState } from '@/flavours/glitch/initial_state';
|
import { initialState } from '@/flavours/glitch/initial_state';
|
||||||
|
|
||||||
import type { EMOJI_DB_NAME_SHORTCODES } from './constants';
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
import { reloadCustomEmojis } from './picker';
|
import { reloadCustomEmojis } from './picker';
|
||||||
import type { LocaleOrCustom } from './types';
|
import type { EmojiWorkerMessage } from './types';
|
||||||
import { emojiLogger } from './utils';
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||||
@ -11,9 +10,10 @@ const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
|||||||
let worker: Worker | null = null;
|
let worker: Worker | null = null;
|
||||||
|
|
||||||
const log = emojiLogger('index');
|
const log = emojiLogger('index');
|
||||||
|
const workerLog = emojiLogger('worker');
|
||||||
|
|
||||||
// This is too short, but better to fallback quickly than wait.
|
// This is too short, but better to fallback quickly than wait.
|
||||||
const WORKER_TIMEOUT = 1_000;
|
const WORKER_TIMEOUT = 2_000;
|
||||||
|
|
||||||
export async function initializeEmoji() {
|
export async function initializeEmoji() {
|
||||||
log('initializing emojis');
|
log('initializing emojis');
|
||||||
@ -43,43 +43,44 @@ export async function initializeEmoji() {
|
|||||||
const { data: message } = event;
|
const { data: message } = event;
|
||||||
|
|
||||||
worker ??= tempWorker;
|
worker ??= tempWorker;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (message === 'ready') {
|
if (message !== 'ready') {
|
||||||
log('worker ready, loading data');
|
workerLog(message);
|
||||||
clearTimeout(timeoutId);
|
return;
|
||||||
messageWorker('shortcodes');
|
|
||||||
void loadCustomEmoji();
|
|
||||||
void loadEmojiLocale(userLocale);
|
|
||||||
} else {
|
|
||||||
log('got worker message: %s', message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debugValue = localStorage.getItem('debug');
|
||||||
|
if (debugValue) {
|
||||||
|
messageWorker({ type: 'debug', debugValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
workerLog('loading data');
|
||||||
|
messageWorker(userLocale);
|
||||||
|
messageWorker('custom');
|
||||||
|
messageWorker('shortcodes');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fallbackLoad() {
|
async function fallbackLoad() {
|
||||||
log('falling back to main thread for loading');
|
log('falling back to main thread for loading');
|
||||||
|
|
||||||
await loadCustomEmoji();
|
const { importCustomEmojiData, importLegacyShortcodes, importEmojiData } =
|
||||||
const { importLegacyShortcodes } = await import('./loader');
|
await import('./loader');
|
||||||
|
|
||||||
|
const customEmojis = await importCustomEmojiData();
|
||||||
|
if (customEmojis && customEmojis.length > 0) {
|
||||||
|
log('loaded %d custom emojis', customEmojis.length);
|
||||||
|
await reloadCustomEmojis();
|
||||||
|
}
|
||||||
const shortcodes = await importLegacyShortcodes();
|
const shortcodes = await importLegacyShortcodes();
|
||||||
if (shortcodes?.length) {
|
if (shortcodes?.length) {
|
||||||
log('loaded %d legacy shortcodes', shortcodes.length);
|
log('loaded %d legacy shortcodes', shortcodes.length);
|
||||||
}
|
}
|
||||||
await loadEmojiLocale(userLocale);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEmojiLocale(localeString: string) {
|
const emojis = await importEmojiData(userLocale);
|
||||||
const locale = toSupportedLocale(localeString);
|
if (emojis) {
|
||||||
const { importEmojiData } = await import('./loader');
|
log('loaded %d emojis to locale %s', emojis.length, userLocale);
|
||||||
|
|
||||||
if (worker) {
|
|
||||||
log('asking worker to load locale %s', locale);
|
|
||||||
messageWorker(locale);
|
|
||||||
} else {
|
|
||||||
const emojis = await importEmojiData(locale);
|
|
||||||
if (emojis) {
|
|
||||||
log('loaded %d emojis to locale %s', emojis.length, locale);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,11 +97,16 @@ export async function loadCustomEmoji() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageWorker(
|
function messageWorker(data: EmojiWorkerMessage | string) {
|
||||||
locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES,
|
|
||||||
) {
|
|
||||||
if (!worker) {
|
if (!worker) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
worker.postMessage({ locale });
|
if (typeof data === 'string') {
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'load',
|
||||||
|
storeName: data,
|
||||||
|
} satisfies EmojiWorkerMessage);
|
||||||
|
} else {
|
||||||
|
worker.postMessage(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { basename, resolve } from 'path';
|
|||||||
import { flattenEmojiData } from 'emojibase';
|
import { flattenEmojiData } from 'emojibase';
|
||||||
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
||||||
|
|
||||||
import { unicodeToTwemojiHex } from './normalize';
|
import { extractTokens, unicodeToTwemojiHex } from './normalize';
|
||||||
|
|
||||||
const emojiSVGFiles = await readdir(
|
const emojiSVGFiles = await readdir(
|
||||||
// This assumes tests are run from project root
|
// This assumes tests are run from project root
|
||||||
@ -33,3 +33,32 @@ describe('unicodeToTwemojiHex', () => {
|
|||||||
expect(svgFileNamesWithoutBorder).toContain(result);
|
expect(svgFileNamesWithoutBorder).toContain(result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractTokens', () => {
|
||||||
|
test('returns an empty array for blank input', () => {
|
||||||
|
expect(extractTokens(' ', null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check token word breaking with Intl.Segmenter', () => {
|
||||||
|
const segmenter = new Intl.Segmenter('en', { granularity: 'word' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
extractTokens('thumbs_up smiling-face camelCase', segmenter),
|
||||||
|
).toEqual(['thumbs', 'up', 'smiling', 'face', 'camel', 'case']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check token word breaking with regex', () => {
|
||||||
|
expect(extractTokens('Smile_face joy-test A ok 7 z', null)).toEqual([
|
||||||
|
'smile',
|
||||||
|
'face',
|
||||||
|
'joy',
|
||||||
|
'test',
|
||||||
|
'ok',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensure +1 and -1 are preserved', () => {
|
||||||
|
expect(extractTokens('+1', null)).toEqual(['+1']);
|
||||||
|
expect(extractTokens('-1', null)).toEqual(['-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -214,6 +214,11 @@ export function extractTokens(
|
|||||||
}
|
}
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
|
|
||||||
|
// Handle the edge case of thumbs up and down emoticons.
|
||||||
|
if (input === '+1' || input === '-1') {
|
||||||
|
return [input];
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer to use Intl.Segmenter if available for better locale support.
|
// Prefer to use Intl.Segmenter if available for better locale support.
|
||||||
if (segmenter) {
|
if (segmenter) {
|
||||||
for (const { isWordLike, segment } of segmenter.segment(
|
for (const { isWordLike, segment } of segmenter.segment(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import type { CategoryName, CustomEmoji } from 'emoji-mart';
|
import type { CategoryName, CustomEmoji } from 'emoji-mart';
|
||||||
|
|
||||||
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
||||||
|
import { createLimitedCache } from '@/flavours/glitch/utils/cache';
|
||||||
|
|
||||||
import { emojiLogger } from './utils';
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
@ -21,6 +22,8 @@ let customCategories = [
|
|||||||
'flags',
|
'flags',
|
||||||
] as CategoryName[];
|
] as CategoryName[];
|
||||||
|
|
||||||
|
const searchCache = createLimitedCache<LegacyEmoji[]>({ maxSize: 10, log });
|
||||||
|
|
||||||
export async function fetchCustomEmojiData() {
|
export async function fetchCustomEmojiData() {
|
||||||
if (customEmojis !== null) {
|
if (customEmojis !== null) {
|
||||||
return customEmojis;
|
return customEmojis;
|
||||||
@ -89,6 +92,7 @@ export async function reloadCustomEmojis() {
|
|||||||
await import('@/flavours/glitch/hooks/useCustomEmojis');
|
await import('@/flavours/glitch/hooks/useCustomEmojis');
|
||||||
|
|
||||||
await Promise.all([fetchCustomEmojiData(), loadEmojisIntoCache()]);
|
await Promise.all([fetchCustomEmojiData(), loadEmojisIntoCache()]);
|
||||||
|
searchCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replicates the old legacy search function.
|
// Replicates the old legacy search function.
|
||||||
@ -102,16 +106,25 @@ export async function emojiMartSearch(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${query}|${locale}|${limit}`;
|
||||||
|
const cachedResult = searchCache.get(cacheKey);
|
||||||
|
if (cachedResult) {
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
|
||||||
const { search } = await import('./database');
|
const { search } = await import('./database');
|
||||||
const results = await search({ query, locale, limit });
|
const results = await search({ query, locale, limit });
|
||||||
return results.map((emoji) =>
|
const legacyResults = results.map((emoji) =>
|
||||||
'shortcode' in emoji
|
'shortcode' in emoji
|
||||||
? { id: emoji.shortcode, custom: true }
|
? ({ id: emoji.shortcode, custom: true } as const)
|
||||||
: {
|
: {
|
||||||
id: emoji.label.replaceAll(' ', '_').toLowerCase(),
|
id: emoji.label.replaceAll(' ', '_').toLowerCase(),
|
||||||
native: emoji.unicode,
|
native: emoji.unicode,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
searchCache.set(cacheKey, legacyResults);
|
||||||
|
|
||||||
|
return legacyResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePickerEmojis() {
|
export function usePickerEmojis() {
|
||||||
|
|||||||
@ -80,3 +80,13 @@ export type ExtraCustomEmojiMap = Record<
|
|||||||
string,
|
string,
|
||||||
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
|
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type EmojiWorkerMessage =
|
||||||
|
| {
|
||||||
|
type: 'load';
|
||||||
|
storeName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'debug';
|
||||||
|
debugValue: string;
|
||||||
|
};
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import { emojiRegexPolyfill } from '@/flavours/glitch/polyfills';
|
|||||||
import { VARIATION_SELECTOR_CODE } from './constants';
|
import { VARIATION_SELECTOR_CODE } from './constants';
|
||||||
|
|
||||||
export function emojiLogger(segment: string) {
|
export function emojiLogger(segment: string) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return debug(`emojis:worker:${segment}`);
|
||||||
|
}
|
||||||
return debug(`emojis:${segment}`);
|
return debug(`emojis:${segment}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +1,36 @@
|
|||||||
|
import debug from 'debug';
|
||||||
|
|
||||||
import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
|
import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
|
||||||
import {
|
import {
|
||||||
importCustomEmojiData,
|
importCustomEmojiData,
|
||||||
importEmojiData,
|
importEmojiData,
|
||||||
importLegacyShortcodes,
|
importLegacyShortcodes,
|
||||||
} from './loader';
|
} from './loader';
|
||||||
|
import type { EmojiWorkerMessage } from './types';
|
||||||
|
|
||||||
addEventListener('message', handleMessage);
|
addEventListener('message', handleMessage);
|
||||||
self.postMessage('ready'); // After the worker is ready, notify the main thread
|
self.postMessage('ready'); // After the worker is ready, notify the main thread
|
||||||
|
|
||||||
function handleMessage(event: MessageEvent<{ locale: string }>) {
|
function handleMessage(event: MessageEvent<EmojiWorkerMessage>) {
|
||||||
const {
|
const { data } = event;
|
||||||
data: { locale },
|
if (data.type === 'debug') {
|
||||||
} = event;
|
debug.enable(data.debugValue);
|
||||||
void loadData(locale);
|
} else {
|
||||||
|
void loadData(data.storeName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData(locale: string) {
|
async function loadData(storeName: string) {
|
||||||
let importCount: number | undefined;
|
let importCount: number | undefined;
|
||||||
if (locale === EMOJI_TYPE_CUSTOM) {
|
if (storeName === EMOJI_TYPE_CUSTOM) {
|
||||||
importCount = (await importCustomEmojiData())?.length;
|
importCount = (await importCustomEmojiData())?.length;
|
||||||
} else if (locale === EMOJI_DB_NAME_SHORTCODES) {
|
} else if (storeName === EMOJI_DB_NAME_SHORTCODES) {
|
||||||
importCount = (await importLegacyShortcodes())?.length;
|
importCount = (await importLegacyShortcodes())?.length;
|
||||||
} else {
|
} else {
|
||||||
importCount = (await importEmojiData(locale))?.length;
|
importCount = (await importEmojiData(storeName))?.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (importCount) {
|
if (importCount) {
|
||||||
self.postMessage(`loaded ${importCount} emojis into ${locale}`);
|
self.postMessage(`loaded ${importCount} emojis into ${storeName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { toServerSideType } from 'flavours/glitch/utils/filters';
|
import { toServerSideType } from 'flavours/glitch/utils/filters';
|
||||||
|
|
||||||
const mapStateToProps = (state, { filterId }) => ({
|
const mapStateToProps = (state, { filterId }) => ({
|
||||||
@ -71,7 +72,9 @@ class AddedToFilter extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' /></h3>
|
<NavigationFocusTarget as='h1' className='report-dialog-modal__title'>
|
||||||
|
<FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' />
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p className='report-dialog-modal__lead'>
|
<p className='report-dialog-modal__lead'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='filter_modal.added.short_explanation'
|
id='filter_modal.added.short_explanation'
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import fuzzysort from 'fuzzysort';
|
|||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { injectIntl } from '@/flavours/glitch/components/intl';
|
import { injectIntl } from '@/flavours/glitch/components/intl';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { toServerSideType } from 'flavours/glitch/utils/filters';
|
import { toServerSideType } from 'flavours/glitch/utils/filters';
|
||||||
import { loupeIcon, deleteIcon } from 'flavours/glitch/utils/icons';
|
import { loupeIcon, deleteIcon } from 'flavours/glitch/utils/icons';
|
||||||
|
|
||||||
@ -176,7 +177,9 @@ class SelectFilter extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3>
|
<NavigationFocusTarget as='h1' className='report-dialog-modal__title'>
|
||||||
|
<FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' />
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
|
<p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
|
||||||
|
|
||||||
<div className='emoji-mart-search'>
|
<div className='emoji-mart-search'>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { escapeRegExp } from 'lodash';
|
|||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
import { DisplayName } from '@/flavours/glitch/components/display_name';
|
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 { openModal, closeModal } from 'flavours/glitch/actions/modal';
|
||||||
import { apiRequest } from 'flavours/glitch/api';
|
import { apiRequest } from 'flavours/glitch/api';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
@ -474,12 +475,12 @@ const InteractionModal: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<div className='modal-root__modal interaction-modal'>
|
<div className='modal-root__modal interaction-modal'>
|
||||||
<div className='interaction-modal__lead'>
|
<div className='interaction-modal__lead'>
|
||||||
<h3>
|
<NavigationFocusTarget as='h1'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='interaction_modal.title'
|
id='interaction_modal.title'
|
||||||
defaultMessage='Sign in to continue'
|
defaultMessage='Sign in to continue'
|
||||||
/>
|
/>
|
||||||
</h3>
|
</NavigationFocusTarget>
|
||||||
<p>
|
<p>
|
||||||
{intent === 'follow' ? (
|
{intent === 'follow' ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import type { ApiListJSON } from 'flavours/glitch/api_types/lists';
|
|||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { getOrderedLists } from 'flavours/glitch/selectors/lists';
|
import { getOrderedLists } from 'flavours/glitch/selectors/lists';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
@ -182,13 +183,13 @@ const ListAdder: React.FC<{
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className='dialog-modal__header__title'>
|
<NavigationFocusTarget as='h1' className='dialog-modal__header__title'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='lists.add_to_lists'
|
id='lists.add_to_lists'
|
||||||
defaultMessage='Add {name} to lists'
|
defaultMessage='Add {name} to lists'
|
||||||
values={{ name: <strong>@{account?.acct}</strong> }}
|
values={{ name: <strong>@{account?.acct}</strong> }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</NavigationFocusTarget>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='dialog-modal__content'>
|
<div className='dialog-modal__content'>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type { List as ImmutableList, RecordOf } from 'immutable';
|
|||||||
|
|
||||||
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
|
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
|
||||||
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
||||||
|
import { FOCUS_TARGET } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
||||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
||||||
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
|
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
|
||||||
@ -67,7 +68,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||||||
const path = `/@${account.acct}/${statusId}`;
|
const path = `/@${account.acct}/${statusId}`;
|
||||||
|
|
||||||
if (button === 0 && !(ctrlKey || metaKey)) {
|
if (button === 0 && !(ctrlKey || metaKey)) {
|
||||||
history.push(path);
|
history.push(path, { focusTarget: FOCUS_TARGET.POST });
|
||||||
} else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) {
|
} else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) {
|
||||||
window.open(path, '_blank', 'noopener');
|
window.open(path, '_blank', 'noopener');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,15 @@ import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
|||||||
|
|
||||||
import { Helmet } from '@unhead/react/helmet';
|
import { Helmet } from '@unhead/react/helmet';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { apiGetPrivacyPolicy } from 'flavours/glitch/api/instance';
|
import { apiGetPrivacyPolicy } from 'flavours/glitch/api/instance';
|
||||||
import type { ApiPrivacyPolicyJSON } from 'flavours/glitch/api_types/instance';
|
import type { ApiPrivacyPolicyJSON } from 'flavours/glitch/api_types/instance';
|
||||||
import { Column } from 'flavours/glitch/components/column';
|
import { Column } from 'flavours/glitch/components/column';
|
||||||
import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
|
import { FormattedDateWrapper } from 'flavours/glitch/components/formatted_date';
|
||||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||||
|
|
||||||
|
import { getColumnSkipLinkId } from '../ui/components/skip_links';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },
|
title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },
|
||||||
});
|
});
|
||||||
@ -40,12 +43,12 @@ const PrivacyPolicy: React.FC<{
|
|||||||
>
|
>
|
||||||
<div className='scrollable privacy-policy'>
|
<div className='scrollable privacy-policy'>
|
||||||
<div className='column-title'>
|
<div className='column-title'>
|
||||||
<h3>
|
<NavigationFocusTarget as='h1' id={getColumnSkipLinkId(1)}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='privacy_policy.title'
|
id='privacy_policy.title'
|
||||||
defaultMessage='Privacy Policy'
|
defaultMessage='Privacy Policy'
|
||||||
/>
|
/>
|
||||||
</h3>
|
</NavigationFocusTarget>
|
||||||
<p>
|
<p>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='privacy_policy.last_updated'
|
id='privacy_policy.last_updated'
|
||||||
|
|||||||
@ -3,11 +3,10 @@ import { PureComponent } from 'react';
|
|||||||
|
|
||||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { injectIntl } from '@/flavours/glitch/components/intl';
|
import { injectIntl } from '@/flavours/glitch/components/intl';
|
||||||
|
|
||||||
import Option from './components/option';
|
import Option from './components/option';
|
||||||
@ -78,7 +77,9 @@ class Category extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className='report-dialog-modal__title'><FormattedMessage id='report.category.title' defaultMessage="Tell us what's going on with this {type}" values={{ type: intl.formatMessage(messages[startedFrom]) }} /></h3>
|
<NavigationFocusTarget as='h1' className='report-dialog-modal__title'>
|
||||||
|
<FormattedMessage id='report.category.title' defaultMessage="Tell us what's going on with this {type}" values={{ type: intl.formatMessage(messages[startedFrom]) }} />
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p className='report-dialog-modal__lead'><FormattedMessage id='report.category.subtitle' defaultMessage='Choose the best match' /></p>
|
<p className='report-dialog-modal__lead'><FormattedMessage id='report.category.subtitle' defaultMessage='Choose the best match' /></p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { OrderedSet } from 'immutable';
|
|||||||
import { shallowEqual } from 'react-redux';
|
import { shallowEqual } from 'react-redux';
|
||||||
|
|
||||||
import { Toggle } from '@/flavours/glitch/components/form_fields/toggle_field';
|
import { Toggle } from '@/flavours/glitch/components/form_fields/toggle_field';
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { fetchAccount } from 'flavours/glitch/actions/accounts';
|
import { fetchAccount } from 'flavours/glitch/actions/accounts';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import type { Status } from 'flavours/glitch/models/status';
|
import type { Status } from 'flavours/glitch/models/status';
|
||||||
@ -148,14 +149,18 @@ const Comment: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className='report-dialog-modal__title' id={titleId}>
|
<NavigationFocusTarget
|
||||||
|
as='h1'
|
||||||
|
id={titleId}
|
||||||
|
className='report-dialog-modal__title'
|
||||||
|
>
|
||||||
{modalTitle ?? (
|
{modalTitle ?? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='report.comment.title'
|
id='report.comment.title'
|
||||||
defaultMessage='Is there anything else you think we should know?'
|
defaultMessage='Is there anything else you think we should know?'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</NavigationFocusTarget>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className='report-dialog-modal__textarea'
|
className='report-dialog-modal__textarea'
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
|
|
||||||
import Option from './components/option';
|
import Option from './components/option';
|
||||||
|
|
||||||
@ -40,7 +41,9 @@ class Rules extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className='report-dialog-modal__title'><FormattedMessage id='report.rules.title' defaultMessage='Which rules are being violated?' /></h3>
|
<NavigationFocusTarget as='h1' className='report-dialog-modal__title'>
|
||||||
|
<FormattedMessage id='report.rules.title' defaultMessage='Which rules are being violated?' />
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p className='report-dialog-modal__lead'><FormattedMessage id='report.rules.subtitle' defaultMessage='Select all that apply' /></p>
|
<p className='report-dialog-modal__lead'><FormattedMessage id='report.rules.subtitle' defaultMessage='Select all that apply' /></p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { connect } from 'react-redux';
|
|||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
import StatusCheckBox from 'flavours/glitch/features/report/containers/status_check_box_container';
|
import StatusCheckBox from 'flavours/glitch/features/report/containers/status_check_box_container';
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
|
|
||||||
const mapStateToProps = (state, { accountId }) => ({
|
const mapStateToProps = (state, { accountId }) => ({
|
||||||
availableStatusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])),
|
availableStatusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])),
|
||||||
@ -37,7 +38,9 @@ class Statuses extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className='report-dialog-modal__title'><FormattedMessage id='report.statuses.title' defaultMessage='Are there any posts that back up this report?' /></h3>
|
<NavigationFocusTarget as='h1' className='report-dialog-modal__title'>
|
||||||
|
<FormattedMessage id='report.statuses.title' defaultMessage='Are there any posts that back up this report?' />
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p className='report-dialog-modal__lead'><FormattedMessage id='report.statuses.subtitle' defaultMessage='Select all that apply' /></p>
|
<p className='report-dialog-modal__lead'><FormattedMessage id='report.statuses.subtitle' defaultMessage='Select all that apply' /></p>
|
||||||
|
|
||||||
<div className='report-dialog-modal__statuses'>
|
<div className='report-dialog-modal__statuses'>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
blockAccount,
|
blockAccount,
|
||||||
} from 'flavours/glitch/actions/accounts';
|
} from 'flavours/glitch/actions/accounts';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
|
|
||||||
const mapStateToProps = () => ({});
|
const mapStateToProps = () => ({});
|
||||||
|
|
||||||
@ -52,7 +53,9 @@ class Thanks extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className='report-dialog-modal__title'>{submitted ? <FormattedMessage id='report.thanks.title_actionable' defaultMessage="Thanks for reporting, we'll look into this." /> : <FormattedMessage id='report.thanks.title' defaultMessage="Don't want to see this?" />}</h3>
|
<NavigationFocusTarget as='h1' className='report-dialog-modal__title'>
|
||||||
|
{submitted ? <FormattedMessage id='report.thanks.title_actionable' defaultMessage="Thanks for reporting, we'll look into this." /> : <FormattedMessage id='report.thanks.title' defaultMessage="Don't want to see this?" />}
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p className='report-dialog-modal__lead'>{submitted ? <FormattedMessage id='report.thanks.take_action_actionable' defaultMessage='While we review this, you can take action against @{name}:' values={{ name: account.get('username') }} /> : <FormattedMessage id='report.thanks.take_action' defaultMessage='Here are your options for controlling what you see on Mastodon:' />}</p>
|
<p className='report-dialog-modal__lead'>{submitted ? <FormattedMessage id='report.thanks.take_action_actionable' defaultMessage='While we review this, you can take action against @{name}:' values={{ name: account.get('username') }} /> : <FormattedMessage id='report.thanks.take_action' defaultMessage='Here are your options for controlling what you see on Mastodon:' />}</p>
|
||||||
|
|
||||||
{account.getIn(['relationship', 'following']) && (
|
{account.getIn(['relationship', 'following']) && (
|
||||||
|
|||||||
@ -66,6 +66,7 @@ import ActionBar from './components/action_bar';
|
|||||||
import { DetailedStatus } from './components/detailed_status';
|
import { DetailedStatus } from './components/detailed_status';
|
||||||
import { RefreshController } from './components/refresh_controller';
|
import { RefreshController } from './components/refresh_controller';
|
||||||
import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed';
|
import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed';
|
||||||
|
import { FOCUS_TARGET, NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
||||||
@ -630,7 +631,13 @@ class Status extends ImmutablePureComponent {
|
|||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
<Hotkeys handlers={handlers}>
|
<Hotkeys handlers={handlers}>
|
||||||
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader({intl, status, expanded: isExpanded})} ref={this.setStatusRef}>
|
<NavigationFocusTarget
|
||||||
|
as='div'
|
||||||
|
focusTargetName={FOCUS_TARGET.POST}
|
||||||
|
className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={textForScreenReader({intl, status, expanded: isExpanded})} ref={this.setStatusRef}
|
||||||
|
>
|
||||||
<DetailedStatus
|
<DetailedStatus
|
||||||
key={`details-${status.get('id')}`}
|
key={`details-${status.get('id')}`}
|
||||||
status={status}
|
status={status}
|
||||||
@ -669,7 +676,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
onPin={this.handlePin}
|
onPin={this.handlePin}
|
||||||
onEmbed={this.handleEmbed}
|
onEmbed={this.handleEmbed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</NavigationFocusTarget>
|
||||||
</Hotkeys>
|
</Hotkeys>
|
||||||
|
|
||||||
{descendants}
|
{descendants}
|
||||||
|
|||||||
@ -11,11 +11,14 @@ import { Link, useParams } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Helmet } from '@unhead/react/helmet';
|
import { Helmet } from '@unhead/react/helmet';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { apiGetTermsOfService } from 'flavours/glitch/api/instance';
|
import { apiGetTermsOfService } from 'flavours/glitch/api/instance';
|
||||||
import type { ApiTermsOfServiceJSON } from 'flavours/glitch/api_types/instance';
|
import type { ApiTermsOfServiceJSON } from 'flavours/glitch/api_types/instance';
|
||||||
import { Column } from 'flavours/glitch/components/column';
|
import { Column } from 'flavours/glitch/components/column';
|
||||||
import { BundleColumnError } from 'flavours/glitch/features/ui/components/bundle_column_error';
|
import { BundleColumnError } from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||||
|
|
||||||
|
import { getColumnSkipLinkId } from '../ui/components/skip_links';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'terms_of_service.title', defaultMessage: 'Terms of Service' },
|
title: { id: 'terms_of_service.title', defaultMessage: 'Terms of Service' },
|
||||||
});
|
});
|
||||||
@ -55,12 +58,12 @@ const TermsOfService: React.FC<{
|
|||||||
>
|
>
|
||||||
<div className='scrollable privacy-policy'>
|
<div className='scrollable privacy-policy'>
|
||||||
<div className='column-title'>
|
<div className='column-title'>
|
||||||
<h3>
|
<NavigationFocusTarget as='h1' id={getColumnSkipLinkId(1)}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='terms_of_service.title'
|
id='terms_of_service.title'
|
||||||
defaultMessage='Terms of Service'
|
defaultMessage='Terms of Service'
|
||||||
/>
|
/>
|
||||||
</h3>
|
</NavigationFocusTarget>
|
||||||
<p className='prose'>
|
<p className='prose'>
|
||||||
{response?.effective ? (
|
{response?.effective ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
|||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||||
import { blockAccount } from 'flavours/glitch/actions/accounts';
|
import { blockAccount } from 'flavours/glitch/actions/accounts';
|
||||||
import { closeModal } from 'flavours/glitch/actions/modal';
|
import { closeModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
@ -46,7 +47,9 @@ export const BlockModal = ({ accountId, acct }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1><FormattedMessage id='block_modal.title' defaultMessage='Block user?' /></h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
<FormattedMessage id='block_modal.title' defaultMessage='Block user?' />
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p>@{acct}</p>
|
<p>@{acct}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import classNames from 'classnames';
|
|||||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown';
|
import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown';
|
||||||
import { EmbeddedStatus } from 'flavours/glitch/features/notifications_v2/components/embedded_status';
|
import { EmbeddedStatus } from 'flavours/glitch/features/notifications_v2/components/embedded_status';
|
||||||
import type { Status, StatusVisibility } from 'flavours/glitch/models/status';
|
import type { Status, StatusVisibility } from 'flavours/glitch/models/status';
|
||||||
@ -69,7 +70,7 @@ export const BoostModal: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
{status.get('reblogged') ? (
|
{status.get('reblogged') ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='boost_modal.undo_reblog'
|
id='boost_modal.undo_reblog'
|
||||||
@ -81,7 +82,7 @@ export const BoostModal: React.FC<{
|
|||||||
defaultMessage='Boost post?'
|
defaultMessage='Boost post?'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
<div>
|
<div>
|
||||||
{missingMediaDescription ? (
|
{missingMediaDescription ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage, defineMessages } from 'react-intl';
|
import { FormattedMessage, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import {
|
import {
|
||||||
ModalShell,
|
ModalShell,
|
||||||
@ -75,7 +76,13 @@ export const ConfirmationModal: React.FC<
|
|||||||
return (
|
return (
|
||||||
<ModalShell onSubmit={handleClick}>
|
<ModalShell onSubmit={handleClick}>
|
||||||
<ModalShellBody className={className}>
|
<ModalShellBody className={className}>
|
||||||
<h1 id={titleId}>{title}</h1>
|
{noFocusButton ? (
|
||||||
|
<NavigationFocusTarget as='h1' id={titleId}>
|
||||||
|
{title}
|
||||||
|
</NavigationFocusTarget>
|
||||||
|
) : (
|
||||||
|
<h1>{title}</h1>
|
||||||
|
)}
|
||||||
{message && <p>{message}</p>}
|
{message && <p>{message}</p>}
|
||||||
|
|
||||||
{extraContent ?? children}
|
{extraContent ?? children}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import classNames from 'classnames';
|
|||||||
|
|
||||||
import { Button } from '@/flavours/glitch/components/button';
|
import { Button } from '@/flavours/glitch/components/button';
|
||||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
|
||||||
export type { BaseConfirmationModalProps as DialogModalProps } from './confirmation_modals/confirmation_modal';
|
export type { BaseConfirmationModalProps as DialogModalProps } from './confirmation_modals/confirmation_modal';
|
||||||
@ -47,7 +48,9 @@ export const DialogModal: FC<DialogModalProps> = ({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h1 className='dialog-modal__header__title'>{title}</h1>
|
<NavigationFocusTarget as='h1' className='dialog-modal__header__title'>
|
||||||
|
{title}
|
||||||
|
</NavigationFocusTarget>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='dialog-modal__content'>
|
<div className='dialog-modal__content'>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { apiRequest } from 'flavours/glitch/api';
|
|||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
import { useAppDispatch } from 'flavours/glitch/store';
|
import { useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
@ -77,12 +78,12 @@ export const DomainBlockModal: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='domain_block_modal.title'
|
id='domain_block_modal.title'
|
||||||
defaultMessage='Block domain?'
|
defaultMessage='Block domain?'
|
||||||
/>
|
/>
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
<p>{domain}</p>
|
<p>{domain}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { showAlertForError } from 'flavours/glitch/actions/alerts';
|
|||||||
import api from 'flavours/glitch/api';
|
import api from 'flavours/glitch/api';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { CopyPasteText } from 'flavours/glitch/components/copy_paste_text';
|
import { CopyPasteText } from 'flavours/glitch/components/copy_paste_text';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { useAppDispatch } from 'flavours/glitch/store';
|
import { useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
interface OEmbedResponse {
|
interface OEmbedResponse {
|
||||||
@ -76,9 +77,9 @@ const EmbedModal: React.FC<{
|
|||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
<FormattedMessage id='report.close' defaultMessage='Done' />
|
<FormattedMessage id='report.close' defaultMessage='Done' />
|
||||||
</Button>
|
</Button>
|
||||||
<span className='dialog-modal__header__title'>
|
<NavigationFocusTarget as='h1' className='dialog-modal__header__title'>
|
||||||
<FormattedMessage id='status.embed' defaultMessage='Get embed code' />
|
<FormattedMessage id='status.embed' defaultMessage='Get embed code' />
|
||||||
</span>
|
</NavigationFocusTarget>
|
||||||
<Button secondary onClick={onClose}>
|
<Button secondary onClick={onClose}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='confirmation_modal.cancel'
|
id='confirmation_modal.cancel'
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { closeModal } from 'flavours/glitch/actions/modal';
|
|||||||
import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies';
|
import { updateNotificationsPolicy } from 'flavours/glitch/actions/notification_policies';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
|
|
||||||
export const IgnoreNotificationsModal = ({ filterType }) => {
|
export const IgnoreNotificationsModal = ({ filterType }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@ -57,7 +58,7 @@ export const IgnoreNotificationsModal = ({ filterType }) => {
|
|||||||
<div className='modal-root__modal safety-action-modal'>
|
<div className='modal-root__modal safety-action-modal'>
|
||||||
<div className='safety-action-modal__top'>
|
<div className='safety-action-modal__top'>
|
||||||
<div className='safety-action-modal__header'>
|
<div className='safety-action-modal__header'>
|
||||||
<h1>{title}</h1>
|
<NavigationFocusTarget as='h1'>{title}</NavigationFocusTarget>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className='safety-action-modal__bullet-points'>
|
<ul className='safety-action-modal__bullet-points'>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { closeModal } from 'flavours/glitch/actions/modal';
|
|||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { CheckBox } from 'flavours/glitch/components/check_box';
|
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@ -84,7 +85,9 @@ export const MuteModal = ({ accountId, acct }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1><FormattedMessage id='mute_modal.title' defaultMessage='Mute user?' /></h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
<FormattedMessage id='mute_modal.title' defaultMessage='Mute user?' />
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p>@{acct}</p>
|
<p>@{acct}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { fetchServer } from 'flavours/glitch/actions/server';
|
|||||||
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
|
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
|
||||||
import { useAccount } from 'flavours/glitch/hooks/useAccount';
|
import { useAccount } from 'flavours/glitch/hooks/useAccount';
|
||||||
import { useAppDispatch } from 'flavours/glitch/store';
|
import { useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
@ -23,12 +24,12 @@ const CollectionThanks: React.FC<{
|
|||||||
}> = ({ onClose }) => {
|
}> = ({ onClose }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className='report-dialog-modal__title'>
|
<NavigationFocusTarget as='h1' className='report-dialog-modal__title'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='report.thanks.title_actionable'
|
id='report.thanks.title_actionable'
|
||||||
defaultMessage="Thanks for reporting, we'll look into this."
|
defaultMessage="Thanks for reporting, we'll look into this."
|
||||||
/>
|
/>
|
||||||
</h3>
|
</NavigationFocusTarget>
|
||||||
|
|
||||||
<div className='flex-spacer' />
|
<div className='flex-spacer' />
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { Button } from '@/flavours/glitch/components/button';
|
|||||||
import { Dropdown } from '@/flavours/glitch/components/dropdown';
|
import { Dropdown } from '@/flavours/glitch/components/dropdown';
|
||||||
import type { SelectItem } from '@/flavours/glitch/components/dropdown_selector';
|
import type { SelectItem } from '@/flavours/glitch/components/dropdown_selector';
|
||||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||||
|
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
|
||||||
import { messages as privacyMessages } from '@/flavours/glitch/features/compose/components/privacy_dropdown';
|
import { messages as privacyMessages } from '@/flavours/glitch/features/compose/components/privacy_dropdown';
|
||||||
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
|
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
@ -217,14 +218,15 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
iconComponent={CloseIcon}
|
iconComponent={CloseIcon}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<FormattedMessage
|
<NavigationFocusTarget
|
||||||
id='visibility_modal.header'
|
as='h1'
|
||||||
defaultMessage='Visibility and interaction'
|
className='dialog-modal__header__title'
|
||||||
>
|
>
|
||||||
{(chunks) => (
|
<FormattedMessage
|
||||||
<span className='dialog-modal__header__title'>{chunks}</span>
|
id='visibility_modal.header'
|
||||||
)}
|
defaultMessage='Visibility and interaction'
|
||||||
</FormattedMessage>
|
/>
|
||||||
|
</NavigationFocusTarget>
|
||||||
</div>
|
</div>
|
||||||
<div className='dialog-modal__content'>
|
<div className='dialog-modal__content'>
|
||||||
<div className='dialog-modal__content__description'>
|
<div className='dialog-modal__content__description'>
|
||||||
|
|||||||
@ -195,7 +195,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
<ColumnsContextProvider multiColumn={!singleColumn}>
|
<ColumnsContextProvider multiColumn={!singleColumn}>
|
||||||
<ColumnsArea ref={this.setRef} singleColumn={singleColumn} domain={domain} minimalShell={!signedIn && landingPage === 'overview'}>
|
<ColumnsArea ref={this.setRef} singleColumn={singleColumn} domain={domain} minimalShell={!signedIn && landingPage === 'overview'}>
|
||||||
<WrappedSwitch>
|
<WrappedSwitch>
|
||||||
<Redirect from='/' to={{pathname: rootRedirect, state: this.props.location.state}} exact />
|
<Redirect from='/' to={{pathname: rootRedirect, state: {...this.props.location.state, focusTarget: false}}} exact />
|
||||||
|
|
||||||
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
|
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
|
||||||
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
|
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
|
||||||
|
|||||||
@ -237,6 +237,10 @@ function appendMedia(state, media, file) {
|
|||||||
|
|
||||||
if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
|
if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
|
||||||
map.set('sensitive', true);
|
map.set('sensitive', true);
|
||||||
|
|
||||||
|
if (state.get('default_sensitive')) {
|
||||||
|
map.set('spoiler', true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -483,7 +487,7 @@ export const composeReducer = (state = initialState, action) => {
|
|||||||
map.set('spoiler', !state.get('spoiler'));
|
map.set('spoiler', !state.get('spoiler'));
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
|
|
||||||
if (state.get('media_attachments').size >= 1 && !state.get('default_sensitive')) {
|
if (state.get('media_attachments').size >= 1) {
|
||||||
map.set('sensitive', !state.get('spoiler'));
|
map.set('sensitive', !state.get('spoiler'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -665,7 +669,7 @@ export const composeReducer = (state = initialState, action) => {
|
|||||||
map.set('sensitive', true);
|
map.set('sensitive', true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
map.set('spoiler', false);
|
map.set('spoiler', action.status.get('sensitive') && action.status.get('media_attachments').size > 0);
|
||||||
map.set('spoiler_text', '');
|
map.set('spoiler_text', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -704,7 +708,7 @@ export const composeReducer = (state = initialState, action) => {
|
|||||||
map.set('spoiler', true);
|
map.set('spoiler', true);
|
||||||
map.set('spoiler_text', action.spoiler_text);
|
map.set('spoiler_text', action.spoiler_text);
|
||||||
} else {
|
} else {
|
||||||
map.set('spoiler', false);
|
map.set('spoiler', action.status.get('sensitive') && action.status.get('media_attachments').size > 0);
|
||||||
map.set('spoiler_text', '');
|
map.set('spoiler_text', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,8 +17,10 @@ const Modal = ImmutableRecord<Modal>({
|
|||||||
modalProps: ImmutableRecord({})(),
|
modalProps: ImmutableRecord({})(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const IGNORE_FOCUS_ON_OPEN = 'on-open';
|
||||||
|
|
||||||
interface ModalState {
|
interface ModalState {
|
||||||
ignoreFocus: boolean;
|
ignoreFocus: boolean | typeof IGNORE_FOCUS_ON_OPEN;
|
||||||
stack: Stack<ImmutableRecord<Modal>>;
|
stack: Stack<ImmutableRecord<Modal>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,9 +55,10 @@ const pushModal = (
|
|||||||
modalType: ModalType,
|
modalType: ModalType,
|
||||||
modalProps: ModalProps,
|
modalProps: ModalProps,
|
||||||
previousModalProps?: ModalProps,
|
previousModalProps?: ModalProps,
|
||||||
|
ignoreFocusOnOpen = false,
|
||||||
): State => {
|
): State => {
|
||||||
return state.withMutations((record) => {
|
return state.withMutations((record) => {
|
||||||
record.set('ignoreFocus', false);
|
record.set('ignoreFocus', ignoreFocusOnOpen ? IGNORE_FOCUS_ON_OPEN : false);
|
||||||
record.update('stack', (stack) => {
|
record.update('stack', (stack) => {
|
||||||
let tmp = stack;
|
let tmp = stack;
|
||||||
|
|
||||||
@ -92,6 +95,7 @@ export const modalReducer: Reducer<State> = (state = initialState, action) => {
|
|||||||
action.payload.modalType,
|
action.payload.modalType,
|
||||||
action.payload.modalProps,
|
action.payload.modalProps,
|
||||||
action.payload.previousModalProps,
|
action.payload.previousModalProps,
|
||||||
|
action.payload.ignoreFocus,
|
||||||
);
|
);
|
||||||
else if (closeModal.match(action)) return popModal(state, action.payload);
|
else if (closeModal.match(action)) return popModal(state, action.payload);
|
||||||
// TODO: type those actions
|
// TODO: type those actions
|
||||||
|
|||||||
@ -3656,6 +3656,8 @@ a.account__display-name {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding-bottom: 32px;
|
padding-bottom: 32px;
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@ -4730,6 +4732,7 @@ a.status-card {
|
|||||||
&__title-wrapper {
|
&__title-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
@ -4745,9 +4748,7 @@ a.status-card {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
text-overflow: ellipsis;
|
min-width: 0;
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&--with-back-button {
|
&--with-back-button {
|
||||||
padding-inline-start: 0;
|
padding-inline-start: 0;
|
||||||
@ -4762,6 +4763,12 @@ a.status-card {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.column-header__back-button {
|
.column-header__back-button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: var(--color-text-brand);
|
color: var(--color-text-brand);
|
||||||
@ -9482,6 +9489,8 @@ noscript {
|
|||||||
padding-bottom: 32px;
|
padding-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
line-height: 33px;
|
line-height: 33px;
|
||||||
@ -9512,6 +9521,8 @@ noscript {
|
|||||||
&__lead {
|
&__lead {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
@ -9587,6 +9598,8 @@ noscript {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,11 +40,13 @@ describe('createCache', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('removes oldest item cached if it exceeds a set size', () => {
|
test('removes oldest item cached if it exceeds a set size', () => {
|
||||||
const cache = createLimitedCache({ maxSize: 1 });
|
const cache = createLimitedCache({ maxSize: 2 });
|
||||||
cache.set('test1', 1);
|
cache.set('test1', 1);
|
||||||
cache.set('test2', 2);
|
cache.set('test2', 2);
|
||||||
|
cache.set('test3', 3);
|
||||||
expect(cache.get('test1')).toBeUndefined();
|
expect(cache.get('test1')).toBeUndefined();
|
||||||
expect(cache.get('test2')).toBe(2);
|
expect(cache.get('test2')).toBe(2);
|
||||||
|
expect(cache.get('test3')).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('retrieving a value bumps up last access', () => {
|
test('retrieving a value bumps up last access', () => {
|
||||||
@ -63,13 +65,13 @@ describe('createCache', () => {
|
|||||||
const cache = createLimitedCache({ maxSize: 1, log });
|
const cache = createLimitedCache({ maxSize: 1, log });
|
||||||
cache.set('test1', 1);
|
cache.set('test1', 1);
|
||||||
expect(log).toHaveBeenLastCalledWith(
|
expect(log).toHaveBeenLastCalledWith(
|
||||||
'Added %s to cache, now size %d',
|
'Added %o to cache, now size %d',
|
||||||
'test1',
|
'test1',
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
cache.set('test2', 1);
|
cache.set('test2', 1);
|
||||||
expect(log).toHaveBeenLastCalledWith(
|
expect(log).toHaveBeenLastCalledWith(
|
||||||
'Added %s and deleted %s from cache, now size %d',
|
'Added %o and deleted %o from cache, now size %d',
|
||||||
'test2',
|
'test2',
|
||||||
'test1',
|
'test1',
|
||||||
1,
|
1,
|
||||||
|
|||||||
@ -43,13 +43,13 @@ export function createLimitedCache<CacheValue, CacheKey = string>({
|
|||||||
cacheMap.delete(lastKey);
|
cacheMap.delete(lastKey);
|
||||||
cacheKeys.delete(lastKey);
|
cacheKeys.delete(lastKey);
|
||||||
log(
|
log(
|
||||||
'Added %s and deleted %s from cache, now size %d',
|
'Added %o and deleted %o from cache, now size %d',
|
||||||
key,
|
key,
|
||||||
lastKey,
|
lastKey,
|
||||||
cacheMap.size,
|
cacheMap.size,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
log('Added %s to cache, now size %d', key, cacheMap.size);
|
log('Added %o to cache, now size %d', key, cacheMap.size);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clear: () => {
|
clear: () => {
|
||||||
|
|||||||
@ -93,7 +93,7 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
export const ensureComposeIsVisible = (getState) => {
|
export const ensureComposeIsVisible = (getState) => {
|
||||||
if (!getState().getIn(['compose', 'mounted'])) {
|
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,
|
message: statusId === null ? messages.published : messages.saved,
|
||||||
action: messages.open,
|
action: messages.open,
|
||||||
dismissAfter: 10000,
|
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) {
|
}).catch(function (error) {
|
||||||
dispatch(submitComposeFail(error));
|
dispatch(submitComposeFail(error));
|
||||||
@ -341,11 +344,6 @@ export function uploadCompose(files) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getState().getIn(['compose', 'poll'])) {
|
|
||||||
dispatch(showAlert({ message: messages.uploadErrorPoll }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(uploadComposeRequest());
|
dispatch(uploadComposeRequest());
|
||||||
|
|
||||||
for (const [i, file] of Array.from(files).entries()) {
|
for (const [i, file] of Array.from(files).entries()) {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// If there's a mismatch, re-import all custom emojis.
|
// 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 clearCache('custom');
|
||||||
await loadCustomEmoji();
|
await loadCustomEmoji();
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ interface OpenModalPayload {
|
|||||||
modalType: ModalType;
|
modalType: ModalType;
|
||||||
modalProps: ModalProps;
|
modalProps: ModalProps;
|
||||||
previousModalProps?: ModalProps;
|
previousModalProps?: ModalProps;
|
||||||
|
ignoreFocus?: boolean;
|
||||||
}
|
}
|
||||||
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { FollowsYouBadge } from '../badge';
|
|||||||
import { CopyButton } from '../copy_button';
|
import { CopyButton } from '../copy_button';
|
||||||
import { DisplayName } from '../display_name';
|
import { DisplayName } from '../display_name';
|
||||||
import { Icon } from '../icon';
|
import { Icon } from '../icon';
|
||||||
|
import { NavigationFocusTarget } from '../navigation_focus_target';
|
||||||
|
|
||||||
import { AccountBadges } from './badges';
|
import { AccountBadges } from './badges';
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
@ -56,9 +57,9 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
return (
|
return (
|
||||||
<div className={classes.nameWrapper}>
|
<div className={classes.nameWrapper}>
|
||||||
<div className={classes.name}>
|
<div className={classes.name}>
|
||||||
<h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
<DisplayName account={account} variant='simple' />
|
<DisplayName account={account} variant='simple' />
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
{relationship?.followed_by && <FollowsYouBadge />}
|
{relationship?.followed_by && <FollowsYouBadge />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-wrap: nowrap;
|
text-wrap: nowrap;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { useIdentity } from 'mastodon/identity_context';
|
|||||||
import { useColumnIndexContext } from '../features/ui/components/columns_area';
|
import { useColumnIndexContext } from '../features/ui/components/columns_area';
|
||||||
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
|
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from './navigation_focus_target';
|
||||||
import { useAppHistory } from './router';
|
import { useAppHistory } from './router';
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
@ -272,7 +273,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
{!backButton && hasIcon && (
|
{!backButton && hasIcon && (
|
||||||
<Icon id={icon} icon={iconComponent} className='column-header__icon' />
|
<Icon id={icon} icon={iconComponent} className='column-header__icon' />
|
||||||
)}
|
)}
|
||||||
{title}
|
<span className='column-header__text'>{title}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -285,7 +286,10 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
<div className={headingClassName}>
|
<div className={headingClassName}>
|
||||||
{backButton}
|
{backButton}
|
||||||
{hasTitle && (
|
{hasTitle && (
|
||||||
<h1 className='column-header__title-wrapper'>
|
<NavigationFocusTarget
|
||||||
|
as='h1'
|
||||||
|
className='column-header__title-wrapper'
|
||||||
|
>
|
||||||
{onClick ? (
|
{onClick ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleTitleClick}
|
onClick={handleTitleClick}
|
||||||
@ -304,7 +308,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
{titleContents}
|
{titleContents}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='column-header__buttons'>
|
<div className='column-header__buttons'>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { multiply } from 'color-blend';
|
|||||||
import { createBrowserHistory } from 'history';
|
import { createBrowserHistory } from 'history';
|
||||||
|
|
||||||
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
|
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
|
||||||
|
import { IGNORE_FOCUS_ON_OPEN } from '../reducers/modal';
|
||||||
|
|
||||||
class ModalRoot extends PureComponent {
|
class ModalRoot extends PureComponent {
|
||||||
|
|
||||||
@ -21,7 +22,10 @@ class ModalRoot extends PureComponent {
|
|||||||
b: PropTypes.number,
|
b: PropTypes.number,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
ignoreFocus: PropTypes.bool,
|
ignoreFocus: PropTypes.oneOfType([
|
||||||
|
PropTypes.bool,
|
||||||
|
PropTypes.string, // 'on-open', see IGNORE_FOCUS_ON_OPEN
|
||||||
|
]),
|
||||||
...WithOptionalRouterPropTypes,
|
...WithOptionalRouterPropTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -118,7 +122,11 @@ class ModalRoot extends PureComponent {
|
|||||||
_ensureHistoryBuffer () {
|
_ensureHistoryBuffer () {
|
||||||
const { pathname, search, hash, state } = this.history.location;
|
const { pathname, search, hash, state } = this.history.location;
|
||||||
if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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<React.MutableRefObject<FocusTarget> | 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<FocusTarget>(false);
|
||||||
|
const previousLocationRef = useRef<
|
||||||
|
| (Pick<MastodonLocation, 'pathname' | 'search'> & {
|
||||||
|
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 (
|
||||||
|
<FocusTargetContext.Provider value={focusTargetRef}>
|
||||||
|
{children}
|
||||||
|
</FocusTargetContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Component ref={focusOnNavigation} tabIndex={-1} {...otherProps}>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history';
|
|||||||
import { layoutFromWindow } from 'mastodon/is_mobile';
|
import { layoutFromWindow } from 'mastodon/is_mobile';
|
||||||
import { isDevelopment } from 'mastodon/utils/environment';
|
import { isDevelopment } from 'mastodon/utils/environment';
|
||||||
|
|
||||||
|
import type { FocusTarget } from './navigation_focus_target';
|
||||||
|
|
||||||
interface MastodonLocationState {
|
interface MastodonLocationState {
|
||||||
fromMastodon?: boolean;
|
fromMastodon?: boolean;
|
||||||
mastodonModalKey?: string;
|
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
|
// Prevent the rightmost column in advanced UI from scrolling
|
||||||
// into view on location changes
|
// into view on location changes
|
||||||
preventMultiColumnAutoScroll?: string;
|
preventMultiColumnAutoScroll?: string;
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import StatusContent from './status_content';
|
|||||||
import { StatusThreadLabel } from './status_thread_label';
|
import { StatusThreadLabel } from './status_thread_label';
|
||||||
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
||||||
import { compareUrls } from '../utils/compare_urls';
|
import { compareUrls } from '../utils/compare_urls';
|
||||||
|
import { FOCUS_TARGET } from './navigation_focus_target';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
@ -311,9 +312,9 @@ class Status extends ImmutablePureComponent {
|
|||||||
window.open(path, '_blank', 'noopener');
|
window.open(path, '_blank', 'noopener');
|
||||||
} else {
|
} else {
|
||||||
if (history.location.pathname.replace('/deck/', '/') === path) {
|
if (history.location.pathname.replace('/deck/', '/') === path) {
|
||||||
history.replace(path);
|
history.replace(path, {focusTarget: FOCUS_TARGET.POST});
|
||||||
} else {
|
} else {
|
||||||
history.push(path);
|
history.push(path, {focusTarget: FOCUS_TARGET.POST});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux';
|
|||||||
import { hydrateStore } from 'mastodon/actions/store';
|
import { hydrateStore } from 'mastodon/actions/store';
|
||||||
import { connectUserStream } from 'mastodon/actions/streaming';
|
import { connectUserStream } from 'mastodon/actions/streaming';
|
||||||
import ErrorBoundary from 'mastodon/components/error_boundary';
|
import ErrorBoundary from 'mastodon/components/error_boundary';
|
||||||
|
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { Router } from 'mastodon/components/router';
|
import { Router } from 'mastodon/components/router';
|
||||||
import UI from 'mastodon/features/ui';
|
import UI from 'mastodon/features/ui';
|
||||||
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context';
|
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context';
|
||||||
@ -49,7 +50,9 @@ export default class Mastodon extends PureComponent {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollContext>
|
<ScrollContext>
|
||||||
<Route path='/' component={UI} />
|
<FocusTargetProvider>
|
||||||
|
<Route path='/' component={UI} />
|
||||||
|
</FocusTargetProvider>
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
<BodyScrollLock />
|
<BodyScrollLock />
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@ -8,10 +8,13 @@ import { Helmet } from '@unhead/react/helmet';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { domain } from 'mastodon/initial_state';
|
||||||
|
|
||||||
import { injectIntl } from '@/mastodon/components/intl';
|
import { injectIntl } from '@/mastodon/components/intl';
|
||||||
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
|
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
|
||||||
import { Account } from 'mastodon/components/account';
|
import { Account } from 'mastodon/components/account';
|
||||||
import Column from 'mastodon/components/column';
|
import Column from 'mastodon/components/column';
|
||||||
|
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
|
||||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||||
import { Skeleton } from 'mastodon/components/skeleton';
|
import { Skeleton } from 'mastodon/components/skeleton';
|
||||||
import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
|
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(', ')}
|
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'
|
className='about__header__hero'
|
||||||
/>
|
/>
|
||||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.domain}</h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
{isLoading ? <Skeleton width='10ch' /> : domain}
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
|
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const AccountFieldActions: FC<{ id: string }> = ({ id }) => {
|
|||||||
openModal({
|
openModal({
|
||||||
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
||||||
modalProps: { fieldKey: id },
|
modalProps: { fieldKey: id },
|
||||||
|
ignoreFocus: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|||||||
@ -137,16 +137,28 @@ export const AccountEdit: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenModal = useCallback(
|
const handleOpenModal = useCallback(
|
||||||
(type: ModalType, props?: Record<string, unknown>) => {
|
(
|
||||||
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
|
type: ModalType,
|
||||||
|
{
|
||||||
|
modalProps = {},
|
||||||
|
ignoreFocus = false,
|
||||||
|
}: { modalProps?: Record<string, unknown>; ignoreFocus?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: type,
|
||||||
|
modalProps,
|
||||||
|
ignoreFocus,
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
const handleNameEdit = useCallback(() => {
|
const handleNameEdit = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_NAME');
|
handleOpenModal('ACCOUNT_EDIT_NAME', { ignoreFocus: true });
|
||||||
}, [handleOpenModal]);
|
}, [handleOpenModal]);
|
||||||
const handleBioEdit = useCallback(() => {
|
const handleBioEdit = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
handleOpenModal('ACCOUNT_EDIT_BIO', { ignoreFocus: true });
|
||||||
}, [handleOpenModal]);
|
}, [handleOpenModal]);
|
||||||
const handleCustomFieldAdd = useCallback(() => {
|
const handleCustomFieldAdd = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT');
|
handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT');
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
ModalShellActions,
|
ModalShellActions,
|
||||||
ModalShellBody,
|
ModalShellBody,
|
||||||
} from '@/mastodon/components/modal_shell';
|
} from '@/mastodon/components/modal_shell';
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
|
|
||||||
import { useFieldHtml } from '../hooks/useFieldHtml';
|
import { useFieldHtml } from '../hooks/useFieldHtml';
|
||||||
|
|
||||||
@ -24,12 +25,14 @@ export const AccountFieldModal: FC<{
|
|||||||
return (
|
return (
|
||||||
<ModalShell>
|
<ModalShell>
|
||||||
<ModalShellBody>
|
<ModalShellBody>
|
||||||
<EmojiHTML
|
<NavigationFocusTarget as='h1'>
|
||||||
as='h2'
|
<EmojiHTML
|
||||||
htmlString={field.name_emojified}
|
as='span'
|
||||||
onElement={handleLabelElement}
|
htmlString={field.name_emojified}
|
||||||
className={classes.fieldName}
|
onElement={handleLabelElement}
|
||||||
/>
|
className={classes.fieldName}
|
||||||
|
/>
|
||||||
|
</NavigationFocusTarget>
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
as='p'
|
as='p'
|
||||||
htmlString={field.value_emojified}
|
htmlString={field.value_emojified}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { List, Map } from 'immutable';
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
|
||||||
|
import { Router } from '@/mastodon/components/router';
|
||||||
import type { RootState } from 'mastodon/store';
|
import type { RootState } from 'mastodon/store';
|
||||||
import { useAppSelector } from 'mastodon/store';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
@ -27,9 +29,13 @@ describe('<AltTextModal />', () => {
|
|||||||
|
|
||||||
const renderComponent = () => {
|
const renderComponent = () => {
|
||||||
return render(
|
return render(
|
||||||
<IntlProvider locale='en' messages={{}}>
|
<Router>
|
||||||
<AltTextModal mediaId={mediaId} onClose={handleClose} />
|
<FocusTargetProvider>
|
||||||
</IntlProvider>,
|
<IntlProvider locale='en' messages={{}}>
|
||||||
|
<AltTextModal mediaId={mediaId} onClose={handleClose} />
|
||||||
|
</IntlProvider>
|
||||||
|
</FocusTargetProvider>
|
||||||
|
</Router>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { changeUploadCompose } from 'mastodon/actions/compose_typed';
|
|||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { GIFV } from 'mastodon/components/gifv';
|
import { GIFV } from 'mastodon/components/gifv';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
|
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
|
||||||
import { Skeleton } from 'mastodon/components/skeleton';
|
import { Skeleton } from 'mastodon/components/skeleton';
|
||||||
import { Audio } from 'mastodon/features/audio';
|
import { Audio } from 'mastodon/features/audio';
|
||||||
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
||||||
@ -412,12 +413,15 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className='dialog-modal__header__title'>
|
<NavigationFocusTarget
|
||||||
|
as='h1'
|
||||||
|
className='dialog-modal__header__title'
|
||||||
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='alt_text_modal.add_alt_text'
|
id='alt_text_modal.add_alt_text'
|
||||||
defaultMessage='Add alt text'
|
defaultMessage='Add alt text'
|
||||||
/>
|
/>
|
||||||
</span>
|
</NavigationFocusTarget>
|
||||||
|
|
||||||
<Button secondary onClick={onClose}>
|
<Button secondary onClick={onClose}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import classNames from 'classnames/bind';
|
|||||||
import { closeModal } from '@/mastodon/actions/modal';
|
import { closeModal } from '@/mastodon/actions/modal';
|
||||||
import { IconButton } from '@/mastodon/components/icon_button';
|
import { IconButton } from '@/mastodon/components/icon_button';
|
||||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { getReport } from '@/mastodon/reducers/slices/annual_report';
|
import { getReport } from '@/mastodon/reducers/slices/annual_report';
|
||||||
import {
|
import {
|
||||||
createAppSelector,
|
createAppSelector,
|
||||||
@ -83,7 +84,9 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1>Wrapstodon {report.year}</h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
Wrapstodon {report.year}
|
||||||
|
</NavigationFocusTarget>
|
||||||
{account && <p>@{account.acct}</p>}
|
{account && <p>@{account.acct}</p>}
|
||||||
{context === 'modal' && (
|
{context === 'modal' && (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
|||||||
|
|
||||||
import { fetchServer } from 'mastodon/actions/server';
|
import { fetchServer } from 'mastodon/actions/server';
|
||||||
import { domain } from 'mastodon/initial_state';
|
import { domain } from 'mastodon/initial_state';
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
message: state.getIn(['server', 'server', 'item', 'registrations', 'message']),
|
message: state.getIn(['server', 'server', 'item', 'registrations', 'message']),
|
||||||
@ -42,7 +43,9 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<div className='modal-root__modal interaction-modal'>
|
<div className='modal-root__modal interaction-modal'>
|
||||||
<div className='interaction-modal__lead'>
|
<div className='interaction-modal__lead'>
|
||||||
<h3><FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' /></h3>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
<FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' />
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p>
|
<p>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='closed_registrations_modal.preamble'
|
id='closed_registrations_modal.preamble'
|
||||||
@ -53,12 +56,12 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
|
|||||||
|
|
||||||
<div className='interaction-modal__choices'>
|
<div className='interaction-modal__choices'>
|
||||||
<div className='interaction-modal__choices__choice'>
|
<div className='interaction-modal__choices__choice'>
|
||||||
<h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
|
<h2><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h2>
|
||||||
{closedRegistrationsMessage}
|
{closedRegistrationsMessage}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='interaction-modal__choices__choice'>
|
<div className='interaction-modal__choices__choice'>
|
||||||
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
|
<h2><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h2>
|
||||||
<p className='prose'>
|
<p className='prose'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='closed_registrations.other_server_instructions'
|
id='closed_registrations.other_server_instructions'
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
|||||||
|
|
||||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||||
import type { Account } from '@/mastodon/models/account';
|
import type { Account } from '@/mastodon/models/account';
|
||||||
import {
|
import {
|
||||||
@ -114,13 +115,17 @@ export const CollectionAdder: React.FC<{
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className='dialog-modal__header__title' id={titleId}>
|
<NavigationFocusTarget
|
||||||
|
as='h1'
|
||||||
|
id={titleId}
|
||||||
|
className='dialog-modal__header__title'
|
||||||
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='collections.add_to_collection'
|
id='collections.add_to_collection'
|
||||||
defaultMessage='Add {name} to collections'
|
defaultMessage='Add {name} to collections'
|
||||||
values={{ name: <strong>@{account?.acct}</strong> }}
|
values={{ name: <strong>@{account?.acct}</strong> }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</NavigationFocusTarget>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='dialog-modal__content'>
|
<div className='dialog-modal__content'>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||||||
|
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { me } from '@/mastodon/initial_state';
|
import { me } from '@/mastodon/initial_state';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { changeCompose, focusCompose } from 'mastodon/actions/compose';
|
import { changeCompose, focusCompose } from 'mastodon/actions/compose';
|
||||||
@ -66,7 +67,7 @@ export const CollectionShareModal: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<ModalShell>
|
<ModalShell>
|
||||||
<ModalShellBody>
|
<ModalShellBody>
|
||||||
<h1 className={classes.heading}>
|
<NavigationFocusTarget as='h1' className={classes.heading}>
|
||||||
{isNew ? (
|
{isNew ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='collection.share_modal.title_new'
|
id='collection.share_modal.title_new'
|
||||||
@ -78,7 +79,7 @@ export const CollectionShareModal: React.FC<{
|
|||||||
defaultMessage='Share collection'
|
defaultMessage='Share collection'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Helmet } from '@unhead/react/helmet';
|
import { Helmet } from '@unhead/react/helmet';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
|
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
|
||||||
@ -71,7 +72,9 @@ export const Collections: React.FC<{
|
|||||||
|
|
||||||
<Scrollable>
|
<Scrollable>
|
||||||
<header className={classes.header}>
|
<header className={classes.header}>
|
||||||
<h1 className={classes.heading}>{pageTitleHtml}</h1>
|
<NavigationFocusTarget as='h1' className={classes.heading}>
|
||||||
|
{pageTitleHtml}
|
||||||
|
</NavigationFocusTarget>
|
||||||
<TabList plain>
|
<TabList plain>
|
||||||
<TabLink exact to={`/@${account?.acct}/collections`}>
|
<TabLink exact to={`/@${account?.acct}/collections`}>
|
||||||
{intl.formatMessage(createdByTabMessage, {
|
{intl.formatMessage(createdByTabMessage, {
|
||||||
|
|||||||
@ -220,7 +220,9 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
||||||
this.textareaRef.current.focus();
|
this.textareaRef.current.focus();
|
||||||
} else if (this.props.spoiler !== prevProps.spoiler) {
|
} else if (this.props.spoiler !== prevProps.spoiler) {
|
||||||
if (this.props.spoiler) {
|
const mediaJustAdded = this.props.anyMedia && !prevProps.anyMedia;
|
||||||
|
|
||||||
|
if (this.props.spoiler && !mediaJustAdded) {
|
||||||
this.spoilerText.input.focus();
|
this.spoilerText.input.focus();
|
||||||
} else if (prevProps.spoiler) {
|
} else if (prevProps.spoiler) {
|
||||||
this.textareaRef.current.focus();
|
this.textareaRef.current.focus();
|
||||||
|
|||||||
@ -4,12 +4,10 @@ import { addPoll, removePoll } from '../../../actions/compose';
|
|||||||
import PollButton from '../components/poll_button';
|
import PollButton from '../components/poll_button';
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = state => {
|
||||||
const readyAttachmentsSize = state.compose.get('media_attachments').size ?? 0;
|
|
||||||
const hasAttachments = readyAttachmentsSize > 0 || !!state.compose.get('is_uploading');
|
|
||||||
const hasQuote = !!state.compose.get('quoted_status_id');
|
const hasQuote = !!state.compose.get('quoted_status_id');
|
||||||
|
|
||||||
return ({
|
return ({
|
||||||
disabled: hasAttachments || hasQuote,
|
disabled: hasQuote,
|
||||||
active: state.getIn(['compose', 'poll']) !== null,
|
active: state.getIn(['compose', 'poll']) !== null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { uploadCompose } from '../../../actions/compose';
|
|||||||
import UploadButton from '../components/upload_button';
|
import UploadButton from '../components/upload_button';
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = state => {
|
||||||
const isPoll = state.getIn(['compose', 'poll']) !== null;
|
|
||||||
const isUploading = state.getIn(['compose', 'is_uploading']);
|
const isUploading = state.getIn(['compose', 'is_uploading']);
|
||||||
const readyAttachmentsSize = state.getIn(['compose', 'media_attachments']).size ?? 0;
|
const readyAttachmentsSize = state.getIn(['compose', 'media_attachments']).size ?? 0;
|
||||||
const pendingAttachmentsSize = state.getIn(['compose', 'pending_media_attachments']).size ?? 0;
|
const pendingAttachmentsSize = state.getIn(['compose', 'pending_media_attachments']).size ?? 0;
|
||||||
@ -14,7 +13,7 @@ const mapStateToProps = state => {
|
|||||||
const hasQuote = !!state.compose.get('quoted_status_id');
|
const hasQuote = !!state.compose.get('quoted_status_id');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disabled: isPoll || isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
|
disabled: isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
|
||||||
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Helmet } from '@unhead/react/helmet';
|
import { Helmet } from '@unhead/react/helmet';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { fetchServer } from 'mastodon/actions/server';
|
import { fetchServer } from 'mastodon/actions/server';
|
||||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||||
import { TabLink, TabList } from 'mastodon/components/tab_list';
|
import { TabLink, TabList } from 'mastodon/components/tab_list';
|
||||||
@ -40,7 +41,9 @@ export const CustomHomepage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={classes.topSection}>
|
<div className={classes.topSection}>
|
||||||
<h1>{server.item?.domain}</h1>
|
<NavigationFocusTarget as='h1'>
|
||||||
|
{server.item?.domain}
|
||||||
|
</NavigationFocusTarget>
|
||||||
<p>{server.item?.description}</p>
|
<p>{server.item?.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,10 @@ function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('emoji database', () => {
|
describe('emoji database', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testGet(); // Loads the database schema.
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
testClear();
|
testClear();
|
||||||
indexedDB = new IDBFactory();
|
indexedDB = new IDBFactory();
|
||||||
|
|||||||
@ -143,7 +143,28 @@ export async function search({
|
|||||||
return intersection;
|
return intersection;
|
||||||
})
|
})
|
||||||
.values(),
|
.values(),
|
||||||
).toSorted((a, b) => a.score - b.score);
|
);
|
||||||
|
|
||||||
|
// If there are no results, try a cursor-based custom emoji search instead.
|
||||||
|
if (results.length === 0) {
|
||||||
|
const trx = db.transaction('custom', 'readonly');
|
||||||
|
const foundEmojis = new Set<string>();
|
||||||
|
for await (const cursor of trx.store) {
|
||||||
|
const emoji = cursor.value;
|
||||||
|
const score = getScoreForEmoji(emoji, query, false);
|
||||||
|
if (score === null || foundEmojis.has(emoji.shortcode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ ...emoji, score });
|
||||||
|
foundEmojis.add(emoji.shortcode);
|
||||||
|
}
|
||||||
|
log('cursor search found %d results for "%s"', foundEmojis.size, query);
|
||||||
|
await trx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score, descending.
|
||||||
|
results.sort((a, b) => a.score - b.score);
|
||||||
|
|
||||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||||
log(
|
log(
|
||||||
@ -159,14 +180,19 @@ export async function search({
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScoreForEmoji(emoji: AnyEmojiData, query: string) {
|
function getScoreForEmoji(
|
||||||
|
emoji: AnyEmojiData,
|
||||||
|
query: string,
|
||||||
|
checkTokens = true,
|
||||||
|
) {
|
||||||
const id = 'shortcode' in emoji ? emoji.shortcode : emoji.label;
|
const id = 'shortcode' in emoji ? emoji.shortcode : emoji.label;
|
||||||
if (id === query) {
|
if (id === query) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let index = 1;
|
let index = 1;
|
||||||
for (const token of [id, ...emoji.tokens]) {
|
const searchTokens = checkTokens ? [id, ...emoji.tokens] : [id];
|
||||||
|
for (const token of searchTokens) {
|
||||||
const tokenIndex = token.indexOf(query);
|
const tokenIndex = token.indexOf(query);
|
||||||
if (tokenIndex !== -1) {
|
if (tokenIndex !== -1) {
|
||||||
return index + tokenIndex / token.length;
|
return index + tokenIndex / token.length;
|
||||||
@ -246,6 +272,13 @@ export async function clearCache(key: CacheKey) {
|
|||||||
log('Cleared cache for %s', key);
|
log('Cleared cache for %s', key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetDatabase() {
|
||||||
|
const db = await loadDB();
|
||||||
|
const storeNames = [...db.objectStoreNames];
|
||||||
|
await Promise.all(storeNames.map((storeName) => db.clear(storeName)));
|
||||||
|
log(storeNames, 'Reset emoji database stores:');
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadEmojiByHexcode(
|
export async function loadEmojiByHexcode(
|
||||||
hexcode: string,
|
hexcode: string,
|
||||||
localeString: string,
|
localeString: string,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
StoreNames,
|
StoreNames,
|
||||||
} from 'idb';
|
} from 'idb';
|
||||||
|
|
||||||
|
import { resetDatabase } from './database';
|
||||||
import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types';
|
import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types';
|
||||||
import { emojiLogger } from './utils';
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
@ -57,7 +58,7 @@ type Transaction<Mode extends IDBTransactionMode = 'versionchange'> =
|
|||||||
|
|
||||||
export type Database = IDBPDatabase<EmojiDB>;
|
export type Database = IDBPDatabase<EmojiDB>;
|
||||||
|
|
||||||
const SCHEMA_VERSION = 3;
|
const SCHEMA_VERSION = 4;
|
||||||
|
|
||||||
export async function openEmojiDB() {
|
export async function openEmojiDB() {
|
||||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||||
@ -98,6 +99,8 @@ export async function openEmojiDB() {
|
|||||||
});
|
});
|
||||||
deleteOldIndexes(shortcodeTable, ['hexcode']);
|
deleteOldIndexes(shortcodeTable, ['hexcode']);
|
||||||
|
|
||||||
|
void resetDatabase();
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'Upgraded emoji database from version %d to %d',
|
'Upgraded emoji database from version %d to %d',
|
||||||
oldVersion,
|
oldVersion,
|
||||||
|
|||||||
@ -57,6 +57,8 @@ export const Emoji: FC<EmojiProps> = ({
|
|||||||
const { mode } = useEmojiAppState();
|
const { mode } = useEmojiAppState();
|
||||||
return (
|
return (
|
||||||
<EmojiRaw
|
<EmojiRaw
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- In React props are frozen, but the library we use is old so doesn't respect that.
|
||||||
|
{...(EmojiRaw.defaultProps ?? {})}
|
||||||
data={EmojiData}
|
data={EmojiData}
|
||||||
set={set}
|
set={set}
|
||||||
sheetSize={sheetSize}
|
sheetSize={sheetSize}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { initialState } from '@/mastodon/initial_state';
|
import { initialState } from '@/mastodon/initial_state';
|
||||||
|
|
||||||
import type { EMOJI_DB_NAME_SHORTCODES } from './constants';
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
import { reloadCustomEmojis } from './picker';
|
import { reloadCustomEmojis } from './picker';
|
||||||
import type { LocaleOrCustom } from './types';
|
import type { EmojiWorkerMessage } from './types';
|
||||||
import { emojiLogger } from './utils';
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||||
@ -11,9 +10,10 @@ const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
|||||||
let worker: Worker | null = null;
|
let worker: Worker | null = null;
|
||||||
|
|
||||||
const log = emojiLogger('index');
|
const log = emojiLogger('index');
|
||||||
|
const workerLog = emojiLogger('worker');
|
||||||
|
|
||||||
// This is too short, but better to fallback quickly than wait.
|
// This is too short, but better to fallback quickly than wait.
|
||||||
const WORKER_TIMEOUT = 1_000;
|
const WORKER_TIMEOUT = 2_000;
|
||||||
|
|
||||||
export async function initializeEmoji() {
|
export async function initializeEmoji() {
|
||||||
log('initializing emojis');
|
log('initializing emojis');
|
||||||
@ -43,43 +43,44 @@ export async function initializeEmoji() {
|
|||||||
const { data: message } = event;
|
const { data: message } = event;
|
||||||
|
|
||||||
worker ??= tempWorker;
|
worker ??= tempWorker;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (message === 'ready') {
|
if (message !== 'ready') {
|
||||||
log('worker ready, loading data');
|
workerLog(message);
|
||||||
clearTimeout(timeoutId);
|
return;
|
||||||
messageWorker('shortcodes');
|
|
||||||
void loadCustomEmoji();
|
|
||||||
void loadEmojiLocale(userLocale);
|
|
||||||
} else {
|
|
||||||
log('got worker message: %s', message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debugValue = localStorage.getItem('debug');
|
||||||
|
if (debugValue) {
|
||||||
|
messageWorker({ type: 'debug', debugValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
workerLog('loading data');
|
||||||
|
messageWorker(userLocale);
|
||||||
|
messageWorker('custom');
|
||||||
|
messageWorker('shortcodes');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fallbackLoad() {
|
async function fallbackLoad() {
|
||||||
log('falling back to main thread for loading');
|
log('falling back to main thread for loading');
|
||||||
|
|
||||||
await loadCustomEmoji();
|
const { importCustomEmojiData, importLegacyShortcodes, importEmojiData } =
|
||||||
const { importLegacyShortcodes } = await import('./loader');
|
await import('./loader');
|
||||||
|
|
||||||
|
const customEmojis = await importCustomEmojiData();
|
||||||
|
if (customEmojis && customEmojis.length > 0) {
|
||||||
|
log('loaded %d custom emojis', customEmojis.length);
|
||||||
|
await reloadCustomEmojis();
|
||||||
|
}
|
||||||
const shortcodes = await importLegacyShortcodes();
|
const shortcodes = await importLegacyShortcodes();
|
||||||
if (shortcodes?.length) {
|
if (shortcodes?.length) {
|
||||||
log('loaded %d legacy shortcodes', shortcodes.length);
|
log('loaded %d legacy shortcodes', shortcodes.length);
|
||||||
}
|
}
|
||||||
await loadEmojiLocale(userLocale);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEmojiLocale(localeString: string) {
|
const emojis = await importEmojiData(userLocale);
|
||||||
const locale = toSupportedLocale(localeString);
|
if (emojis) {
|
||||||
const { importEmojiData } = await import('./loader');
|
log('loaded %d emojis to locale %s', emojis.length, userLocale);
|
||||||
|
|
||||||
if (worker) {
|
|
||||||
log('asking worker to load locale %s', locale);
|
|
||||||
messageWorker(locale);
|
|
||||||
} else {
|
|
||||||
const emojis = await importEmojiData(locale);
|
|
||||||
if (emojis) {
|
|
||||||
log('loaded %d emojis to locale %s', emojis.length, locale);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,11 +97,16 @@ export async function loadCustomEmoji() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageWorker(
|
function messageWorker(data: EmojiWorkerMessage | string) {
|
||||||
locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES,
|
|
||||||
) {
|
|
||||||
if (!worker) {
|
if (!worker) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
worker.postMessage({ locale });
|
if (typeof data === 'string') {
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'load',
|
||||||
|
storeName: data,
|
||||||
|
} satisfies EmojiWorkerMessage);
|
||||||
|
} else {
|
||||||
|
worker.postMessage(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { basename, resolve } from 'path';
|
|||||||
import { flattenEmojiData } from 'emojibase';
|
import { flattenEmojiData } from 'emojibase';
|
||||||
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
||||||
|
|
||||||
import { unicodeToTwemojiHex } from './normalize';
|
import { extractTokens, unicodeToTwemojiHex } from './normalize';
|
||||||
|
|
||||||
const emojiSVGFiles = await readdir(
|
const emojiSVGFiles = await readdir(
|
||||||
// This assumes tests are run from project root
|
// This assumes tests are run from project root
|
||||||
@ -33,3 +33,32 @@ describe('unicodeToTwemojiHex', () => {
|
|||||||
expect(svgFileNamesWithoutBorder).toContain(result);
|
expect(svgFileNamesWithoutBorder).toContain(result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractTokens', () => {
|
||||||
|
test('returns an empty array for blank input', () => {
|
||||||
|
expect(extractTokens(' ', null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check token word breaking with Intl.Segmenter', () => {
|
||||||
|
const segmenter = new Intl.Segmenter('en', { granularity: 'word' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
extractTokens('thumbs_up smiling-face camelCase', segmenter),
|
||||||
|
).toEqual(['thumbs', 'up', 'smiling', 'face', 'camel', 'case']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check token word breaking with regex', () => {
|
||||||
|
expect(extractTokens('Smile_face joy-test A ok 7 z', null)).toEqual([
|
||||||
|
'smile',
|
||||||
|
'face',
|
||||||
|
'joy',
|
||||||
|
'test',
|
||||||
|
'ok',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensure +1 and -1 are preserved', () => {
|
||||||
|
expect(extractTokens('+1', null)).toEqual(['+1']);
|
||||||
|
expect(extractTokens('-1', null)).toEqual(['-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user