Merge branch 'ts' into 'develop'
TypeScript, React.FC See merge request soapbox-pub/soapbox-fe!1524
This commit is contained in:
commit
7ff991f8b3
103 changed files with 654 additions and 43 deletions
Binary file not shown.
|
@ -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(() => {
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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,
|
||||
|
|
Binary file not shown.
Binary file not shown.
229
app/soapbox/features/filters/index.tsx
Normal file
229
app/soapbox/features/filters/index.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
Binary file not shown.
48
app/soapbox/features/ui/components/boost_modal.tsx
Normal file
48
app/soapbox/features/ui/components/boost_modal.tsx
Normal 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;
|
Binary file not shown.
82
app/soapbox/features/ui/components/confirmation_modal.tsx
Normal file
82
app/soapbox/features/ui/components/confirmation_modal.tsx
Normal 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;
|
Binary file not shown.
16
app/soapbox/features/ui/components/crypto_donate_modal.tsx
Normal file
16
app/soapbox/features/ui/components/crypto_donate_modal.tsx
Normal 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;
|
Binary file not shown.
45
app/soapbox/features/ui/components/list_panel.tsx
Normal file
45
app/soapbox/features/ui/components/list_panel.tsx
Normal 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;
|
Binary file not shown.
77
app/soapbox/features/ui/components/mute_modal.tsx
Normal file
77
app/soapbox/features/ui/components/mute_modal.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
Binary file not shown.
44
app/soapbox/features/ui/components/poll_preview.tsx
Normal file
44
app/soapbox/features/ui/components/poll_preview.tsx
Normal 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;
|
22
app/soapbox/normalizers/filter.ts
Normal file
22
app/soapbox/normalizers/filter.ts
Normal 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)),
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
app/soapbox/reducers/filters.ts
Normal file
23
app/soapbox/reducers/filters.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
36
app/soapbox/reducers/profile_hover_card.ts
Normal file
36
app/soapbox/reducers/profile_hover_card.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue