Merge commit '9e0a3aaf08e7d4fc700f7f19d61f5f8fee346b6a' into glitch-soc/merge-upstream

This commit is contained in:
Claire 2026-06-10 18:52:58 +02:00
commit 89c2ac444f
111 changed files with 891 additions and 238 deletions

View File

@ -210,7 +210,7 @@ FROM media-build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
ARG VIPS_VERSION=8.18.2
ARG VIPS_VERSION=8.18.3
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download

View File

@ -10,6 +10,7 @@ class CollectionsController < ApplicationController
before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_collection
before_action :redirect_to_canonical_url
skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode?
@ -18,7 +19,6 @@ class CollectionsController < ApplicationController
respond_to do |format|
format.html do
expires_in expiration_duration, public: true unless user_signed_in?
render template: 'home/index'
end
format.json do
@ -46,6 +46,10 @@ class CollectionsController < ApplicationController
not_found
end
def redirect_to_canonical_url
redirect_to collection_path(@collection) if request.format.html? && request.path.starts_with?('/ap/')
end
def expiration_duration
recently_updated = @collection.updated_at > 15.minutes.ago
recently_updated ? 30.seconds : 5.minutes

View File

@ -93,7 +93,7 @@ const messages = defineMessages({
export const ensureComposeIsVisible = (getState) => {
if (!getState().getIn(['compose', 'mounted'])) {
browserHistory.push('/publish');
browserHistory.push('/publish', { focusTarget: false });
}
};
@ -294,7 +294,10 @@ export function submitCompose(successCallback) {
message: statusId === null ? messages.published : messages.saved,
action: messages.open,
dismissAfter: 10000,
onClick: () => browserHistory.push(`/@${response.data.account.username}/${response.data.id}`),
onClick: () => browserHistory.push(
`/@${response.data.account.username}/${response.data.id}`,
{ focusTarget: 'detailed-status' }
),
}));
}).catch(function (error) {
dispatch(submitComposeFail(error));
@ -341,11 +344,6 @@ export function uploadCompose(files) {
return;
}
if (getState().getIn(['compose', 'poll'])) {
dispatch(showAlert({ message: messages.uploadErrorPoll }));
return;
}
dispatch(uploadComposeRequest());
for (const [i, file] of Array.from(files).entries()) {

View File

@ -18,7 +18,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
);
// If there's a mismatch, re-import all custom emojis.
if (existingEmojis.length < emojis.length) {
if (existingEmojis.length > 0 && existingEmojis.length < emojis.length) {
await clearCache('custom');
await loadCustomEmoji();

View File

@ -10,6 +10,7 @@ interface OpenModalPayload {
modalType: ModalType;
modalProps: ModalProps;
previousModalProps?: ModalProps;
ignoreFocus?: boolean;
}
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');

View File

@ -19,6 +19,7 @@ import { FollowsYouBadge } from '../badge';
import { CopyButton } from '../copy_button';
import { DisplayName } from '../display_name';
import { Icon } from '../icon';
import { NavigationFocusTarget } from '../navigation_focus_target';
import { AccountBadges } from './badges';
import classes from './styles.module.scss';
@ -56,9 +57,9 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
return (
<div className={classes.nameWrapper}>
<div className={classes.name}>
<h1>
<NavigationFocusTarget as='h1'>
<DisplayName account={account} variant='simple' />
</h1>
</NavigationFocusTarget>
{relationship?.followed_by && <FollowsYouBadge />}
</div>

View File

@ -62,6 +62,7 @@
background: none;
border: none;
color: inherit;
font: inherit;
font-weight: 500;
padding: 0;
text-wrap: nowrap;

View File

@ -19,6 +19,7 @@ import { useIdentity } from 'mastodon/identity_context';
import { useColumnIndexContext } from '../features/ui/components/columns_area';
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
import { NavigationFocusTarget } from './navigation_focus_target';
import { useAppHistory } from './router';
export const messages = defineMessages({
@ -272,7 +273,7 @@ export const ColumnHeader: React.FC<Props> = ({
{!backButton && hasIcon && (
<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}>
{backButton}
{hasTitle && (
<h1 className='column-header__title-wrapper'>
<NavigationFocusTarget
as='h1'
className='column-header__title-wrapper'
>
{onClick ? (
<button
onClick={handleTitleClick}
@ -304,7 +308,7 @@ export const ColumnHeader: React.FC<Props> = ({
{titleContents}
</span>
)}
</h1>
</NavigationFocusTarget>
)}
<div className='column-header__buttons'>

View File

@ -7,6 +7,7 @@ import { multiply } from 'color-blend';
import { createBrowserHistory } from 'history';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
import { IGNORE_FOCUS_ON_OPEN } from '../reducers/modal';
class ModalRoot extends PureComponent {
@ -21,7 +22,10 @@ class ModalRoot extends PureComponent {
b: PropTypes.number,
}),
]),
ignoreFocus: PropTypes.bool,
ignoreFocus: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.string, // 'on-open', see IGNORE_FOCUS_ON_OPEN
]),
...WithOptionalRouterPropTypes,
};
@ -118,7 +122,11 @@ class ModalRoot extends PureComponent {
_ensureHistoryBuffer () {
const { pathname, search, hash, state } = this.history.location;
if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
this.history.push({ pathname, search, hash }, { ...state, mastodonModalKey: this._modalHistoryKey });
this.history.push({ pathname, search, hash }, {
...state,
focusTarget: this.props.ignoreFocus !== IGNORE_FOCUS_ON_OPEN,
mastodonModalKey: this._modalHistoryKey,
});
}
}

View File

@ -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>
);
});

View File

@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history';
import { layoutFromWindow } from 'mastodon/is_mobile';
import { isDevelopment } from 'mastodon/utils/environment';
import type { FocusTarget } from './navigation_focus_target';
interface MastodonLocationState {
fromMastodon?: boolean;
mastodonModalKey?: string;
// Controls which element is focused after a navigation.
// Set to `false` to prevent navigation focus.
focusTarget?: FocusTarget;
// Prevent the rightmost column in advanced UI from scrolling
// into view on location changes
preventMultiColumnAutoScroll?: string;

View File

@ -33,6 +33,7 @@ import StatusContent from './status_content';
import { StatusThreadLabel } from './status_thread_label';
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
import { compareUrls } from '../utils/compare_urls';
import { FOCUS_TARGET } from './navigation_focus_target';
const domParser = new DOMParser();
@ -311,9 +312,9 @@ class Status extends ImmutablePureComponent {
window.open(path, '_blank', 'noopener');
} else {
if (history.location.pathname.replace('/deck/', '/') === path) {
history.replace(path);
history.replace(path, {focusTarget: FOCUS_TARGET.POST});
} else {
history.push(path);
history.push(path, {focusTarget: FOCUS_TARGET.POST});
}
}
};

View File

@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux';
import { hydrateStore } from 'mastodon/actions/store';
import { connectUserStream } from 'mastodon/actions/streaming';
import ErrorBoundary from 'mastodon/components/error_boundary';
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
import { Router } from 'mastodon/components/router';
import UI from 'mastodon/features/ui';
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context';
@ -49,7 +50,9 @@ export default class Mastodon extends PureComponent {
<ErrorBoundary>
<Router>
<ScrollContext>
<FocusTargetProvider>
<Route path='/' component={UI} />
</FocusTargetProvider>
</ScrollContext>
<BodyScrollLock />
</Router>

View File

@ -8,10 +8,13 @@ import { Helmet } from '@unhead/react/helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { domain } from 'mastodon/initial_state';
import { injectIntl } from '@/mastodon/components/intl';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
import { Account } from 'mastodon/components/account';
import Column from 'mastodon/components/column';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import { Skeleton } from 'mastodon/components/skeleton';
import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
@ -91,7 +94,9 @@ class About extends PureComponent {
srcSet={Object.keys(server.item?.thumbnail.versions ?? {}).map((key) => `${server.item?.thumbnail.versions && server.item.thumbnail.versions[key]} ${key.replace('@', '')}`).join(', ')}
className='about__header__hero'
/>
<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>
</div>

View File

@ -26,6 +26,7 @@ export const AccountFieldActions: FC<{ id: string }> = ({ id }) => {
openModal({
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
modalProps: { fieldKey: id },
ignoreFocus: true,
}),
);
}, [dispatch, id]);

View File

@ -137,16 +137,28 @@ export const AccountEdit: FC = () => {
);
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],
);
const handleNameEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_NAME');
handleOpenModal('ACCOUNT_EDIT_NAME', { ignoreFocus: true });
}, [handleOpenModal]);
const handleBioEdit = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_BIO');
handleOpenModal('ACCOUNT_EDIT_BIO', { ignoreFocus: true });
}, [handleOpenModal]);
const handleCustomFieldAdd = useCallback(() => {
handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT');

View File

@ -10,6 +10,7 @@ import {
ModalShellActions,
ModalShellBody,
} from '@/mastodon/components/modal_shell';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { useFieldHtml } from '../hooks/useFieldHtml';
@ -24,12 +25,14 @@ export const AccountFieldModal: FC<{
return (
<ModalShell>
<ModalShellBody>
<NavigationFocusTarget as='h1'>
<EmojiHTML
as='h2'
as='span'
htmlString={field.name_emojified}
onElement={handleLabelElement}
className={classes.fieldName}
/>
</NavigationFocusTarget>
<EmojiHTML
as='p'
htmlString={field.value_emojified}

View File

@ -7,6 +7,8 @@ import { List, Map } from 'immutable';
import { render } from '@testing-library/react';
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 { useAppSelector } from 'mastodon/store';
@ -27,9 +29,13 @@ describe('<AltTextModal />', () => {
const renderComponent = () => {
return render(
<Router>
<FocusTargetProvider>
<IntlProvider locale='en' messages={{}}>
<AltTextModal mediaId={mediaId} onClose={handleClose} />
</IntlProvider>,
</IntlProvider>
</FocusTargetProvider>
</Router>,
);
};

View File

@ -22,6 +22,7 @@ import { changeUploadCompose } from 'mastodon/actions/compose_typed';
import { Button } from 'mastodon/components/button';
import { GIFV } from 'mastodon/components/gifv';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { Skeleton } from 'mastodon/components/skeleton';
import { Audio } from 'mastodon/features/audio';
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
@ -412,12 +413,15 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
)}
</Button>
<span className='dialog-modal__header__title'>
<NavigationFocusTarget
as='h1'
className='dialog-modal__header__title'
>
<FormattedMessage
id='alt_text_modal.add_alt_text'
defaultMessage='Add alt text'
/>
</span>
</NavigationFocusTarget>
<Button secondary onClick={onClose}>
<FormattedMessage

View File

@ -10,6 +10,7 @@ import classNames from 'classnames/bind';
import { closeModal } from '@/mastodon/actions/modal';
import { IconButton } from '@/mastodon/components/icon_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { getReport } from '@/mastodon/reducers/slices/annual_report';
import {
createAppSelector,
@ -83,7 +84,9 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
return (
<div className={styles.wrapper} data-color-scheme='dark'>
<div className={styles.header}>
<h1>Wrapstodon {report.year}</h1>
<NavigationFocusTarget as='h1'>
Wrapstodon {report.year}
</NavigationFocusTarget>
{account && <p>@{account.acct}</p>}
{context === 'modal' && (
<IconButton

View File

@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import { fetchServer } from 'mastodon/actions/server';
import { domain } from 'mastodon/initial_state';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
const mapStateToProps = state => ({
message: state.getIn(['server', 'server', 'item', 'registrations', 'message']),
@ -42,7 +43,9 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
return (
<div className='modal-root__modal interaction-modal'>
<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>
<FormattedMessage
id='closed_registrations_modal.preamble'
@ -53,12 +56,12 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
<div className='interaction-modal__choices'>
<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}
</div>
<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'>
<FormattedMessage
id='closed_registrations.other_server_instructions'

View File

@ -4,6 +4,7 @@ import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import type { Account } from '@/mastodon/models/account';
import {
@ -114,13 +115,17 @@ export const CollectionAdder: React.FC<{
onClick={onClose}
/>
<span className='dialog-modal__header__title' id={titleId}>
<NavigationFocusTarget
as='h1'
id={titleId}
className='dialog-modal__header__title'
>
<FormattedMessage
id='collections.add_to_collection'
defaultMessage='Add {name} to collections'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</NavigationFocusTarget>
</div>
<div className='dialog-modal__content'>

View File

@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useLocation } from 'react-router';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { me } from '@/mastodon/initial_state';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { changeCompose, focusCompose } from 'mastodon/actions/compose';
@ -66,7 +67,7 @@ export const CollectionShareModal: React.FC<{
return (
<ModalShell>
<ModalShellBody>
<h1 className={classes.heading}>
<NavigationFocusTarget as='h1' className={classes.heading}>
{isNew ? (
<FormattedMessage
id='collection.share_modal.title_new'
@ -78,7 +79,7 @@ export const CollectionShareModal: React.FC<{
defaultMessage='Share collection'
/>
)}
</h1>
</NavigationFocusTarget>
<IconButton
title={intl.formatMessage({

View File

@ -4,6 +4,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
@ -71,7 +72,9 @@ export const Collections: React.FC<{
<Scrollable>
<header className={classes.header}>
<h1 className={classes.heading}>{pageTitleHtml}</h1>
<NavigationFocusTarget as='h1' className={classes.heading}>
{pageTitleHtml}
</NavigationFocusTarget>
<TabList plain>
<TabLink exact to={`/@${account?.acct}/collections`}>
{intl.formatMessage(createdByTabMessage, {

View File

@ -220,7 +220,9 @@ class ComposeForm extends ImmutablePureComponent {
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
this.textareaRef.current.focus();
} 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();
} else if (prevProps.spoiler) {
this.textareaRef.current.focus();

View File

@ -4,12 +4,10 @@ import { addPoll, removePoll } from '../../../actions/compose';
import PollButton from '../components/poll_button';
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');
return ({
disabled: hasAttachments || hasQuote,
disabled: hasQuote,
active: state.getIn(['compose', 'poll']) !== null,
});
};

View File

@ -4,7 +4,6 @@ import { uploadCompose } from '../../../actions/compose';
import UploadButton from '../components/upload_button';
const mapStateToProps = state => {
const isPoll = state.getIn(['compose', 'poll']) !== null;
const isUploading = state.getIn(['compose', 'is_uploading']);
const readyAttachmentsSize = state.getIn(['compose', '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');
return {
disabled: isPoll || isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
disabled: isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
resetFileKey: state.getIn(['compose', 'resetFileKey']),
};
};

View File

@ -6,6 +6,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { fetchServer } from 'mastodon/actions/server';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import { TabLink, TabList } from 'mastodon/components/tab_list';
@ -40,7 +41,9 @@ export const CustomHomepage: React.FC = () => {
/>
<div className={classes.topSection}>
<h1>{server.item?.domain}</h1>
<NavigationFocusTarget as='h1'>
{server.item?.domain}
</NavigationFocusTarget>
<p>{server.item?.description}</p>
</div>

View File

@ -22,6 +22,10 @@ function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
}
describe('emoji database', () => {
beforeEach(async () => {
await testGet(); // Loads the database schema.
});
afterEach(() => {
testClear();
indexedDB = new IDBFactory();

View File

@ -143,7 +143,28 @@ export async function search({
return intersection;
})
.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');
log(
@ -159,14 +180,19 @@ export async function search({
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;
if (id === query) {
return 0;
}
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);
if (tokenIndex !== -1) {
return index + tokenIndex / token.length;
@ -246,6 +272,13 @@ export async function clearCache(key: CacheKey) {
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(
hexcode: string,
localeString: string,

View File

@ -10,6 +10,7 @@ import type {
StoreNames,
} from 'idb';
import { resetDatabase } from './database';
import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types';
import { emojiLogger } from './utils';
@ -57,7 +58,7 @@ type Transaction<Mode extends IDBTransactionMode = 'versionchange'> =
export type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 3;
const SCHEMA_VERSION = 4;
export async function openEmojiDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
@ -98,6 +99,8 @@ export async function openEmojiDB() {
});
deleteOldIndexes(shortcodeTable, ['hexcode']);
void resetDatabase();
log(
'Upgraded emoji database from version %d to %d',
oldVersion,

View File

@ -57,6 +57,8 @@ export const Emoji: FC<EmojiProps> = ({
const { mode } = useEmojiAppState();
return (
<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}
set={set}
sheetSize={sheetSize}

View File

@ -1,9 +1,8 @@
import { initialState } from '@/mastodon/initial_state';
import type { EMOJI_DB_NAME_SHORTCODES } from './constants';
import { toSupportedLocale } from './locale';
import { reloadCustomEmojis } from './picker';
import type { LocaleOrCustom } from './types';
import type { EmojiWorkerMessage } from './types';
import { emojiLogger } from './utils';
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
@ -11,9 +10,10 @@ const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
let worker: Worker | null = null;
const log = emojiLogger('index');
const workerLog = emojiLogger('worker');
// 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() {
log('initializing emojis');
@ -43,43 +43,44 @@ export async function initializeEmoji() {
const { data: message } = event;
worker ??= tempWorker;
if (message === 'ready') {
log('worker ready, loading data');
clearTimeout(timeoutId);
messageWorker('shortcodes');
void loadCustomEmoji();
void loadEmojiLocale(userLocale);
} else {
log('got worker message: %s', message);
if (message !== 'ready') {
workerLog(message);
return;
}
const debugValue = localStorage.getItem('debug');
if (debugValue) {
messageWorker({ type: 'debug', debugValue });
}
workerLog('loading data');
messageWorker(userLocale);
messageWorker('custom');
messageWorker('shortcodes');
});
}
async function fallbackLoad() {
log('falling back to main thread for loading');
await loadCustomEmoji();
const { importLegacyShortcodes } = await import('./loader');
const { importCustomEmojiData, importLegacyShortcodes, importEmojiData } =
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();
if (shortcodes?.length) {
log('loaded %d legacy shortcodes', shortcodes.length);
}
await loadEmojiLocale(userLocale);
}
async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
const { importEmojiData } = await import('./loader');
if (worker) {
log('asking worker to load locale %s', locale);
messageWorker(locale);
} else {
const emojis = await importEmojiData(locale);
const emojis = await importEmojiData(userLocale);
if (emojis) {
log('loaded %d emojis to locale %s', emojis.length, locale);
}
log('loaded %d emojis to locale %s', emojis.length, userLocale);
}
}
@ -96,11 +97,16 @@ export async function loadCustomEmoji() {
}
}
function messageWorker(
locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES,
) {
function messageWorker(data: EmojiWorkerMessage | string) {
if (!worker) {
return;
}
worker.postMessage({ locale });
if (typeof data === 'string') {
worker.postMessage({
type: 'load',
storeName: data,
} satisfies EmojiWorkerMessage);
} else {
worker.postMessage(data);
}
}

View File

@ -4,7 +4,7 @@ import { basename, resolve } from 'path';
import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json';
import { unicodeToTwemojiHex } from './normalize';
import { extractTokens, unicodeToTwemojiHex } from './normalize';
const emojiSVGFiles = await readdir(
// This assumes tests are run from project root
@ -33,3 +33,32 @@ describe('unicodeToTwemojiHex', () => {
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']);
});
});

View File

@ -214,6 +214,11 @@ export function extractTokens(
}
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.
if (segmenter) {
for (const { isWordLike, segment } of segmenter.segment(

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import type { CategoryName, CustomEmoji } from 'emoji-mart';
import { autoPlayGif } from '@/mastodon/initial_state';
import { createLimitedCache } from '@/mastodon/utils/cache';
import { emojiLogger } from './utils';
@ -21,6 +22,8 @@ let customCategories = [
'flags',
] as CategoryName[];
const searchCache = createLimitedCache<LegacyEmoji[]>({ maxSize: 10, log });
export async function fetchCustomEmojiData() {
if (customEmojis !== null) {
return customEmojis;
@ -89,6 +92,7 @@ export async function reloadCustomEmojis() {
await import('@/mastodon/hooks/useCustomEmojis');
await Promise.all([fetchCustomEmojiData(), loadEmojisIntoCache()]);
searchCache.clear();
}
// Replicates the old legacy search function.
@ -102,16 +106,25 @@ export async function emojiMartSearch(
return [];
}
const cacheKey = `${query}|${locale}|${limit}`;
const cachedResult = searchCache.get(cacheKey);
if (cachedResult) {
return cachedResult;
}
const { search } = await import('./database');
const results = await search({ query, locale, limit });
return results.map((emoji) =>
const legacyResults = results.map((emoji) =>
'shortcode' in emoji
? { id: emoji.shortcode, custom: true }
? ({ id: emoji.shortcode, custom: true } as const)
: {
id: emoji.label.replaceAll(' ', '_').toLowerCase(),
native: emoji.unicode,
},
);
searchCache.set(cacheKey, legacyResults);
return legacyResults;
}
export function usePickerEmojis() {

View File

@ -80,3 +80,13 @@ export type ExtraCustomEmojiMap = Record<
string,
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
>;
export type EmojiWorkerMessage =
| {
type: 'load';
storeName: string;
}
| {
type: 'debug';
debugValue: string;
};

View File

@ -5,6 +5,9 @@ import { emojiRegexPolyfill } from '@/mastodon/polyfills';
import { VARIATION_SELECTOR_CODE } from './constants';
export function emojiLogger(segment: string) {
if (typeof window === 'undefined') {
return debug(`emojis:worker:${segment}`);
}
return debug(`emojis:${segment}`);
}

View File

@ -1,31 +1,36 @@
import debug from 'debug';
import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import {
importCustomEmojiData,
importEmojiData,
importLegacyShortcodes,
} from './loader';
import type { EmojiWorkerMessage } from './types';
addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread
function handleMessage(event: MessageEvent<{ locale: string }>) {
const {
data: { locale },
} = event;
void loadData(locale);
function handleMessage(event: MessageEvent<EmojiWorkerMessage>) {
const { data } = event;
if (data.type === 'debug') {
debug.enable(data.debugValue);
} else {
void loadData(data.storeName);
}
}
async function loadData(locale: string) {
async function loadData(storeName: string) {
let importCount: number | undefined;
if (locale === EMOJI_TYPE_CUSTOM) {
if (storeName === EMOJI_TYPE_CUSTOM) {
importCount = (await importCustomEmojiData())?.length;
} else if (locale === EMOJI_DB_NAME_SHORTCODES) {
} else if (storeName === EMOJI_DB_NAME_SHORTCODES) {
importCount = (await importLegacyShortcodes())?.length;
} else {
importCount = (await importEmojiData(locale))?.length;
importCount = (await importEmojiData(storeName))?.length;
}
if (importCount) {
self.postMessage(`loaded ${importCount} emojis into ${locale}`);
self.postMessage(`loaded ${importCount} emojis into ${storeName}`);
}
}

View File

@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { Button } from 'mastodon/components/button';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { toServerSideType } from 'mastodon/utils/filters';
const mapStateToProps = (state, { filterId }) => ({
@ -71,7 +72,9 @@ class AddedToFilter extends PureComponent {
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'>
<FormattedMessage
id='filter_modal.added.short_explanation'

View File

@ -10,6 +10,7 @@ import fuzzysort from 'fuzzysort';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import { Icon } from 'mastodon/components/icon';
import { injectIntl } from '@/mastodon/components/intl';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { toServerSideType } from 'mastodon/utils/filters';
import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
@ -176,7 +177,9 @@ class SelectFilter extends PureComponent {
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>
<div className='emoji-mart-search'>

View File

@ -8,6 +8,7 @@ import { escapeRegExp } from 'lodash';
import { useDebouncedCallback } from 'use-debounce';
import { DisplayName } from '@/mastodon/components/display_name';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { openModal, closeModal } from 'mastodon/actions/modal';
import { apiRequest } from 'mastodon/api';
import { Button } from 'mastodon/components/button';
@ -474,12 +475,12 @@ const InteractionModal: React.FC<{
return (
<div className='modal-root__modal interaction-modal'>
<div className='interaction-modal__lead'>
<h3>
<NavigationFocusTarget as='h1'>
<FormattedMessage
id='interaction_modal.title'
defaultMessage='Sign in to continue'
/>
</h3>
</NavigationFocusTarget>
<p>
{intent === 'follow' ? (
<FormattedMessage

View File

@ -18,6 +18,7 @@ import type { ApiListJSON } from 'mastodon/api_types/lists';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
@ -182,13 +183,13 @@ const ListAdder: React.FC<{
onClick={onClose}
/>
<span className='dialog-modal__header__title'>
<NavigationFocusTarget as='h1' className='dialog-modal__header__title'>
<FormattedMessage
id='lists.add_to_lists'
defaultMessage='Add {name} to lists'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</NavigationFocusTarget>
</div>
<div className='dialog-modal__content'>

View File

@ -8,6 +8,7 @@ import type { List as ImmutableList, RecordOf } from 'immutable';
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import { FOCUS_TARGET } from '@/mastodon/components/navigation_focus_target';
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 { toggleStatusSpoilers } from 'mastodon/actions/statuses';
@ -67,7 +68,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
const path = `/@${account.acct}/${statusId}`;
if (button === 0 && !(ctrlKey || metaKey)) {
history.push(path);
history.push(path, { focusTarget: FOCUS_TARGET.POST });
} else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) {
window.open(path, '_blank', 'noopener');
}

View File

@ -4,12 +4,15 @@ import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Helmet } from '@unhead/react/helmet';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { apiGetPrivacyPolicy } from 'mastodon/api/instance';
import type { ApiPrivacyPolicyJSON } from 'mastodon/api_types/instance';
import { Column } from 'mastodon/components/column';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import { Skeleton } from 'mastodon/components/skeleton';
import { getColumnSkipLinkId } from '../ui/components/skip_links';
const messages = defineMessages({
title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' },
});
@ -40,12 +43,12 @@ const PrivacyPolicy: React.FC<{
>
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3>
<NavigationFocusTarget as='h1' id={getColumnSkipLinkId(1)}>
<FormattedMessage
id='privacy_policy.title'
defaultMessage='Privacy Policy'
/>
</h3>
</NavigationFocusTarget>
<p>
<FormattedMessage
id='privacy_policy.last_updated'

View File

@ -3,11 +3,10 @@ import { PureComponent } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { Button } from 'mastodon/components/button';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { injectIntl } from '@/mastodon/components/intl';
import Option from './components/option';
@ -84,7 +83,9 @@ class Category extends PureComponent {
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>
<div>

View File

@ -7,6 +7,7 @@ import { OrderedSet } from 'immutable';
import { shallowEqual } from 'react-redux';
import { Toggle } from '@/mastodon/components/form_fields/toggle_field';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { fetchAccount } from 'mastodon/actions/accounts';
import { Button } from 'mastodon/components/button';
import type { Status } from 'mastodon/models/status';
@ -148,14 +149,18 @@ const Comment: React.FC<Props> = ({
return (
<>
<h3 className='report-dialog-modal__title' id={titleId}>
<NavigationFocusTarget
as='h1'
id={titleId}
className='report-dialog-modal__title'
>
{modalTitle ?? (
<FormattedMessage
id='report.comment.title'
defaultMessage='Is there anything else you think we should know?'
/>
)}
</h3>
</NavigationFocusTarget>
<textarea
className='report-dialog-modal__textarea'

View File

@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { Button } from 'mastodon/components/button';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import Option from './components/option';
@ -40,7 +41,9 @@ class Rules extends PureComponent {
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>
<div>

View File

@ -10,6 +10,7 @@ import { connect } from 'react-redux';
import { Button } from 'mastodon/components/button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusCheckBox from 'mastodon/features/report/containers/status_check_box_container';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
const mapStateToProps = (state, { accountId }) => ({
availableStatusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])),
@ -37,7 +38,9 @@ class Statuses extends PureComponent {
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>
<div className='report-dialog-modal__statuses'>

View File

@ -12,6 +12,7 @@ import {
blockAccount,
} from 'mastodon/actions/accounts';
import { Button } from 'mastodon/components/button';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
const mapStateToProps = () => ({});
@ -52,7 +53,9 @@ class Thanks extends PureComponent {
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>
{account.getIn(['relationship', 'following']) && (

View File

@ -72,6 +72,7 @@ import ActionBar from './components/action_bar';
import { DetailedStatus } from './components/detailed_status';
import { RefreshController } from './components/refresh_controller';
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
import { FOCUS_TARGET, NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
const messages = defineMessages({
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
@ -585,7 +586,13 @@ class Status extends ImmutablePureComponent {
{ancestors}
<Hotkeys handlers={handlers}>
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader({intl, status})} 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})} ref={this.setStatusRef}
>
<DetailedStatus
key={`details-${status.get('id')}`}
status={status}
@ -626,7 +633,7 @@ class Status extends ImmutablePureComponent {
onPin={this.handlePin}
onEmbed={this.handleEmbed}
/>
</div>
</NavigationFocusTarget>
</Hotkeys>
{descendants}

View File

@ -11,11 +11,14 @@ import { Link, useParams } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { apiGetTermsOfService } from 'mastodon/api/instance';
import type { ApiTermsOfServiceJSON } from 'mastodon/api_types/instance';
import { Column } from 'mastodon/components/column';
import { BundleColumnError } from 'mastodon/features/ui/components/bundle_column_error';
import { getColumnSkipLinkId } from '../ui/components/skip_links';
const messages = defineMessages({
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='column-title'>
<h3>
<NavigationFocusTarget as='h1' id={getColumnSkipLinkId(1)}>
<FormattedMessage
id='terms_of_service.title'
defaultMessage='Terms of Service'
/>
</h3>
</NavigationFocusTarget>
<p className='prose'>
{response?.effective ? (
<FormattedMessage

View File

@ -1,6 +1,7 @@
import { render, fireEvent, screen } from '@/testing/rendering';
import Column from '../column';
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
const fakeIcon = () => <span />;
@ -9,9 +10,11 @@ describe('<Column />', () => {
it('runs the scroll animation if the column contains scrollable content', () => {
const scrollToMock = vi.fn();
const { container } = render(
<FocusTargetProvider>
<Column heading='notifications' icon='notifications' iconComponent={fakeIcon}>
<div className='scrollable' />
</Column>,
</Column>
</FocusTargetProvider>,
);
container.querySelector('.scrollable').scrollTo = scrollToMock;
fireEvent.click(screen.getByText('notifications'));
@ -19,7 +22,11 @@ describe('<Column />', () => {
});
it('does not try to scroll if there is no scrollable content', () => {
render(<Column heading='notifications' icon='notifications' iconComponent={fakeIcon} />);
render(
<FocusTargetProvider>
<Column heading='notifications' icon='notifications' iconComponent={fakeIcon} />
</FocusTargetProvider>
);
fireEvent.click(screen.getByText('notifications'));
});
});

View File

@ -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 { blockAccount } from 'mastodon/actions/accounts';
import { closeModal } from 'mastodon/actions/modal';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
@ -46,7 +47,9 @@ export const BlockModal = ({ accountId, acct }) => {
</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>
</div>
</div>

View File

@ -7,6 +7,7 @@ import classNames from 'classnames';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import PrivacyDropdown from 'mastodon/features/compose/components/privacy_dropdown';
import { EmbeddedStatus } from 'mastodon/features/notifications_v2/components/embedded_status';
import type { Status, StatusVisibility } from 'mastodon/models/status';
@ -68,7 +69,7 @@ export const BoostModal: React.FC<{
</div>
<div>
<h1>
<NavigationFocusTarget as='h1'>
{status.get('reblogged') ? (
<FormattedMessage
id='boost_modal.undo_reblog'
@ -80,7 +81,7 @@ export const BoostModal: React.FC<{
defaultMessage='Boost post?'
/>
)}
</h1>
</NavigationFocusTarget>
<div>
<FormattedMessage
id='boost_modal.combo'

View File

@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { Button } from 'mastodon/components/button';
import {
ModalShell,
@ -67,7 +68,13 @@ export const ConfirmationModal: React.FC<
return (
<ModalShell onSubmit={handleClick}>
<ModalShellBody className={className}>
<h1 id={titleId}>{title}</h1>
{noFocusButton ? (
<NavigationFocusTarget as='h1' id={titleId}>
{title}
</NavigationFocusTarget>
) : (
<h1>{title}</h1>
)}
{message && <p>{message}</p>}
{extraContent ?? children}

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Button } from '@/mastodon/components/button';
import { IconButton } from '@/mastodon/components/icon_button';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
export type { BaseConfirmationModalProps as DialogModalProps } from './confirmation_modals/confirmation_modal';
@ -47,7 +48,9 @@ export const DialogModal: FC<DialogModalProps> = ({
onClick={onClose}
/>
<h1 className='dialog-modal__header__title'>{title}</h1>
<NavigationFocusTarget as='h1' className='dialog-modal__header__title'>
{title}
</NavigationFocusTarget>
</div>
<div className='dialog-modal__content'>

View File

@ -15,6 +15,7 @@ import { apiRequest } from 'mastodon/api';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { ShortNumber } from 'mastodon/components/short_number';
import { useAppDispatch } from 'mastodon/store';
@ -77,12 +78,12 @@ export const DomainBlockModal: React.FC<{
</div>
<div>
<h1>
<NavigationFocusTarget as='h1'>
<FormattedMessage
id='domain_block_modal.title'
defaultMessage='Block domain?'
/>
</h1>
</NavigationFocusTarget>
<p>{domain}</p>
</div>
</div>

View File

@ -6,6 +6,7 @@ import { showAlertForError } from 'mastodon/actions/alerts';
import api from 'mastodon/api';
import { Button } from 'mastodon/components/button';
import { CopyPasteText } from 'mastodon/components/copy_paste_text';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { useAppDispatch } from 'mastodon/store';
interface OEmbedResponse {
@ -76,9 +77,9 @@ const EmbedModal: React.FC<{
<Button onClick={onClose}>
<FormattedMessage id='report.close' defaultMessage='Done' />
</Button>
<span className='dialog-modal__header__title'>
<NavigationFocusTarget as='h1' className='dialog-modal__header__title'>
<FormattedMessage id='status.embed' defaultMessage='Get embed code' />
</span>
</NavigationFocusTarget>
<Button secondary onClick={onClose}>
<FormattedMessage
id='confirmation_modal.cancel'

View File

@ -12,6 +12,7 @@ import { closeModal } from 'mastodon/actions/modal';
import { updateNotificationsPolicy } from 'mastodon/actions/notification_policies';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
export const IgnoreNotificationsModal = ({ filterType }) => {
const dispatch = useDispatch();
@ -57,7 +58,7 @@ export const IgnoreNotificationsModal = ({ filterType }) => {
<div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'>
<div className='safety-action-modal__header'>
<h1>{title}</h1>
<NavigationFocusTarget as='h1'>{title}</NavigationFocusTarget>
</div>
<ul className='safety-action-modal__bullet-points'>

View File

@ -18,6 +18,7 @@ import { closeModal } from 'mastodon/actions/modal';
import { Button } from 'mastodon/components/button';
import { CheckBox } from 'mastodon/components/check_box';
import { Icon } from 'mastodon/components/icon';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { RadioButton } from 'mastodon/components/radio_button';
const messages = defineMessages({
@ -84,7 +85,9 @@ export const MuteModal = ({ accountId, acct }) => {
</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>
</div>
</div>

View File

@ -9,6 +9,7 @@ import { fetchServer } from 'mastodon/actions/server';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Button } from 'mastodon/components/button';
import { IconButton } from 'mastodon/components/icon_button';
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
import { useAccount } from 'mastodon/hooks/useAccount';
import { useAppDispatch } from 'mastodon/store';
@ -23,12 +24,12 @@ const CollectionThanks: React.FC<{
}> = ({ onClose }) => {
return (
<>
<h3 className='report-dialog-modal__title'>
<NavigationFocusTarget as='h1' className='report-dialog-modal__title'>
<FormattedMessage
id='report.thanks.title_actionable'
defaultMessage="Thanks for reporting, we'll look into this."
/>
</h3>
</NavigationFocusTarget>
<div className='flex-spacer' />

View File

@ -13,6 +13,7 @@ import { Button } from '@/mastodon/components/button';
import { Dropdown } from '@/mastodon/components/dropdown';
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
import { IconButton } from '@/mastodon/components/icon_button';
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
import { messages as privacyMessages } from '@/mastodon/features/compose/components/privacy_dropdown';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
@ -217,14 +218,15 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
iconComponent={CloseIcon}
onClick={onClose}
/>
<NavigationFocusTarget
as='h1'
className='dialog-modal__header__title'
>
<FormattedMessage
id='visibility_modal.header'
defaultMessage='Visibility and interaction'
>
{(chunks) => (
<span className='dialog-modal__header__title'>{chunks}</span>
)}
</FormattedMessage>
/>
</NavigationFocusTarget>
</div>
<div className='dialog-modal__content'>
<div className='dialog-modal__content__description'>

View File

@ -187,7 +187,7 @@ class SwitchingColumnsArea extends PureComponent {
<ColumnsContextProvider multiColumn={!singleColumn}>
<ColumnsArea ref={this.setRef} singleColumn={singleColumn} domain={domain} minimalShell={!signedIn && landingPage === 'overview'}>
<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 && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Прагледзець абнаўленні",
"home.pending_critical_update.title": "Даступна крытычнае абнаўленне бяспекі!",
"home.show_announcements": "Паказаць аб'явы",
"ignore_notifications_modal.bots_title": "Ігнараваць апавяшчэнні ад ботаў?",
"ignore_notifications_modal.disclaimer": "Mastodon не можа паведамляць карыстальнікам, што Вы праігнаравалі апавяшчэнні ад іх. Ігнараванне апавяшчэнняў не спыніць адпраўку саміх паведамленняў.",
"ignore_notifications_modal.filter_instead": "Замест гэтага адфільтраваць",
"ignore_notifications_modal.filter_to_act_users": "Вы па-ранейшаму зможаце прымаць, адхіляць ці скардзіцца на карыстальнікаў",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Iгнараваць",
"notifications.policy.drop_hint": "Адправіць у бездань, адкуль больш ніколі не ўбачыце",
"notifications.policy.filter": "Фільтраваць",
"notifications.policy.filter_bots_hint": "Уліковыя запісы, пазначаныя як аўтаматычныя",
"notifications.policy.filter_bots_title": "Боты",
"notifications.policy.filter_hint": "Адправіць у скрыню адфільтраваных апавяшчэнняў",
"notifications.policy.filter_limited_accounts_hint": "Абмежавана мадэратарамі сервера",
"notifications.policy.filter_limited_accounts_title": "Уліковыя запісы пад мадэрацыяй",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Se opdateringer",
"home.pending_critical_update.title": "Kritisk sikkerhedsopdatering tilgængelig!",
"home.show_announcements": "Vis bekendtgørelser",
"ignore_notifications_modal.bots_title": "Ignorér notifikationer fra bots?",
"ignore_notifications_modal.disclaimer": "Mastodon kan ikke informere brugere om, at du har ignoreret deres notifikationer. At ignorere notifikationer forhindrer ikke selve beskederne i at blive sendt.",
"ignore_notifications_modal.filter_instead": "Filtrér i stedet",
"ignore_notifications_modal.filter_to_act_users": "Du vil stadig kunne acceptere, afvise eller anmelde brugere",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Ignorér",
"notifications.policy.drop_hint": "Send til intetheden, for aldrig at blive set igen",
"notifications.policy.filter": "Filter",
"notifications.policy.filter_bots_hint": "Konti markeret som automatiseret",
"notifications.policy.filter_bots_title": "Bots",
"notifications.policy.filter_hint": "Send til filtrerede notifikationsindbakke",
"notifications.policy.filter_limited_accounts_hint": "Begrænset af servermoderatorer",
"notifications.policy.filter_limited_accounts_title": "Modererede konti",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Updates ansehen",
"home.pending_critical_update.title": "Kritisches Sicherheitsupdate verfügbar!",
"home.show_announcements": "Ankündigungen anzeigen",
"ignore_notifications_modal.bots_title": "Benachrichtigungen von Bots ignorieren?",
"ignore_notifications_modal.disclaimer": "Mastodon kann anderen Nutzer*innen nicht mitteilen, dass du deren Benachrichtigungen ignorierst. Das Ignorieren von Benachrichtigungen wird nicht das Absenden der Nachricht selbst unterbinden.",
"ignore_notifications_modal.filter_instead": "Stattdessen filtern",
"ignore_notifications_modal.filter_to_act_users": "Du wirst weiterhin die Möglichkeit haben, andere Nutzer*innen zu akzeptieren, abzulehnen oder zu melden",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Ignorieren",
"notifications.policy.drop_hint": "Ins Nirwana befördern und auf Nimmerwiedersehen!",
"notifications.policy.filter": "Filtern",
"notifications.policy.filter_bots_hint": "Als automatisiert gekennzeichnete Konten",
"notifications.policy.filter_bots_title": "Bots",
"notifications.policy.filter_hint": "Im separaten Feed „Gefilterte Benachrichtigungen“ anzeigen",
"notifications.policy.filter_limited_accounts_hint": "Durch Server-Moderator*innen eingeschränkte Profile",
"notifications.policy.filter_limited_accounts_title": "eingeschränkten Konten",

View File

@ -577,7 +577,7 @@
"content_warning.show_more": "Εμφάνιση περισσότερων",
"content_warning.show_short": "Εμφάνιση",
"conversation.delete": "Διαγραφή συνομιλίας",
"conversation.mark_as_read": "Σήμανση ως αναγνωσμένη",
"conversation.mark_as_read": "Σήμανση ως διαβασμένη",
"conversation.open": "Προβολή συνομιλίας",
"conversation.with": "Με {names}",
"copy_icon_button.copied": "Αντιγράφηκε στο πρόχειρο",
@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Δείτε ενημερώσεις",
"home.pending_critical_update.title": "Κρίσιμη ενημέρωση ασφαλείας διαθέσιμη!",
"home.show_announcements": "Εμφάνιση ανακοινώσεων",
"ignore_notifications_modal.bots_title": "Αγνόηση ειδοποιήσεων από bots;",
"ignore_notifications_modal.disclaimer": "Το Mastodon δε μπορεί να ενημερώσει τους χρήστες ότι αγνόησες τις ειδοποιήσεις του. Η αγνόηση ειδοποιήσεων δεν θα εμποδίσει την αποστολή των ίδιων των μηνυμάτων.",
"ignore_notifications_modal.filter_instead": "Φίλτρο αντ' αυτού",
"ignore_notifications_modal.filter_to_act_users": "Θα μπορείς ακόμη να αποδεχθείς, να απορρίψεις ή να αναφέρεις χρήστες",
@ -1011,41 +1012,43 @@
"notifications.column_settings.filter_bar.category": "Μπάρα γρήγορου φίλτρου",
"notifications.column_settings.follow": "Νέοι ακόλουθοι:",
"notifications.column_settings.follow_request": "Νέο αίτημα ακολούθησης:",
"notifications.column_settings.group": "Ομάδα",
"notifications.column_settings.group": "Ομαδοποίηση",
"notifications.column_settings.mention": "Επισημάνσεις:",
"notifications.column_settings.poll": "Αποτελέσματα δημοσκόπησης:",
"notifications.column_settings.push": "Ειδοποιήσεις Push",
"notifications.column_settings.quote": "Παραθέσεις:",
"notifications.column_settings.reblog": "Ενισχύσεις:",
"notifications.column_settings.show": "Εμφάνισε σε στήλη",
"notifications.column_settings.show": "Εμφάνιση σε στήλη",
"notifications.column_settings.sound": "Αναπαραγωγή ήχου",
"notifications.column_settings.status": "Νέες αναρτήσεις:",
"notifications.column_settings.unread_notifications.category": "Μη αναγνωσμένες ειδοποιήσεις",
"notifications.column_settings.unread_notifications.highlight": "Επισήμανση μη αναγνωσμένων ειδοποιήσεων",
"notifications.column_settings.unread_notifications.category": "Αδιάβαστες ειδοποιήσεις",
"notifications.column_settings.unread_notifications.highlight": "Επισήμανση αδιάβαστων ειδοποιήσεων",
"notifications.column_settings.update": "Επεξεργασίες:",
"notifications.filter.all": "Όλες",
"notifications.filter.boosts": "Προωθήσεις",
"notifications.filter.collections": "Συλλογές",
"notifications.filter.favourites": "Αγαπημένα",
"notifications.filter.follows": "Ακολουθείς",
"notifications.filter.follows": "Νέοι ακόλουθοι",
"notifications.filter.mentions": "Επισημάνσεις",
"notifications.filter.polls": "Αποτελέσματα δημοσκόπησης",
"notifications.filter.statuses": "Ενημερώσεις από όσους ακολουθείς",
"notifications.grant_permission": "Χορήγηση άδειας.",
"notifications.group": "{count} ειδοποιήσεις",
"notifications.mark_as_read": "Σήμανε όλες τις ειδοποιήσεις ως αναγνωσμένες",
"notifications.mark_as_read": "Σήμανε όλες τις ειδοποιήσεις ως διαβασμένες",
"notifications.permission_denied": "Οι ειδοποιήσεις για υπολογιστή δεν είναι διαθέσιμες διότι έχει απορριφθεί κάποιο προηγούμενο αίτημα άδειας",
"notifications.permission_denied_alert": "Δεν είναι δυνατή η ενεργοποίηση των ειδοποιήσεων για υπολογιστή, καθώς η άδεια του προγράμματος περιήγησης έχει απορριφθεί προηγουμένων",
"notifications.permission_denied_alert": "Δεν είναι δυνατή η ενεργοποίηση των ειδοποιήσεων για υπολογιστή, καθώς η άδεια του προγράμματος περιήγησης έχει απορριφθεί προηγουμένως",
"notifications.permission_required": "Οι ειδοποιήσεις για υπολογιστή δεν είναι διαθέσιμες επειδή δεν έχει δοθεί η απαιτούμενη άδεια.",
"notifications.policy.accept": "Αποδοχή",
"notifications.policy.accept_hint": "Εμφάνιση στις ειδοποιήσεις",
"notifications.policy.drop": "Αγνόηση",
"notifications.policy.drop_hint": "Στείλε τες στο υπερπέραν, για να μην τις ξαναδείτε",
"notifications.policy.filter": "Φίλτρο",
"notifications.policy.drop_hint": "Στείλε τες στο υπερπέραν, για να μην τις ξαναδείς",
"notifications.policy.filter": "Φιλτράρισμα",
"notifications.policy.filter_bots_hint": "Λογαριασμοί σημασμένοι ως αυτοματοποιημένοι",
"notifications.policy.filter_bots_title": "Bots",
"notifications.policy.filter_hint": "Αποστολή στα εισερχόμενα φιλτραρισμένων ειδοποιήσεων",
"notifications.policy.filter_limited_accounts_hint": "Περιορισμένη από συντονιστές διακομιστή",
"notifications.policy.filter_limited_accounts_hint": "Περιορισμένοι από συντονιστές διακομιστή",
"notifications.policy.filter_limited_accounts_title": "Συντονισμένοι λογαριασμοί",
"notifications.policy.filter_new_accounts.hint": "Δημιουργήθηκε εντός {days, plural, one {της τελευταίας ημέρας} other {των τελευταίων # ημερών}}",
"notifications.policy.filter_new_accounts.hint": "Δημιουργημένοι εντός {days, plural, one {της τελευταίας ημέρας} other {των τελευταίων # ημερών}}",
"notifications.policy.filter_new_accounts_title": "Νέοι λογαριασμοί",
"notifications.policy.filter_not_followers_hint": "Συμπεριλαμβανομένων των ατόμων που σας έχουν ακολουθήσει λιγότερο από {days, plural, one {μια ημέρα} other {# ημέρες}} πριν",
"notifications.policy.filter_not_followers_title": "Άτομα που δε σε ακολουθούν",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Ver actualizaciones",
"home.pending_critical_update.title": "¡Actualización de seguridad crítica disponible!",
"home.show_announcements": "Mostrar anuncios",
"ignore_notifications_modal.bots_title": "¿Ignorar notificaciones de bots?",
"ignore_notifications_modal.disclaimer": "Mastodon no puede informar a los usuarios que ignoraste sus notificaciones. Ignorar notificaciones no impedirá que se sigan enviando los mensajes.",
"ignore_notifications_modal.filter_instead": "Filtrar en vez de ignorar",
"ignore_notifications_modal.filter_to_act_users": "Aún podrás aceptar, rechazar o denunciar a usuarios",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Ignorar",
"notifications.policy.drop_hint": "Enviar al vacío, no volver a mostrar nunca",
"notifications.policy.filter": "Filtrar",
"notifications.policy.filter_bots_hint": "Cuentas marcadas como automatizadas",
"notifications.policy.filter_bots_title": "Botes",
"notifications.policy.filter_hint": "Enviar a la bandeja de entrada de notificaciones filtradas",
"notifications.policy.filter_limited_accounts_hint": "Limitada por los moderadores del servidor",
"notifications.policy.filter_limited_accounts_title": "Cuentas moderadas",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Ver actualizaciones",
"home.pending_critical_update.title": "¡Actualización de seguridad crítica disponible!",
"home.show_announcements": "Mostrar anuncios",
"ignore_notifications_modal.bots_title": "¿Ignorar notificaciones de bots?",
"ignore_notifications_modal.disclaimer": "Mastodon no puede informar a los usuarios que has ignorado sus notificaciones. Ignorar notificaciones no impedirá que se sigan enviando los mensajes.",
"ignore_notifications_modal.filter_instead": "Filtrar en su lugar",
"ignore_notifications_modal.filter_to_act_users": "Aún podrás aceptar, rechazar o reportar usuarios",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Ignorar",
"notifications.policy.drop_hint": "Enviar al vacío, no volver a mostrar nunca",
"notifications.policy.filter": "Filtrar",
"notifications.policy.filter_bots_hint": "Cuentas etiquetadas como automatizadas",
"notifications.policy.filter_bots_title": "Bots",
"notifications.policy.filter_hint": "Enviar a la bandeja de entrada de notificaciones filtradas",
"notifications.policy.filter_limited_accounts_hint": "Limitadas por los moderadores del servidor",
"notifications.policy.filter_limited_accounts_title": "Cuentas moderadas",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Ver actualizaciones",
"home.pending_critical_update.title": "¡Actualización de seguridad crítica disponible!",
"home.show_announcements": "Mostrar comunicaciones",
"ignore_notifications_modal.bots_title": "¿Ignorar notificaciones de bots?",
"ignore_notifications_modal.disclaimer": "Mastodon no puede informar a los usuarios de que has ignorado sus notificaciones. Ignorar notificaciones no impedirá que se sigan enviando los mensajes.",
"ignore_notifications_modal.filter_instead": "Filtrar en vez de ignorar",
"ignore_notifications_modal.filter_to_act_users": "Aún podrás aceptar, rechazar o reportar usuarios",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Ignorar",
"notifications.policy.drop_hint": "Enviar al vacío, no volver a mostrar nunca",
"notifications.policy.filter": "Filtrar",
"notifications.policy.filter_bots_hint": "Cuentas etiquetadas como automatizadas",
"notifications.policy.filter_bots_title": "Bots",
"notifications.policy.filter_hint": "Enviar a la bandeja de entrada de notificaciones filtradas",
"notifications.policy.filter_limited_accounts_hint": "Limitadas por los moderadores del servidor",
"notifications.policy.filter_limited_accounts_title": "Cuentas moderadas",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Tutustu päivityssisältöihin",
"home.pending_critical_update.title": "Kriittinen tietoturvapäivitys saatavilla!",
"home.show_announcements": "Näytä tiedotteet",
"ignore_notifications_modal.bots_title": "Sivuutetaanko ilmoitukset boteilta?",
"ignore_notifications_modal.disclaimer": "Mastodon ei voi ilmoittaa käyttäjille, että olet sivuuttanut heidän ilmoituksensa. Ilmoitusten sivuuttaminen ei lopeta itse viestien lähetystä.",
"ignore_notifications_modal.filter_instead": "Suodata sen sijaan",
"ignore_notifications_modal.filter_to_act_users": "Voit kuitenkin yhä hyväksyä, hylätä tai raportoida käyttäjiä",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Sivuuta",
"notifications.policy.drop_hint": "Lähetä tyhjyyteen, jotta et näe niitä enää koskaan",
"notifications.policy.filter": "Suodata",
"notifications.policy.filter_bots_hint": "Automatisoiduiksi merkityt tilit",
"notifications.policy.filter_bots_title": "Botit",
"notifications.policy.filter_hint": "Lähetä suodatettuihin ilmoituksiin",
"notifications.policy.filter_limited_accounts_hint": "Palvelimen moderaattorien rajoittamat",
"notifications.policy.filter_limited_accounts_title": "Moderoidut tilit",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Féach nuashonruithe",
"home.pending_critical_update.title": "Nuashonrú slándála ríthábhachtach ar fáil!",
"home.show_announcements": "Taispeáin fógraí",
"ignore_notifications_modal.bots_title": "Neamhaird a dhéanamh dfhógraí ó bhotaí?",
"ignore_notifications_modal.disclaimer": "Ní féidir le Mastodon úsáideoirí a chur ar an eolas gur thug tú neamhaird dá bhfógraí. Má dhéantar neamhaird de fhógraí, ní stopfar na teachtaireachtaí iad féin a sheoladh.",
"ignore_notifications_modal.filter_instead": "Scag ina ionad sin",
"ignore_notifications_modal.filter_to_act_users": "Beidh tú fós in ann glacadh le húsáideoirí, iad a dhiúltú nó a thuairisciú",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Déan neamhaird de",
"notifications.policy.drop_hint": "Seol chuig an neamhní, gan a bheith le feiceáil arís",
"notifications.policy.filter": "Scagaire",
"notifications.policy.filter_bots_hint": "Cuntais atá marcáilte mar uathoibrithe",
"notifications.policy.filter_bots_title": "Botanna",
"notifications.policy.filter_hint": "Seol chuig an mbosca isteach fógraí scagtha",
"notifications.policy.filter_limited_accounts_hint": "Teoranta ag modhnóirí freastalaí",
"notifications.policy.filter_limited_accounts_title": "Cuntais mhodhnaithe",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Mira as actualizacións",
"home.pending_critical_update.title": "Hai una actualización crítica de seguridade!",
"home.show_announcements": "Amosar anuncios",
"ignore_notifications_modal.bots_title": "Ignorar as notificacións dos robots?",
"ignore_notifications_modal.disclaimer": "Mastodon non pode informar ás usuarias de que ignoraches as súas notificacións. Ao ignorar as notificacións non evitarás que as mensaxes sexan enviadas igualmente.",
"ignore_notifications_modal.filter_instead": "Filtrar igualmente",
"ignore_notifications_modal.filter_to_act_users": "Poderás seguir aceptando, rexeitando e denunciando usuarias",
@ -846,6 +847,7 @@
"keyboard_shortcuts.toggle_sensitivity": "Para amosar/agochar contido multimedia",
"keyboard_shortcuts.toot": "Para escribir unha nova publicación",
"keyboard_shortcuts.top": "Mover arriba de todo",
"keyboard_shortcuts.translate": "Traducir unha publicación",
"keyboard_shortcuts.unfocus": "Para deixar de destacar a área de escritura/procura",
"keyboard_shortcuts.up": "Para mover cara arriba na listaxe",
"learn_more_link.got_it": "Entendo",
@ -1041,6 +1043,8 @@
"notifications.policy.drop": "Ignorar",
"notifications.policy.drop_hint": "Esquecer isto, non volver a velo",
"notifications.policy.filter": "Filtrar",
"notifications.policy.filter_bots_hint": "Contas marcadas como automatizadas",
"notifications.policy.filter_bots_title": "Robots",
"notifications.policy.filter_hint": "Enviar á caixa de notificacións filtradas",
"notifications.policy.filter_limited_accounts_hint": "Limitada pola moderación do servidor",
"notifications.policy.filter_limited_accounts_title": "Contas moderadas",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "צפיה בעדכונים",
"home.pending_critical_update.title": "יצא עדכון אבטחה חשוב!",
"home.show_announcements": "הצג הכרזות",
"ignore_notifications_modal.bots_title": "להתעלם מהתראות מחשבונות רובוטיים?",
"ignore_notifications_modal.disclaimer": "מסטודון אינו יכול ליידע משתמשים שהתעלמתם מהתראותיהם. התעלמות מהתראות לא תחסום את ההודעות עצמן מלהשלח.",
"ignore_notifications_modal.filter_instead": "לסנן במקום",
"ignore_notifications_modal.filter_to_act_users": "עדיין ביכולתך לקבל, לדחות ולדווח על משתמשים אחרים",
@ -846,6 +847,7 @@
"keyboard_shortcuts.toggle_sensitivity": "הצגת/הסתרת מדיה",
"keyboard_shortcuts.toot": "להתחיל חיצרוץ חדש",
"keyboard_shortcuts.top": "העברה לראש הרשימה",
"keyboard_shortcuts.translate": "לתרגם הודעה",
"keyboard_shortcuts.unfocus": "לצאת מתיבת חיבור/חיפוש",
"keyboard_shortcuts.up": "לנוע במעלה הרשימה",
"learn_more_link.got_it": "הבנתי",
@ -1041,6 +1043,8 @@
"notifications.policy.drop": "להתעלם",
"notifications.policy.drop_hint": "שליחה אל מצולות הנשיה, ולא יוודעו אודותיה לעולם",
"notifications.policy.filter": "מסנן",
"notifications.policy.filter_bots_hint": "חשבונות המסומנים כאוטומטיים",
"notifications.policy.filter_bots_title": "בוטים",
"notifications.policy.filter_hint": "שליחה לתיבה נכנסת מסוננת",
"notifications.policy.filter_limited_accounts_hint": "הוגבל על ידי מנהלי הדיונים",
"notifications.policy.filter_limited_accounts_title": "חשבומות תחת ניהול תוכן",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Skoða uppfærslur",
"home.pending_critical_update.title": "Áríðandi öryggisuppfærsla er tiltæk!",
"home.show_announcements": "Birta auglýsingar",
"ignore_notifications_modal.bots_title": "Hunsa tilkynningar frá yrkjum?",
"ignore_notifications_modal.disclaimer": "Mastodon getur ekki upplýst notendur um að þú hunsir tilkynningar frá þeim. Hunsun tilkynninga kemur ekki í veg fyrir að sjálf skilaboðin verði send.",
"ignore_notifications_modal.filter_instead": "Sía frekar",
"ignore_notifications_modal.filter_to_act_users": "Þú munt áfram geta samþykkt, hafnað eða kært notendur",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Hunsa",
"notifications.policy.drop_hint": "Senda út í tómið, svo það sjáist aldrei framar",
"notifications.policy.filter": "Sía",
"notifications.policy.filter_bots_hint": "Aðgangar merktir fyrir yrki",
"notifications.policy.filter_bots_title": "Yrki",
"notifications.policy.filter_hint": "Senda í pósthólf fyrir síaðar tilkynningar",
"notifications.policy.filter_limited_accounts_hint": "Takmarkað af umsjónarmönnum netþjóns",
"notifications.policy.filter_limited_accounts_title": "Aðgangar í umsjón",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Visualizza aggiornamenti",
"home.pending_critical_update.title": "Aggiornamento critico di sicurezza disponibile!",
"home.show_announcements": "Mostra annunci",
"ignore_notifications_modal.bots_title": "Ignorare le notifiche dai bot?",
"ignore_notifications_modal.disclaimer": "Mastodon non può informare gli utenti che hai ignorato le loro notifiche. Ignorare le notifiche non impedirà l'invio dei messaggi stessi.",
"ignore_notifications_modal.filter_instead": "Filtra invece",
"ignore_notifications_modal.filter_to_act_users": "Potrai comunque accettare, rifiutare o segnalare gli utenti",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Ignora",
"notifications.policy.drop_hint": "Scarta definitivamente, per non essere mai più visto",
"notifications.policy.filter": "Filtrare",
"notifications.policy.filter_bots_hint": "Account contrassegnati come automatizzati",
"notifications.policy.filter_bots_title": "Bot",
"notifications.policy.filter_hint": "Invia alla casella in arrivo delle notifiche filtrate",
"notifications.policy.filter_limited_accounts_hint": "Limitato dai moderatori del server",
"notifications.policy.filter_limited_accounts_title": "Account moderati",

View File

@ -43,6 +43,7 @@
"account.featured": "Wyróżnione",
"account.featured.accounts": "Profile",
"account.featured.collections": "Kolekcje",
"account.featured.new_collection": "Nowa kolekcja",
"account.field_overflow": "Pokaż całą zawartość",
"account.filters.all": "Wszystkie aktywności",
"account.filters.boosts_toggle": "Pokaż ulepszenia",
@ -68,9 +69,13 @@
"account.go_to_profile": "Przejdź do profilu",
"account.hide_reblogs": "Ukryj podbicia od @{name}",
"account.in_memoriam": "Ku pamięci.",
"account.join_modal.day": "Dzień",
"account.join_modal.me": "Dołączyłeś(aś) na {server}",
"account.join_modal.me_today": "To Twój pierwszy dzień na {server}!",
"account.join_modal.other": "{name} dołączył(a) na {server}",
"account.join_modal.share.celebrate": "Udostępnij uroczysty post",
"account.join_modal.share.intro": "Udostępnij post wprowadzający",
"account.join_modal.share.welcome": "Udostępnij post powitalny",
"account.joined_short": "Dołączył(a)",
"account.languages": "Zmień subskrybowane języki",
"account.last_active": "Ostatnia aktywność",
@ -120,6 +125,7 @@
"account.note.edit_button": "Edytuj",
"account.note.title": "Osobista notatka (widoczna tylko dla Ciebie)",
"account.open_original_page": "Otwórz stronę oryginalną",
"account.pending": "Oczekuje",
"account.posts": "Wpisy",
"account.remove_from_followers": "Usuń {name} z obserwujących",
"account.report": "Zgłoś @{name}",
@ -173,9 +179,12 @@
"account_edit.field_edit_modal.discard_confirm": "Odrzuć",
"account_edit.field_edit_modal.discard_message": "Masz niezapisane zmiany. Czy na pewno chcesz je odrzucić?",
"account_edit.field_edit_modal.edit_title": "Edytuj dodatkowe pole",
"account_edit.field_edit_modal.name_hint": "Np. Strona osobista",
"account_edit.field_edit_modal.name_label": "Etykieta",
"account_edit.field_edit_modal.value_label": "Wartość",
"account_edit.image_alt_modal.add_title": "Dodaj tekst alternatywny",
"account_edit.image_alt_modal.edit_title": "Edytuj tekst alternatywny",
"account_edit.image_alt_modal.text_hint": "Tekst alternatywny pomaga użytkownikom czytników ekranu zrozumieć twoją treść.",
"account_edit.image_alt_modal.text_label": "Tekst alternatywny",
"account_edit.image_delete_modal.delete_button": "Usuń",
"account_edit.image_delete_modal.title": "Usunąć obraz?",
@ -185,6 +194,8 @@
"account_edit.image_edit.remove_button": "Usuń obraz",
"account_edit.image_edit.replace_button": "Zastąp obraz",
"account_edit.item_list.delete": "Usuń {name}",
"account_edit.name_modal.add_title": "Dodaj nazwę wyświetlaną",
"account_edit.name_modal.edit_title": "Edytuj nazwę wyświetlaną",
"account_edit.profile_tab.button_label": "Dostosuj",
"account_edit.profile_tab.hint.description": "Te ustawienia wpływają na to, co użytkownicy widzą na {server} w oficjalnych aplikacjach, ale mogą nie obowiązywać na innych serwerach i w aplikacjach zewnętrznych.",
"account_edit.profile_tab.hint.title": "Wyświetlanie może się różnić",
@ -204,12 +215,14 @@
"account_edit.upload_modal.next": "Następne",
"account_edit.upload_modal.step_crop.zoom": "Powiększenie",
"account_edit.upload_modal.step_upload.button": "Wybierz pliki",
"account_edit.upload_modal.step_upload.dragging": "Upuść, aby przesłać",
"account_edit.upload_modal.step_upload.header": "Wybierz obraz",
"account_edit.upload_modal.step_upload.hint": "WEBP, PNG, GIF lub JPG, maksymalnie {limit} MB.{br}Obraz zostanie przeskalowany do {width}x{height} px.",
"account_edit.upload_modal.title_add.avatar": "Dodaj zdjęcie profilowe",
"account_edit.upload_modal.title_add.header": "Dodaj zdjęcie nagłówka",
"account_edit.upload_modal.title_replace.avatar": "Zmień zdjęcie profilowe",
"account_edit.upload_modal.title_replace.header": "Zmień zdjęcie nagłówka",
"account_edit.verified_modal.invisible_link.summary": "Co zrobić, aby link był niewidoczny?",
"account_edit_tags.add_tag": "Dodaj #{tagName}",
"account_edit_tags.search_placeholder": "Dodaj hashtag...",
"admin.dashboard.daily_retention": "Wskaźnik utrzymania użytkowników według dni od rejestracji",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Ver atualizações",
"home.pending_critical_update.title": "Atualização de segurança crítica disponível!",
"home.show_announcements": "Mostrar anúncios",
"ignore_notifications_modal.bots_title": "Ignorar notificações de robôs?",
"ignore_notifications_modal.disclaimer": "O Mastodon não informa os usuários se você ignorar as notificações deles. Ignorar notificações não impedirá as mensagens de serem enviadas.",
"ignore_notifications_modal.filter_instead": "Filtrar em vez disso",
"ignore_notifications_modal.filter_to_act_users": "Você ainda poderá aceitar, rejeitar ou denunciar",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Ignorar",
"notifications.policy.drop_hint": "Enviar para o vácuo, para nunca mais ser visto",
"notifications.policy.filter": "Filtrar",
"notifications.policy.filter_bots_hint": "Contas marcadas como automáticas",
"notifications.policy.filter_bots_title": "Robôs",
"notifications.policy.filter_hint": "Enviar para a caixa de notificações filtradas",
"notifications.policy.filter_limited_accounts_hint": "Limitado pelos moderadores do servidor",
"notifications.policy.filter_limited_accounts_title": "Contas moderadas",

View File

@ -841,6 +841,7 @@
"keyboard_shortcuts.toggle_sensitivity": "Për shfaqje/fshehje mediash",
"keyboard_shortcuts.toot": "Për të filluar një mesazh të ri",
"keyboard_shortcuts.top": "Shpjere në krye të listës",
"keyboard_shortcuts.translate": "Përkthe një postim",
"keyboard_shortcuts.unfocus": "Për heqjen e fokusit nga fusha e hartimit të mesazheve apo kërkimeve",
"keyboard_shortcuts.up": "Për ngjitje sipër nëpër listë",
"learn_more_link.got_it": "E mora vesh",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "Xem bản cập nhật",
"home.pending_critical_update.title": "Có bản cập nhật bảo mật quan trọng!",
"home.show_announcements": "Xem thông báo máy chủ",
"ignore_notifications_modal.bots_title": "Bỏ qua thông báo từ các tài khoản bot?",
"ignore_notifications_modal.disclaimer": "Mastodon sẽ không thông báo cho tài khoản rằng bạn đã bỏ qua thông báo của họ. Họ sẽ vẫn có thể tương tác với bạn.",
"ignore_notifications_modal.filter_instead": "Lọc thay thế",
"ignore_notifications_modal.filter_to_act_users": "Bạn vẫn có thể chấp nhận, từ chối hoặc báo cáo tài khoản khác",
@ -846,6 +847,7 @@
"keyboard_shortcuts.toggle_sensitivity": "ẩn/hiện ảnh hoặc video",
"keyboard_shortcuts.toot": "soạn tút mới",
"keyboard_shortcuts.top": "di chuyển đến đầu danh sách",
"keyboard_shortcuts.translate": "Dịch tút",
"keyboard_shortcuts.unfocus": "đưa con trỏ ra khỏi ô soạn thảo hoặc ô tìm kiếm",
"keyboard_shortcuts.up": "di chuyển lên trên danh sách",
"learn_more_link.got_it": "Đã hiểu",
@ -1041,6 +1043,8 @@
"notifications.policy.drop": "Bỏ qua",
"notifications.policy.drop_hint": "Bỏ qua vĩnh viễn",
"notifications.policy.filter": "Lọc",
"notifications.policy.filter_bots_hint": "Những tài khoản đánh dấu là tự động",
"notifications.policy.filter_bots_title": "Bot",
"notifications.policy.filter_hint": "Cho vào mục thông báo đã lọc",
"notifications.policy.filter_limited_accounts_hint": "Chỉ dành cho kiểm duyệt viên",
"notifications.policy.filter_limited_accounts_title": "Kiểm duyệt tài khoản",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "查看更新",
"home.pending_critical_update.title": "有紧急安全更新!",
"home.show_announcements": "显示公告",
"ignore_notifications_modal.bots_title": "是否忽略来自机器人账号的通知?",
"ignore_notifications_modal.disclaimer": "Mastodon无法通知对方用户你忽略了他们的通知。忽略通知不会阻止消息本身的发送。",
"ignore_notifications_modal.filter_instead": "改为过滤",
"ignore_notifications_modal.filter_to_act_users": "你仍然可以接受、拒绝或举报用户",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "忽略",
"notifications.policy.drop_hint": "送入虚空,再也不查看",
"notifications.policy.filter": "过滤",
"notifications.policy.filter_bots_hint": "被标记为自动化的账号",
"notifications.policy.filter_bots_title": "机器人",
"notifications.policy.filter_hint": "发送到被过滤通知列表",
"notifications.policy.filter_limited_accounts_hint": "被服务器管理员限制的账号",
"notifications.policy.filter_limited_accounts_title": "受限账号",

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "檢視更新內容",
"home.pending_critical_update.title": "有可取得的重要安全性更新!",
"home.show_announcements": "顯示公告",
"ignore_notifications_modal.bots_title": "是否忽略來自機器人帳號之推播通知?",
"ignore_notifications_modal.disclaimer": "Mastodon 無法通知您已忽略推播通知之使用者。忽略通知不會阻止訊息本身的發送。",
"ignore_notifications_modal.filter_instead": "改為過濾",
"ignore_notifications_modal.filter_to_act_users": "您仍能接受、拒絕、或檢舉使用者",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "忽略",
"notifications.policy.drop_hint": "送至黑洞,永不相見",
"notifications.policy.filter": "過濾器",
"notifications.policy.filter_bots_hint": "自動化帳號",
"notifications.policy.filter_bots_title": "機器人",
"notifications.policy.filter_hint": "送至已過濾推播通知收件夾",
"notifications.policy.filter_limited_accounts_hint": "已被伺服器管理員限制",
"notifications.policy.filter_limited_accounts_title": "受管制帳號",

View File

@ -155,6 +155,10 @@ function appendMedia(state, media, file) {
if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
map.set('sensitive', true);
if (state.get('default_sensitive')) {
map.set('spoiler', true);
}
}
});
}
@ -400,7 +404,7 @@ export const composeReducer = (state = initialState, action) => {
map.set('spoiler', !state.get('spoiler'));
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'));
}
});
@ -545,7 +549,7 @@ export const composeReducer = (state = initialState, action) => {
map.set('spoiler', true);
map.set('spoiler_text', action.status.get('spoiler_text'));
} else {
map.set('spoiler', false);
map.set('spoiler', action.status.get('sensitive') && action.status.get('media_attachments').size > 0);
map.set('spoiler_text', '');
}
@ -582,7 +586,7 @@ export const composeReducer = (state = initialState, action) => {
map.set('spoiler', true);
map.set('spoiler_text', action.spoiler_text);
} else {
map.set('spoiler', false);
map.set('spoiler', action.status.get('sensitive') && action.status.get('media_attachments').size > 0);
map.set('spoiler_text', '');
}

View File

@ -17,8 +17,10 @@ const Modal = ImmutableRecord<Modal>({
modalProps: ImmutableRecord({})(),
});
export const IGNORE_FOCUS_ON_OPEN = 'on-open';
interface ModalState {
ignoreFocus: boolean;
ignoreFocus: boolean | typeof IGNORE_FOCUS_ON_OPEN;
stack: Stack<ImmutableRecord<Modal>>;
}
@ -53,9 +55,10 @@ const pushModal = (
modalType: ModalType,
modalProps: ModalProps,
previousModalProps?: ModalProps,
ignoreFocusOnOpen = false,
): State => {
return state.withMutations((record) => {
record.set('ignoreFocus', false);
record.set('ignoreFocus', ignoreFocusOnOpen ? IGNORE_FOCUS_ON_OPEN : false);
record.update('stack', (stack) => {
let tmp = stack;
@ -92,6 +95,7 @@ export const modalReducer: Reducer<State> = (state = initialState, action) => {
action.payload.modalType,
action.payload.modalProps,
action.payload.previousModalProps,
action.payload.ignoreFocus,
);
else if (closeModal.match(action)) return popModal(state, action.payload);
// TODO: type those actions

View File

@ -40,11 +40,13 @@ describe('createCache', () => {
});
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('test2', 2);
cache.set('test3', 3);
expect(cache.get('test1')).toBeUndefined();
expect(cache.get('test2')).toBe(2);
expect(cache.get('test3')).toBe(3);
});
test('retrieving a value bumps up last access', () => {
@ -63,13 +65,13 @@ describe('createCache', () => {
const cache = createLimitedCache({ maxSize: 1, log });
cache.set('test1', 1);
expect(log).toHaveBeenLastCalledWith(
'Added %s to cache, now size %d',
'Added %o to cache, now size %d',
'test1',
1,
);
cache.set('test2', 1);
expect(log).toHaveBeenLastCalledWith(
'Added %s and deleted %s from cache, now size %d',
'Added %o and deleted %o from cache, now size %d',
'test2',
'test1',
1,

View File

@ -43,13 +43,13 @@ export function createLimitedCache<CacheValue, CacheKey = string>({
cacheMap.delete(lastKey);
cacheKeys.delete(lastKey);
log(
'Added %s and deleted %s from cache, now size %d',
'Added %o and deleted %o from cache, now size %d',
key,
lastKey,
cacheMap.size,
);
} else {
log('Added %s to cache, now size %d', key, cacheMap.size);
log('Added %o to cache, now size %d', key, cacheMap.size);
}
},
clear: () => {

View File

@ -3590,6 +3590,8 @@ a.account__display-name {
text-align: center;
padding-bottom: 32px;
h1,
h2,
h3 {
font-size: 24px;
line-height: 1.5;
@ -4641,6 +4643,7 @@ a.status-card {
&__title-wrapper {
display: flex;
flex-grow: 1;
min-width: 0;
}
&__title {
@ -4656,9 +4659,7 @@ a.status-card {
background: transparent;
font: inherit;
text-align: start;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
min-width: 0;
&--with-back-button {
padding-inline-start: 0;
@ -4673,6 +4674,12 @@ a.status-card {
}
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.column-header__back-button {
flex: 1;
color: var(--color-text-brand);
@ -9141,6 +9148,8 @@ noscript {
padding-bottom: 32px;
}
h1,
h2,
h3 {
font-size: 22px;
line-height: 33px;
@ -9171,6 +9180,8 @@ noscript {
&__lead {
margin-bottom: 20px;
h1,
h2,
h3 {
margin-bottom: 15px;
}
@ -9246,6 +9257,8 @@ noscript {
flex: 1;
box-sizing: border-box;
h1,
h2,
h3 {
margin-bottom: 20px;
}

View File

@ -39,8 +39,8 @@ class ReportFilter
end
def status_scope
resolved = params.key?(:resolved)
unresolved = params.key?(:unresolved)
resolved = ActiveModel::Type::Boolean.new.cast(params[:resolved])
unresolved = ActiveModel::Type::Boolean.new.cast(params[:unresolved])
return Report.all if resolved && unresolved
return Report.resolved if resolved

View File

@ -44,6 +44,16 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
end
end
class ImageWithDescription < SimpleDelegator
attr_reader :description
def initialize(object, description)
super(object)
@description = description
end
end
has_one :endpoints, serializer: EndpointsSerializer
has_one :icon, serializer: ActivityPub::ImageSerializer, if: :avatar_exists?
@ -120,11 +130,11 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
end
def icon
object.avatar
ImageWithDescription.new(object.avatar, object.avatar_description)
end
def image
object.header
ImageWithDescription.new(object.header, object.header_description)
end
def public_key

View File

@ -7,6 +7,7 @@ class ActivityPub::ImageSerializer < ActivityPub::Serializer
attributes :type, :media_type, :url
attribute :focal_point, if: :focal_point?
attribute :summary, if: :summary?
def type
'Image'
@ -27,4 +28,12 @@ class ActivityPub::ImageSerializer < ActivityPub::Serializer
def focal_point
[object.meta['focus']['x'], object.meta['focus']['y']]
end
def summary?
object.respond_to?(:description) && object.description.present?
end
def summary
object.description
end
end

View File

@ -206,7 +206,7 @@ class PostStatusService < BaseService
return
end
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > Status::MEDIA_ATTACHMENTS_LIMIT || @options[:poll].present?
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > Status::MEDIA_ATTACHMENTS_LIMIT
@media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(Status::MEDIA_ATTACHMENTS_LIMIT).map(&:to_i))

View File

@ -23,12 +23,15 @@ class ProcessLinksService < BaseService
def scan_text!
urls = @status.text.scan(FetchLinkCardService::URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
domains = urls.map(&:domain).uniq
valid_domains = Instance.searchable.where(domain: domains).pluck(:domain)
urls.each do |url|
# We only support `FeaturedCollection` at this time
# TODO: We probably want to resolve unknown objects at authoring time
object = ActivityPub::TagManager.instance.uri_to_resource(url.to_s, Collection)
next if object.nil?
object ||= ResolveURLService.new.call(url.to_s) if valid_domains.include?(url.domain)
next unless object.is_a?(Collection)
tagged_object = @previous_objects.find { |x| x.object == object || x.uri == url }
tagged_object ||= @current_objects.find { |x| x.object == object || x.uri == url }

View File

@ -0,0 +1,20 @@
- content_for :page_title, @collection.name
- content_for :header_tags do
%meta{ name: 'robots', content: 'noindex, noarchive' }/
%link{ rel: 'alternate', type: 'application/activity+json', href: ap_account_collection_url(@collection.account_id, @collection) }/
%link{ rel: 'alternate', type: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', href: ap_account_collection_url(@collection.account_id, @collection) }/
- unless @collection.sensitive?
%meta{ name: 'description', content: @collection.description }/
= opengraph 'og:description', @collection.description
= opengraph 'og:site_name', site_title
= opengraph 'og:type', 'website'
= opengraph 'og:title', @collection.name
= opengraph 'og:url', collection_url(@collection)
- if @collection.language.present?
= opengraph 'og:locale', @collection.language
= opengraph 'twitter:card', 'summary'
= render 'shared/web_app'

View File

@ -525,6 +525,21 @@ be:
no_lists_yet: Пакуль няма спісаў
last_email: Апошняя электронная пошта
lead: Уліковыя запісы, якія ўключылі гэту функцыю і маюць падпісчыкаў, будуць паказаныя знізу.
show:
confirm_disable_feature: Адключыць рассылку па электроннай пошце для %{name}? Абнаўленні больш не будуць прыходзіць на пошту гэтага ўліковага запісу. Карыстальнік усё яшчэ зможа ўключыць гэту функцыю нанова ў наладах уліковага запісу. Каб назаўсёды прыбраць доступ да гэтай функцыі, змяніце дазволы ўліковага запісу ў Ролях.
confirm_remove_subscriber: "%{email} больш не будзе атрымліваць электронныя лісты ад %{name}. Гэтае дзеянне незваротнае."
consent: Падпісчыкі пагадзіліся толькі атрымліваць допісы на электронную пошту. Не карыстайцеся гэтым спісам у іншых мэтах.
date: Дата рэгістрацыі
disable_feature: Адключыць функцыю
disabled: Функцыя была адключаная і электронныя лісты больш не будуць дасылацца гэтаму спісу.
email: Адрас электроннай пошты
empty:
hint: Ніхто яшчэ не аформіў падпіску на гэты ўліковы запіс.
no_subscribers_yet: Пакуль няма падпісчыкаў
enable_feature: Уключыць функцыю
no_access_html: У гэтага ўліковага запісу больш няма дазволаў, патрэбных, каб уключыць гэту функцыю. Змяніце гэта ў <a href="%{roles_path}">Ролях</a>.
title: Паштовая рассылка %{name}
view_account: Прагл. уліковы запіс
status: Стан
subscribers: Падпісчыкі
title: Спісы рассылкі
@ -1541,6 +1556,7 @@ be:
your_appeal_rejected: Ваша абскарджанне было адхілена
edit_profile:
other: Іншае
privacy_redesign_body: Уключыць ці адключыць паказ Вашых падпісчыкаў і падпісак цяпер можна прама ў Вашым профілі.
redesign_body: Рэдагаванне профілю цяпер даступнае наўпрост са старонкі профілю.
redesign_button: Перайсці туды
redesign_title: Адбыліся змены ў рэдагаванні профілю
@ -1575,7 +1591,9 @@ be:
success_html: Вы цяпер пачняце атрымліваць электронныя лісты, калі %{name} будзе рабіць новыя допісы. Дадайце %{sender} у свае кантакты, каб гэтыя допісы не траплялі ў папку са спамам.
title: Вы падпісаліся праз эл. пошту
unsubscribe: Адпісацца
disabled: Адключана
inactive: Неактыўная
no_access: Няма доступу
status: Стан
subscribers: Падпісчыкі па эл.пошце
emoji_styles:

View File

@ -505,6 +505,21 @@ gl:
no_lists_yet: Aínda non hai listas
last_email: Último correo
lead: Aquí móstranse as contas que activaron esta ferramenta e teñen subscritoras.
show:
confirm_disable_feature: Desactivar o boletín para %{name}? Non se van enviar máis novidades desta conta por correo. A usuaria poderá reactivar esta ferramenta nos axustes da súa conta. Para quitar de xeito permanente o acceso á ferramenta, edita os permisos da conta en Roles.
confirm_remove_subscriber: "%{email} non vai seguir recibindo correos de %{name}. Esta acción non é reversible."
consent: As subscritoras consentiron exclusivamente recibir no correo as publicacións. Non uses esta lista para outros propósitos.
date: Data da subscrición
disable_feature: Desactivar a ferramenta
disabled: Esta ferramenta desactivouse e non se van seguir enviando correos a esta lista.
email: Enderezo de correo
empty:
hint: Aínda non hai subscricións a esta conta.
no_subscribers_yet: Sen subscricións
enable_feature: Activar a ferramenta
no_access_html: Esta conta xa non ten os permisos requeridos para activar a ferramenta. Cambiaos en <a href="%{roles_path}">Roles</a>.
title: Boletín por correo de %{name}
view_account: Ver conta
status: Estado
subscribers: Subscritoras
title: Listas de correo
@ -1532,7 +1547,9 @@ gl:
success_html: Vas comezar a recibir correos cando %{name} publique novas publicacións. Engade %{sender} á túa libreta de enderezos para que os correos non vaian directamente ao cartafol de Spam.
title: Subscribícheste
unsubscribe: Anular subscrición
disabled: Desactivado
inactive: Inactiva
no_access: Sen acceso
status: Estado
subscribers: Subscritoras
emoji_styles:

View File

@ -525,6 +525,21 @@ he:
no_lists_yet: אין רשימות עדיין
last_email: הודעת דואל אחרונה
lead: חשבונות שבהם הופעלה האפשרות ויש להם מנויים יופיעו להלן.
show:
confirm_disable_feature: להשבית מנשרי דוא"ל של %{name}? עדכוני דוא"ל לא ישלחו עוד מטעם חשבון זה. המשתמשת תוכל לאפשר מחדש את התכונה מהעדפות החשבון שלה. כדי לכבות לצמיתות גישה לתכונה הזו, יש לערוך את הרשאות החשבון תחת תפקידים.
confirm_remove_subscriber: '%{email} לא יקבלו יותר דוא"ל מאת %{name}. פעולה זו לא בלתי הפיכה.'
consent: מנויים הסכימו רק לקבלת הודעות דרך דוא"ל. אין להשתש ברשימה זו לאף מטרה אחרת.
date: תאריך ההצטרפות
disable_feature: כיבוי תכונה
disabled: תכונה זו הושבתה והודעות דוא"ל לא נשלחות יותר אל רשימה זו.
email: כתובת דוא"ל
empty:
hint: אין לחשבון זה נרשמים עדיין.
no_subscribers_yet: אין מנויים עדיין
enable_feature: אפשר תכונה
no_access_html: לחשבון זה אין יותר את ההרשאות הדרושות להפעלת התכונה. שנו זאת תחת <a href="%{roles_path}">תפקידים</a>.
title: מנשרי דוא"ל של %{name}
view_account: הצג חשבון
status: מצב
subscribers: מנויים
title: רשימות תפוצה
@ -1576,7 +1591,9 @@ he:
success_html: מעתה תקבלנה דוא"ל כאשר %{name} יפרסמו הודעות חדשות. הוספנה את %{sender} לאנשי הקשר כדי שההודעות האלו לא יגיעו אל פח הספאם שלכן.
title: נרשמת
unsubscribe: ביטול ההרשמה
disabled: כבוי
inactive: לא פעילים
no_access: אין גישה
status: מצב
subscribers: מנויים
emoji_styles:

View File

@ -506,13 +506,18 @@ is:
last_email: Síðasti tölvupóstur
lead: Aðgangar sem hafa virkjað eiginleikann og eru með áskrifendur munu birtast hér fyrir neðan.
show:
confirm_disable_feature: Á að gera óvirkar fréttir í tölvupósti frá %{name}? Færslur verða ekki lengur sendar í tölvupósti frá þessum notandaaðgangi. Notandinn mun samt geta virkjað aftur þennan eiginleika í stillingunum sínum. Til að fjarlægja endanlega aðgang að þessum eiginleika skaltu breyta heimildum aðgangsins í gegnum 'Hlutverk'.
confirm_remove_subscriber: "%{email} mun ekki lengur fá tölvupósta frá %{name}. Ekki er hægt að afturkalla þessa aðgerð."
consent: Áskrifendur hafa einungis samþykkt að fá sendar færslur í tölvupósti. Ekki nota þennan lista í neinum öðrum tilgangi.
date: Dagsetning skráningar
disable_feature: Gera eiginleika óvirkan
disabled: Eiginleikinn var gerður óvirkur og eru tölvupóstar ekki lengur sendir á þenna lista.
email: Tölvupóstfang
empty:
hint: Enginn hefur enn gerst áskrifandi að þessum notandaaðgangi.
no_subscribers_yet: Engir áskrifendur ennþá
enable_feature: Virkja eiginleika
no_access_html: Þessi aðgangur hefur ekki lengur heimild til að virkja þennan eiginleika. Breyttu því í <a href="%{roles_path}">Hlutverk</a>.
title: Tölvupóstfréttir frá %{name}
view_account: Skoða notandaaðgang
status: Staða

View File

@ -505,6 +505,21 @@ sq:
no_lists_yet: Ende pa lista
last_email: Email-i i fundit
lead: Llogaritë që kanë aktivizuar veçorinë dhe kanë pajtimtarë do të shfaqen më poshtë.
show:
confirm_disable_feature: Të çaktivizohen buletine me email për %{name}? Për këtë llogari sdo të dërgohen më përditësime me email. Përdoruesi do të jetë prapëseprapë në gjendje të riaktivizojë veçorinë që nga rregullimet e llogarisë të vet. Për të hequr përgjithnjë përdorimin e kësaj veçorie, përpunoni lejen e llogarisë te Role.
confirm_remove_subscriber: "%{email} sdo të marrë më email-e nga %{name}. Ky veprim smund të zhbëhet."
consent: Pajtimtarët kanë pranuar vetëm të marrin postime përmes email-i. Mos e përdorni këtë listë për qëllime të tjera.
date: Datë e regjistrimit
disable_feature: Çaktivizoje veçorinë
disabled: Veçoria qe çaktivizuar dhe te kjo listë sdërgohen më email-e.
email: Adresë email
empty:
hint: Te kjo llogari sështë pajtuar ende dikush.
no_subscribers_yet: Ende pa pajtimtarë
enable_feature: Aktivizoje veçorinë
no_access_html: Kjo llogari ska më lejet e domosdoshme për aktivizimin e veçorisë. Ndryshojeni këtë që nga <a href="%{roles_path}">Role</a>.
title: Buletine me email nga %{name}
view_account: Shiheni llogarinë
status: Gjendje
subscribers: Pajtimtarë
title: Lista postimesh
@ -1521,7 +1536,9 @@ sq:
success_html: Tani do të filloni të merrni email-e, kur %{name} boton postime të reja. Shtojeni %{sender} te kontaktet tuaj, që këto postime të mos përfundojnë te dosja juaj e Të padëshiruarve.
title: U regjistruat
unsubscribe: Shpajtohuni
disabled: E çaktivizuar
inactive: Joaktiv
no_access: Slejohet përdorim
status: Gjendje
subscribers: Pajtimtarë
errors:

View File

@ -495,6 +495,21 @@ vi:
no_lists_yet: Chưa có danh sách nào
last_email: Email gần nhất
lead: Các tài khoản đã kích hoạt tính năng này và có người đăng ký sẽ hiển thị bên dưới.
show:
confirm_disable_feature: Tắt bản tin email cho %{name}? Bản tin cập nhật qua email sẽ không còn được gửi cho tài khoản này nữa. Người dùng vẫn có thể bật lại tính năng này trong cài đặt tài khoản của họ. Để xóa vĩnh viễn quyền truy cập vào tính năng này, hãy chỉnh sửa quyền của tài khoản trong mục Vai trò.
confirm_remove_subscriber: "%{email} sẽ không còn nhận được email từ %{name} nữa. Hành động này không thể hoàn tác."
consent: Người đăng ký chỉ đồng ý nhận bài viết qua email. Vui lòng không sử dụng danh sách này cho mục đích khác.
date: Ngày đăng ký
disable_feature: Tắt tính năng
disabled: Tính năng này đã bị vô hiệu hóa và email không còn được gửi đến danh sách này nữa.
email: Địa chỉ email
empty:
hint: Hiện chưa có ai đăng ký theo dõi tài khoản này.
no_subscribers_yet: Chưa có đăng ký theo dõi
enable_feature: Bật tính năng
no_access_html: Tài khoản này không còn quyền hạn cần thiết để kích hoạt tính năng này nữa. Thay đổi trong <a href="%{roles_path}">Vai trò</a>.
title: Bản tin email của %{name}
view_account: Xem tài khoản
status: Trạng thái
subscribers: Người đăng ký đọc
title: Danh sách gửi thư
@ -1510,7 +1525,9 @@ vi:
success_html: Bạn sẽ bắt đầu nhận được email khi %{name} đăng tút mới. Thêm %{sender} vào danh bạ của bạn để những tút này không bị chuyển vào thư mục Spam.
title: Bạn đã đăng ký đọc
unsubscribe: Hủy đăng ký đọc
disabled: Đã tắt
inactive: Không hoạt động
no_access: Không có quyền
status: Trạng thái
subscribers: Người đăng ký đọc
emoji_styles:

Some files were not shown because too many files have changed in this diff Show More