Allow creating v2 filters
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
@@ -8,13 +8,9 @@ import api from '../api';
|
||||
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
const FILTERS_V1_FETCH_REQUEST = 'FILTERS_V1_FETCH_REQUEST';
|
||||
const FILTERS_V1_FETCH_SUCCESS = 'FILTERS_V1_FETCH_SUCCESS';
|
||||
const FILTERS_V1_FETCH_FAIL = 'FILTERS_V1_FETCH_FAIL';
|
||||
|
||||
const FILTERS_V2_FETCH_REQUEST = 'FILTERS_V2_FETCH_REQUEST';
|
||||
const FILTERS_V2_FETCH_SUCCESS = 'FILTERS_V2_FETCH_SUCCESS';
|
||||
const FILTERS_V2_FETCH_FAIL = 'FILTERS_V2_FETCH_FAIL';
|
||||
const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
|
||||
const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
|
||||
const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
|
||||
|
||||
const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
|
||||
const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
|
||||
@@ -29,22 +25,24 @@ const messages = defineMessages({
|
||||
removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' },
|
||||
});
|
||||
|
||||
type FilterKeywords = { keyword: string, whole_word: boolean }[];
|
||||
|
||||
const fetchFiltersV1 = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({
|
||||
type: FILTERS_V1_FETCH_REQUEST,
|
||||
type: FILTERS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
api(getState)
|
||||
.get('/api/v1/filters')
|
||||
.then(({ data }) => dispatch({
|
||||
type: FILTERS_V1_FETCH_SUCCESS,
|
||||
type: FILTERS_FETCH_SUCCESS,
|
||||
filters: data,
|
||||
skipLoading: true,
|
||||
}))
|
||||
.catch(err => dispatch({
|
||||
type: FILTERS_V1_FETCH_FAIL,
|
||||
type: FILTERS_FETCH_FAIL,
|
||||
err,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
@@ -54,26 +52,26 @@ const fetchFiltersV1 = () =>
|
||||
const fetchFiltersV2 = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({
|
||||
type: FILTERS_V2_FETCH_REQUEST,
|
||||
type: FILTERS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
api(getState)
|
||||
.get('/api/v2/filters')
|
||||
.then(({ data }) => dispatch({
|
||||
type: FILTERS_V2_FETCH_SUCCESS,
|
||||
type: FILTERS_FETCH_SUCCESS,
|
||||
filters: data,
|
||||
skipLoading: true,
|
||||
}))
|
||||
.catch(err => dispatch({
|
||||
type: FILTERS_V2_FETCH_FAIL,
|
||||
type: FILTERS_FETCH_FAIL,
|
||||
err,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchFilters = () =>
|
||||
const fetchFilters = (fromFiltersPage = false) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return;
|
||||
|
||||
@@ -81,19 +79,19 @@ const fetchFilters = () =>
|
||||
const instance = state.instance;
|
||||
const features = getFeatures(instance);
|
||||
|
||||
if (features.filtersV2) return dispatch(fetchFiltersV2());
|
||||
if (features.filtersV2 && fromFiltersPage) return dispatch(fetchFiltersV2());
|
||||
|
||||
if (features.filters) return dispatch(fetchFiltersV1());
|
||||
};
|
||||
|
||||
const createFilter = (phrase: string, expires_at: string, context: Array<string>, whole_word: boolean, irreversible: boolean) =>
|
||||
const createFilterV1 = (title: string, expires_at: string, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: FILTERS_CREATE_REQUEST });
|
||||
return api(getState).post('/api/v1/filters', {
|
||||
phrase,
|
||||
phrase: keywords[0].keyword,
|
||||
context,
|
||||
irreversible,
|
||||
whole_word,
|
||||
irreversible: hide,
|
||||
whole_word: keywords[0].whole_word,
|
||||
expires_at,
|
||||
}).then(response => {
|
||||
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
|
||||
@@ -103,7 +101,35 @@ const createFilter = (phrase: string, expires_at: string, context: Array<string>
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFilter = (id: string) =>
|
||||
const createFilterV2 = (title: string, expires_at: string, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: FILTERS_CREATE_REQUEST });
|
||||
return api(getState).post('/api/v2/filters', {
|
||||
title,
|
||||
context,
|
||||
filter_action: hide ? 'hide' : 'warn',
|
||||
expires_at,
|
||||
keywords_attributes,
|
||||
}).then(response => {
|
||||
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
|
||||
toast.success(messages.added);
|
||||
}).catch(error => {
|
||||
dispatch({ type: FILTERS_CREATE_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
const createFilter = (title: string, expires_at: string, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const instance = state.instance;
|
||||
const features = getFeatures(instance);
|
||||
|
||||
if (features.filtersV2) return dispatch(createFilterV2(title, expires_at, context, hide, keywords));
|
||||
|
||||
return dispatch(createFilterV1(title, expires_at, context, hide, keywords));
|
||||
};
|
||||
|
||||
const deleteFilterV1 = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: FILTERS_DELETE_REQUEST });
|
||||
return api(getState).delete(`/api/v1/filters/${id}`).then(response => {
|
||||
@@ -114,13 +140,32 @@ const deleteFilter = (id: string) =>
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFilterV2 = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: FILTERS_DELETE_REQUEST });
|
||||
return api(getState).delete(`/api/v2/filters/${id}`).then(response => {
|
||||
dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data });
|
||||
toast.success(messages.removed);
|
||||
}).catch(error => {
|
||||
dispatch({ type: FILTERS_DELETE_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFilter = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const instance = state.instance;
|
||||
const features = getFeatures(instance);
|
||||
|
||||
if (features.filtersV2) return dispatch(deleteFilterV2(id));
|
||||
|
||||
return dispatch(deleteFilterV1(id));
|
||||
};
|
||||
|
||||
export {
|
||||
FILTERS_V1_FETCH_REQUEST,
|
||||
FILTERS_V1_FETCH_SUCCESS,
|
||||
FILTERS_V1_FETCH_FAIL,
|
||||
FILTERS_V2_FETCH_REQUEST,
|
||||
FILTERS_V2_FETCH_SUCCESS,
|
||||
FILTERS_V2_FETCH_FAIL,
|
||||
FILTERS_FETCH_REQUEST,
|
||||
FILTERS_FETCH_SUCCESS,
|
||||
FILTERS_FETCH_FAIL,
|
||||
FILTERS_CREATE_REQUEST,
|
||||
FILTERS_CREATE_SUCCESS,
|
||||
FILTERS_CREATE_FAIL,
|
||||
|
||||
@@ -296,7 +296,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.filters && (
|
||||
{(features.filters || features.filtersV2) && (
|
||||
<SidebarLink
|
||||
to='/filters'
|
||||
icon={require('@tabler/icons/filter.svg')}
|
||||
|
||||
@@ -33,6 +33,8 @@ interface IStreamfield {
|
||||
onChange: (values: any[]) => void
|
||||
/** Input to render for each value. */
|
||||
component: StreamfieldComponent<any>
|
||||
/** Minimum number of allowed inputs. */
|
||||
minItems?: number
|
||||
/** Maximum number of allowed inputs. */
|
||||
maxItems?: number
|
||||
}
|
||||
@@ -47,6 +49,7 @@ const Streamfield: React.FC<IStreamfield> = ({
|
||||
onChange,
|
||||
component: Component,
|
||||
maxItems = Infinity,
|
||||
minItems = 0,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -70,7 +73,7 @@ const Streamfield: React.FC<IStreamfield> = ({
|
||||
{values.map((value, i) => (
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Component key={i} onChange={handleChange(i)} value={value} />
|
||||
{onRemoveItem && (
|
||||
{values.length > minItems && onRemoveItem && (
|
||||
<IconButton
|
||||
iconClassName='h-4 w-4'
|
||||
className='bg-transparent text-gray-400 hover:text-gray-600'
|
||||
|
||||
@@ -4,22 +4,34 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, HStack, IconButton, Input, Stack, Text, Toggle } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, HStack, IconButton, Input, Stack, Streamfield, Text, Toggle } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
|
||||
|
||||
interface IFilterField {
|
||||
keyword: string
|
||||
whole_word: boolean
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.filters', defaultMessage: 'Muted words' },
|
||||
subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' },
|
||||
title: { id: 'column.filters.title', defaultMessage: 'Title' },
|
||||
keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' },
|
||||
keywords: { id: 'column.filters.keywords', defaultMessage: 'Keywords or phrases' },
|
||||
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' },
|
||||
accounts: { id: 'column.filters.accounts', defaultMessage: 'Accounts' },
|
||||
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' },
|
||||
hide_header: { id: 'column.filters.hide_header', defaultMessage: 'Hide completely' },
|
||||
hide_hint: { id: 'column.filters.hide_hint', defaultMessage: 'Completely hide the filtered content, instead of showing a warning' },
|
||||
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' },
|
||||
@@ -34,31 +46,68 @@ const contexts = {
|
||||
public: messages.public_timeline,
|
||||
notifications: messages.notifications,
|
||||
thread: messages.conversations,
|
||||
account: messages.accounts,
|
||||
};
|
||||
|
||||
// const expirations = {
|
||||
// null: 'Never',
|
||||
// // 3600: '30 minutes',
|
||||
// // 21600: '1 hour',
|
||||
// // 1800: '30 minutes',
|
||||
// // 3600: '1 hour',
|
||||
// // 21600: '6 hour',
|
||||
// // 43200: '12 hours',
|
||||
// // 86400 : '1 day',
|
||||
// // 604800: '1 week',
|
||||
// };
|
||||
|
||||
const FilterField: StreamfieldComponent<IFilterField> = ({ value, onChange }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleChange = (key: string): React.ChangeEventHandler<HTMLInputElement> =>
|
||||
e => {
|
||||
// console.log({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] });
|
||||
onChange({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack space={2} grow>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-2/5 grow'
|
||||
value={value.keyword}
|
||||
onChange={handleChange('keyword')}
|
||||
placeholder={intl.formatMessage(messages.keyword)}
|
||||
/>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Toggle
|
||||
checked={value.whole_word}
|
||||
onChange={handleChange('whole_word')}
|
||||
icons={false}
|
||||
/>
|
||||
|
||||
<Text tag='span' theme='muted'>
|
||||
<FormattedMessage id='column.filters.whole_word' defaultMessage='Whole word' />
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
const Filters = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const filters = useAppSelector((state) => state.filters);
|
||||
|
||||
const [phrase, setPhrase] = useState('');
|
||||
const [title, setTitle] = 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 [accounts, setAccounts] = useState(false);
|
||||
const [hide, setHide] = useState(false);
|
||||
const [keywords, setKeywords] = useState<{ keyword: string, whole_word: boolean }[]>([{ keyword: '', whole_word: false }]);
|
||||
|
||||
// const handleSelectChange = e => {
|
||||
// this.setState({ [e.target.name]: e.target.value });
|
||||
@@ -80,9 +129,12 @@ const Filters = () => {
|
||||
if (conversations) {
|
||||
context.push('thread');
|
||||
}
|
||||
if (accounts) {
|
||||
context.push('account');
|
||||
}
|
||||
|
||||
dispatch(createFilter(phrase, expiresAt, context, wholeWord, irreversible)).then(() => {
|
||||
return dispatch(fetchFilters());
|
||||
dispatch(createFilter(title, expiresAt, context, hide, keywords)).then(() => {
|
||||
return dispatch(fetchFilters(true));
|
||||
}).catch(error => {
|
||||
toast.error(intl.formatMessage(messages.create_error));
|
||||
});
|
||||
@@ -90,14 +142,20 @@ const Filters = () => {
|
||||
|
||||
const handleFilterDelete = (id: string) => () => {
|
||||
dispatch(deleteFilter(id)).then(() => {
|
||||
return dispatch(fetchFilters());
|
||||
return dispatch(fetchFilters(true));
|
||||
}).catch(() => {
|
||||
toast.error(intl.formatMessage(messages.delete_error));
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeKeyword = (keywords: { keyword: string, whole_word: boolean }[]) => setKeywords(keywords);
|
||||
|
||||
const handleAddKeyword = () => setKeywords(keywords => [...keywords, { keyword: '', whole_word: false }]);
|
||||
|
||||
const handleRemoveKeyword = (i: number) => setKeywords(keywords => keywords.filter((_, index) => index !== i));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchFilters());
|
||||
dispatch(fetchFilters(true));
|
||||
}, []);
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.filters' defaultMessage="You haven't created any muted words yet." />;
|
||||
@@ -108,12 +166,13 @@ const Filters = () => {
|
||||
<CardTitle title={intl.formatMessage(messages.subheading_add_new)} />
|
||||
</CardHeader>
|
||||
<Form onSubmit={handleAddNew}>
|
||||
<FormGroup labelText={intl.formatMessage(messages.keyword)}>
|
||||
<FormGroup labelText={intl.formatMessage(messages.title)}>
|
||||
<Input
|
||||
required
|
||||
type='text'
|
||||
name='phrase'
|
||||
onChange={({ target }) => setPhrase(target.value)}
|
||||
name='title'
|
||||
value={title}
|
||||
onChange={({ target }) => setTitle(target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/* <FormGroup labelText={intl.formatMessage(messages.expires)} hintText={intl.formatMessage(messages.expires_hint)}>
|
||||
@@ -162,20 +221,29 @@ const Filters = () => {
|
||||
onChange={({ target }) => setConversations(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
{features.filtersV2 && (
|
||||
<ListItem label={intl.formatMessage(messages.accounts)}>
|
||||
<Toggle
|
||||
name='accounts'
|
||||
checked={accounts}
|
||||
onChange={({ target }) => setAccounts(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
|
||||
<List>
|
||||
<ListItem
|
||||
label={intl.formatMessage(messages.drop_header)}
|
||||
hint={intl.formatMessage(messages.drop_hint)}
|
||||
label={intl.formatMessage(features.filtersV2 ? messages.hide_header : messages.drop_header)}
|
||||
hint={intl.formatMessage(features.filtersV2 ? messages.hide_hint : messages.drop_hint)}
|
||||
>
|
||||
<Toggle
|
||||
name='irreversible'
|
||||
checked={irreversible}
|
||||
onChange={({ target }) => setIrreversible(target.checked)}
|
||||
name='hide'
|
||||
checked={hide}
|
||||
onChange={({ target }) => setHide(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
{/* <ListItem
|
||||
label={intl.formatMessage(messages.whole_word_header)}
|
||||
hint={intl.formatMessage(messages.whole_word_hint)}
|
||||
>
|
||||
@@ -184,9 +252,20 @@ const Filters = () => {
|
||||
checked={wholeWord}
|
||||
onChange={({ target }) => setWholeWord(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListItem> */}
|
||||
</List>
|
||||
|
||||
<Streamfield
|
||||
label={intl.formatMessage(messages.keywords)}
|
||||
component={FilterField}
|
||||
values={keywords}
|
||||
onChange={handleChangeKeyword}
|
||||
onAddItem={handleAddKeyword}
|
||||
onRemoveItem={handleRemoveKeyword}
|
||||
minItems={1}
|
||||
maxItems={features.filtersV2 ? Infinity : 1}
|
||||
/>
|
||||
|
||||
<FormActions>
|
||||
<Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button>
|
||||
</FormActions>
|
||||
@@ -207,7 +286,7 @@ const Filters = () => {
|
||||
<Text weight='medium'>
|
||||
<FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' />
|
||||
{' '}
|
||||
<Text theme='muted' tag='span'>{filter.phrase}</Text>
|
||||
<Text theme='muted' tag='span'>{filter.keywords.map(keyword => keyword.keyword).join(', ')}</Text>
|
||||
</Text>
|
||||
<Text weight='medium'>
|
||||
<FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' />
|
||||
@@ -215,7 +294,7 @@ const Filters = () => {
|
||||
<Text theme='muted' tag='span'>{filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')}</Text>
|
||||
</Text>
|
||||
<HStack space={4}>
|
||||
<Text weight='medium'>
|
||||
{/* <Text weight='medium'>
|
||||
{filter.irreversible ?
|
||||
<FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> :
|
||||
<FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />}
|
||||
@@ -224,7 +303,7 @@ const Filters = () => {
|
||||
<Text weight='medium'>
|
||||
<FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' />
|
||||
</Text>
|
||||
)}
|
||||
)} */}
|
||||
</HStack>
|
||||
</Stack>
|
||||
<IconButton
|
||||
|
||||
@@ -45,7 +45,7 @@ const LinkFooter: React.FC = (): JSX.Element => {
|
||||
)}
|
||||
<FooterLink to='/blocks'><FormattedMessage id='navigation_bar.blocks' defaultMessage='Blocks' /></FooterLink>
|
||||
<FooterLink to='/mutes'><FormattedMessage id='navigation_bar.mutes' defaultMessage='Mutes' /></FooterLink>
|
||||
{features.filters && (
|
||||
{(features.filters || features.filtersV2) && (
|
||||
<FooterLink to='/filters'><FormattedMessage id='navigation_bar.filters' defaultMessage='Filters' /></FooterLink>
|
||||
)}
|
||||
{features.federating && (
|
||||
|
||||
@@ -96,7 +96,7 @@ const EditAnnouncementModal: React.FC<IEditAnnouncementModal> = ({ onClose }) =>
|
||||
/>)}
|
||||
</BundleContainer>
|
||||
</FormGroup>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Toggle
|
||||
icons={false}
|
||||
checked={allDay}
|
||||
|
||||
@@ -266,7 +266,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
||||
<WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} />
|
||||
{features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />}
|
||||
<WrappedRoute path='/mutes' page={DefaultPage} component={Mutes} content={children} />
|
||||
{features.filters && <WrappedRoute path='/filters' page={DefaultPage} component={Filters} content={children} />}
|
||||
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters' page={DefaultPage} component={Filters} content={children} />}
|
||||
<WrappedRoute path='/@:username' publicRoute exact component={AccountTimeline} page={ProfilePage} content={children} />
|
||||
<WrappedRoute path='/@:username/with_replies' publicRoute={!authenticatedProfile} component={AccountTimeline} page={ProfilePage} content={children} componentParams={{ withReplies: true }} />
|
||||
<WrappedRoute path='/@:username/followers' publicRoute={!authenticatedProfile} component={Followers} page={ProfilePage} content={children} />
|
||||
|
||||
@@ -321,6 +321,7 @@
|
||||
"column.favourites": "Likes",
|
||||
"column.federation_restrictions": "Federation Restrictions",
|
||||
"column.filters": "Muted words",
|
||||
"column.filters.accounts": "Accounts",
|
||||
"column.filters.add_new": "Add New Filter",
|
||||
"column.filters.conversations": "Conversations",
|
||||
"column.filters.create_error": "Error adding filter",
|
||||
@@ -330,12 +331,17 @@
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.hide_header": "Hide completely",
|
||||
"column.filters.hide_hint": "Completely hide the filtered content, instead of showing a warning",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.keywords": "Keywords or phrases",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.title": "Title",
|
||||
"column.filters.whole_word": "Whole word",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Follow requests",
|
||||
@@ -731,10 +737,7 @@
|
||||
"filters.context_header": "Filter contexts",
|
||||
"filters.context_hint": "One or multiple contexts where the filter should apply",
|
||||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Authorize",
|
||||
@@ -1384,6 +1387,7 @@
|
||||
"status.sensitive_warning": "Sensitive content",
|
||||
"status.sensitive_warning.subtitle": "This content may not be suitable for all audiences.",
|
||||
"status.share": "Share",
|
||||
"status.show_filter_reason": "Show anyway",
|
||||
"status.show_less_all": "Show less for all",
|
||||
"status.show_more_all": "Show more for all",
|
||||
"status.show_original": "Show original",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Filter normalizer:
|
||||
* Converts API filters into our internal format.
|
||||
* @see {@link https://docs.joinmastodon.org/entities/V1_Filter/}
|
||||
*/
|
||||
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
|
||||
|
||||
import type { ContextType } from './filter';
|
||||
|
||||
// https://docs.joinmastodon.org/entities/V1_Filter/
|
||||
export const FilterV1Record = ImmutableRecord({
|
||||
id: '',
|
||||
phrase: '',
|
||||
context: ImmutableList<ContextType>(),
|
||||
whole_word: false,
|
||||
expires_at: '',
|
||||
irreversible: false,
|
||||
});
|
||||
|
||||
export const normalizeFilterV1 = (filter: Record<string, any>) => {
|
||||
return FilterV1Record(
|
||||
ImmutableMap(fromJS(filter)),
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { FilterKeyword, FilterStatus } from 'soapbox/types/entities';
|
||||
import { normalizeFilterKeyword } from './filter-keyword';
|
||||
import { normalizeFilterStatus } from './filter-status';
|
||||
|
||||
export type ContextType = 'home' | 'public' | 'notifications' | 'thread';
|
||||
export type ContextType = 'home' | 'public' | 'notifications' | 'thread' | 'account';
|
||||
export type FilterActionType = 'warn' | 'hide';
|
||||
|
||||
// https://docs.joinmastodon.org/entities/filter/
|
||||
@@ -24,6 +24,15 @@ export const FilterRecord = ImmutableRecord({
|
||||
statuses: ImmutableList<FilterStatus>(),
|
||||
});
|
||||
|
||||
const normalizeFilterV1 = (filter: ImmutableMap<string, any>) =>
|
||||
filter
|
||||
.set('title', filter.get('phrase'))
|
||||
.set('keywords', ImmutableList([ImmutableMap({
|
||||
keyword: filter.get('phrase'),
|
||||
whole_word: filter.get('whole_word'),
|
||||
})]))
|
||||
.set('filter_action', filter.get('irreversible') ? 'hide' : 'warn');
|
||||
|
||||
const normalizeKeywords = (filter: ImmutableMap<string, any>) =>
|
||||
filter.update('keywords', ImmutableList(), keywords =>
|
||||
keywords.map(normalizeFilterKeyword),
|
||||
@@ -37,6 +46,7 @@ const normalizeStatuses = (filter: ImmutableMap<string, any>) =>
|
||||
export const normalizeFilter = (filter: Record<string, any>) =>
|
||||
FilterRecord(
|
||||
ImmutableMap(fromJS(filter)).withMutations(filter => {
|
||||
if (filter.has('phrase')) normalizeFilterV1(filter);
|
||||
normalizeKeywords(filter);
|
||||
normalizeStatuses(filter);
|
||||
}),
|
||||
|
||||
@@ -12,7 +12,6 @@ export { EmojiReactionRecord } from './emoji-reaction';
|
||||
export { FilterRecord, normalizeFilter } from './filter';
|
||||
export { FilterKeywordRecord, normalizeFilterKeyword } from './filter-keyword';
|
||||
export { FilterStatusRecord, normalizeFilterStatus } from './filter-status';
|
||||
export { FilterV1Record, normalizeFilterV1 } from './filter-v1';
|
||||
export { GroupRecord, normalizeGroup } from './group';
|
||||
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
|
||||
export { HistoryRecord, normalizeHistory } from './history';
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { normalizeFilterV1 } from 'soapbox/normalizers';
|
||||
import { normalizeFilter } from 'soapbox/normalizers';
|
||||
|
||||
import { FILTERS_V1_FETCH_SUCCESS } from '../actions/filters';
|
||||
import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity, FilterV1 as FilterV1Entity } from 'soapbox/types/entities';
|
||||
import type { APIEntity, Filter as FilterEntity } from 'soapbox/types/entities';
|
||||
|
||||
type State = ImmutableList<FilterV1Entity>;
|
||||
type State = ImmutableList<FilterEntity>;
|
||||
|
||||
const importFiltersV1 = (_state: State, filters: APIEntity[]): State => {
|
||||
return ImmutableList(filters.map((filter) => normalizeFilterV1(filter)));
|
||||
};
|
||||
const importFilters = (_state: State, filters: APIEntity[]): State =>
|
||||
ImmutableList(filters.map((filter) => normalizeFilter(filter)));
|
||||
|
||||
export default function filters(state: State = ImmutableList(), action: AnyAction): State {
|
||||
switch (action.type) {
|
||||
case FILTERS_V1_FETCH_SUCCESS:
|
||||
return importFiltersV1(state, action.filters);
|
||||
case FILTERS_FETCH_SUCCESS:
|
||||
return importFilters(state, action.filters);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { shouldFilter } from 'soapbox/utils/timelines';
|
||||
import type { ContextType } from 'soapbox/normalizers/filter';
|
||||
import type { ReducerChat } from 'soapbox/reducers/chats';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { FilterV1 as FilterV1Entity, Notification } from 'soapbox/types/entities';
|
||||
import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities';
|
||||
|
||||
const normalizeId = (id: any): string => typeof id === 'string' ? id : '';
|
||||
|
||||
@@ -115,45 +115,65 @@ export const getFilters = (state: RootState, query: FilterContext) => {
|
||||
const escapeRegExp = (string: string) =>
|
||||
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
|
||||
export const regexFromFilters = (filters: ImmutableList<FilterV1Entity>) => {
|
||||
export const regexFromFilters = (filters: ImmutableList<FilterEntity>) => {
|
||||
if (filters.size === 0) return null;
|
||||
|
||||
return new RegExp(filters.map(filter => {
|
||||
let expr = escapeRegExp(filter.phrase);
|
||||
return new RegExp(filters.map(filter =>
|
||||
filter.keywords.map(keyword => {
|
||||
let expr = escapeRegExp(keyword.keyword);
|
||||
|
||||
if (filter.whole_word) {
|
||||
if (/^[\w]/.test(expr)) {
|
||||
expr = `\\b${expr}`;
|
||||
if (keyword.whole_word) {
|
||||
if (/^[\w]/.test(expr)) {
|
||||
expr = `\\b${expr}`;
|
||||
}
|
||||
|
||||
if (/[\w]$/.test(expr)) {
|
||||
expr = `${expr}\\b`;
|
||||
}
|
||||
}
|
||||
|
||||
if (/[\w]$/.test(expr)) {
|
||||
expr = `${expr}\\b`;
|
||||
}
|
||||
}
|
||||
|
||||
return expr;
|
||||
}).join('|'), 'i');
|
||||
return expr;
|
||||
}).join('|'),
|
||||
).join('|'), 'i');
|
||||
};
|
||||
|
||||
const checkFiltered = (index: string, filters: ImmutableList<FilterV1Entity>) =>
|
||||
filters.reduce((result, filter) => {
|
||||
let expr = escapeRegExp(filter.phrase);
|
||||
const checkFiltered = (index: string, filters: ImmutableList<FilterEntity>) =>
|
||||
filters.reduce((result, filter) =>
|
||||
result.concat(filter.keywords.reduce((result, keyword) => {
|
||||
let expr = escapeRegExp(keyword.keyword);
|
||||
|
||||
if (filter.whole_word) {
|
||||
if (/^[\w]/.test(expr)) {
|
||||
expr = `\\b${expr}`;
|
||||
if (keyword.whole_word) {
|
||||
if (/^[\w]/.test(expr)) {
|
||||
expr = `\\b${expr}`;
|
||||
}
|
||||
|
||||
if (/[\w]$/.test(expr)) {
|
||||
expr = `${expr}\\b`;
|
||||
}
|
||||
}
|
||||
|
||||
if (/[\w]$/.test(expr)) {
|
||||
expr = `${expr}\\b`;
|
||||
}
|
||||
}
|
||||
const regex = new RegExp(expr);
|
||||
|
||||
const regex = new RegExp(expr);
|
||||
if (regex.test(index)) return result.concat(filter.title);
|
||||
return result;
|
||||
}, ImmutableList<string>())), ImmutableList<string>());
|
||||
// const results =
|
||||
// let expr = escapeRegExp(filter.phrase);
|
||||
|
||||
if (regex.test(index)) return result.push(filter.phrase);
|
||||
return result;
|
||||
}, ImmutableList<string>());
|
||||
// if (filter.whole_word) {
|
||||
// if (/^[\w]/.test(expr)) {
|
||||
// expr = `\\b${expr}`;
|
||||
// }
|
||||
|
||||
// if (/[\w]$/.test(expr)) {
|
||||
// expr = `${expr}\\b`;
|
||||
// }
|
||||
// }
|
||||
|
||||
// const regex = new RegExp(expr);
|
||||
|
||||
// if (regex.test(index)) return result.join(filter.phrase);
|
||||
// return result;
|
||||
|
||||
type APIStatus = { id: string, username?: string };
|
||||
|
||||
@@ -194,7 +214,7 @@ export const makeGetStatus = () => {
|
||||
// @ts-ignore
|
||||
map.set('group', group || null);
|
||||
|
||||
if (features.filters && (accountReblog || accountBase).id !== me) {
|
||||
if ((features.filters || features.filtersV2) && (accountReblog || accountBase).id !== me) {
|
||||
const filtered = checkFiltered(statusReblog?.search_index || statusBase.search_index, filters);
|
||||
|
||||
map.set('filtered', filtered);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
FilterRecord,
|
||||
FilterKeywordRecord,
|
||||
FilterStatusRecord,
|
||||
FilterV1Record,
|
||||
GroupRecord,
|
||||
GroupRelationshipRecord,
|
||||
HistoryRecord,
|
||||
@@ -49,7 +48,6 @@ type Field = ReturnType<typeof FieldRecord>;
|
||||
type Filter = ReturnType<typeof FilterRecord>;
|
||||
type FilterKeyword = ReturnType<typeof FilterKeywordRecord>;
|
||||
type FilterStatus = ReturnType<typeof FilterStatusRecord>;
|
||||
type FilterV1 = ReturnType<typeof FilterV1Record>;
|
||||
type Group = ReturnType<typeof GroupRecord>;
|
||||
type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>;
|
||||
type History = ReturnType<typeof HistoryRecord>;
|
||||
@@ -97,7 +95,6 @@ export {
|
||||
Filter,
|
||||
FilterKeyword,
|
||||
FilterStatus,
|
||||
FilterV1,
|
||||
Group,
|
||||
GroupRelationship,
|
||||
History,
|
||||
|
||||
Reference in New Issue
Block a user