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

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-develop-3zknud/deployments/30
marcin mikołajczak 2 years ago
commit beef2de673

@ -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: []

@ -57,6 +57,10 @@ export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST';
export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS';
export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL';
export const ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST';
export const ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS';
export const ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL';
export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST';
export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS';
export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL';
@ -520,6 +524,42 @@ export function unsubscribeAccountFail(error) {
};
}
export function removeFromFollowers(id) {
return (dispatch, getState) => {
if (!isLoggedIn(getState)) return;
dispatch(muteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/remove_from_followers`).then(response => {
dispatch(removeFromFollowersSuccess(response.data));
}).catch(error => {
dispatch(removeFromFollowersFail(id, error));
});
};
}
export function removeFromFollowersRequest(id) {
return {
type: ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST,
id,
};
}
export function removeFromFollowersSuccess(relationship) {
return {
type: ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS,
relationship,
};
}
export function removeFromFollowersFail(error) {
return {
type: ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL,
error,
};
}
export function fetchFollowers(id) {
return (dispatch, getState) => {
dispatch(fetchFollowersRequest(id));

@ -1,104 +0,0 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import api, { getLinks } from '../api';
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' },
});
function fileExport(content, fileName) {
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);
}
function listAccounts(state) {
return async apiResponse => {
const followings = apiResponse.data;
let accounts = [];
let next = getLinks(apiResponse).refs.find(link => link.rel === 'next');
while (next) {
apiResponse = await api(state).get(next.uri);
next = getLinks(apiResponse).refs.find(link => link.rel === 'next');
Array.prototype.push.apply(followings, apiResponse.data);
}
accounts = followings.map(account => account.fqn);
return [... new Set(accounts)];
};
}
export function exportFollows(intl) {
return (dispatch, getState) => {
dispatch({ type: EXPORT_FOLLOWS_REQUEST });
const me = getState().get('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(intl.formatMessage(messages.followersSuccess)));
dispatch({ type: EXPORT_FOLLOWS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_FOLLOWS_FAIL, error });
});
};
}
export function exportBlocks(intl) {
return (dispatch, getState) => {
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(intl.formatMessage(messages.blocksSuccess)));
dispatch({ type: EXPORT_BLOCKS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_BLOCKS_FAIL, error });
});
};
}
export function exportMutes(intl) {
return (dispatch, getState) => {
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(intl.formatMessage(messages.mutesSuccess)));
dispatch({ type: EXPORT_MUTES_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_MUTES_FAIL, error });
});
};
}

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

@ -4,6 +4,9 @@ import snackbar from 'soapbox/actions/snackbar';
import api from '../api';
import type { SnackbarAction } from './snackbar';
import type { RootState } from 'soapbox/store';
export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST';
export const IMPORT_FOLLOWS_SUCCESS = 'IMPORT_FOLLOWS_SUCCESS';
export const IMPORT_FOLLOWS_FAIL = 'IMPORT_FOLLOWS_FAIL';
@ -16,50 +19,61 @@ export const IMPORT_MUTES_REQUEST = 'IMPORT_MUTES_REQUEST';
export const IMPORT_MUTES_SUCCESS = 'IMPORT_MUTES_SUCCESS';
export const IMPORT_MUTES_FAIL = 'IMPORT_MUTES_FAIL';
type ImportDataActions = {
type: typeof IMPORT_FOLLOWS_REQUEST
| typeof IMPORT_FOLLOWS_SUCCESS
| typeof IMPORT_FOLLOWS_FAIL
| typeof IMPORT_BLOCKS_REQUEST
| typeof IMPORT_BLOCKS_SUCCESS
| typeof IMPORT_BLOCKS_FAIL
| typeof IMPORT_MUTES_REQUEST
| typeof IMPORT_MUTES_SUCCESS
| typeof IMPORT_MUTES_FAIL,
error?: any,
config?: string
} | SnackbarAction
const messages = defineMessages({
blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' },
followersSuccess: { id: 'import_data.success.followers', defaultMessage: 'Followers imported successfully' },
mutesSuccess: { id: 'import_data.success.mutes', defaultMessage: 'Mutes imported successfully' },
});
export function importFollows(intl, params) {
return (dispatch, getState) => {
export const importFollows = (params: FormData) =>
(dispatch: React.Dispatch<ImportDataActions>, getState: () => RootState) => {
dispatch({ type: IMPORT_FOLLOWS_REQUEST });
return api(getState)
.post('/api/pleroma/follow_import', params)
.then(response => {
dispatch(snackbar.success(intl.formatMessage(messages.followersSuccess)));
dispatch(snackbar.success(messages.followersSuccess));
dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data });
}).catch(error => {
dispatch({ type: IMPORT_FOLLOWS_FAIL, error });
});
};
}
export function importBlocks(intl, params) {
return (dispatch, getState) => {
export const importBlocks = (params: FormData) =>
(dispatch: React.Dispatch<ImportDataActions>, getState: () => RootState) => {
dispatch({ type: IMPORT_BLOCKS_REQUEST });
return api(getState)
.post('/api/pleroma/blocks_import', params)
.then(response => {
dispatch(snackbar.success(intl.formatMessage(messages.blocksSuccess)));
dispatch(snackbar.success(messages.blocksSuccess));
dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data });
}).catch(error => {
dispatch({ type: IMPORT_BLOCKS_FAIL, error });
});
};
}
export function importMutes(intl, params) {
return (dispatch, getState) => {
export const importMutes = (params: FormData) =>
(dispatch: React.Dispatch<ImportDataActions>, getState: () => RootState) => {
dispatch({ type: IMPORT_MUTES_REQUEST });
return api(getState)
.post('/api/pleroma/mutes_import', params)
.then(response => {
dispatch(snackbar.success(intl.formatMessage(messages.mutesSuccess)));
dispatch(snackbar.success(messages.mutesSuccess));
dispatch({ type: IMPORT_MUTES_SUCCESS, config: response.data });
}).catch(error => {
dispatch({ type: IMPORT_MUTES_FAIL, error });
});
};
}

@ -149,7 +149,6 @@ export function favourite(status) {
dispatch(favouriteRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function(response) {
dispatch(importFetchedStatus(response.data));
dispatch(favouriteSuccess(status));
}).catch(function(error) {
dispatch(favouriteFail(status, error));

@ -1,28 +0,0 @@
import { ALERT_SHOW } from './alerts';
export const show = (severity, message, actionLabel, actionLink) => ({
type: ALERT_SHOW,
message,
actionLabel,
actionLink,
severity,
});
export function info(message, actionLabel, actionLink) {
return show('info', message, actionLabel, actionLink);
}
export function success(message, actionLabel, actionLink) {
return show('success', message, actionLabel, actionLink);
}
export function error(message, actionLabel, actionLink) {
return show('error', message, actionLabel, actionLink);
}
export default {
info,
success,
error,
show,
};

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

@ -1,7 +1,7 @@
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import api from '../api';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
@ -32,11 +32,17 @@ export function fetchSuggestionsV1(params = {}) {
export function fetchSuggestionsV2(params = {}) {
return (dispatch, getState) => {
const next = getState().getIn(['suggestions', 'next']);
dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true });
return api(getState).get('/api/v2/suggestions', { params }).then(({ data: suggestions }) => {
return api(getState).get(next ? next.uri : '/api/v2/suggestions', next ? {} : { params }).then((response) => {
const suggestions = response.data;
const accounts = suggestions.map(({ account }) => account);
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(accounts));
dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, skipLoading: true });
dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, next, skipLoading: true });
return suggestions;
}).catch(error => {
dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true });

@ -244,7 +244,9 @@ function checkEmailAvailability(email) {
return api(getState).get(`/api/v1/pepe/account/exists?email=${email}`, {
headers: { Authorization: `Bearer ${token}` },
}).finally(() => dispatch({ type: SET_LOADING, value: false }));
})
.catch(() => {})
.then(() => dispatch({ type: SET_LOADING, value: false }));
};
}
@ -323,6 +325,20 @@ function requestPhoneVerification(phone) {
};
}
/**
* Send the user's phone number to Pepe to re-request confirmation
* @param {string} phone
* @returns {promise}
*/
function reRequestPhoneVerification(phone) {
return (dispatch, getState) => {
dispatch({ type: SET_LOADING });
return api(getState).post('/api/v1/pepe/reverify_sms/request', { phone })
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
}
/**
* Confirm the user's phone number with Pepe
* @param {string} code
@ -345,6 +361,20 @@ function confirmPhoneVerification(code) {
};
}
/**
* Re-Confirm the user's phone number with Pepe
* @param {string} code
* @returns {promise}
*/
function reConfirmPhoneVerification(code) {
return (dispatch, getState) => {
dispatch({ type: SET_LOADING });
return api(getState).post('/api/v1/pepe/reverify_sms/confirm', { code })
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
}
/**
* Confirm the user's age with Pepe
* @param {date} birthday
@ -404,6 +434,8 @@ export {
requestEmailVerification,
checkEmailVerification,
postEmailVerification,
reConfirmPhoneVerification,
requestPhoneVerification,
reRequestPhoneVerification,
verifyAge,
};

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

@ -1,12 +0,0 @@
import React from 'react';
import { render, screen } from '../../jest/test-helpers';
import ColumnBackButton from '../column_back_button';
describe('<ColumnBackButton />', () => {
it('renders correctly', () => {
render(<ColumnBackButton />);
expect(screen.getByRole('button')).toHaveTextContent('Back');
});
});

@ -3,7 +3,7 @@ import React from 'react';
import { normalizeAccount } from 'soapbox/normalizers';
import { render, screen } from '../../jest/test-helpers';
import DisplayName from '../display_name';
import DisplayName from '../display-name';
describe('<DisplayName />', () => {
it('renders display name + account name', () => {

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

@ -1,52 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } 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';
export default @connect()
class AttachmentThumbs extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
media: ImmutablePropTypes.list.isRequired,
onClick: PropTypes.func,
sensitive: PropTypes.bool,
};
renderLoading() {
return <div className='media-gallery--compact' />;
}
onOpenMedia = (media, index) => {
this.props.dispatch(openModal('MEDIA', { media, index }));
}
render() {
const { media, onClick, sensitive } = this.props;
return (
<div className='attachment-thumbs'>
<Bundle fetchComponent={MediaGallery} loading={this.renderLoading}>
{Component => (
<Component
media={media}
onOpenMedia={this.onOpenMedia}
height={50}
compact
sensitive={sensitive}
/>
)}
</Bundle>
{onClick && (
<div className='attachment-thumbs__clickable-region' onClick={onClick} />
)}
</div>
);
}
}

@ -1,41 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
export default @withRouter
class ColumnBackButton extends React.PureComponent {
static propTypes = {
to: PropTypes.string,
history: PropTypes.object,
};
handleClick = () => {
const { to } = this.props;
if (window.history?.length === 1) {
this.props.history.push(to ? to : '/');
} else {
this.props.history.goBack();
}
}
handleKeyUp = (e) => {
if (e.key === 'Enter') {
this.handleClick();
}
}
render() {
return (
<button onClick={this.handleClick} onKeyUp={this.handleKeyUp} className='column-back-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>
);
}
}

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

@ -1,85 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
import { displayFqn } from 'soapbox/utils/state';
import { getAcct } from '../utils/accounts';
import Icon from './icon';
import RelativeTimestamp from './relative_timestamp';
import VerificationBadge from './verification_badge';
const mapStateToProps = state => {
return {
displayFqn: displayFqn(state),
};
};
export default @connect(mapStateToProps)
class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
displayFqn: PropTypes.bool,
others: ImmutablePropTypes.list,
children: PropTypes.node,
withDate: PropTypes.bool,
};
static defaultProps = {
withDate: false,
}
render() {
const { account, displayFqn, others, children, withDate } = this.props;
let displayName, suffix;
const verified = account.get('verified');
const createdAt = account.get('created_at');
const joinedAt = createdAt ? (
<div className='account__joined-at'>
<Icon src={require('@tabler/icons/icons/clock.svg')} />
<RelativeTimestamp timestamp={createdAt} />
</div>
) : null;
if (others?.size > 1) {
displayName = others.take(2).map(a => (
<span className='display-name__name' key={a.get('id')}>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>
{verified && <VerificationBadge />}
{withDate && joinedAt}
</span>
)).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else {
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>
);
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>
);
}
}

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

@ -1,8 +1,9 @@
import { List as ImmutableList } from 'immutable';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage, injectIntl } from 'react-intl';
import { FormattedList, FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
@ -42,7 +43,7 @@ class StatusReplyMentions extends ImmutablePureComponent {
return null;
}
const to = status.get('mentions', []);
const to = status.get('mentions', ImmutableList());
// The post is a reply, but it has no mentions.
// Rare, but it can happen.
@ -58,23 +59,27 @@ class StatusReplyMentions extends ImmutablePureComponent {
}
// The typical case with a reply-to and a list of mentions.
const accounts = to.slice(0, 2).map(account => (
<HoverRefWrapper accountId={account.get('id')} inline>
<Link to={`/@${account.get('acct')}`} className='reply-mentions__account'>@{account.get('username')}</Link>
</HoverRefWrapper>
)).toArray();
if (to.size > 2) {
accounts.push(
<span className='hover:underline cursor-pointer' role='presentation' onClick={this.handleOpenMentionsModal}>
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />
</span>,
);
}
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 => (<>
<HoverRefWrapper accountId={account.get('id')} inline>
<Link to={`/@${account.get('acct')}`} className='reply-mentions__account'>@{account.get('username')}</Link>
</HoverRefWrapper>
{' '}
</>)),
more: to.size > 2 && (
<span className='hover:underline cursor-pointer' role='presentation' onClick={this.handleOpenMentionsModal}>
<FormattedMessage id='reply_mentions.more' defaultMessage='and {count} more' values={{ count: to.size - 2 }} />
</span>
),
accounts: <FormattedList type='conjunction' value={accounts} />,
}}
/>
</div>

@ -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>

@ -1,9 +1,9 @@
/**
* iOS style loading spinner.
* Adapted from: https://loading.io/css/
* With some help scaling it: https://signalvnoise.com/posts/2577-loading-spinner-animation-using-css-and-webkit
*/
.spinner {
@apply inline-block relative w-20 h-20;
}

@ -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

@ -48,6 +48,7 @@ const messages = defineMessages({
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
@ -283,6 +284,14 @@ class Header extends ImmutablePureComponent {
});
}
if (features.removeFromFollowers && account.getIn(['relationship', 'followed_by'])) {
menu.push({
text: intl.formatMessage(messages.removeFromFollowers),
action: this.props.onRemoveFromFollowers,
icon: require('@tabler/icons/icons/user-x.svg'),
});
}
if (account.getIn(['relationship', 'muting'])) {
menu.push({
text: intl.formatMessage(messages.unmute, { name: account.get('username') }),

@ -25,6 +25,7 @@ class Header extends ImmutablePureComponent {
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onRemoveFromFollowers: PropTypes.func.isRequired,
username: PropTypes.string,
history: PropTypes.object,
};
@ -141,6 +142,10 @@ class Header extends ImmutablePureComponent {
this.props.onShowNote(this.props.account);
}
handleRemoveFromFollowers = () => {
this.props.onRemoveFromFollowers(this.props.account);
}
render() {
const { account } = this.props;
const moved = (account) ? account.get('moved') : false;
@ -177,6 +182,7 @@ class Header extends ImmutablePureComponent {
onSuggestUser={this.handleSuggestUser}
onUnsuggestUser={this.handleUnsuggestUser}
onShowNote={this.handleShowNote}
onRemoveFromFollowers={this.handleRemoveFromFollowers}
username={this.props.username}
/>
</>

@ -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';

@ -13,6 +13,7 @@ import {
unpinAccount,
subscribeAccount,
unsubscribeAccount,
removeFromFollowers,
} from 'soapbox/actions/accounts';
import {
verifyUser,
@ -56,6 +57,7 @@ const messages = defineMessages({
demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' },
userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' },
userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' },
removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' },
});
const makeMapStateToProps = () => {
@ -269,6 +271,21 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onShowNote(account) {
dispatch(initAccountNoteModal(account));
},
onRemoveFromFollowers(account) {
dispatch((_, getState) => {
const unfollowModal = getSettings(getState()).get('unfollowModal');
if (unfollowModal) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.remove_from_followers.message' defaultMessage='Are you sure you want to remove {name} from your followers?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.removeFromFollowersConfirm),
onConfirm: () => dispatch(removeFromFollowers(account.get('id'))),
}));
} else {
dispatch(removeFromFollowers(account.get('id')));
}
});
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));

@ -7,7 +7,7 @@ import { connect } from 'react-redux';
import { addToAliases } from 'soapbox/actions/aliases';
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 { makeGetAccount } from 'soapbox/selectors';
import { getFeatures } from 'soapbox/utils/features';

@ -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';

@ -77,7 +77,6 @@ class ChatRoom extends ImmutablePureComponent {
return (
<Column label={`@${getAcct(account, displayFqn)}`}>
{/* <div className='chatroom__back'>
<ColumnBackButton />
<Link to={`/@${account.get('acct')}`} className='chatroom__header'>
<Avatar account={account} size={18} />
<div className='chatroom__title'>

@ -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';

@ -3,7 +3,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
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';
@ -28,7 +28,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
return null;
}
const style = {
const style = {
direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
};
@ -59,7 +59,6 @@ export default class ReplyIndicator extends ImmutablePureComponent {
{status.get('media_attachments').size > 0 && (
<AttachmentThumbs
compact
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
/>

@ -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>

@ -8,7 +8,7 @@ import { connect } from 'react-redux';
import { getSettings } from 'soapbox/actions/settings';
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 RelativeTimestamp from 'soapbox/components/relative_timestamp';
import { Text } from 'soapbox/components/ui';

@ -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'>

@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Button, Form, FormActions, Text } from 'soapbox/components/ui';
export default @connect()
@injectIntl
class CSVExporter extends ImmutablePureComponent {
static propTypes = {
action: PropTypes.func.isRequired,
messages: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
isLoading: false,
}
handleClick = (event) => {
const { dispatch, action, intl } = this.props;
this.setState({ isLoading: true });
dispatch(action(intl)).then(() => {
this.setState({ isLoading: false });
}).catch((error) => {
this.setState({ isLoading: false });
});
}
render() {
const { intl, messages } = this.props;
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={this.handleClick}>
{intl.formatMessage(messages.submit)}
</Button>
</FormActions>
</Form>
</>
);
}
}

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

@ -1,15 +1,11 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { defineMessages, useIntl } from 'react-intl';
import {
exportFollows,
exportBlocks,
exportMutes,
} from 'soapbox/actions/export_data';
import { getFeatures } from 'soapbox/utils/features';
import Column from '../ui/components/column';
@ -38,29 +34,16 @@ const muteMessages = defineMessages({
submit: { id: 'export_data.actions.export_mutes', defaultMessage: 'Export mutes' },
});
const mapStateToProps = state => ({
features: getFeatures(state.get('instance')),
});
export default @connect(mapStateToProps)
@injectIntl
class ExportData extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
features: PropTypes.object,
};
render() {
const { intl } = this.props;
const ExportData = () => {
const intl = useIntl();
return (
<Column icon='cloud-download-alt' label={intl.formatMessage(messages.heading)}>
<CSVExporter action={exportFollows} messages={followMessages} />
<CSVExporter action={exportBlocks} messages={blockMessages} />
<CSVExporter action={exportMutes} messages={muteMessages} />
</Column>
);
}
return (
<Column icon='cloud-download-alt' label={intl.formatMessage(messages.heading)}>
<CSVExporter action={exportFollows} messages={followMessages} />
<CSVExporter action={exportBlocks} messages={blockMessages} />
<CSVExporter action={exportMutes} messages={muteMessages} />
</Column>
);
};
}
export default ExportData;

@ -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';

@ -1,71 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Button, Form, FormActions, Text } from 'soapbox/components/ui';
export default @connect()
@injectIntl
class CSVImporter extends ImmutablePureComponent {
static propTypes = {
action: PropTypes.func.isRequired,
messages: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
file: null,
isLoading: false,
}
handleSubmit = (event) => {
const { dispatch, action, intl } = this.props;
const params = new FormData();
params.append('list', this.state.file);
this.setState({ isLoading: true });
dispatch(action(intl, params)).then(() => {
this.setState({ isLoading: false });
}).catch((error) => {
this.setState({ isLoading: false });
});
event.preventDefault();
}
handleFileChange = e => {
const [file] = e.target.files || [];
this.setState({ file });
}
render() {
const { intl, messages } = this.props;
return (
<Form onSubmit={this.handleSubmit} disabled={this.state.isLoading}>
<Text size='xl' weight='bold' tag='label'>{intl.formatMessage(messages.input_label)}</Text>
<div>
<input
className='text-black dark:text-white'
type='file'
accept={['.csv', 'text/csv']}
onChange={this.handleFileChange}
required
/>
<Text theme='muted'>{intl.formatMessage(messages.input_hint)}</Text>
</div>
<FormActions>
<Button type='submit' theme='primary' disabled={this.state.isLoading}>
{intl.formatMessage(messages.submit)}
</Button>
</FormActions>
</Form>
);
}
}

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

@ -1,14 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { defineMessages, useIntl } from 'react-intl';
import {
importFollows,
importBlocks,
importMutes,
} from 'soapbox/actions/import_data';
import { useAppSelector } from 'soapbox/hooks';
import { getFeatures } from 'soapbox/utils/features';
import Column from '../ui/components/column';
@ -38,29 +36,17 @@ const muteMessages = defineMessages({
submit: { id: 'import_data.actions.import_mutes', defaultMessage: 'Import mutes' },
});
const mapStateToProps = state => ({
features: getFeatures(state.get('instance')),
});
export default @connect(mapStateToProps)
@injectIntl
class ImportData extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
features: PropTypes.object,
};
render() {
const { intl, features } = this.props;
const ImportData = () => {
const intl = useIntl();
const features = getFeatures(useAppSelector((state) => state.instance));
return (
<Column icon='cloud-upload-alt' label={intl.formatMessage(messages.heading)}>
<CSVImporter action={importFollows} messages={followMessages} />
<CSVImporter action={importBlocks} messages={blockMessages} />
{features.importMutes && <CSVImporter action={importMutes} messages={muteMessages} />}
</Column>
);
}
return (
<Column icon='cloud-upload-alt' label={intl.formatMessage(messages.heading)}>
<CSVImporter action={importFollows} messages={followMessages} />
<CSVImporter action={importBlocks} messages={blockMessages} />
{features.importMutes && <CSVImporter action={importMutes} messages={muteMessages} />}
</Column>
);
};
}
export default ImportData;

@ -5,7 +5,7 @@ import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import DisplayName from '../../../components/display-name';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {

@ -7,7 +7,7 @@ import { connect } from 'react-redux';
import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import DisplayName from '../../../components/display-name';
import IconButton from '../../../components/icon_button';
import { makeGetAccount } from '../../../selectors';

@ -5,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
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';

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

@ -7,7 +7,7 @@ import { connect } from 'react-redux';
import { fetchAccount } from 'soapbox/actions/accounts';
import { addToMentions, removeFromMentions } from 'soapbox/actions/compose';
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 { makeGetAccount } from 'soapbox/selectors';

@ -4,9 +4,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Link, NavLink } from 'react-router-dom';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import RelativeTimestamp from 'soapbox/components/relative_timestamp';
import StatusContent from 'soapbox/components/status_content';
import StatusReplyMentions from 'soapbox/components/status_reply_mentions';
@ -74,7 +74,6 @@ class ScheduledStatus extends ImmutablePureComponent {
{status.get('media_attachments').size > 0 && (
<AttachmentThumbs
compact
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
/>

@ -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>
</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>h</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></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>n</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></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>f</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open likes 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>p</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='to open pinned posts list' /></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>u</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.my_profile' defaultMessage='to open your profile' /></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>b</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.blocked' defaultMessage='to open blocked users 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>
<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>?</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>

@ -30,6 +30,7 @@ import {
BirthdaysModal,
AccountNoteModal,
CompareHistoryModal,
VerifySmsModal,
} from 'soapbox/features/ui/util/async-components';
import BundleContainer from '../containers/bundle_container';
@ -66,6 +67,7 @@ const MODAL_COMPONENTS = {
'BIRTHDAYS': BirthdaysModal,
'ACCOUNT_NOTE': AccountNoteModal,
'COMPARE_HISTORY': CompareHistoryModal,
'VERIFY_SMS': VerifySmsModal,
};
export default class ModalRoot extends React.PureComponent {

@ -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')}
/>

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

@ -49,7 +49,10 @@ const AgeVerification = () => {
snackbar.error(
intl.formatMessage({
id: 'age_verification.fail',
defaultMessage: `You must be ${ageMinimum} years old or older.`,
defaultMessage: 'You must be {ageMinimum, plural, one {# year} other {# years}} old or older.',
values: {
ageMinimum,
},
}),
),
);

@ -1,7 +1,7 @@
import * as React from 'react';
import { useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import snackbar from 'soapbox/actions/snackbar';
import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification';
@ -167,4 +167,4 @@ const SmsVerification = () => {
};
export default SmsVerification;
export { SmsVerification as default, validPhoneNumberRegex };

@ -1,13 +1,15 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import LandingGradient from 'soapbox/components/landing-gradient';
import SiteLogo from 'soapbox/components/site-logo';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { NotificationsContainer } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { logOut } from '../../actions/auth';
import { Button, Stack, Text } from '../../components/ui';
@ -15,12 +17,24 @@ import { Button, Stack, Text } from '../../components/ui';
const WaitlistPage = ({ account }) => {
const dispatch = useDispatch();
const intl = useIntl();
const title = useAppSelector((state) => state.instance.title);
const me = useOwnAccount();
const isSmsVerified = me.getIn(['source', 'sms_verified']);
const onClickLogOut = (event) => {
event.preventDefault();
dispatch(logOut(intl));
};
const openVerifySmsModal = () => dispatch(openModal('VERIFY_SMS'));
useEffect(() => {
if (!isSmsVerified) {
openVerifySmsModal();
}
}, []);
return (
<div>
<LandingGradient />
@ -41,19 +55,20 @@ const WaitlistPage = ({ account }) => {
</header>
<div className='-mt-16 flex flex-col justify-center items-center h-full'>
<div className='max-w-2xl'>
<div className='max-w-xl'>
<Stack space={4}>
<img src='/instance/images/waitlist.png' className='mx-auto w-32 h-32' alt='Waitlisted' />
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
@{account.acct} has been created successfully!
</Text>
<Text size='lg' theme='muted' align='center' weight='medium'>
Due to massive demand, we have placed you on our waitlist.
We love you, and you're not just another number to us.
We are working to get you on our platform. Stay tuned!
Welcome back to {title}! You were previously placed on our
waitlist. Please verify your phone number to receive
immediate access to your account!
</Text>
<div className='text-center'>
<Button onClick={openVerifySmsModal} theme='primary'>Verify phone number</Button>
</div>
</Stack>
</Stack>
</div>

@ -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…
Cancel
Save