Merge remote-tracking branch 'soapbox/develop' into hotkey-nav

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-05-20 21:00:42 +02:00
commit beef2de673
142 changed files with 1048 additions and 373 deletions

View file

@ -8,17 +8,22 @@ cache:
files:
- yarn.lock
paths:
- node_modules
- node_modules/
stages:
- install
- lint
- test
- build
- deploy
before_script:
- env
- yarn
install-dependencies:
stage: install
script:
- yarn install --ignore-scripts
artifacts:
paths:
- node_modules/
lint-js:
stage: lint
@ -87,6 +92,14 @@ docs-deploy:
# - yarn
# - yarn build
review:
stage: deploy
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://$CI_COMMIT_REF_SLUG.git.soapbox.pub
script:
- npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub
pages:
stage: deploy
before_script: []

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,113 @@
import { defineMessages } from 'react-intl';
import api, { getLinks } from '../api';
import snackbar from './snackbar';
import type { SnackbarAction } from './snackbar';
import type { AxiosResponse } from 'axios';
import type { RootState } from 'soapbox/store';
export const EXPORT_FOLLOWS_REQUEST = 'EXPORT_FOLLOWS_REQUEST';
export const EXPORT_FOLLOWS_SUCCESS = 'EXPORT_FOLLOWS_SUCCESS';
export const EXPORT_FOLLOWS_FAIL = 'EXPORT_FOLLOWS_FAIL';
export const EXPORT_BLOCKS_REQUEST = 'EXPORT_BLOCKS_REQUEST';
export const EXPORT_BLOCKS_SUCCESS = 'EXPORT_BLOCKS_SUCCESS';
export const EXPORT_BLOCKS_FAIL = 'EXPORT_BLOCKS_FAIL';
export const EXPORT_MUTES_REQUEST = 'EXPORT_MUTES_REQUEST';
export const EXPORT_MUTES_SUCCESS = 'EXPORT_MUTES_SUCCESS';
export const EXPORT_MUTES_FAIL = 'EXPORT_MUTES_FAIL';
const messages = defineMessages({
blocksSuccess: { id: 'export_data.success.blocks', defaultMessage: 'Blocks exported successfully' },
followersSuccess: { id: 'export_data.success.followers', defaultMessage: 'Followers exported successfully' },
mutesSuccess: { id: 'export_data.success.mutes', defaultMessage: 'Mutes exported successfully' },
});
type ExportDataActions = {
type: typeof EXPORT_FOLLOWS_REQUEST
| typeof EXPORT_FOLLOWS_SUCCESS
| typeof EXPORT_FOLLOWS_FAIL
| typeof EXPORT_BLOCKS_REQUEST
| typeof EXPORT_BLOCKS_SUCCESS
| typeof EXPORT_BLOCKS_FAIL
| typeof EXPORT_MUTES_REQUEST
| typeof EXPORT_MUTES_SUCCESS
| typeof EXPORT_MUTES_FAIL,
error?: any,
} | SnackbarAction
function fileExport(content: string, fileName: string) {
const fileToDownload = document.createElement('a');
fileToDownload.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(content));
fileToDownload.setAttribute('download', fileName);
fileToDownload.style.display = 'none';
document.body.appendChild(fileToDownload);
fileToDownload.click();
document.body.removeChild(fileToDownload);
}
const listAccounts = (getState: () => RootState) => async(apiResponse: AxiosResponse<any, any>) => {
const followings = apiResponse.data;
let accounts = [];
let next = getLinks(apiResponse).refs.find(link => link.rel === 'next');
while (next) {
apiResponse = await api(getState).get(next.uri);
next = getLinks(apiResponse).refs.find(link => link.rel === 'next');
Array.prototype.push.apply(followings, apiResponse.data);
}
accounts = followings.map((account: { fqn: string }) => account.fqn);
return Array.from(new Set(accounts));
};
export const exportFollows = () => (dispatch: React.Dispatch<ExportDataActions>, getState: () => RootState) => {
dispatch({ type: EXPORT_FOLLOWS_REQUEST });
const me = getState().me;
return api(getState)
.get(`/api/v1/accounts/${me}/following?limit=40`)
.then(listAccounts(getState))
.then((followings) => {
followings = followings.map(fqn => fqn + ',true');
followings.unshift('Account address,Show boosts');
fileExport(followings.join('\n'), 'export_followings.csv');
dispatch(snackbar.success(messages.followersSuccess));
dispatch({ type: EXPORT_FOLLOWS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_FOLLOWS_FAIL, error });
});
};
export const exportBlocks = () => (dispatch: React.Dispatch<ExportDataActions>, getState: () => RootState) => {
dispatch({ type: EXPORT_BLOCKS_REQUEST });
return api(getState)
.get('/api/v1/blocks?limit=40')
.then(listAccounts(getState))
.then((blocks) => {
fileExport(blocks.join('\n'), 'export_block.csv');
dispatch(snackbar.success(messages.blocksSuccess));
dispatch({ type: EXPORT_BLOCKS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_BLOCKS_FAIL, error });
});
};
export const exportMutes = () => (dispatch: React.Dispatch<ExportDataActions>, getState: () => RootState) => {
dispatch({ type: EXPORT_MUTES_REQUEST });
return api(getState)
.get('/api/v1/mutes?limit=40')
.then(listAccounts(getState))
.then((mutes) => {
fileExport(mutes.join('\n'), 'export_mutes.csv');
dispatch(snackbar.success(messages.mutesSuccess));
dispatch({ type: EXPORT_MUTES_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_MUTES_FAIL, error });
});
};

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,39 @@
import { ALERT_SHOW } from './alerts';
import type { MessageDescriptor } from 'react-intl';
type SnackbarActionSeverity = 'info' | 'success' | 'error'
type SnackbarMessage = string | MessageDescriptor
export type SnackbarAction = {
type: typeof ALERT_SHOW
message: SnackbarMessage
actionLabel?: string
actionLink?: string
severity: SnackbarActionSeverity
}
export const show = (severity: SnackbarActionSeverity, message: SnackbarMessage, actionLabel?: string, actionLink?: string): SnackbarAction => ({
type: ALERT_SHOW,
message,
actionLabel,
actionLink,
severity,
});
export const info = (message: SnackbarMessage, actionLabel?: string, actionLink?: string) =>
show('info', message, actionLabel, actionLink);
export const success = (message: SnackbarMessage, actionLabel?: string, actionLink?: string) =>
show('success', message, actionLabel, actionLink);
export const error = (message: SnackbarMessage, actionLabel?: string, actionLink?: string) =>
show('error', message, actionLabel, actionLink);
export default {
info,
success,
error,
show,
};

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,67 @@
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { render, screen } from '../../jest/test-helpers';
import { normalizeAccount } from '../../normalizers';
import Account from '../account';
describe('<Account />', () => {
it('renders account name and username', () => {
const account = normalizeAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
});
const store = {
accounts: ImmutableMap({
'1': account,
}),
};
render(<Account account={account} />, null, store);
expect(screen.getByTestId('account')).toHaveTextContent('Justin L');
expect(screen.getByTestId('account')).toHaveTextContent(/justin-username/i);
});
describe('verification badge', () => {
it('renders verification badge', () => {
const account = normalizeAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
verified: true,
});
const store = {
accounts: ImmutableMap({
'1': account,
}),
};
render(<Account account={account} />, null, store);
expect(screen.getByTestId('verified-badge')).toBeInTheDocument();
});
it('does not render verification badge', () => {
const account = normalizeAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
verified: false,
});
const store = {
accounts: ImmutableMap({
'1': account,
}),
};
render(<Account account={account} />, null, store);
expect(screen.queryAllByTestId('verified-badge')).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,44 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { openModal } from 'soapbox/actions/modals';
import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import type { List as ImmutableList } from 'immutable';
interface IAttachmentThumbs {
media: ImmutableList<Immutable.Record<any>>
onClick?(): void
sensitive?: boolean
}
const AttachmentThumbs = (props: IAttachmentThumbs) => {
const { media, onClick, sensitive } = props;
const dispatch = useDispatch();
const renderLoading = () => <div className='media-gallery--compact' />;
const onOpenMedia = (media: Immutable.Record<any>, index: number) => dispatch(openModal('MEDIA', { media, index }));
return (
<div className='attachment-thumbs'>
<Bundle fetchComponent={MediaGallery} loading={renderLoading}>
{(Component: any) => (
<Component
media={media}
onOpenMedia={onOpenMedia}
height={50}
compact
sensitive={sensitive}
/>
)}
</Bundle>
{onClick && (
<div className='attachment-thumbs__clickable-region' onClick={onClick} />
)}
</div>
);
};
export default AttachmentThumbs;

View file

@ -0,0 +1,51 @@
import * as React from 'react';
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
import { useSoapboxConfig } from 'soapbox/hooks';
import { getAcct } from '../utils/accounts';
import Icon from './icon';
import RelativeTimestamp from './relative_timestamp';
import VerificationBadge from './verification_badge';
import type { Account } from 'soapbox/types/entities';
interface IDisplayName {
account: Account
withDate?: boolean
}
const DisplayName: React.FC<IDisplayName> = ({ account, children, withDate = false }) => {
const { displayFqn = false } = useSoapboxConfig();
const { created_at: createdAt, verified } = account;
const joinedAt = createdAt ? (
<div className='account__joined-at'>
<Icon src={require('@tabler/icons/icons/clock.svg')} />
<RelativeTimestamp timestamp={createdAt} />
</div>
) : null;
const displayName = (
<span className='display-name__name'>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
{verified && <VerificationBadge />}
{withDate && joinedAt}
</span>
);
const suffix = (<span className='display-name__account'>@{getAcct(account, displayFqn)}</span>);
return (
<span className='display-name' data-testid='display-name'>
<HoverRefWrapper accountId={account.get('id')} inline>
{displayName}
</HoverRefWrapper>
{suffix}
{children}
</span>
);
};
export default DisplayName;

View file

@ -42,7 +42,7 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
</Stack>
{hashtag.get('history') && (
<div className='w-[40px]'>
<div className='w-[40px]' data-testid='sparklines'>
<Sparklines
width={40}
height={28}

View file

@ -43,6 +43,8 @@ interface IScrollableList extends VirtuosoProps<any, any> {
className?: string,
itemClassName?: string,
id?: string,
style?: React.CSSProperties,
useWindowScroll?: boolean
}
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
@ -65,6 +67,8 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
placeholderCount = 0,
initialTopMostItemIndex = 0,
scrollerRef,
style = {},
useWindowScroll = true,
}, ref) => {
const settings = useSettings();
const autoloadMore = settings.get('autoloadMore');
@ -131,8 +135,8 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
const renderFeed = (): JSX.Element => (
<Virtuoso
ref={ref}
useWindowScroll
id={id}
useWindowScroll={useWindowScroll}
className={className}
data={data}
startReached={onScrollToTop}
@ -140,6 +144,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
itemContent={renderItem}
initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex}
style={style}
context={{
listClassName: className,
itemClassName,

View file

@ -161,7 +161,9 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
<Stack>
<button type='button' onClick={handleSwitcherClick} className='py-1'>
<HStack alignItems='center' justifyContent='between'>
<Text tag='span' size='sm' weight='medium'>Switch accounts</Text>
<Text tag='span' size='sm' weight='medium'>
<FormattedMessage id='profile_dropdown.switch_account' defaultMessage='Switch accounts' />
</Text>
<Icon
src={require('@tabler/icons/icons/chevron-down.svg')}

View file

@ -14,7 +14,7 @@ import Card from '../features/status/components/card';
import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import AttachmentThumbs from './attachment_thumbs';
import AttachmentThumbs from './attachment-thumbs';
import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
import StatusReplyMentions from './status_reply_mentions';
@ -160,7 +160,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
// Compensate height changes
componentDidUpdate(_prevProps: IStatus, _prevState: IStatusState, snapshot?: ScrollPosition): void {
const doShowCard: boolean = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card);
const doShowCard: boolean = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card);
if (doShowCard && !this.didShowCard) {
this.didShowCard = true;

View file

@ -6,7 +6,7 @@ import { Text } from 'soapbox/components/ui';
/** Represents a deleted item. */
const Tombstone: React.FC = () => {
return (
<div className='my-4 p-9 flex items-center justify-center sm:rounded-xl bg-gray-100 border border-solid border-gray-200 dark:bg-slate-900 dark:border-slate-700'>
<div className='p-9 flex items-center justify-center sm:rounded-xl bg-gray-100 border border-solid border-gray-200 dark:bg-slate-900 dark:border-slate-700'>
<Text>
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts is unavailable.' />
</Text>

View file

@ -10,6 +10,19 @@ const messages = defineMessages({
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
});
type Widths = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
const widths = {
xs: 'max-w-xs',
sm: 'max-w-sm',
md: 'max-w-base',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
};
interface IModal {
/** Callback when the modal is cancelled. */
cancelAction?: () => void,
@ -20,7 +33,7 @@ interface IModal {
/** Position of the close button. */
closePosition?: 'left' | 'right',
/** Callback when the modal is confirmed. */
confirmationAction?: () => void,
confirmationAction?: (event?: React.MouseEvent<HTMLButtonElement>) => void,
/** Whether the confirmation button is disabled. */
confirmationDisabled?: boolean,
/** Confirmation button text. */
@ -30,13 +43,15 @@ interface IModal {
/** Callback when the modal is closed. */
onClose?: () => void,
/** Callback when the secondary action is chosen. */
secondaryAction?: () => void,
secondaryAction?: (event?: React.MouseEvent<HTMLButtonElement>) => void,
/** Secondary button text. */
secondaryText?: React.ReactNode,
secondaryDisabled?: boolean,
/** Don't focus the "confirm" button on mount. */
skipFocus?: boolean,
/** Title text for the modal. */
title: string | React.ReactNode,
width?: Widths,
}
/** Displays a modal dialog box. */
@ -52,9 +67,11 @@ const Modal: React.FC<IModal> = ({
confirmationTheme,
onClose,
secondaryAction,
secondaryDisabled = false,
secondaryText,
skipFocus = false,
title,
width = 'xl',
}) => {
const intl = useIntl();
const buttonRef = React.useRef<HTMLButtonElement>(null);
@ -66,7 +83,7 @@ const Modal: React.FC<IModal> = ({
}, [skipFocus, buttonRef]);
return (
<div data-testid='modal' className='block w-full max-w-xl p-6 mx-auto overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto'>
<div data-testid='modal' className={classNames('block w-full p-6 mx-auto overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto', widths[width])}>
<div className='sm:flex sm:items-start w-full justify-between'>
<div className='w-full'>
<div
@ -113,6 +130,7 @@ const Modal: React.FC<IModal> = ({
<Button
theme='secondary'
onClick={secondaryAction}
disabled={secondaryDisabled}
>
{secondaryText}
</Button>

View file

@ -6,7 +6,7 @@ import Text from '../text/text';
import './spinner.css';
interface ILoadingIndicator {
interface ISpinner {
/** Width and height of the spinner in pixels. */
size?: number,
/** Whether to display "Loading..." beneath the spinner. */
@ -14,7 +14,7 @@ interface ILoadingIndicator {
}
/** Spinning loading placeholder. */
const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) => (
const Spinner = ({ size = 30, withText = true }: ISpinner) => (
<Stack space={2} justifyContent='center' alignItems='center'>
<div className='spinner' style={{ width: size, height: size }}>
{Array.from(Array(12).keys()).map(i => (
@ -30,4 +30,4 @@ const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) =>
</Stack>
);
export default LoadingIndicator;
export default Spinner;

View file

@ -24,7 +24,7 @@ const VerificationBadge: React.FC<IVerificationBadge> = ({ className }) => {
const Element = icon.endsWith('.svg') ? Icon : 'img';
return (
<span className='verified-icon'>
<span className='verified-icon' data-testid='verified-badge'>
<Element className={classNames('w-4 text-accent-500', className)} src={icon} alt={intl.formatMessage(messages.verified)} />
</span>
);

View file

@ -14,10 +14,12 @@ import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox';
import { fetchVerificationConfig } from 'soapbox/actions/verification';
import * as BuildConfig from 'soapbox/build_config';
import Helmet from 'soapbox/components/helmet';
import { Spinner } from 'soapbox/components/ui';
import AuthLayout from 'soapbox/features/auth_layout';
import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard';
import PublicLayout from 'soapbox/features/public_layout';
import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container';
import { ModalContainer } from 'soapbox/features/ui/util/async-components';
import WaitlistPage from 'soapbox/features/verification/waitlist_page';
import { createGlobals } from 'soapbox/globals';
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures, useSoapboxConfig, useSettings, useSystemTheme } from 'soapbox/hooks';
@ -29,6 +31,7 @@ import { checkOnboardingStatus } from '../actions/onboarding';
import { preload } from '../actions/preload';
import ErrorBoundary from '../components/error_boundary';
import UI from '../features/ui';
import BundleContainer from '../features/ui/containers/bundle_container';
import { store } from '../store';
/** Ensure the given locale exists in our codebase */
@ -96,7 +99,7 @@ const SoapboxMount = () => {
MESSAGES[locale]().then(messages => {
setMessages(messages);
setLocaleLoading(false);
}).catch(() => {});
}).catch(() => { });
}, [locale]);
// Load initial data from the API
@ -113,10 +116,25 @@ const SoapboxMount = () => {
return !(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey);
};
if (me === null) return null;
if (me && !account) return null;
if (!isLoaded) return null;
if (localeLoading) return null;
/** Whether to display a loading indicator. */
const showLoading = [
me === null,
me && !account,
!isLoaded,
localeLoading,
].some(Boolean);
if (showLoading) {
return (
<div className='p-4 h-screen w-screen flex items-center justify-center'>
<Helmet>
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
</Helmet>
<Spinner size={40} withText={false} />
</div>
);
}
const waitlisted = account && !account.source.get('approved', true);
@ -172,7 +190,13 @@ const SoapboxMount = () => {
)}
{waitlisted && (
<Route render={(props) => <WaitlistPage {...props} account={account} />} />
<>
<Route render={(props) => <WaitlistPage {...props} account={account} />} />
<BundleContainer fetchComponent={ModalContainer}>
{Component => <Component />}
</BundleContainer>
</>
)}
{!me && (singleUserMode

View file

@ -3,7 +3,7 @@ import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
import AvatarOverlay from 'soapbox/components/avatar_overlay';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import Icon from 'soapbox/components/icon';
import type { Account as AccountEntity } from 'soapbox/types/entities';

View file

@ -40,23 +40,23 @@ const Search: React.FC = () => {
const hasValue = value.length > 0;
return (
<div className='aliases_search search'>
<label>
<div className='flex items-center gap-1'>
<label className='flex-grow relative'>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='search__input'
className='block w-full sm:text-sm dark:bg-slate-800 dark:text-white dark:placeholder:text-gray-500 focus:ring-indigo-500 focus:border-indigo-500 rounded-full'
type='text'
value={value}
onChange={handleChange}
onKeyUp={handleKeyUp}
placeholder={intl.formatMessage(messages.search)}
/>
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
<Icon src={require('@tabler/icons/icons/backspace.svg')} aria-label={intl.formatMessage(messages.search)} className={classNames('svg-icon--backspace', { active: hasValue })} />
</div>
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
<Icon src={require('@tabler/icons/icons/backspace.svg')} aria-label={intl.formatMessage(messages.search)} className={classNames('svg-icon--backspace', { active: hasValue })} />
</div>
</label>
<Button onClick={handleSubmit}>{intl.formatMessage(messages.searchTitle)}</Button>
</div>
);

View file

@ -2,7 +2,7 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import Icon from 'soapbox/components/icon';
import Permalink from 'soapbox/components/permalink';
import { useAppSelector } from 'soapbox/hooks';

View file

@ -2,7 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import Icon from 'soapbox/components/icon';
import emojify from 'soapbox/features/emoji/emoji';
import { useAppSelector } from 'soapbox/hooks';

View file

@ -1,5 +1,5 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedList, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { openModal } from 'soapbox/actions/modals';
@ -47,14 +47,23 @@ const ReplyMentions: React.FC = () => {
);
}
const accounts = to.slice(0, 2).map((acct: string) => (
<span className='reply-mentions__account'>@{acct.split('@')[0]}</span>
)).toArray();
if (to.size > 2) {
accounts.push(
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />,
);
}
return (
<a href='#' className='reply-mentions' onClick={handleClick}>
<FormattedMessage
id='reply_mentions.reply'
defaultMessage='Replying to {accounts}{more}'
defaultMessage='Replying to {accounts}'
values={{
accounts: to.slice(0, 2).map((acct: string) => <><span className='reply-mentions__account'>@{acct.split('@')[0]}</span>{' '}</>),
more: to.size > 2 && <FormattedMessage id='reply_mentions.more' defaultMessage='and {count} more' values={{ count: to.size - 2 }} />,
accounts: <FormattedList type='conjunction' value={accounts} />,
}}
/>
</a>

View file

@ -29,7 +29,7 @@ const ProfilePreview: React.FC<IProfilePreview> = ({ account }) => {
<StillImage alt='' className='h-12 w-12 rounded-full' src={account.avatar} />
</div>
{!account.verified && <div className='absolute -top-1.5 -right-1.5'><VerificationBadge /></div>}
{account.verified && <div className='absolute -top-1.5 -right-1.5'><VerificationBadge /></div>}
</div>
<Stack className='truncate'>

View file

@ -0,0 +1,47 @@
import React from 'react';
import { useState } from 'react';
import { MessageDescriptor, useIntl } from 'react-intl';
import { Button, Form, FormActions, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { AppDispatch } from 'soapbox/store';
interface ICSVExporter {
messages: {
input_label: MessageDescriptor,
input_hint: MessageDescriptor,
submit: MessageDescriptor,
},
action: () => (dispatch: AppDispatch, getState: any) => Promise<void>,
}
const CSVExporter: React.FC<ICSVExporter> = ({ messages, action }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const [isLoading, setIsLoading] = useState(false);
const handleClick: React.MouseEventHandler = (event) => {
setIsLoading(true);
dispatch(action()).then(() => {
setIsLoading(false);
}).catch(() => {
setIsLoading(false);
});
};
return (
<Form>
<Text size='xl' weight='bold'>{intl.formatMessage(messages.input_label)}</Text>
<Text theme='muted'>{intl.formatMessage(messages.input_hint)}</Text>
<FormActions>
<Button theme='primary' onClick={handleClick} disabled={isLoading}>
{intl.formatMessage(messages.submit)}
</Button>
</FormActions>
</Form>
);
};
export default CSVExporter;

View file

@ -1,7 +1,7 @@
import React from 'react';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import Permalink from 'soapbox/components/permalink';
import ActionButton from 'soapbox/features/ui/components/action-button';
import { useAppSelector } from 'soapbox/hooks';

View file

@ -4,7 +4,7 @@ import { useDispatch } from 'react-redux';
import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import IconButton from 'soapbox/components/icon_button';
import Permalink from 'soapbox/components/permalink';
import { useAppSelector } from 'soapbox/hooks';

View file

@ -0,0 +1,66 @@
import React from 'react';
import { useState } from 'react';
import { MessageDescriptor, useIntl } from 'react-intl';
import { Button, FileInput, Form, FormActions, FormGroup, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { AppDispatch } from 'soapbox/store';
interface ICSVImporter {
messages: {
input_label: MessageDescriptor,
input_hint: MessageDescriptor,
submit: MessageDescriptor,
},
action: (params: FormData) => (dispatch: AppDispatch, getState: any) => Promise<void>,
}
const CSVImporter: React.FC<ICSVImporter> = ({ messages, action }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const [isLoading, setIsLoading] = useState(false);
const [file, setFile] = useState<File | null | undefined>(null);
const handleSubmit: React.FormEventHandler = (event) => {
const params = new FormData();
params.append('list', file!);
setIsLoading(true);
dispatch(action(params)).then(() => {
setIsLoading(false);
}).catch(() => {
setIsLoading(false);
});
event.preventDefault();
};
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = e => {
const file = e.target.files?.item(0);
setFile(file);
};
return (
<Form onSubmit={handleSubmit}>
<Text size='xl' weight='bold' tag='label'>{intl.formatMessage(messages.input_label)}</Text>
<FormGroup
hintText={<Text theme='muted'>{intl.formatMessage(messages.input_hint)}</Text>}
>
<FileInput
accept='.csv,text/csv'
onChange={handleFileChange}
required
/>
</FormGroup>
<FormActions>
<Button type='submit' theme='primary' disabled={isLoading}>
{intl.formatMessage(messages.submit)}
</Button>
</FormActions>
</Form>
);
};
export default CSVImporter;

View file

@ -3,6 +3,8 @@ import { HotKeys } from 'react-hotkeys';
import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { useAppSelector } from 'soapbox/hooks';
import Icon from '../../../components/icon';
import Permalink from '../../../components/permalink';
import { HStack, Text, Emoji } from '../../../components/ui';
@ -50,6 +52,7 @@ const icons: Record<NotificationType, string> = {
move: require('@tabler/icons/icons/briefcase.svg'),
'pleroma:chat_mention': require('@tabler/icons/icons/messages.svg'),
'pleroma:emoji_reaction': require('@tabler/icons/icons/mood-happy.svg'),
user_approved: require('@tabler/icons/icons/user-plus.svg'),
};
const messages: Record<NotificationType, { id: string, defaultMessage: string }> = {
@ -93,16 +96,20 @@ const messages: Record<NotificationType, { id: string, defaultMessage: string }>
id: 'notification.pleroma:emoji_reaction',
defaultMessage: '{name} reacted to your post',
},
user_approved: {
id: 'notification.user_approved',
defaultMessage: 'Welcome to {instance}!',
},
};
const buildMessage = (type: NotificationType, account: Account, targetName?: string): JSX.Element => {
const buildMessage = (type: NotificationType, account: Account, targetName: string, instanceTitle: string): JSX.Element => {
const link = buildLink(account);
return (
<FormattedMessageFixed
id={messages[type].id}
defaultMessage={messages[type].defaultMessage}
values={{ name: link, targetName }}
values={{ name: link, targetName, instance: instanceTitle }}
/>
);
};
@ -128,6 +135,7 @@ const Notification: React.FC<INotificaton> = (props) => {
const history = useHistory();
const intl = useIntl();
const instance = useAppSelector((state) => state.instance);
const type = notification.type;
const { account, status } = notification;
@ -216,6 +224,7 @@ const Notification: React.FC<INotificaton> = (props) => {
switch (type) {
case 'follow':
case 'follow_request':
case 'user_approved':
return account && typeof account === 'object' ? (
<AccountContainer
id={account.id}
@ -239,7 +248,7 @@ const Notification: React.FC<INotificaton> = (props) => {
case 'pleroma:emoji_reaction':
return status && typeof status === 'object' ? (
<StatusContainer
// @ts-ignore
// @ts-ignore
id={status.id}
withDismiss
hidden={hidden}
@ -259,7 +268,7 @@ const Notification: React.FC<INotificaton> = (props) => {
const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : '';
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account, targetName) : null;
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account, targetName, instance.title) : null;
return (
<HotKeys handlers={getHandlers()} data-testid='notification'>

View file

@ -1,8 +1,10 @@
import { Map as ImmutableMap } from 'immutable';
import debounce from 'lodash/debounce';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAppSelector } from 'soapbox/hooks';
@ -13,24 +15,42 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const suggestions = useAppSelector((state) => state.suggestions.get('items'));
const suggestionsToRender = suggestions.slice(0, 5);
const hasMore = useAppSelector((state) => !!state.suggestions.get('next'));
const isLoading = useAppSelector((state) => state.suggestions.get('isLoading'));
const handleLoadMore = debounce(() => {
if (isLoading) {
return null;
}
return dispatch(fetchSuggestions());
}, 300);
React.useEffect(() => {
dispatch(fetchSuggestions());
dispatch(fetchSuggestions({ limit: 20 }));
}, []);
const renderSuggestions = () => {
return (
<div className='sm:pt-4 sm:pb-10 flex flex-col divide-y divide-solid divide-gray-200 dark:divide-slate-700'>
{suggestionsToRender.map((suggestion: ImmutableMap<string, any>) => (
<div key={suggestion.get('account')} className='py-2'>
<AccountContainer
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.get('account')}
showProfileHoverCard={false}
/>
</div>
))}
<div className='sm:pt-4 sm:pb-10 flex flex-col'>
<ScrollableList
isLoading={isLoading}
scrollKey='suggestions'
onLoadMore={handleLoadMore}
hasMore={hasMore}
useWindowScroll={false}
style={{ height: 320 }}
>
{suggestions.map((suggestion: ImmutableMap<string, any>) => (
<div key={suggestion.get('account')} className='py-2'>
<AccountContainer
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.get('account')}
showProfileHoverCard={false}
/>
</div>
))}
</ScrollableList>
</div>
);
};
@ -46,7 +66,7 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
};
const renderBody = () => {
if (suggestionsToRender.isEmpty()) {
if (suggestions.isEmpty()) {
return renderEmpty();
} else {
return renderSuggestions();

View file

@ -1,11 +1,11 @@
import * as React from 'react';
import React, { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { fetchMfa } from 'soapbox/actions/mfa';
import List, { ListItem } from 'soapbox/components/list';
import { Button, Card, CardBody, CardHeader, CardTitle, Column } from 'soapbox/components/ui';
import { Card, CardBody, CardHeader, CardTitle, Column } from 'soapbox/components/ui';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { getFeatures } from 'soapbox/utils/features';
@ -22,6 +22,7 @@ const messages = defineMessages({
configureMfa: { id: 'settings.configure_mfa', defaultMessage: 'Configure MFA' },
sessions: { id: 'settings.sessions', defaultMessage: 'Active sessions' },
deleteAccount: { id: 'settings.delete_account', defaultMessage: 'Delete Account' },
other: { id: 'settings.other', defaultMessage: 'Other options' },
});
/** User settings page. */
@ -34,15 +35,16 @@ const Settings = () => {
const features = useAppSelector((state) => getFeatures(state.instance));
const account = useOwnAccount();
const navigateToChangeEmail = React.useCallback(() => history.push('/settings/email'), [history]);
const navigateToChangePassword = React.useCallback(() => history.push('/settings/password'), [history]);
const navigateToMfa = React.useCallback(() => history.push('/settings/mfa'), [history]);
const navigateToSessions = React.useCallback(() => history.push('/settings/tokens'), [history]);
const navigateToEditProfile = React.useCallback(() => history.push('/settings/profile'), [history]);
const navigateToChangeEmail = () => history.push('/settings/email');
const navigateToChangePassword = () => history.push('/settings/password');
const navigateToMfa = () => history.push('/settings/mfa');
const navigateToSessions = () => history.push('/settings/tokens');
const navigateToEditProfile = () => history.push('/settings/profile');
const navigateToDeleteAccount = () => history.push('/settings/account');
const isMfaEnabled = mfa.getIn(['settings', 'totp']);
React.useEffect(() => {
useEffect(() => {
dispatch(fetchMfa());
}, [dispatch]);
@ -92,12 +94,14 @@ const Settings = () => {
<Preferences />
</CardBody>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.other)} />
</CardHeader>
<CardBody>
<div className='mt-4 w-full flex justify-center'>
<Button theme='danger' to='/settings/account'>
{intl.formatMessage(messages.deleteAccount)}
</Button>
</div>
<List>
<ListItem label={intl.formatMessage(messages.deleteAccount)} onClick={navigateToDeleteAccount} />
</List>
</CardBody>
</Card>
</Column>

View file

@ -2,10 +2,10 @@ import classNames from 'classnames';
import { History } from 'history';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage, IntlShape } from 'react-intl';
import { defineMessages, injectIntl, FormattedMessage, IntlShape, FormattedList } from 'react-intl';
import { withRouter } from 'react-router-dom';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import { Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
@ -67,10 +67,9 @@ class QuotedStatus extends ImmutablePureComponent<IQuotedStatus> {
<div className='reply-mentions'>
<FormattedMessage
id='reply_mentions.reply'
defaultMessage='Replying to {accounts}{more}'
defaultMessage='Replying to {accounts}'
values={{
accounts: `@${account.username}`,
more: false,
}}
/>
</div>
@ -84,14 +83,21 @@ class QuotedStatus extends ImmutablePureComponent<IQuotedStatus> {
}
}
const accounts = to.slice(0, 2).map(account => <>@{account.username}</>).toArray();
if (to.size > 2) {
accounts.push(
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />,
);
}
return (
<div className='reply-mentions'>
<FormattedMessage
id='reply_mentions.reply'
defaultMessage='Replying to {accounts}{more}'
defaultMessage='Replying to {accounts}'
values={{
accounts: to.slice(0, 2).map(account => `@${account.username} `),
more: to.size > 2 && <FormattedMessage id='reply_mentions.more' defaultMessage='and {count} more' values={{ count: to.size - 2 }} />,
accounts: <FormattedList type='conjunction' value={accounts} />,
}}
/>
</div>
@ -143,7 +149,6 @@ class QuotedStatus extends ImmutablePureComponent<IQuotedStatus> {
{status.media_attachments.size > 0 && (
<AttachmentThumbs
compact
media={status.media_attachments}
sensitive={status.sensitive}
/>

View file

@ -561,7 +561,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
renderTombstone(id: string) {
return (
<div className='pb-4'>
<div className='py-4 pb-8'>
<Tombstone key={id} />
</div>
);

View file

@ -10,13 +10,19 @@ describe('<TrendsPanel />', () => {
trends: ImmutableMap({
items: fromJS([{
name: 'hashtag 1',
history: [{ accounts: [] }],
history: [{
day: '1652745600',
uses: '294',
accounts: '180',
}],
}]),
}),
};
render(<TrendsPanel limit={1} />, null, store);
expect(screen.getByTestId('hashtag')).toHaveTextContent(/hashtag 1/i);
expect(screen.getByTestId('hashtag')).toHaveTextContent(/180 people talking/i);
expect(screen.getByTestId('sparklines')).toBeInTheDocument();
});
it('renders multiple trends', () => {

View file

@ -4,7 +4,7 @@ import React, { useEffect } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import { fetchHistory } from 'soapbox/actions/history';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import { HStack, Modal, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
@ -75,10 +75,7 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
)}
{version.media_attachments.size > 0 && (
<AttachmentThumbs
compact
media={version.media_attachments}
/>
<AttachmentThumbs media={version.media_attachments} />
)}
<Text align='right' tag='span' theme='muted' size='sm'>

View file

@ -9,6 +9,18 @@ interface IHotkeysModal {
onClose: () => void,
}
const Hotkey: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<kbd className='px-1.5 py-1 bg-primary-50 dark:bg-slate-700 border border-solid border-primary-200 rounded-md dark:border-slate-500 text-xs font-sans'>
{children}
</kbd>
);
const TableCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<td className='pb-3 px-2'>
{children}
</td>
);
const HotkeysModal: React.FC<IHotkeysModal> = ({ onClose }) => {
const features = useAppSelector((state) => getFeatures(state.instance));
@ -16,142 +28,145 @@ const HotkeysModal: React.FC<IHotkeysModal> = ({ onClose }) => {
<Modal
title={<FormattedMessage id='keyboard_shortcuts.heading' defaultMessage='Keyboard shortcuts' />}
onClose={onClose}
width='4xl'
>
<div className='compose-modal__content'>
<div className='flex flex-col lg:flex-row text-xs'>
<table>
<thead>
<tr>
<th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
<th className='pb-2 font-bold'><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
</tr>
</thead>
<tbody>
<tr>
<td><kbd>r</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></td>
<TableCell><Hotkey>r</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></TableCell>
</tr>
<tr>
<td><kbd>m</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></td>
<TableCell><Hotkey>m</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></TableCell>
</tr>
<tr>
<td><kbd>p</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></td>
<TableCell><Hotkey>p</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></TableCell>
</tr>
<tr>
<td><kbd>f</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to like' /></td>
<TableCell><Hotkey>f</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to like' /></TableCell>
</tr>
{features.emojiReacts && (
<tr>
<td><kbd>e</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.react' defaultMessage='to react' /></td>
<TableCell><Hotkey>e</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.react' defaultMessage='to react' /></TableCell>
</tr>
)}
<tr>
<td><kbd>b</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to repost' /></td>
<TableCell><Hotkey>b</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to repost' /></TableCell>
</tr>
<tr>
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open post' /></td>
<TableCell><Hotkey>enter</Hotkey>, <Hotkey>o</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open post' /></TableCell>
</tr>
<tr>
<td><kbd>a</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></td>
<TableCell><Hotkey>a</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></TableCell>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
<th className='pb-2 font-bold'><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
</tr>
</thead>
<tbody>
{features.spoilers && (
<tr>
<td><kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
<TableCell><Hotkey>x</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></TableCell>
</tr>
)}
{features.spoilers && (
<tr>
<td><kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
<TableCell><Hotkey>h</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></TableCell>
</tr>
)}
<tr>
<td><kbd>up</kbd>, <kbd>k</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
<TableCell><Hotkey>up</Hotkey>, <Hotkey>k</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></TableCell>
</tr>
<tr>
<td><kbd>down</kbd>, <kbd>j</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
<TableCell><Hotkey>down</Hotkey>, <Hotkey>j</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></TableCell>
</tr>
<tr>
<td><kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td>
<TableCell><Hotkey>n</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></TableCell>
</tr>
<tr>
<td><kbd>alt</kbd> + <kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a new post' /></td>
<TableCell><Hotkey>alt</Hotkey> + <Hotkey>n</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a new post' /></TableCell>
</tr>
<tr>
<td><kbd>backspace</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
<TableCell><Hotkey>backspace</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></TableCell>
</tr>
<tr>
<td><kbd>s</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td>
<TableCell><Hotkey>s</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></TableCell>
</tr>
<tr>
<td><kbd>esc</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
<TableCell><Hotkey>esc</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></TableCell>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
<th className='pb-2 font-bold'><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
</tr>
</thead>
<tbody>
<tr>
<td><kbd>g</kbd> + <kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>h</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>n</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>f</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open likes list' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>f</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open likes list' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>p</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='to open pinned posts list' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>p</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='to open pinned posts list' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>u</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.my_profile' defaultMessage='to open your profile' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>u</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.my_profile' defaultMessage='to open your profile' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>b</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.blocked' defaultMessage='to open blocked users list' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>b</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.blocked' defaultMessage='to open blocked users list' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>m</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.muted' defaultMessage='to open muted users list' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>m</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.muted' defaultMessage='to open muted users list' /></TableCell>
</tr>
{features.followRequests && (
<tr>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>r</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.requests' defaultMessage='to open follow requests list' /></TableCell>
</tr>
)}
<tr>
<td><kbd>g</kbd> + <kbd>r</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.requests' defaultMessage='to open follow requests list' /></td>
</tr>
<tr>
<td><kbd>?</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></td>
<TableCell><Hotkey>?</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></TableCell>
</tr>
</tbody>
</table>

View file

@ -3,7 +3,7 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import SiteLogo from 'soapbox/components/site-logo';
import { Button, Icon, Modal } from 'soapbox/components/ui';
import { Text, Button, Icon, Modal } from 'soapbox/components/ui';
import { useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
const messages = defineMessages({
@ -17,6 +17,7 @@ interface ILandingPageModal {
onClose: (type: string) => void,
}
/** Login and links to display from the hamburger menu of the homepage. */
const LandingPageModal: React.FC<ILandingPageModal> = ({ onClose }) => {
const intl = useIntl();
@ -41,13 +42,13 @@ const LandingPageModal: React.FC<ILandingPageModal> = ({ onClose }) => {
<a
href={links.get('help')}
target='_blank'
className='p-3 flex items-center rounded-md dark:hover:bg-slate-900/50 hover:bg-gray-50'
className='p-3 space-x-3 flex items-center rounded-md dark:hover:bg-slate-900/50 hover:bg-gray-50'
>
<Icon src={require('@tabler/icons/icons/lifebuoy.svg')} className='flex-shrink-0 h-6 w-6 text-gray-400 dark:text-gray-200' />
<span className='ml-3 text-base font-medium text-gray-900 dark:text-gray-200'>
<Text weight='medium'>
{intl.formatMessage(messages.helpCenter)}
</span>
</Text>
</a>
</nav>
)}

View file

@ -6,7 +6,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { blockAccount } from 'soapbox/actions/accounts';
import { submitReport, submitReportSuccess, submitReportFail } from 'soapbox/actions/reports';
import { expandAccountTimeline } from 'soapbox/actions/timelines';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import StatusContent from 'soapbox/components/status_content';
import { Modal, ProgressBar, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
@ -61,7 +61,6 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => {
{status.get('media_attachments').size > 0 && (
<AttachmentThumbs
compact
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
/>

View file

@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import { verifyCredentials } from 'soapbox/actions/auth';
import { closeModal } from 'soapbox/actions/modals';
import snackbar from 'soapbox/actions/snackbar';
import { reConfirmPhoneVerification, reRequestPhoneVerification } from 'soapbox/actions/verification';
import { FormGroup, Input, Modal, Stack, Text } from 'soapbox/components/ui';
import { validPhoneNumberRegex } from 'soapbox/features/verification/steps/sms-verification';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { getAccessToken } from 'soapbox/utils/auth';
import { formatPhoneNumber } from 'soapbox/utils/phone';
interface IVerifySmsModal {
onClose: (type: string) => void,
}
enum Statuses {
IDLE = 'IDLE',
READY = 'READY',
REQUESTED = 'REQUESTED',
FAIL = 'FAIL',
SUCCESS = 'SUCCESS',
}
const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const accessToken = useAppSelector((state) => getAccessToken(state));
const title = useAppSelector((state) => state.instance.title);
const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean);
const [status, setStatus] = useState<Statuses>(Statuses.IDLE);
const [phone, setPhone] = useState<string>('');
const [verificationCode, setVerificationCode] = useState('');
const [requestedAnother, setAlreadyRequestedAnother] = useState(false);
const isValid = validPhoneNumberRegex.test(phone);
const onChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const formattedPhone = formatPhoneNumber(event.target.value);
setPhone(formattedPhone);
}, []);
const handleSubmit = (event: React.MouseEvent) => {
event.preventDefault();
if (!isValid) {
setStatus(Statuses.IDLE);
dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Please enter a valid phone number.',
}),
),
);
return;
}
dispatch(reRequestPhoneVerification(phone)).then(() => {
dispatch(
snackbar.success(
intl.formatMessage({
id: 'sms_verification.success',
defaultMessage: 'A verification code has been sent to your phone number.',
}),
),
);
})
.finally(() => setStatus(Statuses.REQUESTED))
.catch(() => {
dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.fail',
defaultMessage: 'Failed to send SMS message to your phone number.',
}),
),
);
});
};
const resendVerificationCode = (event?: React.MouseEvent<HTMLButtonElement>) => {
setAlreadyRequestedAnother(true);
handleSubmit(event as React.MouseEvent<HTMLButtonElement>);
};
const onConfirmationClick = (event: any) => {
switch (status) {
case Statuses.IDLE:
setStatus(Statuses.READY);
break;
case Statuses.READY:
handleSubmit(event);
break;
case Statuses.REQUESTED:
submitVerification();
break;
default: break;
}
};
const confirmationText = useMemo(() => {
switch (status) {
case Statuses.IDLE:
return intl.formatMessage({
id: 'sms_verification.modal.verify_sms',
defaultMessage: 'Verify SMS',
});
case Statuses.READY:
return intl.formatMessage({
id: 'sms_verification.modal.verify_number',
defaultMessage: 'Verify phone number',
});
case Statuses.REQUESTED:
return intl.formatMessage({
id: 'sms_verification.modal.verify_code',
defaultMessage: 'Verify code',
});
default:
return null;
}
}, [status]);
const renderModalBody = () => {
switch (status) {
case Statuses.IDLE:
return (
<Text theme='muted'>
{intl.formatMessage({
id: 'sms_verification.modal.verify_help_text',
defaultMessage: 'Verify your phone number to start using {instance}.',
}, {
instance: title,
})}
</Text>
);
case Statuses.READY:
return (
<FormGroup labelText='Phone Number'>
<Input
type='text'
value={phone}
onChange={onChange}
required
autoFocus
/>
</FormGroup>
);
case Statuses.REQUESTED:
return (
<>
<Text theme='muted' size='sm' align='center'>
{intl.formatMessage({
id: 'sms_verification.modal.enter_code',
defaultMessage: 'We sent you a 6-digit code via SMS. Enter it below.',
})}
</Text>
<OtpInput
value={verificationCode}
onChange={setVerificationCode}
numInputs={6}
isInputNum
shouldAutoFocus
isDisabled={isLoading}
containerStyle='flex justify-center mt-2 space-x-4'
inputStyle='w-10i border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500'
/>
</>
);
default:
return null;
}
};
const submitVerification = () => {
// TODO: handle proper validation from Pepe -- expired vs invalid
dispatch(reConfirmPhoneVerification(verificationCode))
.then(() => {
setStatus(Statuses.SUCCESS);
// eslint-disable-next-line promise/catch-or-return
dispatch(verifyCredentials(accessToken))
.then(() => dispatch(closeModal('VERIFY_SMS')));
})
.catch(() => dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Your SMS token has expired.',
}),
),
));
};
useEffect(() => {
if (verificationCode.length === 6) {
submitVerification();
}
}, [verificationCode]);
return (
<Modal
title={
intl.formatMessage({
id: 'sms_verification.modal.verify_title',
defaultMessage: 'Verify your phone number',
})
}
onClose={() => onClose('VERIFY_SMS')}
cancelAction={status === Statuses.IDLE ? () => onClose('VERIFY_SMS') : undefined}
cancelText='Skip for now'
confirmationAction={onConfirmationClick}
confirmationText={confirmationText}
secondaryAction={status === Statuses.REQUESTED ? resendVerificationCode : undefined}
secondaryText={status === Statuses.REQUESTED ? intl.formatMessage({
id: 'sms_verification.modal.resend_code',
defaultMessage: 'Resend verification code?',
}) : undefined}
secondaryDisabled={requestedAnother}
>
<Stack space={4}>
{renderModalBody()}
</Stack>
</Modal>
);
};
export default VerifySmsModal;

View file

@ -501,3 +501,7 @@ export function CompareHistoryModal() {
export function AuthTokenList() {
return import(/* webpackChunkName: "features/auth_token_list" */'../../auth_token_list');
}
export function VerifySmsModal() {
return import(/* webpackChunkName: "features/ui" */'../components/modals/verify-sms-modal');
}

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "إلغاء",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Encaboxar",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Отказ",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "বাতিল করতে",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Cancel",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Cancel·lar",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Bloquejar {target}",
"report.block_hint": "També vols bloquejar aquest compte?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Annullà",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Zrušit",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Zablokovat {target}",
"report.block_hint": "Chcete zablokovat tento účet?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Canslo",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Annuller",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,8 @@
"reply_indicator.cancel": "Abbrechen",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "und {count, plural, one {einen weiteren Nutzer} other {# weitere Nutzer}}",
"reply_mentions.reply": "Antwort an {accounts}{more}",
"reply_mentions.more": "{count, plural, one {einen weiteren Nutzer} other {# weitere Nutzer}}",
"reply_mentions.reply": "Antwort an {accounts}",
"reply_mentions.reply_empty": "Antwort auf einen Beitrag",
"report.block": "{target} blockieren.",
"report.block_hint": "Soll dieses Konto zusammen mit der Meldung auch gleich blockiert werden?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Άκυρο",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,8 @@
"reply_indicator.cancel": "𐑒𐑨𐑯𐑕𐑩𐑤",
"reply_mentions.account.add": "𐑨𐑛 𐑑 𐑥𐑧𐑯𐑖𐑩𐑯𐑟",
"reply_mentions.account.remove": "𐑮𐑦𐑥𐑵𐑝 𐑓𐑮𐑪𐑥 𐑥𐑧𐑯𐑖𐑩𐑯𐑟",
"reply_mentions.more": "𐑯 {count} 𐑥𐑹",
"reply_mentions.reply": "𐑮𐑦𐑐𐑤𐑲𐑦𐑙 𐑑 {accounts}{more}",
"reply_mentions.more": "{count} 𐑥𐑹",
"reply_mentions.reply": "𐑮𐑦𐑐𐑤𐑲𐑦𐑙 𐑑 {accounts}",
"reply_mentions.reply_empty": "𐑮𐑦𐑐𐑤𐑲𐑦𐑙 𐑑 𐑐𐑴𐑕𐑑",
"report.block": "𐑚𐑤𐑪𐑒 {target}",
"report.block_hint": "𐑛𐑵 𐑿 𐑷𐑤𐑕𐑴 𐑢𐑪𐑯𐑑 𐑑 𐑚𐑤𐑪𐑒 𐑞𐑦𐑕 𐑩𐑒𐑬𐑯𐑑?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Cancel",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Nuligi",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Cancelar",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Cancelar",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Tühista",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Utzi",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "لغو",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Peruuta",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Annuler",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Cancel",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Cancelar",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,8 @@
"reply_indicator.cancel": "ביטול",
"reply_mentions.account.add": "הוסף לאזכורים",
"reply_mentions.account.remove": "הסר מהאזכורים",
"reply_mentions.more": "ו-{count} עוד",
"reply_mentions.reply": "משיב ל-{accounts}{more}",
"reply_mentions.more": "{count} עוד",
"reply_mentions.reply": "משיב ל-{accounts}",
"reply_mentions.reply_empty": "משיב לפוסט",
"report.block": "חסום {target}",
"report.block_hint": "האם גם אתה רוצה לחסום את החשבון הזה?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Cancel",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Otkaži",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Mégsem",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

View file

@ -859,8 +859,6 @@
"reply_indicator.cancel": "Չեղարկել",
"reply_mentions.account.add": "Add to mentions",
"reply_mentions.account.remove": "Remove from mentions",
"reply_mentions.more": "and {count} more",
"reply_mentions.reply": "Replying to {accounts}{more}",
"reply_mentions.reply_empty": "Replying to post",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",

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