Merge branch 'ts' into 'develop'

TypeScript, React.FC

See merge request soapbox-pub/soapbox-fe!1524
This commit is contained in:
marcin mikołajczak 2022-06-12 22:35:57 +00:00
commit 7ff991f8b3
103 changed files with 779 additions and 816 deletions

View File

@ -10,7 +10,7 @@ describe('<AutosuggestEmoji />', () => {
colons: ':foobar:',
};
render(<AutosuggestEmoji emoji={emoji} />);
render(<AutosuggestEmoji emoji={emoji as any} />);
expect(screen.getByTestId('emoji')).toHaveTextContent('foobar');
expect(screen.getByRole('img').getAttribute('src')).not.toBe('http://example.com/emoji.png');
@ -24,7 +24,7 @@ describe('<AutosuggestEmoji />', () => {
colons: ':foobar:',
};
render(<AutosuggestEmoji emoji={emoji} />);
render(<AutosuggestEmoji emoji={emoji as any} />);
expect(screen.getByTestId('emoji')).toHaveTextContent('foobar');
expect(screen.getByRole('img').getAttribute('src')).toBe('http://example.com/emoji.png');

View File

@ -68,9 +68,9 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
const me = useAppSelector(state => state.me);
const accountId: string | undefined = useAppSelector(state => state.profile_hover_card.get<string | undefined>('accountId', undefined));
const accountId: string | undefined = useAppSelector(state => state.profile_hover_card.accountId || undefined);
const account = useAppSelector(state => accountId && getAccount(state, accountId));
const targetRef = useAppSelector(state => state.profile_hover_card.getIn(['ref', 'current']) as Element | null);
const targetRef = useAppSelector(state => state.profile_hover_card.ref?.current);
const badges = account ? getBadges(account) : [];
useEffect(() => {

View File

@ -27,7 +27,7 @@ interface IModal {
/** Callback when the modal is cancelled. */
cancelAction?: () => void,
/** Cancel button text. */
cancelText?: string,
cancelText?: React.ReactNode,
/** URL to an SVG icon for the close button. */
closeIcon?: string,
/** Position of the close button. */

View File

@ -9,13 +9,13 @@ import type { Status } from 'soapbox/types/entities';
interface IReplyIndicator {
status?: Status,
onCancel: () => void,
onCancel?: () => void,
hideActions: boolean,
}
const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCancel }) => {
const handleClick = () => {
onCancel();
onCancel!();
};
if (!status) {
@ -23,7 +23,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCanc
}
let actions = {};
if (!hideActions) {
if (!hideActions && onCancel) {
actions = {
onActionClick: handleClick,
actionIcon: require('@tabler/icons/icons/x.svg'),

View File

@ -10,7 +10,7 @@ import { getTitle } from '../utils/coin_db';
import CryptoIcon from './crypto_icon';
interface ICryptoAddress {
export interface ICryptoAddress {
address: string,
ticker: string,
note?: string,

View File

@ -1,15 +1,17 @@
// The output of this module is designed to mimic emoji-mart's
// "data" object, such that we can use it for a light version of emoji-mart's
// emojiIndex.search functionality.
const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
import emojiCompressed from './emoji_compressed';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
const emojis = {};
const [ shortCodesToEmojiData, skins, categories, short_names ] = emojiCompressed;
const emojis: Record<string, any> = {};
// decompress
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
const [
filenameData, // eslint-disable-line @typescript-eslint/no-unused-vars
_filenameData, // eslint-disable-line @typescript-eslint/no-unused-vars
searchData,
] = shortCodesToEmojiData[shortCode];
const [
@ -27,7 +29,14 @@ Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
};
});
module.exports = {
export {
emojis,
skins,
categories,
short_names,
};
export default {
emojis,
skins,
categories,

View File

@ -1,257 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters';
import snackbar from 'soapbox/actions/snackbar';
import Icon from 'soapbox/components/icon';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
import {
FieldsGroup,
Checkbox,
} from 'soapbox/features/forms';
const messages = defineMessages({
heading: { id: 'column.filters', defaultMessage: 'Muted words' },
subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' },
keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' },
expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' },
expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' },
home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' },
public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' },
notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' },
conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' },
drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' },
drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' },
whole_word_header: { id: 'column.filters.whole_word_header', defaultMessage: 'Whole word' },
whole_word_hint: { id: 'column.filters.whole_word_hint', defaultMessage: 'When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word' },
add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' },
create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' },
delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' },
subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' },
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
});
// const expirations = {
// null: 'Never',
// // 3600: '30 minutes',
// // 21600: '1 hour',
// // 43200: '12 hours',
// // 86400 : '1 day',
// // 604800: '1 week',
// };
const mapStateToProps = state => ({
filters: state.get('filters'),
});
export default @connect(mapStateToProps)
@injectIntl
class Filters extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
phrase: '',
expires_at: '',
home_timeline: true,
public_timeline: false,
notifications: false,
conversations: false,
irreversible: false,
whole_word: true,
}
componentDidMount() {
this.props.dispatch(fetchFilters());
}
handleInputChange = e => {
this.setState({ [e.target.name]: e.target.value });
}
handleSelectChange = e => {
this.setState({ [e.target.name]: e.target.value });
}
handleCheckboxChange = e => {
this.setState({ [e.target.name]: e.target.checked });
}
handleAddNew = e => {
e.preventDefault();
const { intl, dispatch } = this.props;
const { phrase, whole_word, expires_at, irreversible } = this.state;
const { home_timeline, public_timeline, notifications, conversations } = this.state;
const context = [];
if (home_timeline) {
context.push('home');
}
if (public_timeline) {
context.push('public');
}
if (notifications) {
context.push('notifications');
}
if (conversations) {
context.push('thread');
}
dispatch(createFilter(intl, phrase, expires_at, context, whole_word, irreversible)).then(response => {
return dispatch(fetchFilters());
}).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.create_error)));
});
}
handleFilterDelete = e => {
const { intl, dispatch } = this.props;
dispatch(deleteFilter(intl, e.currentTarget.dataset.value)).then(response => {
return dispatch(fetchFilters());
}).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.delete_error)));
});
}
render() {
const { intl, filters } = this.props;
const emptyMessage = <FormattedMessage id='empty_column.filters' defaultMessage="You haven't created any muted words yet." />;
return (
<Column className='filter-settings-panel' icon='filter' label={intl.formatMessage(messages.heading)}>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.subheading_add_new)} />
</CardHeader>
<Form onSubmit={this.handleAddNew}>
<FormGroup labelText={intl.formatMessage(messages.keyword)}>
<Input
required
type='text'
name='phrase'
onChange={this.handleInputChange}
/>
</FormGroup>
{/* <FormGroup labelText={intl.formatMessage(messages.expires)} hintText={intl.formatMessage(messages.expires_hint)}>
<SelectDropdown
items={expirations}
defaultValue={expirations.never}
onChange={this.handleSelectChange}
/>
</FormGroup> */}
<FieldsGroup>
<Text tag='label'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text>
<Text theme='muted' size='xs'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text>
<div className='two-col'>
<Checkbox
label={intl.formatMessage(messages.home_timeline)}
name='home_timeline'
checked={this.state.home_timeline}
onChange={this.handleCheckboxChange}
/>
<Checkbox
label={intl.formatMessage(messages.public_timeline)}
name='public_timeline'
checked={this.state.public_timeline}
onChange={this.handleCheckboxChange}
/>
<Checkbox
label={intl.formatMessage(messages.notifications)}
name='notifications'
checked={this.state.notifications}
onChange={this.handleCheckboxChange}
/>
<Checkbox
label={intl.formatMessage(messages.conversations)}
name='conversations'
checked={this.state.conversations}
onChange={this.handleCheckboxChange}
/>
</div>
</FieldsGroup>
<FieldsGroup>
<Checkbox
label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)}
name='irreversible'
checked={this.state.irreversible}
onChange={this.handleCheckboxChange}
/>
<Checkbox
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
name='whole_word'
checked={this.state.whole_word}
onChange={this.handleCheckboxChange}
/>
</FieldsGroup>
<FormActions>
<Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button>
</FormActions>
</Form>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.subheading_filters)} />
</CardHeader>
<ScrollableList
scrollKey='filters'
emptyMessage={emptyMessage}
>
{filters.map((filter, i) => (
<div key={i} className='filter__container'>
<div className='filter__details'>
<div className='filter__phrase'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span>
<span className='filter__list-value'>{filter.get('phrase')}</span>
</div>
<div className='filter__contexts'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' /></span>
<span className='filter__list-value'>
{filter.get('context').map((context, i) => (
<span key={i} className='context'>{context}</span>
))}
</span>
</div>
<div className='filter__details'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
<span className='filter__list-value'>
{filter.get('irreversible') ?
<span><FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /></span> :
<span><FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' /></span>
}
{filter.get('whole_word') &&
<span><FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' /></span>
}
</span>
</div>
</div>
<div className='filter__delete' role='button' tabIndex='0' onClick={this.handleFilterDelete} data-value={filter.get('id')} aria-label={intl.formatMessage(messages.delete)}>
<Icon className='filter__delete-icon' id='times' size={40} />
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
</div>
</div>
))}
</ScrollableList>
</Column>
);
}
}

View File

@ -0,0 +1,229 @@
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters';
import snackbar from 'soapbox/actions/snackbar';
import Icon from 'soapbox/components/icon';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
import {
FieldsGroup,
Checkbox,
} from 'soapbox/features/forms';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.filters', defaultMessage: 'Muted words' },
subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' },
keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' },
expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' },
expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' },
home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' },
public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' },
notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' },
conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' },
drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' },
drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' },
whole_word_header: { id: 'column.filters.whole_word_header', defaultMessage: 'Whole word' },
whole_word_hint: { id: 'column.filters.whole_word_hint', defaultMessage: 'When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word' },
add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' },
create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' },
delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' },
subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' },
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
});
// const expirations = {
// null: 'Never',
// // 3600: '30 minutes',
// // 21600: '1 hour',
// // 43200: '12 hours',
// // 86400 : '1 day',
// // 604800: '1 week',
// };
const Filters = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const filters = useAppSelector((state) => state.filters);
const [phrase, setPhrase] = useState('');
const [expiresAt] = useState('');
const [homeTimeline, setHomeTimeline] = useState(true);
const [publicTimeline, setPublicTimeline] = useState(false);
const [notifications, setNotifications] = useState(false);
const [conversations, setConversations] = useState(false);
const [irreversible, setIrreversible] = useState(false);
const [wholeWord, setWholeWord] = useState(true);
// const handleSelectChange = e => {
// this.setState({ [e.target.name]: e.target.value });
// };
const handleAddNew: React.FormEventHandler = e => {
e.preventDefault();
const context = [];
if (homeTimeline) {
context.push('home');
}
if (publicTimeline) {
context.push('public');
}
if (notifications) {
context.push('notifications');
}
if (conversations) {
context.push('thread');
}
dispatch(createFilter(intl, phrase, expiresAt, context, wholeWord, irreversible)).then(() => {
return dispatch(fetchFilters());
}).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.create_error)));
});
};
const handleFilterDelete: React.MouseEventHandler<HTMLDivElement> = e => {
dispatch(deleteFilter(intl, e.currentTarget.dataset.value)).then(() => {
return dispatch(fetchFilters());
}).catch(() => {
dispatch(snackbar.error(intl.formatMessage(messages.delete_error)));
});
};
useEffect(() => {
dispatch(fetchFilters());
}, []);
const emptyMessage = <FormattedMessage id='empty_column.filters' defaultMessage="You haven't created any muted words yet." />;
return (
<Column className='filter-settings-panel' label={intl.formatMessage(messages.heading)}>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.subheading_add_new)} />
</CardHeader>
<Form onSubmit={handleAddNew}>
<FormGroup labelText={intl.formatMessage(messages.keyword)}>
<Input
required
type='text'
name='phrase'
onChange={({ target }) => setPhrase(target.value)}
/>
</FormGroup>
{/* <FormGroup labelText={intl.formatMessage(messages.expires)} hintText={intl.formatMessage(messages.expires_hint)}>
<SelectDropdown
items={expirations}
defaultValue={expirations.never}
onChange={this.handleSelectChange}
/>
</FormGroup> */}
<FieldsGroup>
<Text tag='label'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text>
<Text theme='muted' size='xs'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text>
<div className='two-col'>
<Checkbox
label={intl.formatMessage(messages.home_timeline)}
name='home_timeline'
checked={homeTimeline}
onChange={({ target }) => setHomeTimeline(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.public_timeline)}
name='public_timeline'
checked={publicTimeline}
onChange={({ target }) => setPublicTimeline(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.notifications)}
name='notifications'
checked={notifications}
onChange={({ target }) => setNotifications(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.conversations)}
name='conversations'
checked={conversations}
onChange={({ target }) => setConversations(target.checked)}
/>
</div>
</FieldsGroup>
<FieldsGroup>
<Checkbox
label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)}
name='irreversible'
checked={irreversible}
onChange={({ target }) => setIrreversible(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
name='whole_word'
checked={wholeWord}
onChange={({ target }) => setWholeWord(target.checked)}
/>
</FieldsGroup>
<FormActions>
<Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button>
</FormActions>
</Form>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.subheading_filters)} />
</CardHeader>
<ScrollableList
scrollKey='filters'
emptyMessage={emptyMessage}
>
{filters.map((filter, i) => (
<div key={i} className='filter__container'>
<div className='filter__details'>
<div className='filter__phrase'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span>
<span className='filter__list-value'>{filter.phrase}</span>
</div>
<div className='filter__contexts'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' /></span>
<span className='filter__list-value'>
{filter.context.map((context, i) => (
<span key={i} className='context'>{context}</span>
))}
</span>
</div>
<div className='filter__details'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
<span className='filter__list-value'>
{filter.irreversible ?
<span><FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /></span> :
<span><FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' /></span>
}
{filter.whole_word &&
<span><FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' /></span>
}
</span>
</div>
</div>
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}>
<Icon className='filter__delete-icon' id='times' size={40} />
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
</div>
</div>
))}
</ScrollableList>
</Column>
);
};
export default Filters;

View File

@ -13,7 +13,7 @@ import { buildStatus } from '../builder';
import ScheduledStatusActionBar from './scheduled_status_action_bar';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
import type { Account as AccountEntity, Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities';
interface IScheduledStatus {
statusId: string,
@ -55,7 +55,7 @@ const ScheduledStatus: React.FC<IScheduledStatus> = ({ statusId, ...other }) =>
/>
)}
{status.poll && <PollPreview poll={status.poll} />}
{status.poll && <PollPreview poll={status.poll as PollEntity} />}
<ScheduledStatusActionBar status={status} {...other} />
</div>

View File

@ -1,70 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
import { Modal, Stack, Text } from 'soapbox/components/ui';
import ReplyIndicator from 'soapbox/features/compose/components/reply_indicator';
const messages = defineMessages({
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
reblog: { id: 'status.reblog', defaultMessage: 'Repost' },
});
export default @injectIntl @withRouter
class BoostModal extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.record.isRequired,
onReblog: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
history: PropTypes.object,
};
handleReblog = () => {
this.props.onReblog(this.props.status);
this.props.onClose();
}
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.onClose();
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
}
}
handleStatusClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.onClose();
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.get('url')}`);
}
}
render() {
const { status, intl } = this.props;
const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
return (
<Modal
title='Repost?'
confirmationAction={this.handleReblog}
confirmationText={intl.formatMessage(buttonText)}
>
<Stack space={4}>
<ReplyIndicator status={status} hideActions />
<Text>
<FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} />
</Text>
</Stack>
</Modal>
);
}
}

View File

@ -0,0 +1,48 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { Modal, Stack, Text } from 'soapbox/components/ui';
import ReplyIndicator from 'soapbox/features/compose/components/reply_indicator';
import type { Status as StatusEntity } from 'soapbox/types/entities';
const messages = defineMessages({
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
reblog: { id: 'status.reblog', defaultMessage: 'Repost' },
});
interface IBoostModal {
status: StatusEntity,
onReblog: (status: StatusEntity) => void,
onClose: () => void,
}
const BoostModal: React.FC<IBoostModal> = ({ status, onReblog, onClose }) => {
const intl = useIntl();
const handleReblog = () => {
onReblog(status);
onClose();
};
const buttonText = status.reblogged ? messages.cancel_reblog : messages.reblog;
return (
<Modal
title='Repost?'
confirmationAction={handleReblog}
confirmationText={intl.formatMessage(buttonText)}
>
<Stack space={4}>
<ReplyIndicator status={status} hideActions />
<Text>
<FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} />
</Text>
</Stack>
</Modal>
);
};
export default BoostModal;

View File

@ -1,84 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { injectIntl, FormattedMessage } from 'react-intl';
import { Modal } from 'soapbox/components/ui';
import { SimpleForm, FieldsGroup, Checkbox } from 'soapbox/features/forms';
export default @injectIntl
class ConfirmationModal extends React.PureComponent {
static propTypes = {
heading: PropTypes.node,
icon: PropTypes.node,
message: PropTypes.node.isRequired,
confirm: PropTypes.node.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
secondary: PropTypes.string,
onSecondary: PropTypes.func,
intl: PropTypes.object.isRequired,
onCancel: PropTypes.func,
checkbox: PropTypes.node,
};
state = {
checked: false,
}
handleClick = () => {
this.props.onClose('CONFIRM');
this.props.onConfirm();
}
handleSecondary = () => {
this.props.onClose('CONFIRM');
this.props.onSecondary();
}
handleCancel = () => {
const { onClose, onCancel } = this.props;
onClose('CONFIRM');
if (onCancel) onCancel();
}
handleCheckboxChange = e => {
this.setState({ checked: e.target.checked });
}
render() {
const { heading, message, confirm, secondary, checkbox } = this.props;
const { checked } = this.state;
return (
<Modal
title={heading}
confirmationAction={this.handleClick}
confirmationText={confirm}
confirmationDisabled={checkbox && !checked}
confirmationTheme='danger'
cancelText={<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />}
cancelAction={this.handleCancel}
secondaryText={secondary}
secondaryAction={this.props.onSecondary && this.handleSecondary}
>
<p className='text-gray-600 dark:text-gray-300'>{message}</p>
<div className='mt-2'>
{checkbox && <div className='confirmation-modal__checkbox'>
<SimpleForm>
<FieldsGroup>
<Checkbox
onChange={this.handleCheckboxChange}
label={checkbox}
checked={checked}
/>
</FieldsGroup>
</SimpleForm>
</div>}
</div>
</Modal>
);
}
}

View File

@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Modal } from 'soapbox/components/ui';
import { SimpleForm, FieldsGroup, Checkbox } from 'soapbox/features/forms';
interface IConfirmationModal {
heading: React.ReactNode,
message: React.ReactNode,
confirm: React.ReactNode,
onClose: (type: string) => void,
onConfirm: () => void,
secondary: React.ReactNode,
onSecondary?: () => void,
onCancel: () => void,
checkbox?: JSX.Element,
}
const ConfirmationModal: React.FC<IConfirmationModal> = ({
heading,
message,
confirm,
onClose,
onConfirm,
secondary,
onSecondary,
onCancel,
checkbox,
}) => {
const [checked, setChecked] = useState(false);
const handleClick = () => {
onClose('CONFIRM');
onConfirm();
};
const handleSecondary = () => {
onClose('CONFIRM');
onSecondary!();
};
const handleCancel = () => {
onClose('CONFIRM');
if (onCancel) onCancel();
};
const handleCheckboxChange: React.ChangeEventHandler<HTMLInputElement> = e => {
setChecked(e.target.checked);
};
return (
<Modal
title={heading}
confirmationAction={handleClick}
confirmationText={confirm}
confirmationDisabled={checkbox && !checked}
confirmationTheme='danger'
cancelText={<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />}
cancelAction={handleCancel}
secondaryText={secondary}
secondaryAction={onSecondary && handleSecondary}
>
<p className='text-gray-600 dark:text-gray-300'>{message}</p>
<div className='mt-2'>
{checkbox && <div className='confirmation-modal__checkbox'>
<SimpleForm>
<FieldsGroup>
<Checkbox
onChange={handleCheckboxChange}
label={checkbox}
checked={checked}
/>
</FieldsGroup>
</SimpleForm>
</div>}
</div>
</Modal>
);
};
export default ConfirmationModal;

View File

@ -1,22 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import DetailedCryptoAddress from 'soapbox/features/crypto_donate/components/detailed_crypto_address';
export default class CryptoDonateModal extends React.PureComponent {
static propTypes = {
address: PropTypes.string.isRequired,
ticker: PropTypes.string.isRequired,
note: PropTypes.string,
};
render() {
return (
<div className='modal-root__modal crypto-donate-modal'>
<DetailedCryptoAddress {...this.props} />
</div>
);
}
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import DetailedCryptoAddress from 'soapbox/features/crypto_donate/components/detailed_crypto_address';
import type { ICryptoAddress } from '../../crypto_donate/components/crypto_address';
const CryptoDonateModal: React.FC<ICryptoAddress> = (props) => {
return (
<div className='modal-root__modal crypto-donate-modal'>
<DetailedCryptoAddress {...props} />
</div>
);
};
export default CryptoDonateModal;

View File

@ -1,56 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { NavLink, withRouter } from 'react-router-dom';
import { createSelector } from 'reselect';
import { fetchLists } from 'soapbox/actions/lists';
import Icon from 'soapbox/components/icon';
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
});
const mapStateToProps = state => ({
lists: getOrderedLists(state),
});
export default @withRouter
@connect(mapStateToProps)
class ListPanel extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
lists: ImmutablePropTypes.list,
};
componentDidMount() {
const { dispatch } = this.props;
dispatch(fetchLists());
}
render() {
const { lists } = this.props;
if (!lists || lists.isEmpty()) {
return null;
}
return (
<div>
<hr />
{lists.map(list => (
<NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
))}
</div>
);
}
}

View File

@ -0,0 +1,45 @@
import React, { useEffect } from 'react';
import { NavLink } from 'react-router-dom';
import { createSelector } from 'reselect';
import { fetchLists } from 'soapbox/actions/lists';
import Icon from 'soapbox/components/icon';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import type { List as ImmutableList } from 'immutable';
import type { RootState } from 'soapbox/store';
import type { List as ListEntity } from 'soapbox/types/entities';
const getOrderedLists = createSelector([(state: RootState) => state.lists], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => (a as ListEntity).title.localeCompare((b as ListEntity).title)).take(4) as ImmutableList<ListEntity>;
});
const ListPanel = () => {
const dispatch = useAppDispatch();
const lists = useAppSelector((state) => getOrderedLists(state));
useEffect(() => {
dispatch(fetchLists());
}, []);
if (!lists || lists.isEmpty()) {
return null;
}
return (
<div>
<hr />
{lists.map(list => (
<NavLink key={list.id} className='column-link column-link--transparent' strict to={`/list/${list.id}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.title}</NavLink>
))}
</div>
);
};
export default ListPanel;

View File

@ -1,108 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import { muteAccount } from 'soapbox/actions/accounts';
import { closeModal } from 'soapbox/actions/modals';
import { toggleHideNotifications } from 'soapbox/actions/mutes';
import { Modal, HStack, Stack, Text } from 'soapbox/components/ui';
const mapStateToProps = state => {
return {
isSubmitting: state.reports.new.isSubmitting,
account: state.getIn(['mutes', 'new', 'account']),
notifications: state.getIn(['mutes', 'new', 'notifications']),
};
};
const mapDispatchToProps = dispatch => {
return {
onConfirm(account, notifications) {
dispatch(muteAccount(account.get('id'), notifications));
},
onClose() {
dispatch(closeModal());
},
onToggleNotifications() {
dispatch(toggleHideNotifications());
},
};
};
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class MuteModal extends React.PureComponent {
static propTypes = {
isSubmitting: PropTypes.bool.isRequired,
account: PropTypes.object.isRequired,
notifications: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
onToggleNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleClick = () => {
this.props.onClose();
this.props.onConfirm(this.props.account, this.props.notifications);
}
handleCancel = () => {
this.props.onClose();
}
toggleNotifications = () => {
this.props.onToggleNotifications();
}
render() {
const { account, notifications } = this.props;
return (
<Modal
title={
<FormattedMessage
id='confirmations.mute.heading'
defaultMessage='Mute @{name}'
values={{ name: account.get('acct') }}
/>
}
onClose={this.handleCancel}
confirmationAction={this.handleClick}
confirmationText={<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />}
cancelText={<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />}
cancelAction={this.handleCancel}
>
<Stack space={4}>
<Text>
<FormattedMessage
id='confirmations.mute.message'
defaultMessage='Are you sure you want to mute {name}?'
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
</Text>
<label>
<HStack alignItems='center' space={2}>
<Text tag='span'>
<FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
</Text>
<Toggle
checked={notifications}
onChange={this.toggleNotifications}
icons={false}
/>
</HStack>
</label>
</Stack>
</Modal>
);
}
}

View File

@ -0,0 +1,77 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import { muteAccount } from 'soapbox/actions/accounts';
import { closeModal } from 'soapbox/actions/modals';
import { toggleHideNotifications } from 'soapbox/actions/mutes';
import { Modal, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
const getAccount = makeGetAccount();
const MuteModal = () => {
const dispatch = useAppDispatch();
const account = useAppSelector((state) => getAccount(state, state.mutes.new.accountId!));
const notifications = useAppSelector((state) => state.mutes.new.notifications);
if (!account) return null;
const handleClick = () => {
dispatch(closeModal());
dispatch(muteAccount(account.id, notifications));
};
const handleCancel = () => {
dispatch(closeModal());
};
const toggleNotifications = () => {
dispatch(toggleHideNotifications());
};
return (
<Modal
title={
<FormattedMessage
id='confirmations.mute.heading'
defaultMessage='Mute @{name}'
values={{ name: account.acct }}
/>
}
onClose={handleCancel}
confirmationAction={handleClick}
confirmationText={<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />}
cancelText={<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />}
cancelAction={handleCancel}
>
<Stack space={4}>
<Text>
<FormattedMessage
id='confirmations.mute.message'
defaultMessage='Are you sure you want to mute {name}?'
values={{ name: <strong>@{account.acct}</strong> }}
/>
</Text>
<label>
<HStack alignItems='center' space={2}>
<Text tag='span'>
<FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
</Text>
<Toggle
checked={notifications}
onChange={toggleNotifications}
icons={false}
/>
</HStack>
</label>
</Stack>
</Modal>
);
};
export default MuteModal;

View File

@ -14,7 +14,7 @@ import { buildStatus } from '../util/pending_status_builder';
import PollPreview from './poll_preview';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
import type { Account as AccountEntity, Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities';
const shouldHaveCard = (pendingStatus: StatusEntity) => {
return Boolean(pendingStatus.content.match(/https?:\/\/\S*/));
@ -81,7 +81,7 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
<PendingStatusMedia status={status} />
{status.poll && <PollPreview poll={status.poll} />}
{status.poll && <PollPreview poll={status.poll as PollEntity} />}
{status.quote && <QuotedStatus statusId={status.quote as string} />}
</div>

View File

@ -1,50 +0,0 @@
import classNames from 'classnames';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class PollPreview extends ImmutablePureComponent {
static propTypes = {
poll: ImmutablePropTypes.map,
};
renderOption(option) {
const { poll } = this.props;
const showResults = poll.get('voted') || poll.get('expired');
return (
<li key={option}>
<label className={classNames('poll__text', { selectable: !showResults })}>
<input
name='vote-options'
type={poll.get('multiple') ? 'checkbox' : 'radio'}
onChange={this.handleOptionChange}
disabled
/>
<span className={classNames('poll__input', { checkbox: poll.get('multiple') })} />
<span dangerouslySetInnerHTML={{ __html: option }} />
</label>
</li>
);
}
render() {
const { poll } = this.props;
if (!poll) {
return null;
}
return (
<div className='poll'>
<ul>
{poll.get('options').map((option, i) => this.renderOption(option, i))}
</ul>
</div>
);
}
}

View File

@ -0,0 +1,44 @@
import classNames from 'classnames';
import React from 'react';
import { Poll as PollEntity, PollOption as PollOptionEntity } from 'soapbox/types/entities';
interface IPollPreview {
poll: PollEntity,
}
const PollPreview: React.FC<IPollPreview> = ({ poll }) => {
const renderOption = (option: PollOptionEntity, index: number) => {
const showResults = poll.voted || poll.expired;
return (
<li key={index}>
<label className={classNames('poll__text', { selectable: !showResults })}>
<input
name='vote-options'
type={poll.multiple ? 'checkbox' : 'radio'}
disabled
/>
<span className={classNames('poll__input', { checkbox: poll.multiple })} />
<span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
</label>
</li>
);
};
if (!poll) {
return null;
}
return (
<div className='poll'>
<ul>
{poll.options.map((option, i) => renderOption(option, i))}
</ul>
</div>
);
};
export default PollPreview;

View File

@ -0,0 +1,22 @@
/**
* Filter normalizer:
* Converts API filters into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/filter/}
*/
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
// https://docs.joinmastodon.org/entities/filter/
export const FilterRecord = ImmutableRecord({
id: '',
phrase: '',
context: ImmutableList<string>(),
whole_word: false,
expires_at: '',
irreversible: false,
});
export const normalizeFilter = (filter: Record<string, any>) => {
return FilterRecord(
ImmutableMap(fromJS(filter)),
);
};

View File

@ -6,6 +6,7 @@ export { CardRecord, normalizeCard } from './card';
export { ChatRecord, normalizeChat } from './chat';
export { ChatMessageRecord, normalizeChatMessage } from './chat_message';
export { EmojiRecord, normalizeEmoji } from './emoji';
export { FilterRecord, normalizeFilter } from './filter';
export { HistoryRecord, normalizeHistory } from './history';
export { InstanceRecord, normalizeInstance } from './instance';
export { ListRecord, normalizeList } from './list';

View File

@ -47,8 +47,18 @@ const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
});
};
const normalizePollOption = (option: ImmutableMap<string, any>, emojis: ImmutableList<ImmutableMap<string, string>> = ImmutableList()) => {
const normalizePollOption = (option: ImmutableMap<string, any> | string, emojis: ImmutableList<ImmutableMap<string, string>> = ImmutableList()) => {
const emojiMap = makeEmojiMap(emojis);
if (typeof option === 'string') {
const titleEmojified = emojify(escapeTextContentForBrowser(option), emojiMap);
return PollOptionRecord({
title: option,
title_emojified: titleEmojified,
});
}
const titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
return PollOptionRecord(

View File

@ -6,7 +6,7 @@ import reducer from '../accounts';
describe('accounts reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap());
expect(reducer(undefined, {} as any)).toEqual(ImmutableMap());
});
describe('ACCOUNT_IMPORT', () => {

View File

@ -9,7 +9,7 @@ import reducer from '../accounts_counters';
describe('accounts_counters reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap());
expect(reducer(undefined, {} as any)).toEqual(ImmutableMap());
});
// it('should handle ACCOUNT_FOLLOW_SUCCESS', () => {

View File

@ -4,7 +4,7 @@ import reducer from '../admin';
describe('admin reducer', () => {
it('should return the initial state', () => {
const result = reducer(undefined, {});
const result = reducer(undefined, {} as any);
expect(ImmutableRecord.isRecord(result)).toBe(true);
expect(result.needsReboot).toBe(false);
});

View File

@ -1,4 +1,4 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { List as ImmutableList, Map as ImmutableMap, fromJS } from 'immutable';
import * as actions from 'soapbox/actions/compose';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from 'soapbox/actions/me';
@ -220,7 +220,7 @@ describe('compose reducer', () => {
});
it('should handle COMPOSE_SPOILERNESS_CHANGE on CW button click', () => {
const state = ImmutableMap({ spoiler_text: 'spoiler text', spoiler: true, media_attachments: { } });
const state = ImmutableMap({ spoiler_text: 'spoiler text', spoiler: true, media_attachments: ImmutableList() });
const action = {
type: actions.COMPOSE_SPOILERNESS_CHANGE,
};
@ -337,7 +337,7 @@ describe('compose reducer', () => {
});
it('should handle COMPOSE_UPLOAD_SUCCESS', () => {
const state = ImmutableMap({ media_attachments: [] });
const state = ImmutableMap({ media_attachments: ImmutableList() });
const media = [
{
description: null,
@ -385,19 +385,18 @@ describe('compose reducer', () => {
});
it('should handle COMPOSE_SUGGESTIONS_CLEAR', () => {
const state = ImmutableMap({ });
const action = {
type: actions.COMPOSE_SUGGESTIONS_CLEAR,
suggestions: [],
suggestion_token: 'aiekdns3',
};
expect(reducer(state, action).toJS()).toMatchObject({
expect(reducer(undefined, action).toJS()).toMatchObject({
suggestion_token: null,
});
});
it('should handle COMPOSE_SUGGESTION_TAGS_UPDATE', () => {
const state = ImmutableMap({ tagHistory: [ 'hashtag' ] });
const state = ImmutableMap({ tagHistory: ImmutableList([ 'hashtag' ]) });
const action = {
type: actions.COMPOSE_SUGGESTION_TAGS_UPDATE,
token: 'aaadken3',
@ -410,12 +409,11 @@ describe('compose reducer', () => {
});
it('should handle COMPOSE_TAG_HISTORY_UPDATE', () => {
const state = ImmutableMap({ });
const action = {
type: actions.COMPOSE_TAG_HISTORY_UPDATE,
tags: [ 'hashtag', 'hashtag2'],
};
expect(reducer(state, action).toJS()).toMatchObject({
expect(reducer(undefined, action).toJS()).toMatchObject({
tagHistory: [ 'hashtag', 'hashtag2' ],
});
});
@ -450,11 +448,10 @@ describe('compose reducer', () => {
});
it('should handle COMPOSE_POLL_REMOVE', () => {
const state = ImmutableMap({ });
const action = {
type: actions.COMPOSE_POLL_REMOVE,
};
expect(reducer(state, action).toJS()).toMatchObject({
expect(reducer(undefined, action).toJS()).toMatchObject({
poll: null,
});
});

View File

@ -13,7 +13,7 @@ import reducer, { ReducerRecord } from '../contexts';
describe('contexts reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ReducerRecord({
expect(reducer(undefined, {} as any)).toEqual(ReducerRecord({
inReplyTos: ImmutableMap(),
replies: ImmutableMap(),
}));
@ -97,18 +97,18 @@ describe('contexts reducer', () => {
inReplyTos: fromJS({
B: 'A',
C: 'B',
}),
}) as ImmutableMap<string, string>,
replies: fromJS({
A: ImmutableOrderedSet(['B']),
B: ImmutableOrderedSet(['C']),
}),
}) as ImmutableMap<string, ImmutableOrderedSet<string>>,
});
const expected = ReducerRecord({
inReplyTos: fromJS({}),
inReplyTos: fromJS({}) as ImmutableMap<string, string>,
replies: fromJS({
A: ImmutableOrderedSet(),
}),
}) as ImmutableMap<string, ImmutableOrderedSet<string>>,
});
expect(reducer(state, action)).toEqual(expected);

View File

@ -4,6 +4,6 @@ import reducer from '../filters';
describe('filters reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableList());
expect(reducer(undefined, {} as any)).toEqual(ImmutableList());
});
});

View File

@ -7,7 +7,7 @@ import reducer from '../instance';
describe('instance reducer', () => {
it('should return the initial state', () => {
const result = reducer(undefined, {});
const result = reducer(undefined, {} as any);
const expected = {
description_limit: 1500,
@ -128,7 +128,7 @@ describe('instance reducer', () => {
};
// The normalizer has `registrations: closed` by default
const state = reducer(undefined, {});
const state = reducer(undefined, {} as any);
expect(state.registrations).toBe(false);
// After importing the configs, registration will be open

View File

@ -6,7 +6,7 @@ import reducer from '../list_adder';
describe('list_adder reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toMatchObject({
expect(reducer(undefined, {} as any)).toMatchObject({
accountId: null,
lists: {
@ -22,7 +22,7 @@ describe('list_adder reducer', () => {
accountId: null,
lists: ImmutableRecord({
items: ImmutableList(),
items: ImmutableList<string>(),
loaded: false,
isLoading: false,
})(),
@ -46,7 +46,7 @@ describe('list_adder reducer', () => {
accountId: null,
lists: ImmutableRecord({
items: ImmutableList(),
items: ImmutableList<string>(),
loaded: false,
isLoading: false,
})(),
@ -70,7 +70,7 @@ describe('list_adder reducer', () => {
accountId: null,
lists: ImmutableRecord({
items: ImmutableList(),
items: ImmutableList<string>(),
loaded: false,
isLoading: false,
})(),

View File

@ -4,6 +4,6 @@ import reducer from '../lists';
describe('lists reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap());
expect(reducer(undefined, {} as any)).toEqual(ImmutableMap());
});
});

View File

@ -2,6 +2,6 @@ import reducer from '../me';
describe('me reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(null);
expect(reducer(undefined, {} as any)).toEqual(null);
});
});

View File

@ -1,4 +1,4 @@
import { List as ImmutableList } from 'immutable';
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import { MODAL_OPEN, MODAL_CLOSE } from 'soapbox/actions/modals';
@ -6,11 +6,11 @@ import reducer from '../modals';
describe('modal reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableList());
expect(reducer(undefined, {} as any)).toEqual(ImmutableList());
});
it('should handle MODAL_OPEN', () => {
const state = ImmutableList();
const state = ImmutableList<any>();
const action = {
type: MODAL_OPEN,
modalType: 'type1',
@ -23,35 +23,43 @@ describe('modal reducer', () => {
});
it('should handle MODAL_CLOSE', () => {
const state = ImmutableList([{
const state = ImmutableList([
ImmutableRecord({
modalType: 'type1',
modalProps: { props1: '1' },
}]);
})(),
]);
const action = {
type: MODAL_CLOSE,
};
expect(reducer(state, action)).toMatchObject(ImmutableList());
expect(reducer(state, action).toJS()).toMatchObject([]);
});
it('should handle MODAL_CLOSE with specified modalType', () => {
const state = ImmutableList([
{
ImmutableRecord({
modalType: 'type1',
},
{
modalProps: null,
})(),
ImmutableRecord({
modalType: 'type2',
},
{
modalProps: null,
})(),
ImmutableRecord({
modalType: 'type1',
},
modalProps: null,
})(),
]);
const action = {
type: MODAL_CLOSE,
modalType: 'type2',
};
expect(reducer(state, action)).toMatchObject(ImmutableList([{
expect(reducer(state, action).toJS()).toEqual([
{
modalType: 'type1',
}]));
modalProps: null,
},
]);
});
});

View File

@ -1,4 +1,4 @@
import { Map as ImmutableMap } from 'immutable';
import { Record as ImmutableRecord } from 'immutable';
import {
MUTES_INIT_MODAL,
@ -9,50 +9,54 @@ import reducer from '../mutes';
describe('mutes reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({
new: ImmutableMap({
expect(reducer(undefined, {}).toJS()).toEqual({
new: {
isSubmitting: false,
account: null,
accountId: null,
notifications: true,
}),
}));
},
});
});
it('should handle MUTES_INIT_MODAL', () => {
const state = ImmutableMap({
new: ImmutableMap({
const state = ImmutableRecord({
new: ImmutableRecord({
isSubmitting: false,
account: null,
accountId: null,
notifications: true,
}),
});
})(),
})();
const action = {
type: MUTES_INIT_MODAL,
account: 'account1',
account: { id: 'account1' },
};
expect(reducer(state, action)).toEqual(ImmutableMap({
new: ImmutableMap({
expect(reducer(state, action).toJS()).toEqual({
new: {
isSubmitting: false,
account: 'account1',
accountId: 'account1',
notifications: true,
}),
}));
},
});
});
it('should handle MUTES_TOGGLE_HIDE_NOTIFICATIONS', () => {
const state = ImmutableMap({
new: ImmutableMap({
const state = ImmutableRecord({
new: ImmutableRecord({
isSubmitting: false,
accountId: null,
notifications: true,
}),
});
})(),
})();
const action = {
type: MUTES_TOGGLE_HIDE_NOTIFICATIONS,
};
expect(reducer(state, action)).toEqual(ImmutableMap({
new: ImmutableMap({
expect(reducer(state, action).toJS()).toEqual({
new: {
isSubmitting: false,
accountId: null,
notifications: false,
}),
}));
},
});
});
});

View File

@ -5,7 +5,7 @@ import reducer from '../patron';
describe('patron reducer', () => {
it('should return the initial state', () => {
const result = reducer(undefined, {});
const result = reducer(undefined, {} as any);
expect(ImmutableRecord.isRecord(result)).toBe(true);
expect(result.instance.url).toBe('');
});

View File

@ -6,7 +6,7 @@ import reducer from '../polls';
describe('polls reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap());
expect(reducer(undefined, {} as any)).toEqual(ImmutableMap());
});
describe('POLLS_IMPORT', () => {

View File

@ -6,7 +6,7 @@ describe('push_notifications reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({
subscription: null,
alerts: new ImmutableMap({
alerts: ImmutableMap({
follow: true,
follow_request: true,
favourite: true,

View File

@ -2,7 +2,7 @@ import reducer from '../reports';
describe('reports reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {}).toJS()).toEqual({
expect(reducer(undefined, {} as any).toJS()).toEqual({
new: {
isSubmitting: false,
account_id: null,

View File

@ -2,6 +2,6 @@ import reducer from '../sidebar';
describe('sidebar reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual({ sidebarOpen: false });
expect(reducer(undefined, {} as any)).toEqual({ sidebarOpen: false });
});
});

View File

@ -1,4 +1,4 @@
import { List as ImmutableList, fromJS } from 'immutable';
import { List as ImmutableList, Map as ImmutableMap, fromJS } from 'immutable';
import { emojis as emojiData } from 'soapbox/features/emoji/emoji_mart_data_light';
import { addCustomToPool } from 'soapbox/features/emoji/emoji_mart_search_light';
@ -6,15 +6,18 @@ import { addCustomToPool } from 'soapbox/features/emoji/emoji_mart_search_light'
import { CUSTOM_EMOJIS_FETCH_SUCCESS } from '../actions/custom_emojis';
import { buildCustomEmojis } from '../features/emoji/emoji';
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
const initialState = ImmutableList();
// Populate custom emojis for composer autosuggest
const autosuggestPopulate = emojis => {
const autosuggestPopulate = (emojis: ImmutableList<ImmutableMap<string, string>>) => {
addCustomToPool(buildCustomEmojis(emojis));
};
const importEmojis = (state, customEmojis) => {
const emojis = fromJS(customEmojis).filter(emoji => {
const importEmojis = (customEmojis: APIEntity[]) => {
const emojis = (fromJS(customEmojis) as ImmutableList<ImmutableMap<string, string>>).filter((emoji) => {
// If a custom emoji has the shortcode of a Unicode emoji, skip it.
// Otherwise it breaks EmojiMart.
// https://gitlab.com/soapbox-pub/soapbox-fe/-/issues/610
@ -26,9 +29,9 @@ const importEmojis = (state, customEmojis) => {
return emojis;
};
export default function custom_emojis(state = initialState, action) {
export default function custom_emojis(state = initialState, action: AnyAction) {
if (action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) {
return importEmojis(state, action.custom_emojis);
return importEmojis(action.custom_emojis);
}
return state;

View File

@ -0,0 +1,23 @@
import { List as ImmutableList } from 'immutable';
import { normalizeFilter } from 'soapbox/normalizers';
import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
import type { AnyAction } from 'redux';
import type { APIEntity, Filter as FilterEntity } from 'soapbox/types/entities';
type State = ImmutableList<FilterEntity>;
const importFilters = (_state: State, filters: APIEntity[]): State => {
return ImmutableList(filters.map((filter) => normalizeFilter(filter)));
};
export default function filters(state: State = ImmutableList<FilterEntity>(), action: AnyAction): State {
switch (action.type) {
case FILTERS_FETCH_SUCCESS:
return importFilters(state, action.filters);
default:
return state;
}
}

View File

@ -1,25 +0,0 @@
import {
Map as ImmutableMap,
List as ImmutableList,
fromJS,
} from 'immutable';
import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
import type { AnyAction } from 'redux';
type Filter = ImmutableMap<string, any>;
type State = ImmutableList<Filter>;
const importFilters = (_state: State, filters: unknown): State => {
return ImmutableList(fromJS(filters)).map(filter => ImmutableMap(fromJS(filter)));
};
export default function filters(state: State = ImmutableList<Filter>(), action: AnyAction): State {
switch (action.type) {
case FILTERS_FETCH_SUCCESS:
return importFilters(state, action.filters);
default:
return state;
}
}

View File

@ -1,24 +1,30 @@
import { Map as ImmutableMap } from 'immutable';
import { Record as ImmutableRecord } from 'immutable';
import {
MUTES_INIT_MODAL,
MUTES_TOGGLE_HIDE_NOTIFICATIONS,
} from '../actions/mutes';
const initialState = ImmutableMap({
new: ImmutableMap({
import type { AnyAction } from 'redux';
const NewMuteRecord = ImmutableRecord({
isSubmitting: false,
account: null,
accountId: null,
notifications: true,
}),
});
export default function mutes(state = initialState, action) {
const ReducerRecord = ImmutableRecord({
new: NewMuteRecord(),
});
type State = ReturnType<typeof ReducerRecord>;
export default function mutes(state: State = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case MUTES_INIT_MODAL:
return state.withMutations((state) => {
state.setIn(['new', 'isSubmitting'], false);
state.setIn(['new', 'account'], action.account);
state.setIn(['new', 'accountId'], action.account.id);
state.setIn(['new', 'notifications'], true);
});
case MUTES_TOGGLE_HIDE_NOTIFICATIONS:

View File

@ -1,28 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import {
PROFILE_HOVER_CARD_OPEN,
PROFILE_HOVER_CARD_CLOSE,
PROFILE_HOVER_CARD_UPDATE,
} from 'soapbox/actions/profile_hover_card';
const initialState = ImmutableMap();
export default function profileHoverCard(state = initialState, action) {
switch (action.type) {
case PROFILE_HOVER_CARD_OPEN:
return ImmutableMap({
ref: action.ref,
accountId: action.accountId,
});
case PROFILE_HOVER_CARD_UPDATE:
return state.set('hovered', true);
case PROFILE_HOVER_CARD_CLOSE:
if (state.get('hovered') === true && !action.force)
return state;
else
return ImmutableMap();
default:
return state;
}
}

View File

@ -0,0 +1,36 @@
import { Record as ImmutableRecord } from 'immutable';
import {
PROFILE_HOVER_CARD_OPEN,
PROFILE_HOVER_CARD_CLOSE,
PROFILE_HOVER_CARD_UPDATE,
} from 'soapbox/actions/profile_hover_card';
import type { AnyAction } from 'redux';
const ReducerRecord = ImmutableRecord({
ref: null as React.MutableRefObject<HTMLDivElement> | null,
accountId: '',
hovered: false,
});
type State = ReturnType<typeof ReducerRecord>;
export default function profileHoverCard(state: State = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case PROFILE_HOVER_CARD_OPEN:
return state.withMutations((state) => {
state.set('ref', action.ref);
state.set('accountId', action.accountId);
});
case PROFILE_HOVER_CARD_UPDATE:
return state.set('hovered', true);
case PROFILE_HOVER_CARD_CLOSE:
if (state.get('hovered') === true && !action.force)
return state;
else
return ReducerRecord();
default:
return state;
}
}

View File

@ -14,7 +14,7 @@ import { shouldFilter } from 'soapbox/utils/timelines';
import type { ReducerChat } from 'soapbox/reducers/chats';
import type { RootState } from 'soapbox/store';
import type { Notification } from 'soapbox/types/entities';
import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities';
const normalizeId = (id: any): string => typeof id === 'string' ? id : '';
@ -104,18 +104,18 @@ const toServerSideType = (columnType: string): string => {
type FilterContext = { contextType?: string };
export const getFilters = (state: RootState, query: FilterContext) => {
return state.filters.filter((filter): boolean => {
return state.filters.filter((filter) => {
return query?.contextType
&& filter.get('context').includes(toServerSideType(query.contextType))
&& (filter.get('expires_at') === null
|| Date.parse(filter.get('expires_at')) > new Date().getTime());
&& filter.context.includes(toServerSideType(query.contextType))
&& (filter.expires_at === null
|| Date.parse(filter.expires_at) > new Date().getTime());
});
};
const escapeRegExp = (string: string) =>
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
export const regexFromFilters = (filters: ImmutableList<ImmutableMap<string, any>>) => {
export const regexFromFilters = (filters: ImmutableList<FilterEntity>) => {
if (filters.size === 0) return null;
return new RegExp(filters.map(filter => {

View File

@ -8,6 +8,7 @@ import {
ChatMessageRecord,
EmojiRecord,
FieldRecord,
FilterRecord,
HistoryRecord,
InstanceRecord,
ListRecord,
@ -31,6 +32,7 @@ type Chat = ReturnType<typeof ChatRecord>;
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
type Emoji = ReturnType<typeof EmojiRecord>;
type Field = ReturnType<typeof FieldRecord>;
type Filter = ReturnType<typeof FilterRecord>;
type History = ReturnType<typeof HistoryRecord>;
type Instance = ReturnType<typeof InstanceRecord>;
type List = ReturnType<typeof ListRecord>;
@ -68,6 +70,7 @@ export {
ChatMessage,
Emoji,
Field,
Filter,
History,
Instance,
List,

View File

@ -1,4 +1,4 @@
import { fromJS } from 'immutable';
import { List as ImmutableList, fromJS } from 'immutable';
import config_db from 'soapbox/__fixtures__/config_db.json';
@ -6,7 +6,7 @@ import { ConfigDB } from '../config_db';
test('find', () => {
const configs = fromJS(config_db).get('configs');
expect(ConfigDB.find(configs, ':phoenix', ':json_library')).toEqual(fromJS({
expect(ConfigDB.find(configs as ImmutableList<any>, ':phoenix', ':json_library')).toEqual(fromJS({
group: ':phoenix',
key: ':json_library',
value: 'Jason',

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