Merge remote-tracking branch 'soapbox/develop' into hotkey-nav
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
commit
beef2de673
142 changed files with 1048 additions and 373 deletions
|
@ -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.
113
app/soapbox/actions/export_data.ts
Normal file
113
app/soapbox/actions/export_data.ts
Normal 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.
Binary file not shown.
39
app/soapbox/actions/snackbar.ts
Normal file
39
app/soapbox/actions/snackbar.ts
Normal 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.
67
app/soapbox/components/__tests__/account.test.tsx
Normal file
67
app/soapbox/components/__tests__/account.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
Binary file not shown.
Binary file not shown.
44
app/soapbox/components/attachment-thumbs.tsx
Normal file
44
app/soapbox/components/attachment-thumbs.tsx
Normal 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;
|
Binary file not shown.
Binary file not shown.
51
app/soapbox/components/display-name.tsx
Normal file
51
app/soapbox/components/display-name.tsx
Normal 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;
|
Binary file not shown.
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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;
|
||||
|
|
Binary file not shown.
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
Binary file not shown.
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -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';
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
|
Binary file not shown.
|
@ -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';
|
||||
|
|
Binary file not shown.
|
@ -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>
|
||||
|
|
Binary file not shown.
|
@ -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'>
|
||||
|
|
Binary file not shown.
47
app/soapbox/features/export_data/components/csv_exporter.tsx
Normal file
47
app/soapbox/features/export_data/components/csv_exporter.tsx
Normal 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;
|
Binary file not shown.
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
Binary file not shown.
66
app/soapbox/features/import_data/components/csv_importer.tsx
Normal file
66
app/soapbox/features/import_data/components/csv_importer.tsx
Normal 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;
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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'>
|
||||
|
|
|
@ -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();
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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>
|
||||
|
|
Binary file not shown.
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
|
|
233
app/soapbox/features/ui/components/modals/verify-sms-modal.tsx
Normal file
233
app/soapbox/features/ui/components/modals/verify-sms-modal.tsx
Normal 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;
|
|
@ -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');
|
||||
}
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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": "𐑛𐑵 𐑿 𐑷𐑤𐑕𐑴 𐑢𐑪𐑯𐑑 𐑑 𐑚𐑤𐑪𐑒 𐑞𐑦𐑕 𐑩𐑒𐑬𐑯𐑑?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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": "האם גם אתה רוצה לחסום את החשבון הזה?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue