kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge remote-tracking branch 'soapbox/develop' into events-
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>environments/review-events-5jp5it/deployments/1372
commit
803dada3e0
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -82,4 +82,4 @@
|
|||
"emojis": [],
|
||||
"card": null,
|
||||
"poll": null
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
|
|
|
@ -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.*/);
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 >
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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 }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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)} />
|
||||
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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 ? (
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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)) && (
|
||||
|
|
|
@ -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!);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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 };
|
|
@ -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');
|
||||
});
|
|
@ -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,
|
||||
};
|
|
@ -1,7 +1,9 @@
|
|||
/** List of supported E164 country codes. */
|
||||
const COUNTRY_CODES = [
|
||||
'1',
|
||||
'351',
|
||||
'44',
|
||||
'55',
|
||||
] as const;
|
||||
|
||||
/** Supported E164 country code. */
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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. */
|
||||
|
|
|
@ -331,14 +331,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.confirmation-modal__checkbox {
|
||||
padding: 0 30px;
|
||||
|
||||
.simple_form {
|
||||
margin-top: -14px;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-mentions-modal__accounts {
|
||||
display: block;
|
||||
flex-direction: row;
|
||||
|
|
Ładowanie…
Reference in New Issue