Merge remote-tracking branch 'soapbox/develop' into events-

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-events-5jp5it/deployments/1372
marcin mikołajczak 2022-09-30 20:05:02 +02:00
commit 803dada3e0
47 zmienionych plików z 885 dodań i 435 usunięć

Wyświetl plik

@ -277,6 +277,7 @@ module.exports = {
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-undef': 'off', // https://stackoverflow.com/a/69155899
'space-before-function-paren': 'off',
},
parser: '@typescript-eslint/parser',
},

Wyświetl plik

@ -82,4 +82,4 @@
"emojis": [],
"card": null,
"poll": null
}
}

Wyświetl plik

@ -20,6 +20,7 @@ import KVStore from 'soapbox/storage/kv_store';
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code';
import { getFeatures } from 'soapbox/utils/features';
import { normalizeUsername } from 'soapbox/utils/input';
import { isStandalone } from 'soapbox/utils/state';
import api, { baseClient } from '../api';
@ -207,16 +208,6 @@ export const loadCredentials = (token: string, accountUrl: string) =>
})
.catch(() => dispatch(verifyCredentials(token, accountUrl)));
/** Trim the username and strip the leading @. */
const normalizeUsername = (username: string): string => {
const trimmed = username.trim();
if (trimmed[0] === '@') {
return trimmed.slice(1);
} else {
return trimmed;
}
};
export const logIn = (username: string, password: string) =>
(dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => {
return dispatch(createUserToken(normalizeUsername(username), password));

Wyświetl plik

@ -323,11 +323,13 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined;
const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined;
const media = getState().compose.get(composeId)!.media_attachments;
const media = getState().compose.get(composeId)?.media_attachments;
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size > attachmentLimit) {
const mediaCount = media ? media.size : 0;
if (files.length + mediaCount > attachmentLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error'));
return;
}
@ -335,7 +337,7 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
dispatch(uploadComposeRequest(composeId));
Array.from(files).forEach(async(f, i) => {
if (media.size + i > attachmentLimit - 1) return;
if (mediaCount + i > attachmentLimit - 1) return;
const isImage = f.type.match(/image.*/);
const isVideo = f.type.match(/video.*/);

Wyświetl plik

@ -5,6 +5,8 @@ import { fetchAccountByUsername } from 'soapbox/actions/accounts';
import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin';
import { openModal } from 'soapbox/actions/modals';
import snackbar from 'soapbox/actions/snackbar';
import OutlineBox from 'soapbox/components/outline-box';
import { Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { isLocal } from 'soapbox/utils/accounts';
@ -43,10 +45,22 @@ const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm =
const acct = state.accounts.get(accountId)!.acct;
const name = state.accounts.get(accountId)!.username;
const message = (
<Stack space={4}>
<OutlineBox>
<AccountContainer id={accountId} />
</OutlineBox>
<Text>
{intl.formatMessage(messages.deactivateUserPrompt, { acct })}
</Text>
</Stack>
);
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/user-off.svg'),
heading: intl.formatMessage(messages.deactivateUserHeading, { acct }),
message: intl.formatMessage(messages.deactivateUserPrompt, { acct }),
message,
confirm: intl.formatMessage(messages.deactivateUserConfirm, { name }),
onConfirm: () => {
dispatch(deactivateUsers([accountId])).then(() => {
@ -64,22 +78,21 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () =
const account = state.accounts.get(accountId)!;
const acct = account.acct;
const name = account.username;
const favicon = account.pleroma.get('favicon');
const local = isLocal(account);
const message = (<>
<AccountContainer id={accountId} />
{intl.formatMessage(messages.deleteUserPrompt, { acct })}
</>);
const message = (
<Stack space={4}>
<OutlineBox>
<AccountContainer id={accountId} />
</OutlineBox>
const confirm = (<>
{favicon &&
<div className='submit__favicon'>
<img src={favicon} alt='' />
</div>}
{intl.formatMessage(messages.deleteUserConfirm, { name })}
</>);
<Text>
{intl.formatMessage(messages.deleteUserPrompt, { acct })}
</Text>
</Stack>
);
const confirm = intl.formatMessage(messages.deleteUserConfirm, { name });
const checkbox = local ? intl.formatMessage(messages.deleteLocalUserCheckbox) : false;
dispatch(openModal('CONFIRM', {

Wyświetl plik

@ -7,6 +7,7 @@
import snackbar from 'soapbox/actions/snackbar';
import { getLoggedInAccount } from 'soapbox/utils/auth';
import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features';
import { normalizeUsername } from 'soapbox/utils/input';
import api from '../api';
@ -84,15 +85,16 @@ const changePassword = (oldPassword: string, newPassword: string, confirmation:
const resetPassword = (usernameOrEmail: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const input = normalizeUsername(usernameOrEmail);
const state = getState();
const v = parseVersion(state.instance.version);
dispatch({ type: RESET_PASSWORD_REQUEST });
const params =
usernameOrEmail.includes('@')
? { email: usernameOrEmail }
: { nickname: usernameOrEmail, username: usernameOrEmail };
input.includes('@')
? { email: input }
: { nickname: input, username: input };
const endpoint =
v.software === TRUTHSOCIAL

Wyświetl plik

@ -7,7 +7,6 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
import StillImage from 'soapbox/components/still_image';
@ -264,14 +263,9 @@ class Item extends React.PureComponent {
}
const mapStateToMediaGalleryProps = state => {
const { links } = getSoapboxConfig(state);
return {
displayMedia: getSettings(state).get('displayMedia'),
links,
};
};
const mapStateToMediaGalleryProps = state => ({
displayMedia: getSettings(state).get('displayMedia'),
});
export default @connect(mapStateToMediaGalleryProps)
@injectIntl
@ -291,7 +285,6 @@ class MediaGallery extends React.PureComponent {
onToggleVisibility: PropTypes.func,
displayMedia: PropTypes.string,
compact: PropTypes.bool,
links: ImmutablePropTypes.map,
};
static defaultProps = {
@ -575,7 +568,7 @@ class MediaGallery extends React.PureComponent {
}
render() {
const { media, intl, sensitive, compact, inReview, links } = this.props;
const { media, intl, sensitive, compact } = this.props;
const { visible } = this.state;
const sizeData = this.getSizeData(media.size);
@ -594,22 +587,14 @@ class MediaGallery extends React.PureComponent {
/>
));
let warning, summary;
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else if (inReview) {
warning = <FormattedMessage id='status.in_review_warning' defaultMessage='Content Under Review' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
if (inReview) {
summary = <FormattedMessage id='status.in_review_summary.summary' defaultMessage='This Truth has been sent to Moderation for review and is only visible to you.' />;
} else {
summary = <FormattedMessage id='status.sensitive_warning.subtitle' defaultMessage='This content may not be suitable for all audiences.' />;
}
return (
<div className={classNames('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.get('style')} ref={this.handleRef}>
<div
@ -619,7 +604,7 @@ class MediaGallery extends React.PureComponent {
'left-1 top-1': visible || compact,
})}
>
{(sensitive || inReview) && (
{sensitive && (
(visible || compact) ? (
<Button
text={intl.formatMessage(messages.toggle_visible)}
@ -633,40 +618,15 @@ class MediaGallery extends React.PureComponent {
onClick={(e) => e.stopPropagation()}
className={
classNames({
'cursor-default backdrop-blur-sm rounded-lg w-full h-full border-0 flex items-center justify-center': true,
'bg-gray-800/75': !inReview,
'bg-danger-600/75': inReview,
'bg-gray-800/75 cursor-default backdrop-blur-sm rounded-lg w-full h-full border-0 flex items-center justify-center': true,
})
}
>
<div className='text-center w-3/4 mx-auto space-y-4'>
<div className='space-y-1'>
<Text theme='white' weight='semibold'>{warning}</Text>
<Text theme='white' size='sm' weight='medium'>
{summary}
{links.get('support') && (
<>
{' '}
<FormattedMessage
id='status.in_review_summary.contact'
defaultMessage='If you believe this is in error please {link}.'
values={{
link: (
<a
className='underline text-inherit'
href={links.get('support')}
>
<FormattedMessage
id='status.in_review_summary.link'
defaultMessage='Contact Support'
/>
</a>
),
}}
/>
</>
)}
<Text size='sm'>
<FormattedMessage id='status.sensitive_warning.subtitle' defaultMessage='This content may not be suitable for all audiences.' />
</Text>
</div>

Wyświetl plik

@ -0,0 +1,21 @@
import classNames from 'clsx';
import React from 'react';
interface IOutlineBox extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode,
className?: string,
}
/** Wraps children in a container with an outline. */
const OutlineBox: React.FC<IOutlineBox> = ({ children, className, ...rest }) => {
return (
<div
className={classNames('p-4 rounded-lg border border-solid border-gray-300 dark:border-gray-800', className)}
{...rest}
>
{children}
</div>
);
};
export default OutlineBox;

Wyświetl plik

@ -9,6 +9,8 @@ import AccountContainer from 'soapbox/containers/account_container';
import { useSettings } from 'soapbox/hooks';
import { defaultMediaVisibility } from 'soapbox/utils/status';
import OutlineBox from './outline-box';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
const messages = defineMessages({
@ -123,38 +125,41 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
}
return (
<Stack
<OutlineBox
data-testid='quoted-status'
space={2}
className={classNames('mt-3 p-4 rounded-lg border border-solid border-gray-300 dark:border-gray-800 cursor-pointer', {
className={classNames('mt-3 cursor-pointer', {
'hover:bg-gray-100 dark:hover:bg-gray-800': !compose,
})}
onClick={handleExpandClick}
>
<AccountContainer
{...actions}
id={account.id}
timestamp={status.created_at}
withRelationship={false}
showProfileHoverCard={!compose}
withLinkToProfile={!compose}
/>
<Stack
space={2}
onClick={handleExpandClick}
>
<AccountContainer
{...actions}
id={account.id}
timestamp={status.created_at}
withRelationship={false}
showProfileHoverCard={!compose}
withLinkToProfile={!compose}
/>
{renderReplyMentions()}
{renderReplyMentions()}
<Text
className='break-words status__content status__content--quote'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
/>
<Text
className='break-words status__content status__content--quote'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
/>
<StatusMedia
status={status}
muted={compose}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
</Stack>
<StatusMedia
status={status}
muted={compose}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
</Stack>
</OutlineBox>
);
};

Wyświetl plik

@ -149,7 +149,6 @@ const StatusMedia: React.FC<IStatusMedia> = ({
<Component
media={mediaAttachments}
sensitive={status.sensitive}
inReview={status.visibility === 'self'}
height={285}
onOpenMedia={openMedia}
visible={showMedia}

Wyświetl plik

@ -19,6 +19,7 @@ import StatusActionBar from './status-action-bar';
import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions';
import StatusContent from './status_content';
import ModerationOverlay from './statuses/moderation-overlay';
import { Card, HStack, Text } from './ui';
import type { Map as ImmutableMap } from 'immutable';
@ -300,6 +301,8 @@ const Status: React.FC<IStatus> = (props) => {
const accountAction = props.accountAction || reblogElement;
const inReview = status.visibility === 'self';
return (
<HotKeys handlers={handlers} data-testid='status'>
<div
@ -349,7 +352,15 @@ const Status: React.FC<IStatus> = (props) => {
/>
</div>
<div className='status__content-wrapper'>
<div
className={classNames('status__content-wrapper relative', {
'min-h-[220px]': inReview,
})}
>
{inReview ? (
<ModerationOverlay />
) : null}
{!group && actualStatus.group && (
<div className='status__meta'>
Posted in <NavLink to={`/groups/${actualStatus.getIn(['group', 'id'])}`}>{String(actualStatus.getIn(['group', 'title']))}</NavLink>
@ -390,8 +401,8 @@ const Status: React.FC<IStatus> = (props) => {
)}
</div>
</Card>
</div>
</HotKeys>
</div >
</HotKeys >
);
};

Wyświetl plik

@ -0,0 +1,19 @@
import React from 'react';
import { fireEvent, render, screen } from '../../../jest/test-helpers';
import ModerationOverlay from '../moderation-overlay';
describe('<ModerationOverlay />', () => {
it('defaults to enabled', () => {
render(<ModerationOverlay />);
expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Content Under Review');
});
it('can be toggled', () => {
render(<ModerationOverlay />);
fireEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('moderation-overlay')).not.toHaveTextContent('Content Under Review');
expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Hide');
});
});

Wyświetl plik

@ -0,0 +1,93 @@
import classNames from 'clsx';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useSoapboxConfig } from 'soapbox/hooks';
import { Button, HStack, Text } from '../ui';
const messages = defineMessages({
hide: { id: 'moderation_overlay.hide', defaultMessage: 'Hide' },
title: { id: 'moderation_overlay.title', defaultMessage: 'Content Under Review' },
subtitle: { id: 'moderation_overlay.subtitle', defaultMessage: 'This Post has been sent to Moderation for review and is only visible to you. If you believe this is an error please contact Support.' },
contact: { id: 'moderation_overlay.contact', defaultMessage: 'Contact' },
show: { id: 'moderation_overlay.show', defaultMessage: 'Show Content' },
});
const ModerationOverlay = () => {
const intl = useIntl();
const { links } = useSoapboxConfig();
const [visible, setVisible] = useState<boolean>(false);
const toggleVisibility = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setVisible((prevValue) => !prevValue);
};
return (
<div
className={classNames('absolute z-40', {
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center items-center': !visible,
'bg-gray-800/75 inset-0': !visible,
'top-1 left-1': visible,
})}
data-testid='moderation-overlay'
>
{visible ? (
<Button
text={intl.formatMessage(messages.hide)}
icon={require('@tabler/icons/eye-off.svg')}
onClick={toggleVisibility}
theme='transparent'
size='sm'
/>
) : (
<div className='text-center w-3/4 mx-auto space-y-4'>
<div className='space-y-1'>
<Text theme='white' weight='semibold'>
{intl.formatMessage(messages.title)}
</Text>
<Text theme='white' size='sm' weight='medium'>
{intl.formatMessage(messages.subtitle)}
</Text>
</div>
<HStack alignItems='center' justifyContent='center' space={2}>
{links.get('support') && (
<a
href={links.get('support')}
target='_blank'
onClick={(event) => event.stopPropagation()}
>
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/headset.svg')}
>
{intl.formatMessage(messages.contact)}
</Button>
</a>
)}
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/eye.svg')}
onClick={toggleVisibility}
>
{intl.formatMessage(messages.show)}
</Button>
</HStack>
</div>
)}
</div>
);
};
export default ModerationOverlay;

Wyświetl plik

@ -41,8 +41,8 @@ const Widget: React.FC<IWidget> = ({
action,
}): JSX.Element => {
return (
<Stack space={2}>
<HStack alignItems='center'>
<Stack space={4}>
<HStack alignItems='center' justifyContent='between'>
<WidgetTitle title={title} />
{action || (onActionClick && (
<IconButton

Wyświetl plik

@ -18,7 +18,7 @@ import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest
import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea';
import Icon from 'soapbox/components/icon';
import { Button, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useCompose, useFeatures, usePrevious } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is_mobile';
import EmojiPickerDropdown from '../components/emoji-picker/emoji-picker-dropdown';
@ -57,15 +57,15 @@ const messages = defineMessages({
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
});
interface IComposeForm {
id: string,
interface IComposeForm<ID extends string> {
id: ID extends 'default' ? never : ID,
shouldCondense?: boolean,
autoFocus?: boolean,
clickableAreaRef?: React.RefObject<HTMLDivElement>,
eventDiscussion?: boolean
}
const ComposeForm: React.FC<IComposeForm> = ({ id, shouldCondense, autoFocus, clickableAreaRef, eventDiscussion }) => {
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, eventDiscussion }: IComposeForm<ID>) => {
const history = useHistory();
const intl = useIntl();
const dispatch = useAppDispatch();
@ -78,6 +78,7 @@ const ComposeForm: React.FC<IComposeForm> = ({ id, shouldCondense, autoFocus, cl
const features = useFeatures();
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose;
const prevSpoiler = usePrevious(spoiler);
const hasPoll = !!compose.poll;
const isEditing = compose.id !== null;
@ -208,9 +209,10 @@ const ComposeForm: React.FC<IComposeForm> = ({ id, shouldCondense, autoFocus, cl
}, []);
useEffect(() => {
switch (spoiler) {
case true: focusSpoilerInput(); break;
case false: focusTextarea(); break;
if (spoiler && !prevSpoiler) {
focusSpoilerInput();
} else if (!spoiler && prevSpoiler) {
focusTextarea();
}
}, [spoiler]);

Wyświetl plik

@ -36,7 +36,9 @@ const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
dispatch(openModal('REPLY_MENTIONS'));
dispatch(openModal('REPLY_MENTIONS', {
composeId,
}));
};
if (!parentTo || (parentTo.size === 0)) {

Wyświetl plik

@ -119,7 +119,7 @@ const Upload: React.FC<IUpload> = ({ composeId, id }) => {
setDirtyDescription(null);
if (dirtyDescription !== null) {
dispatch(changeUploadCompose(composeId, media.id, { dirtyDescription }));
dispatch(changeUploadCompose(composeId, media.id, { description: dirtyDescription }));
}
};

Wyświetl plik

@ -11,7 +11,7 @@ import ActionButton from '../ui/components/action-button';
import type { Account } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested profiles' },
heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested Profiles' },
viewAll: { id: 'feed_suggestions.view_all', defaultMessage: 'View all' },
});
@ -65,7 +65,7 @@ const FeedSuggestions = () => {
if (!isLoading && suggestedProfiles.size === 0) return null;
return (
<Card size='lg' variant='rounded' className='space-y-4'>
<Card size='lg' variant='rounded' className='space-y-6'>
<HStack justifyContent='between' alignItems='center'>
<CardTitle title={intl.formatMessage(messages.heading)} />

Wyświetl plik

@ -10,7 +10,7 @@ import Column from 'soapbox/features/ui/components/column';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested profiles' },
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested Profiles' },
});
const FollowRecommendations: React.FC = () => {

Wyświetl plik

@ -1,119 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { moveAccount } from 'soapbox/actions/security';
import snackbar from 'soapbox/actions/snackbar';
// import Column from 'soapbox/features/ui/components/column';
import { Button, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
const messages = defineMessages({
heading: { id: 'column.migration', defaultMessage: 'Account migration' },
submit: { id: 'migration.submit', defaultMessage: 'Move followers' },
moveAccountSuccess: { id: 'migration.move_account.success', defaultMessage: 'Account successfully moved.' },
moveAccountFail: { id: 'migration.move_account.fail', defaultMessage: 'Account migration failed.' },
acctFieldLabel: { id: 'migration.fields.acct.label', defaultMessage: 'Handle of the new account' },
acctFieldPlaceholder: { id: 'migration.fields.acct.placeholder', defaultMessage: 'username@domain' },
currentPasswordFieldLabel: { id: 'migration.fields.confirm_password.label', defaultMessage: 'Current password' },
});
export default @connect()
@injectIntl
class Migration extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
targetAccount: '',
password: '',
isLoading: false,
}
handleInputChange = e => {
this.setState({ [e.target.name]: e.target.value });
}
clearForm = () => {
this.setState({ targetAccount: '', password: '' });
}
handleSubmit = e => {
const { targetAccount, password } = this.state;
const { dispatch, intl } = this.props;
this.setState({ isLoading: true });
return dispatch(moveAccount(targetAccount, password)).then(() => {
this.clearForm();
dispatch(snackbar.success(intl.formatMessage(messages.moveAccountSuccess)));
}).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.moveAccountFail)));
}).then(() => {
this.setState({ isLoading: false });
});
}
render() {
const { intl } = this.props;
return (
<Column label={intl.formatMessage(messages.heading)}>
<Form onSubmit={this.handleSubmit}>
<Text theme='muted'>
<FormattedMessage
id='migration.hint'
defaultMessage='This will move your followers to the new account. No other data will be moved. To perform migration, you need to {link} on your new account first.'
values={{
link: (
<Link
className='hover:underline text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-500'
to='/settings/aliases'
>
<FormattedMessage
id='migration.hint.link'
defaultMessage='create an account alias'
/>
</Link>
),
}}
/>
</Text>
<FormGroup
labelText={intl.formatMessage(messages.acctFieldLabel)}
>
<Input
name='targetAccount'
placeholder={intl.formatMessage(messages.acctFieldPlaceholder)}
onChange={this.handleInputChange}
value={this.state.targetAccount}
required
/>
</FormGroup>
<FormGroup
labelText={intl.formatMessage(messages.currentPasswordFieldLabel)}
>
<Input
type='password'
name='password'
onChange={this.handleInputChange}
value={this.state.password}
required
/>
</FormGroup>
<FormActions>
<Button
theme='primary'
text={intl.formatMessage(messages.submit)}
onClick={this.handleSubmit}
/>
</FormActions>
</Form>
</Column>
);
}
}

Wyświetl plik

@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { moveAccount } from 'soapbox/actions/security';
import snackbar from 'soapbox/actions/snackbar';
import { Button, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.migration', defaultMessage: 'Account migration' },
submit: { id: 'migration.submit', defaultMessage: 'Move followers' },
moveAccountSuccess: { id: 'migration.move_account.success', defaultMessage: 'Account successfully moved.' },
moveAccountFail: { id: 'migration.move_account.fail', defaultMessage: 'Account migration failed.' },
moveAccountFailCooldownPeriod: { id: 'migration.move_account.fail.cooldown_period', defaultMessage: 'You have moved your account too recently. Please try again later.' },
acctFieldLabel: { id: 'migration.fields.acct.label', defaultMessage: 'Handle of the new account' },
acctFieldPlaceholder: { id: 'migration.fields.acct.placeholder', defaultMessage: 'username@domain' },
currentPasswordFieldLabel: { id: 'migration.fields.confirm_password.label', defaultMessage: 'Current password' },
});
const Migration = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const cooldownPeriod = useAppSelector((state) => state.instance.pleroma.getIn(['metadata', 'migration_cooldown_period'])) as number | undefined;
const [targetAccount, setTargetAccount] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = e => {
if (e.target.name === 'password') setPassword(e.target.value);
else setTargetAccount(e.target.value);
};
const clearForm = () => {
setTargetAccount('');
setPassword('');
};
const handleSubmit: React.FormEventHandler = e => {
setIsLoading(true);
return dispatch(moveAccount(targetAccount, password)).then(() => {
clearForm();
dispatch(snackbar.success(intl.formatMessage(messages.moveAccountSuccess)));
}).catch(error => {
let message = intl.formatMessage(messages.moveAccountFail);
const errorMessage = (error.response?.data)?.error;
if (errorMessage === 'You are within cooldown period.') {
message = intl.formatMessage(messages.moveAccountFailCooldownPeriod);
}
dispatch(snackbar.error(message));
}).then(() => {
setIsLoading(false);
});
};
return (
<Column label={intl.formatMessage(messages.heading)}>
<Form onSubmit={handleSubmit}>
<Text theme='muted'>
<FormattedMessage
id='migration.hint'
defaultMessage='This will move your followers to the new account. No other data will be moved. To perform migration, you need to {link} on your new account first.'
values={{
link: (
<Link
className='hover:underline text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-500'
to='/settings/aliases'
>
<FormattedMessage
id='migration.hint.link'
defaultMessage='create an account alias'
/>
</Link>
),
}}
/>
{!!cooldownPeriod && (<>
{' '}
<FormattedMessage
id='migration.hint.cooldown_period'
defaultMessage='If you migrate your account, you will not be able to migrate your account for {cooldownPeriod, plural, one {one day} other {the next # days}}.'
values={{ cooldownPeriod }}
/>
</>)}
</Text>
<FormGroup
labelText={intl.formatMessage(messages.acctFieldLabel)}
>
<Input
name='targetAccount'
placeholder={intl.formatMessage(messages.acctFieldPlaceholder)}
onChange={handleInputChange}
value={targetAccount}
required
/>
</FormGroup>
<FormGroup
labelText={intl.formatMessage(messages.currentPasswordFieldLabel)}
>
<Input
type='password'
name='password'
onChange={handleInputChange}
value={password}
required
/>
</FormGroup>
<FormActions>
<Button
theme='primary'
text={intl.formatMessage(messages.submit)}
onClick={handleSubmit}
disabled={isLoading}
/>
</FormActions>
</Form>
</Column>
);
};
export default Migration;

Wyświetl plik

@ -17,10 +17,6 @@ const BioStep = ({ onNext }: { onNext: () => void }) => {
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [errors, setErrors] = React.useState<string[]>([]);
const trimmedValue = value.trim();
const isValid = trimmedValue.length > 0;
const isDisabled = !isValid;
const handleSubmit = () => {
setSubmitting(true);
@ -79,7 +75,7 @@ const BioStep = ({ onNext }: { onNext: () => void }) => {
block
theme='primary'
type='submit'
disabled={isDisabled || isSubmitting}
disabled={isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? (

Wyświetl plik

@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
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 useOnboardingSuggestions from 'soapbox/queries/suggestions';
import { useOnboardingSuggestions } from 'soapbox/queries/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const { data, fetchNextPage, hasNextPage, isFetching } = useOnboardingSuggestions();

Wyświetl plik

@ -0,0 +1,29 @@
import React from 'react';
import { HStack, Stack } from 'soapbox/components/ui';
import { randomIntFromInterval, generateText } from '../utils';
export default ({ limit }: { limit: number }) => {
const length = randomIntFromInterval(15, 3);
const acctLength = randomIntFromInterval(15, 3);
return (
<>
{new Array(limit).fill(undefined).map((_, idx) => (
<HStack key={idx} alignItems='center' space={2} className='animate-pulse'>
<Stack space={3} className='text-center'>
<div
className='w-9 h-9 block mx-auto rounded-full bg-primary-200 dark:bg-primary-700'
/>
</Stack>
<Stack className='text-primary-200 dark:text-primary-700'>
<p>{generateText(length)}</p>
<p>{generateText(acctLength)}</p>
</Stack>
</HStack>
))}
</>
);
};

Wyświetl plik

@ -19,7 +19,7 @@ const Share = () => {
.join('\n\n');
if (text) {
dispatch(openComposeWithText(text));
dispatch(openComposeWithText('compose-modal', text));
}
return (
@ -27,4 +27,4 @@ const Share = () => {
);
};
export default Share;
export default Share;

Wyświetl plik

@ -29,6 +29,7 @@ import MissingIndicator from 'soapbox/components/missing_indicator';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import ScrollableList from 'soapbox/components/scrollable_list';
import StatusActionBar from 'soapbox/components/status-action-bar';
import ModerationOverlay from 'soapbox/components/statuses/moderation-overlay';
import SubNavigation from 'soapbox/components/sub_navigation';
import Tombstone from 'soapbox/components/tombstone';
import { Column, Stack } from 'soapbox/components/ui';
@ -134,6 +135,7 @@ const Thread: React.FC<IThread> = (props) => {
const me = useAppSelector(state => state.me);
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
const displayMedia = settings.get('displayMedia') as DisplayMedia;
const inReview = status?.visibility === 'self';
const { ancestorsIds, descendantsIds } = useAppSelector(state => {
let ancestorsIds = ImmutableOrderedSet<string>();
@ -465,11 +467,19 @@ const Thread: React.FC<IThread> = (props) => {
<HotKeys handlers={handlers}>
<div
ref={statusRef}
className='detailed-status__wrapper focusable'
className={
classNames('detailed-status__wrapper focusable relative', {
'min-h-[220px]': inReview,
})
}
tabIndex={0}
// FIXME: no "reblogged by" text is added for the screen reader
aria-label={textForScreenReader(intl, status)}
>
{inReview ? (
<ModerationOverlay />
) : null}
<DetailedStatus
status={status}
onOpenVideo={handleOpenVideo}

Wyświetl plik

@ -1,123 +1,201 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import { normalizeAccount } from '../../../../normalizers';
import { __stub } from 'soapbox/api';
import { render, rootState, screen, waitFor } from '../../../../jest/test-helpers';
import { normalizeInstance } from '../../../../normalizers';
import WhoToFollowPanel from '../who-to-follow-panel';
const buildTruthSuggestion = (id: string) => ({
account_avatar: 'avatar',
account_id: id,
acct: 'acct',
display_name: 'my name',
note: 'hello',
verified: true,
});
const buildSuggestion = (id: string) => ({
source: 'staff',
account: {
username: 'username',
verified: true,
id,
acct: 'acct',
avatar: 'avatar',
avatar_static: 'avatar',
display_name: 'my name',
},
});
describe('<WhoToFollow />', () => {
it('renders suggested accounts', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([{
source: 'staff',
account: '1',
}]),
},
};
let store: any;
render(<WhoToFollowPanel limit={1} />, undefined, store);
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
describe('using Truth Social software', () => {
beforeEach(() => {
store = rootState
.set('me', '1234')
.set('instance', normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
}));
});
describe('with a single suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [buildTruthSuggestion('1')], {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
});
});
});
describe('with a multiple suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={2} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(2);
});
});
});
describe('with a set limit', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('respects the limit prop', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
});
});
describe('when the API returns an empty list', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [], {
link: '',
});
});
});
it('renders empty', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(0);
});
});
});
});
it('renders multiple accounts', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
'2': normalizeAccount({
id: '1',
acct: 'username2',
display_name: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([
{
source: 'staff',
account: '1',
},
{
source: 'staff',
account: '2',
},
]),
},
};
describe('using Pleroma software', () => {
beforeEach(() => {
store = rootState.set('me', '1234');
});
render(<WhoToFollowPanel limit={3} />, undefined, store);
expect(screen.queryAllByTestId('account')).toHaveLength(2);
});
describe('with a single suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [buildSuggestion('1')], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('respects the limit prop', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
'2': normalizeAccount({
id: '1',
acct: 'username2',
display_name: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([
{
source: 'staff',
account: '1',
},
{
source: 'staff',
account: '2',
},
]),
},
};
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
render(<WhoToFollowPanel limit={1} />, undefined, store);
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
await waitFor(() => {
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
});
});
});
it('renders empty', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
'2': normalizeAccount({
id: '1',
acct: 'username2',
display_name: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([]),
},
};
describe('with a multiple suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [buildSuggestion('1'), buildSuggestion('2')], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
render(<WhoToFollowPanel limit={1} />, undefined, store);
expect(screen.queryAllByTestId('account')).toHaveLength(0);
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={2} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(2);
});
});
});
describe('with a set limit', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [buildSuggestion('1'), buildSuggestion('2')], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('respects the limit prop', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
});
});
describe('when the API returns an empty list', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [], {
link: '',
});
});
});
it('renders empty', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(0);
});
});
});
});
});

Wyświetl plik

@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Modal, Text } from 'soapbox/components/ui';
import { SimpleForm, FieldsGroup, Checkbox } from 'soapbox/features/forms';
import List, { ListItem } from 'soapbox/components/list';
import { Modal, Stack, Text, Toggle } from 'soapbox/components/ui';
interface IConfirmationModal {
heading: React.ReactNode,
@ -60,23 +60,23 @@ const ConfirmationModal: React.FC<IConfirmationModal> = ({
secondaryText={secondary}
secondaryAction={onSecondary && handleSecondary}
>
<Text>
{message}
</Text>
<Stack space={4}>
<Text>
{message}
</Text>
<div className='mt-2'>
{checkbox && <div className='confirmation-modal__checkbox'>
<SimpleForm>
<FieldsGroup>
<Checkbox
onChange={handleCheckboxChange}
label={checkbox}
{checkbox && (
<List>
<ListItem label={checkbox}>
<Toggle
checked={checked}
onChange={handleCheckboxChange}
required
/>
</FieldsGroup>
</SimpleForm>
</div>}
</div>
</ListItem>
</List>
)}
</Stack>
</Modal>
);
};

Wyświetl plik

@ -13,6 +13,7 @@ import snackbar from 'soapbox/actions/snackbar';
import Account from 'soapbox/components/account';
import List, { ListItem } from 'soapbox/components/list';
import MissingIndicator from 'soapbox/components/missing_indicator';
import OutlineBox from 'soapbox/components/outline-box';
import { Button, Text, HStack, Modal, Stack, Toggle } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
@ -109,14 +110,14 @@ const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, ac
onClose={handleClose}
>
<Stack space={4}>
<div className='p-4 rounded-lg border border-solid border-gray-300 dark:border-gray-800'>
<OutlineBox>
<Account
account={account}
showProfileHoverCard={false}
withLinkToProfile={false}
hideActions
/>
</div>
</OutlineBox>
<List>
{(ownAccount.admin && isLocal(account)) && (

Wyświetl plik

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Modal } from 'soapbox/components/ui';
@ -18,7 +18,8 @@ interface IReplyMentionsModal {
const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ composeId, onClose }) => {
const compose = useCompose(composeId);
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: compose.in_reply_to! }));
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector<StatusEntity | null>(state => getStatus(state, { id: compose.in_reply_to! }));
const account = useAppSelector((state) => state.accounts.get(state.me));
const mentions = statusToMentionsAccountIdsArray(status!, account!);

Wyświetl plik

@ -1,11 +1,11 @@
import * as React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link } from 'react-router-dom';
import { fetchSuggestions, dismissSuggestion } from 'soapbox/actions/suggestions';
import { Widget } from 'soapbox/components/ui';
import { Text, Widget } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAppSelector } from 'soapbox/hooks';
import PlaceholderSidebarSuggestions from 'soapbox/features/placeholder/components/placeholder-sidebar-suggestions';
import { useDismissSuggestion, useSuggestions } from 'soapbox/queries/suggestions';
import type { Account as AccountEntity } from 'soapbox/types/entities';
@ -18,44 +18,44 @@ interface IWhoToFollowPanel {
}
const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => {
const dispatch = useDispatch();
const intl = useIntl();
const suggestions = useAppSelector((state) => state.suggestions.items);
const { data: suggestions, isFetching } = useSuggestions();
const dismissSuggestion = useDismissSuggestion();
const suggestionsToRender = suggestions.slice(0, limit);
const handleDismiss = (account: AccountEntity) => {
dispatch(dismissSuggestion(account.id));
dismissSuggestion.mutate(account.id);
};
React.useEffect(() => {
dispatch(fetchSuggestions());
}, []);
if (suggestionsToRender.isEmpty()) {
if (!isFetching && !suggestions.length) {
return null;
}
// FIXME: This page actually doesn't look good right now
// const handleAction = () => {
// history.push('/suggestions');
// };
return (
<Widget
title={<FormattedMessage id='who_to_follow.title' defaultMessage='People To Follow' />}
// onAction={handleAction}
action={
<Link to='/suggestions'>
<Text tag='span' theme='primary' size='sm' className='hover:underline'>View all</Text>
</Link>
}
>
{suggestionsToRender.map((suggestion) => (
<AccountContainer
key={suggestion.account}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.account}
actionIcon={require('@tabler/icons/x.svg')}
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
onActionClick={handleDismiss}
/>
))}
{isFetching ? (
<PlaceholderSidebarSuggestions limit={limit} />
) : (
suggestionsToRender.map((suggestion: any) => (
<AccountContainer
key={suggestion.account}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.account}
actionIcon={require('@tabler/icons/x.svg')}
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
onActionClick={handleDismiss}
/>
))
)}
</Widget>
);
};

Wyświetl plik

@ -4,7 +4,6 @@ import debounce from 'lodash/debounce';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { HotKeys } from 'react-hotkeys';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
import { fetchFollowRequests } from 'soapbox/actions/accounts';
@ -26,7 +25,7 @@ import Icon from 'soapbox/components/icon';
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
import ThumbNavigation from 'soapbox/components/thumb_navigation';
import { Layout } from 'soapbox/components/ui';
import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
import AdminPage from 'soapbox/pages/admin_page';
import DefaultPage from 'soapbox/pages/default_page';
import EventPage from 'soapbox/pages/event_page';
@ -334,7 +333,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
const UI: React.FC = ({ children }) => {
const intl = useIntl();
const history = useHistory();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const [draggingOver, setDraggingOver] = useState<boolean>(false);
const [mobile, setMobile] = useState<boolean>(isMobile(window.innerWidth));
@ -391,9 +390,13 @@ const UI: React.FC = ({ children }) => {
setDraggingOver(false);
dragTargets.current = [];
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
dispatch(uploadCompose('home', e.dataTransfer.files, intl));
}
dispatch((_, getState) => {
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
const modals = getState().modals;
const isModalOpen = modals.last()?.modalType === 'COMPOSE';
dispatch(uploadCompose(isModalOpen ? 'compose-modal' : 'home', e.dataTransfer.files, intl));
}
});
};
const handleDragLeave = (e: DragEvent) => {

Wyświetl plik

@ -8,6 +8,7 @@ export { useFeatures } from './useFeatures';
export { useLocale } from './useLocale';
export { useOnScreen } from './useOnScreen';
export { useOwnAccount } from './useOwnAccount';
export { usePrevious } from './usePrevious';
export { useRefEventHandler } from './useRefEventHandler';
export { useSettings } from './useSettings';
export { useSoapboxConfig } from './useSoapboxConfig';

Wyświetl plik

@ -0,0 +1,13 @@
import { useRef, useEffect } from 'react';
/** Get the last version of this value. */
// https://usehooks.com/usePrevious/
export const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
};

Wyświetl plik

@ -990,7 +990,7 @@
"status.favourite": "Like",
"status.filtered": "Filtered",
"status.in_review_warning": "Content Under Review",
"status.in_review_summary.summary": "This Truth has been sent to Moderation for review and is only visible to you.",
"status.in_review_summary.summary": "This post has been sent to Moderation for review and is only visible to you.",
"status.in_review_summary.contact": "If you believe this is in error please {link}.",
"status.in_review_summary.link": "Contact Support",
"status.load_more": "Load more",

Wyświetl plik

@ -41,7 +41,7 @@ const DefaultPage: React.FC = ({ children }) => {
)}
{features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} key='wtf-panel' />}
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />

Wyświetl plik

@ -105,7 +105,7 @@ const HomePage: React.FC = ({ children }) => {
)}
{features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} />}
{Component => <Component limit={3} />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />

Wyświetl plik

@ -139,7 +139,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
</BundleContainer>
) : features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} key='wtf-panel' />}
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />

Wyświetl plik

@ -45,7 +45,7 @@ const StatusPage: React.FC<IStatusPage> = ({ children }) => {
)}
{features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} key='wtf-panel' />}
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />

Wyświetl plik

@ -1,7 +1,7 @@
import { __stub } from 'soapbox/api';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import useOnboardingSuggestions from '../suggestions';
import { useOnboardingSuggestions } from '../suggestions';
describe('useCarouselAvatars', () => {
describe('with a successful query', () => {
@ -17,7 +17,7 @@ describe('useCarouselAvatars', () => {
});
});
it('is successful', async() => {
it('is successful', async () => {
const { result } = renderHook(() => useOnboardingSuggestions());
await waitFor(() => expect(result.current.isFetching).toBe(false));
@ -33,7 +33,7 @@ describe('useCarouselAvatars', () => {
});
});
it('is successful', async() => {
it('is successful', async () => {
const { result } = renderHook(() => useOnboardingSuggestions());
await waitFor(() => expect(result.current.isFetching).toBe(false));

Wyświetl plik

@ -1,9 +1,12 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { fetchRelationships } from 'soapbox/actions/accounts';
import { importFetchedAccounts } from 'soapbox/actions/importer';
import { SuggestedProfile } from 'soapbox/actions/suggestions';
import { getLinks } from 'soapbox/api';
import { useApi, useAppDispatch } from 'soapbox/hooks';
import { useApi, useAppDispatch, useFeatures } from 'soapbox/hooks';
import { PaginatedResult, removePageItem } from '../utils/queries';
type Account = {
acct: string
@ -35,11 +38,124 @@ type Suggestion = {
account: Account
}
export default function useOnboardingSuggestions() {
type TruthSuggestion = {
account_avatar: string
account_id: string
acct: string
display_name: string
note: string
verified: boolean
}
type Result = TruthSuggestion | {
account: string
}
type PageParam = {
link?: string
}
const suggestionKeys = {
suggestions: ['suggestions'] as const,
};
const mapSuggestedProfileToAccount = (suggestedProfile: SuggestedProfile) => ({
id: suggestedProfile.account_id,
avatar: suggestedProfile.account_avatar,
avatar_static: suggestedProfile.account_avatar,
acct: suggestedProfile.acct,
display_name: suggestedProfile.display_name,
note: suggestedProfile.note,
verified: suggestedProfile.verified,
});
const useSuggestions = () => {
const api = useApi();
const dispatch = useAppDispatch();
const features = useFeatures();
const getV2Suggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
const endpoint = pageParam?.link || '/api/v2/suggestions';
const response = await api.get<Suggestion[]>(endpoint);
const hasMore = !!response.headers.link;
const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri;
const accounts = response.data.map(({ account }) => account);
const accountIds = accounts.map((account) => account.id);
dispatch(importFetchedAccounts(accounts));
dispatch(fetchRelationships(accountIds));
return {
result: response.data.map(x => ({ ...x, account: x.account.id })),
link: nextLink,
hasMore,
};
};
const getTruthSuggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
const endpoint = pageParam?.link || '/api/v1/truth/carousels/suggestions';
const response = await api.get<TruthSuggestion[]>(endpoint);
const hasMore = !!response.headers.link;
const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri;
const accounts = response.data.map(mapSuggestedProfileToAccount);
dispatch(importFetchedAccounts(accounts, { should_refetch: true }));
return {
result: response.data.map((x) => ({ ...x, account: x.account_id })),
link: nextLink,
hasMore,
};
};
const getSuggestions = (pageParam: PageParam) => {
if (features.truthSuggestions) {
return getTruthSuggestions(pageParam);
} else {
return getV2Suggestions(pageParam);
}
};
const result = useInfiniteQuery(
suggestionKeys.suggestions,
({ pageParam }: any) => getSuggestions(pageParam),
{
keepPreviousData: true,
getNextPageParam: (config) => {
if (config?.hasMore) {
return { nextLink: config?.link };
}
return undefined;
},
});
const data: any = result.data?.pages.reduce<Suggestion[]>(
(prev: any, curr: any) => [...prev, ...curr.result],
[],
);
return {
...result,
data: data || [],
};
};
const useDismissSuggestion = () => {
const api = useApi();
return useMutation((accountId: string) => api.delete(`/api/v1/suggestions/${accountId}`), {
onMutate(accountId: string) {
removePageItem(suggestionKeys.suggestions, accountId, (o: any, n: any) => o.account_id === n);
},
});
};
function useOnboardingSuggestions() {
const api = useApi();
const dispatch = useAppDispatch();
const getV2Suggestions = async(pageParam: any): Promise<{ data: Suggestion[], link: string | undefined, hasMore: boolean }> => {
const getV2Suggestions = async (pageParam: any): Promise<{ data: Suggestion[], link: string | undefined, hasMore: boolean }> => {
const link = pageParam?.link || '/api/v2/suggestions';
const response = await api.get<Suggestion[]>(link);
const hasMore = !!response.headers.link;
@ -78,3 +194,5 @@ export default function useOnboardingSuggestions() {
data,
};
}
export { useOnboardingSuggestions, useSuggestions, useDismissSuggestion };

Wyświetl plik

@ -0,0 +1,7 @@
import { normalizeUsername } from '../input';
test('normalizeUsername', () => {
expect(normalizeUsername('@alex')).toBe('alex');
expect(normalizeUsername('alex@alexgleason.me')).toBe('alex@alexgleason.me');
expect(normalizeUsername('@alex@gleasonator.com')).toBe('alex@gleasonator.com');
});

Wyświetl plik

@ -0,0 +1,13 @@
/** Trim the username and strip the leading @. */
const normalizeUsername = (username: string): string => {
const trimmed = username.trim();
if (trimmed[0] === '@') {
return trimmed.slice(1);
} else {
return trimmed;
}
};
export {
normalizeUsername,
};

Wyświetl plik

@ -1,7 +1,9 @@
/** List of supported E164 country codes. */
const COUNTRY_CODES = [
'1',
'351',
'44',
'55',
] as const;
/** Supported E164 country code. */

Wyświetl plik

@ -0,0 +1,61 @@
import { queryClient } from 'soapbox/queries/client';
import type { InfiniteData, QueryKey, UseInfiniteQueryResult } from '@tanstack/react-query';
export interface PaginatedResult<T> {
result: T[],
hasMore: boolean,
link?: string,
}
/** Flatten paginated results into a single array. */
const flattenPages = <T>(queryInfo: UseInfiniteQueryResult<PaginatedResult<T>>) => {
return queryInfo.data?.pages.reduce<T[]>(
(prev: T[], curr) => [...prev, ...curr.result],
[],
);
};
/** Traverse pages and update the item inside if found. */
const updatePageItem = <T>(queryKey: QueryKey, newItem: T, isItem: (item: T, newItem: T) => boolean) => {
queryClient.setQueriesData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
if (data) {
const pages = data.pages.map(page => {
const result = page.result.map(item => isItem(item, newItem) ? newItem : item);
return { ...page, result };
});
return { ...data, pages };
}
});
};
/** Insert the new item at the beginning of the first page. */
const appendPageItem = <T>(queryKey: QueryKey, newItem: T) => {
queryClient.setQueryData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
if (data) {
const pages = [...data.pages];
pages[0] = { ...pages[0], result: [...pages[0].result, newItem] };
return { ...data, pages };
}
});
};
/** Remove an item inside if found. */
const removePageItem = <T>(queryKey: QueryKey, itemToRemove: T, isItem: (item: T, newItem: T) => boolean) => {
queryClient.setQueriesData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
if (data) {
const pages = data.pages.map(page => {
const result = page.result.filter(item => !isItem(item, itemToRemove));
return { ...page, result };
});
return { ...data, pages };
}
});
};
export {
flattenPages,
updatePageItem,
appendPageItem,
removePageItem,
};

Wyświetl plik

@ -11,9 +11,7 @@ export const defaultMediaVisibility = (status: StatusEntity | undefined | null,
status = status.reblog;
}
const isSensitive = status.sensitive || status.visibility === 'self';
return (displayMedia !== 'hide_all' && !isSensitive || displayMedia === 'show_all');
return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all');
};
/** Grab the first external link from a status. */

Wyświetl plik

@ -331,14 +331,6 @@
}
}
.confirmation-modal__checkbox {
padding: 0 30px;
.simple_form {
margin-top: -14px;
}
}
.reply-mentions-modal__accounts {
display: block;
flex-direction: row;