Compare commits
10 commits
master
...
fix-filter
Author | SHA1 | Date | |
---|---|---|---|
|
e4d2b29361 | ||
|
f3263be6eb | ||
|
549b177b2f | ||
|
a17f572d05 | ||
|
e4c30e746a | ||
|
68a157540a | ||
|
a7caf62887 | ||
|
75fdfffe35 | ||
|
ab2709afd0 | ||
|
0c06f9b786 |
15 changed files with 256 additions and 279 deletions
|
@ -1,11 +1,14 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { getFilters } from 'soapbox/selectors';
|
||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { STATUS_APPLY_FILTERS } from './statuses';
|
||||
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
|
||||
|
@ -26,7 +29,7 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
const fetchFilters = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
|
||||
const state = getState();
|
||||
|
@ -40,19 +43,26 @@ const fetchFilters = () =>
|
|||
skipLoading: true,
|
||||
});
|
||||
|
||||
api(getState)
|
||||
.get('/api/v1/filters')
|
||||
.then(({ data }) => dispatch({
|
||||
try {
|
||||
const { data } = await api(getState).get('/api/v1/filters');
|
||||
await dispatch({
|
||||
type: FILTERS_FETCH_SUCCESS,
|
||||
filters: data,
|
||||
skipLoading: true,
|
||||
}))
|
||||
.catch(err => dispatch({
|
||||
});
|
||||
const filters = getFilters(getState(), null);
|
||||
dispatch({
|
||||
type: STATUS_APPLY_FILTERS,
|
||||
filters,
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: FILTERS_FETCH_FAIL,
|
||||
err,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
}));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createFilter = (phrase: string, expires_at: string, context: Array<string>, whole_word: boolean, irreversible: boolean) =>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { getFilters } from 'soapbox/selectors';
|
||||
|
||||
import { getSettings } from '../settings';
|
||||
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
@ -21,14 +23,16 @@ export function importAccounts(accounts: APIEntity[]) {
|
|||
export function importStatus(status: APIEntity, idempotencyKey?: string) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
|
||||
const filters = getFilters(getState(), null);
|
||||
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers, filters });
|
||||
};
|
||||
}
|
||||
|
||||
export function importStatuses(statuses: APIEntity[]) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
|
||||
const filters = getFilters(getState(), null);
|
||||
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers, filters });
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -107,9 +107,11 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
|
|||
filtered = regex && regex.test(searchIndex);
|
||||
}
|
||||
|
||||
if (filtered) return;
|
||||
|
||||
// Desktop notifications
|
||||
try {
|
||||
if (showAlert && !filtered) {
|
||||
if (showAlert) {
|
||||
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
|
||||
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
|
||||
|
||||
|
@ -128,7 +130,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
|
|||
console.warn(e);
|
||||
}
|
||||
|
||||
if (playSound && !filtered) {
|
||||
if (playSound) {
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_UPDATE_NOOP,
|
||||
meta: { sound: 'boop' },
|
||||
|
|
|
@ -47,6 +47,8 @@ const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
|
|||
const STATUS_REVEAL = 'STATUS_REVEAL';
|
||||
const STATUS_HIDE = 'STATUS_HIDE';
|
||||
|
||||
const STATUS_APPLY_FILTERS = 'STATUS_APPLY_FILTERS';
|
||||
|
||||
const statusExists = (getState: () => RootState, statusId: string) => {
|
||||
return (getState().statuses.get(statusId) || null) !== null;
|
||||
};
|
||||
|
@ -353,6 +355,7 @@ export {
|
|||
STATUS_TRANSLATE_REQUEST,
|
||||
STATUS_TRANSLATE_SUCCESS,
|
||||
STATUS_TRANSLATE_FAIL,
|
||||
STATUS_APPLY_FILTERS,
|
||||
createStatus,
|
||||
editStatus,
|
||||
fetchStatus,
|
||||
|
|
|
@ -51,35 +51,35 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
|
|||
{poll.pleroma.get('non_anonymous') && (
|
||||
<>
|
||||
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
|
||||
<Text theme='muted' weight='medium'>
|
||||
<Text tag='span' theme='muted' weight='medium'>
|
||||
<FormattedMessage id='poll.non_anonymous' defaultMessage='Public poll' />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
|
||||
<Text theme='muted'>·</Text>
|
||||
<Text tag='span' theme='muted'>·</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showResults && (
|
||||
<>
|
||||
<button className='text-gray-600 underline' onClick={handleRefresh} data-testid='poll-refresh'>
|
||||
<Text theme='muted' weight='medium'>
|
||||
<Text tag='span' theme='muted' weight='medium'>
|
||||
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
|
||||
</Text>
|
||||
</button>
|
||||
|
||||
<Text theme='muted'>·</Text>
|
||||
<Text tag='span' theme='muted'>·</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text theme='muted' weight='medium'>
|
||||
<Text tag='span' theme='muted' weight='medium'>
|
||||
{votesCount}
|
||||
</Text>
|
||||
|
||||
{poll.expires_at && (
|
||||
<>
|
||||
<Text theme='muted'>·</Text>
|
||||
<Text weight='medium' theme='muted' data-testid='poll-expiration'>{timeRemaining}</Text>
|
||||
<Text tag='span' theme='muted'>·</Text>
|
||||
<Text tag='span' weight='medium' theme='muted' data-testid='poll-expiration'>{timeRemaining}</Text>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
|
|
|
@ -204,21 +204,6 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (status.filtered || actualStatus.filtered) {
|
||||
const minHandlers = muted ? undefined : {
|
||||
moveUp: handleHotkeyMoveUp,
|
||||
moveDown: handleHotkeyMoveDown,
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={minHandlers}>
|
||||
<div className={classNames('status__wrapper', 'status__wrapper--filtered', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
let quote;
|
||||
|
||||
if (actualStatus.quote) {
|
||||
|
@ -348,15 +333,21 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
collapsable
|
||||
/>
|
||||
|
||||
<StatusMedia
|
||||
status={actualStatus}
|
||||
muted={muted}
|
||||
onClick={handleClick}
|
||||
showMedia={showMedia}
|
||||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
/>
|
||||
{
|
||||
!actualStatus.hidden && (
|
||||
<>
|
||||
<StatusMedia
|
||||
status={actualStatus}
|
||||
muted={muted}
|
||||
onClick={handleClick}
|
||||
showMedia={showMedia}
|
||||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
/>
|
||||
{ quote }
|
||||
</>
|
||||
|
||||
{!actualStatus.hidden && quote}
|
||||
)
|
||||
}
|
||||
|
||||
{!hideActionBar && (
|
||||
<div className='pt-4'>
|
||||
|
|
|
@ -11,6 +11,7 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content';
|
|||
import { isRtl } from '../rtl';
|
||||
|
||||
import Poll from './polls/poll';
|
||||
import { Button, Text } from './ui';
|
||||
|
||||
import type { Status, Mention } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -28,10 +29,11 @@ interface IReadMoreButton {
|
|||
|
||||
/** Button to expand a truncated status (due to too much content) */
|
||||
const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick }) => (
|
||||
<button className='status__content__read-more-button' onClick={onClick}>
|
||||
<Button onClick={onClick} theme='link' size='sm' classNames='-mx-3'>
|
||||
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
|
||||
<Icon className='inline-block h-5 w-5' src={require('@tabler/icons/chevron-right.svg')} fixedWidth />
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
);
|
||||
|
||||
interface ISpoilerButton {
|
||||
|
@ -42,18 +44,9 @@ interface ISpoilerButton {
|
|||
|
||||
/** Button to expand status text behind a content warning */
|
||||
const SpoilerButton: React.FC<ISpoilerButton> = ({ onClick, hidden, tabIndex }) => (
|
||||
<button
|
||||
tabIndex={tabIndex}
|
||||
className={classNames(
|
||||
'inline-block rounded-md px-1.5 py-0.5 ml-[0.5em]',
|
||||
'text-black dark:text-white',
|
||||
'font-bold text-[11px] uppercase',
|
||||
'bg-primary-100 dark:bg-primary-900',
|
||||
'hover:bg-primary-300 dark:hover:bg-primary-600',
|
||||
'focus:bg-primary-200 dark:focus:bg-primary-600',
|
||||
'hover:no-underline',
|
||||
'duration-100',
|
||||
)}
|
||||
<Button
|
||||
theme='ghost'
|
||||
size='sm'
|
||||
onClick={onClick}
|
||||
>
|
||||
{hidden ? (
|
||||
|
@ -61,9 +54,68 @@ const SpoilerButton: React.FC<ISpoilerButton> = ({ onClick, hidden, tabIndex })
|
|||
) : (
|
||||
<FormattedMessage id='status.show_less' defaultMessage='Show less' />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
|
||||
interface ISpoiler {
|
||||
hidden: boolean,
|
||||
status: Status,
|
||||
onClick: (event: React.MouseEvent<Element, MouseEvent>) => void,
|
||||
}
|
||||
|
||||
const Spoiler: React.FC<ISpoiler> = ({ hidden, onClick, status }) => {
|
||||
return (
|
||||
<div className='flex items-center justify-between bg-gray-100 dark:bg-slate-700 p-2 rounded mt-1'>
|
||||
{
|
||||
status.spoiler_text.length > 0 ? (
|
||||
<span>
|
||||
<Text tag='span' weight='medium'>
|
||||
<FormattedMessage
|
||||
id='status.cw'
|
||||
defaultMessage='Warning:'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} lang={status.language || undefined} />
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<Text tag='span'>
|
||||
<FormattedMessage
|
||||
id='status.filtered'
|
||||
defaultMessage='Filtered'
|
||||
/>
|
||||
</Text>
|
||||
<br />
|
||||
<Text size='xs' theme='muted' tag='span'>
|
||||
<FormattedMessage
|
||||
id='status.filtered-hint'
|
||||
defaultMessage='Status was hidden by filter settings'
|
||||
/>
|
||||
</Text>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<div className='flex gap-3 items-center'>
|
||||
{
|
||||
status.media_attachments?.count() > 0 && (
|
||||
<div aria-hidden className='flex gap-1 items-center'>
|
||||
<Icon className='inline-block' src={require('@tabler/icons/paperclip.svg')} />
|
||||
<Text tag='span' size='xs' theme='muted'>{ status.media_attachments.count() }</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<SpoilerButton
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
hidden={hidden}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStatusContent {
|
||||
status: Status,
|
||||
expanded?: boolean,
|
||||
|
@ -214,7 +266,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
|
|||
const isHidden = onExpandedToggle ? !expanded : hidden;
|
||||
|
||||
const content = { __html: parsedHtml };
|
||||
const spoilerContent = { __html: status.spoilerHtml };
|
||||
const directionStyle: React.CSSProperties = { direction: 'ltr' };
|
||||
const className = classNames('status__content', {
|
||||
'status__content--with-action': onClick,
|
||||
|
@ -227,80 +278,50 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
|
|||
directionStyle.direction = 'rtl';
|
||||
}
|
||||
|
||||
if (status.spoiler_text.length > 0) {
|
||||
return (
|
||||
<div className={className} ref={node} tabIndex={0} style={directionStyle} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
|
||||
<p style={{ marginBottom: isHidden && status.mentions.isEmpty() ? 0 : undefined }}>
|
||||
<span dangerouslySetInnerHTML={spoilerContent} lang={status.language || undefined} />
|
||||
|
||||
<SpoilerButton
|
||||
tabIndex={0}
|
||||
onClick={handleSpoilerClick}
|
||||
hidden={isHidden}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div
|
||||
tabIndex={!isHidden ? 0 : undefined}
|
||||
className={classNames('status__content__text', {
|
||||
'status__content__text--visible': !isHidden,
|
||||
})}
|
||||
style={directionStyle}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.language || undefined}
|
||||
/>
|
||||
|
||||
{!isHidden && status.poll && typeof status.poll === 'string' && (
|
||||
<Poll id={status.poll} status={status.url} />
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<div className={`${className} flex flex-col gap-2`} ref={node} tabIndex={0} style={directionStyle} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
|
||||
{
|
||||
// post has a spoiler or was filtered
|
||||
(status.spoiler_text.length > 0 || status.filtered) && (
|
||||
<Spoiler
|
||||
status={status}
|
||||
hidden={isHidden}
|
||||
onClick={handleSpoilerClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
// actual content
|
||||
!isHidden && (
|
||||
<div
|
||||
tabIndex={!isHidden ? 0 : undefined}
|
||||
className={classNames('min-h-0 overflow-hidden text-ellipsis status__content__text', {
|
||||
'status__content__text--visible': !isHidden,
|
||||
})}
|
||||
style={directionStyle}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.language || undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
// post folded because too long
|
||||
collapsed && onClick && (
|
||||
<div>
|
||||
<ReadMoreButton onClick={onClick} key='read-more' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
// post has a poll
|
||||
status.poll && typeof status.poll === 'string' && (
|
||||
<Poll id={status.poll} key='poll' status={status.url} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
} else if (onClick) {
|
||||
const output = [
|
||||
<div
|
||||
ref={node}
|
||||
tabIndex={0}
|
||||
key='content'
|
||||
className={className}
|
||||
style={directionStyle}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.language || undefined}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>,
|
||||
];
|
||||
|
||||
if (collapsed) {
|
||||
output.push(<ReadMoreButton onClick={onClick} key='read-more' />);
|
||||
}
|
||||
|
||||
const hasPoll = status.poll && typeof status.poll === 'string';
|
||||
if (hasPoll) {
|
||||
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
|
||||
}
|
||||
|
||||
return <div className={classNames({ 'rounded-md p-4': hasPoll })}>{output}</div>;
|
||||
} else {
|
||||
const output = [
|
||||
<div
|
||||
ref={node}
|
||||
tabIndex={0}
|
||||
key='content'
|
||||
className={classNames('status__content', {
|
||||
'status__content--big': onlyEmoji,
|
||||
})}
|
||||
style={directionStyle}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.language || undefined}
|
||||
/>,
|
||||
];
|
||||
|
||||
if (status.poll && typeof status.poll === 'string') {
|
||||
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
|
||||
}
|
||||
|
||||
return <>{output}</>;
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(StatusContent);
|
||||
|
|
|
@ -12,7 +12,6 @@ interface IButton {
|
|||
block?: boolean,
|
||||
/** Elements inside the <button> */
|
||||
children?: React.ReactNode,
|
||||
/** @deprecated unused */
|
||||
classNames?: string,
|
||||
/** Prevent the button from being clicked. */
|
||||
disabled?: boolean,
|
||||
|
@ -22,7 +21,6 @@ interface IButton {
|
|||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void,
|
||||
/** A predefined button size. */
|
||||
size?: ButtonSizes,
|
||||
/** @deprecated unused */
|
||||
style?: React.CSSProperties,
|
||||
/** Text inside the button. Takes precedence over `children`. */
|
||||
text?: React.ReactNode,
|
||||
|
@ -37,6 +35,7 @@ interface IButton {
|
|||
/** Customizable button element with various themes. */
|
||||
const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
|
||||
const {
|
||||
classNames,
|
||||
block = false,
|
||||
children,
|
||||
disabled = false,
|
||||
|
@ -47,6 +46,7 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
|
|||
theme = 'accent',
|
||||
to,
|
||||
type = 'button',
|
||||
style,
|
||||
} = props;
|
||||
|
||||
const themeClass = useButtonStyles({
|
||||
|
@ -72,12 +72,13 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
|
|||
|
||||
const renderButton = () => (
|
||||
<button
|
||||
className={themeClass}
|
||||
className={`${themeClass} ${classNames}`}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
ref={ref}
|
||||
type={type}
|
||||
data-testid='button'
|
||||
style={style}
|
||||
>
|
||||
{renderIcon()}
|
||||
{text || children}
|
||||
|
|
|
@ -4,13 +4,13 @@ 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 { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||
import {
|
||||
FieldsGroup,
|
||||
Checkbox,
|
||||
} from 'soapbox/features/forms';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { Filter } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.filters', defaultMessage: 'Muted words' },
|
||||
|
@ -20,8 +20,9 @@ const messages = defineMessages({
|
|||
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' },
|
||||
notifications: { id: 'column.filters.notifications', defaultMessage: 'Also apply for notifications' },
|
||||
conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' },
|
||||
drop_notifications: { id: 'column.filters.drop_notifications', defaultMessage: 'Will also hide status in notifications' },
|
||||
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' },
|
||||
|
@ -33,15 +34,6 @@ const messages = defineMessages({
|
|||
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();
|
||||
|
@ -50,43 +42,20 @@ const 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: Array<string> = [];
|
||||
|
||||
if (homeTimeline) {
|
||||
context.push('home');
|
||||
}
|
||||
if (publicTimeline) {
|
||||
context.push('public');
|
||||
}
|
||||
if (notifications) {
|
||||
context.push('notifications');
|
||||
}
|
||||
if (conversations) {
|
||||
context.push('thread');
|
||||
}
|
||||
|
||||
dispatch(createFilter(phrase, expiresAt, context, wholeWord, irreversible)).then(() => {
|
||||
dispatch(createFilter(phrase, expiresAt, [], wholeWord, irreversible)).then(() => {
|
||||
return dispatch(fetchFilters());
|
||||
}).catch(error => {
|
||||
dispatch(snackbar.error(intl.formatMessage(messages.create_error)));
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilterDelete: React.MouseEventHandler<HTMLDivElement> = e => {
|
||||
dispatch(deleteFilter(e.currentTarget.dataset.value!)).then(() => {
|
||||
const handleFilterDelete = (filter: Filter) => {
|
||||
dispatch(deleteFilter(filter.id)).then(() => {
|
||||
return dispatch(fetchFilters());
|
||||
}).catch(() => {
|
||||
dispatch(snackbar.error(intl.formatMessage(messages.delete_error)));
|
||||
|
@ -113,65 +82,23 @@ const Filters = () => {
|
|||
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'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Checkbox
|
||||
label={intl.formatMessage(messages.home_timeline)}
|
||||
name='home_timeline'
|
||||
checked={homeTimeline}
|
||||
onChange={({ target }) => setHomeTimeline(target.checked)}
|
||||
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.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)}
|
||||
label={intl.formatMessage(messages.whole_word_header)}
|
||||
hint={intl.formatMessage(messages.whole_word_hint)}
|
||||
name='whole_word'
|
||||
checked={wholeWord}
|
||||
onChange={({ target }) => setWholeWord(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>
|
||||
|
@ -183,27 +110,19 @@ const Filters = () => {
|
|||
<CardTitle title={intl.formatMessage(messages.subheading_filters)} />
|
||||
</CardHeader>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='filters'
|
||||
emptyMessage={emptyMessage}
|
||||
>
|
||||
<div>
|
||||
{
|
||||
filters.size === 0 && <div>{ emptyMessage }</div>
|
||||
}
|
||||
{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>
|
||||
<div key={i} className='filter__container rounded bg-gray-100 dark:bg-slate-900 p-2 my-3'>
|
||||
<div className=''>
|
||||
<div className='mb-1'>
|
||||
<span className='pr-1 text-gray-600 dark:text-gray-400'><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>
|
||||
<div className=''>
|
||||
<span className='pr-1 text-gray-600 dark:text-gray-400'><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> :
|
||||
|
@ -215,13 +134,18 @@ const Filters = () => {
|
|||
</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' src={require('@tabler/icons/x.svg')} />
|
||||
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
|
||||
<div>
|
||||
<Button theme='ghost' onClick={() => handleFilterDelete(filter)} aria-label={intl.formatMessage(messages.delete)}>
|
||||
<div className='flex items-end gap-1'>
|
||||
<FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' />
|
||||
<Icon src={require('@tabler/icons/x.svg')} />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -161,13 +161,16 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
)
|
||||
}
|
||||
|
||||
<StatusMedia
|
||||
status={actualStatus}
|
||||
showMedia={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>
|
||||
|
||||
{!actualStatus.hidden && quote}
|
||||
{!actualStatus.hidden && (
|
||||
<>
|
||||
<StatusMedia
|
||||
status={actualStatus}
|
||||
showMedia={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>
|
||||
{ quote }
|
||||
</>
|
||||
)}
|
||||
|
||||
<HStack justifyContent='between' alignItems='center' className='py-2'>
|
||||
<StatusInteractionBar status={actualStatus} />
|
||||
|
|
|
@ -242,9 +242,8 @@
|
|||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Accueil",
|
||||
"column.filters.keyword": "Mot-clé ou phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Découvrir",
|
||||
"column.filters.subheading_add_new": "Ajouter le nouveau filtre",
|
||||
"column.filters.subheading_add_new": "Ajouter un nouveau filtre",
|
||||
"column.filters.subheading_filters": "Filtres actuels",
|
||||
"column.filters.whole_word_header": "Mot entier",
|
||||
"column.filters.whole_word_hint": "Le filtre ne sera appliqué que s'il correspond exactement au mot entier.",
|
||||
|
@ -1063,7 +1062,9 @@
|
|||
"status.edit": "Éditer",
|
||||
"status.embed": "Intégrer",
|
||||
"status.favourite": "Réagir",
|
||||
"status.cw": "Avertissement:",
|
||||
"status.filtered": "Filtré",
|
||||
"status.filtered-hint": "Statut masqué par vos paramètres de filtrage",
|
||||
"status.load_more": "Charger plus",
|
||||
"status.media_hidden": "Média caché",
|
||||
"status.mention": "Mentionner @{name}",
|
||||
|
|
|
@ -3,6 +3,8 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
|||
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import { regexFromFilters } from 'soapbox/selectors';
|
||||
import { Filter } from 'soapbox/types/entities';
|
||||
import { simulateEmojiReact, simulateUnEmojiReact } from 'soapbox/utils/emoji_reacts';
|
||||
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html';
|
||||
import { makeEmojiMap, normalizeId } from 'soapbox/utils/normalizers';
|
||||
|
@ -31,6 +33,7 @@ import {
|
|||
STATUS_DELETE_REQUEST,
|
||||
STATUS_DELETE_FAIL,
|
||||
STATUS_TRANSLATE_SUCCESS,
|
||||
STATUS_APPLY_FILTERS,
|
||||
} from '../actions/statuses';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
|
||||
|
@ -103,18 +106,28 @@ export const calculateStatus = (
|
|||
});
|
||||
} else {
|
||||
const spoilerText = status.spoiler_text;
|
||||
const searchContent = buildSearchContent(status);
|
||||
const searchIndex = domParser.parseFromString(buildSearchContent(status), 'text/html').documentElement.textContent || '';
|
||||
const emojiMap = makeEmojiMap(status.emojis);
|
||||
|
||||
return status.merge({
|
||||
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
|
||||
search_index: searchIndex,
|
||||
contentHtml: stripCompatibilityFeatures(emojify(status.content, emojiMap)),
|
||||
spoilerHtml: emojify(escapeTextContentForBrowser(spoilerText), emojiMap),
|
||||
hidden: expandSpoilers ? false : spoilerText.length > 0 || status.sensitive,
|
||||
hidden: (expandSpoilers ? false : spoilerText.length > 0 || status.sensitive),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// apply filters on a status
|
||||
const fixFilters = (status: StatusRecord, filters: ImmutableList<Filter>): StatusRecord => {
|
||||
const regex = regexFromFilters(filters);
|
||||
const filtered = Boolean(regex && regex.test(status.search_index));
|
||||
return status.merge({
|
||||
hidden: status.hidden || filtered,
|
||||
filtered,
|
||||
});
|
||||
};
|
||||
|
||||
// Check whether a status is a quote by secondary characteristics
|
||||
const isQuote = (status: StatusRecord) => {
|
||||
return Boolean(status.pleroma.get('quote_url'));
|
||||
|
@ -131,21 +144,22 @@ const fixQuote = (status: StatusRecord, oldStatus?: StatusRecord): StatusRecord
|
|||
}
|
||||
};
|
||||
|
||||
const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean): ReducerStatus => {
|
||||
const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean, filters: ImmutableList<Filter>): ReducerStatus => {
|
||||
const oldStatus = state.get(status.id);
|
||||
|
||||
return normalizeStatus(status).withMutations(status => {
|
||||
fixQuote(status, oldStatus);
|
||||
calculateStatus(status, oldStatus, expandSpoilers);
|
||||
fixFilters(status, filters);
|
||||
minifyStatus(status);
|
||||
}) as ReducerStatus;
|
||||
};
|
||||
|
||||
const importStatus = (state: State, status: APIEntity, expandSpoilers: boolean): State =>
|
||||
state.set(status.id, fixStatus(state, status, expandSpoilers));
|
||||
const importStatus = (state: State, status: APIEntity, expandSpoilers: boolean, filters: ImmutableList<Filter>): State =>
|
||||
state.set(status.id, fixStatus(state, status, expandSpoilers, filters));
|
||||
|
||||
const importStatuses = (state: State, statuses: APIEntities, expandSpoilers: boolean): State =>
|
||||
state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status, expandSpoilers)));
|
||||
const importStatuses = (state: State, statuses: APIEntities, expandSpoilers: boolean, filters: ImmutableList<Filter>): State =>
|
||||
state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status, expandSpoilers, filters)));
|
||||
|
||||
const translateStatus = (state: State, statusId: string, language: string, text: string) =>
|
||||
state.updateIn([statusId, 'translations', language], () => text);
|
||||
|
@ -197,14 +211,24 @@ const simulateFavourite = (
|
|||
return state.set(statusId, updatedStatus);
|
||||
};
|
||||
|
||||
// When filters are fetched we want to apply them to already retrieved status
|
||||
const applyFilters = (
|
||||
state: State,
|
||||
filters: ImmutableList<Filter>,
|
||||
): State => {
|
||||
return state.map((status) => fixFilters(status, filters) as ReducerStatus);
|
||||
};
|
||||
|
||||
const initialState: State = ImmutableMap();
|
||||
|
||||
export default function statuses(state = initialState, action: AnyAction): State {
|
||||
switch (action.type) {
|
||||
case STATUS_APPLY_FILTERS:
|
||||
return applyFilters(state, action.filters);
|
||||
case STATUS_IMPORT:
|
||||
return importStatus(state, action.status, action.expandSpoilers);
|
||||
return importStatus(state, action.status, action.expandSpoilers, action.filters);
|
||||
case STATUSES_IMPORT:
|
||||
return importStatuses(state, action.statuses, action.expandSpoilers);
|
||||
return importStatuses(state, action.statuses, action.expandSpoilers, action.filters);
|
||||
case STATUS_TRANSLATE_SUCCESS:
|
||||
return translateStatus(state, action.statusId, action.language, action.text);
|
||||
case STATUS_CREATE_REQUEST:
|
||||
|
|
|
@ -105,9 +105,10 @@ type FilterContext = { contextType?: string };
|
|||
|
||||
export const getFilters = (state: RootState, query: FilterContext) => {
|
||||
return state.filters.filter((filter) => {
|
||||
return query?.contextType
|
||||
&& filter.context.includes(toServerSideType(query.contextType))
|
||||
&& (filter.expires_at === null
|
||||
// if contextType is provided we want to filter by context
|
||||
if (query?.contextType && !filter.context.includes(toServerSideType(query.contextType))) return false;
|
||||
|
||||
return (filter.expires_at === null
|
||||
|| Date.parse(filter.expires_at) > new Date().getTime());
|
||||
});
|
||||
};
|
||||
|
@ -165,14 +166,10 @@ export const makeGetStatus = () => {
|
|||
statusReblog = undefined;
|
||||
}
|
||||
|
||||
const regex = (accountReblog || accountBase).id !== me && regexFromFilters(filters);
|
||||
const filtered = regex && regex.test(statusReblog?.search_index || statusBase.search_index);
|
||||
|
||||
return statusBase.withMutations(map => {
|
||||
map.set('reblog', statusReblog || null);
|
||||
// @ts-ignore :(
|
||||
map.set('account', accountBase || null);
|
||||
map.set('filtered', Boolean(filtered));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
}
|
||||
|
||||
.filter__container {
|
||||
@apply flex justify-between py-5 px-2 text-sm text-black dark:text-white;
|
||||
@apply flex justify-between p-2 text-sm text-black dark:text-white;
|
||||
|
||||
.filter__phrase,
|
||||
.filter__contexts,
|
||||
|
@ -68,12 +68,8 @@
|
|||
@apply py-1;
|
||||
}
|
||||
|
||||
span.filter__list-label {
|
||||
@apply pr-1 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
span.filter__list-value span {
|
||||
@apply pr-1 capitalize;
|
||||
@apply pr-1;
|
||||
|
||||
&::after {
|
||||
content: ",";
|
||||
|
|
|
@ -224,7 +224,7 @@
|
|||
}
|
||||
|
||||
.status__content {
|
||||
@apply text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative;
|
||||
@apply text-gray-900 dark:text-gray-100 break-words relative;
|
||||
|
||||
&:focus {
|
||||
@apply outline-none;
|
||||
|
|
Loading…
Reference in a new issue