Merge branch 'filters-v2' into 'develop'

filters v2

See merge request soapbox-pub/soapbox!2321
This commit is contained in:
marcin mikołajczak 2023-03-10 11:03:46 +00:00
commit b1471be142
28 changed files with 818 additions and 262 deletions

View file

@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Posts: Support posts filtering on recent Mastodon versions
### Changed ### Changed
- Posts: truncate Nostr pubkeys in reply mentions. - Posts: truncate Nostr pubkeys in reply mentions.

View file

@ -75,7 +75,7 @@ One disadvantage of this approach is that it does not help the software spread.
© Alex Gleason & other Soapbox contributors © Alex Gleason & other Soapbox contributors
© Eugen Rochko & other Mastodon contributors © Eugen Rochko & other Mastodon contributors
© Trump Media & Technology Group © Trump Media & Technology Group
© Gab AI, Inc. © Gab AI, Inc.
Soapbox is free software: you can redistribute it and/or modify Soapbox is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by

View file

@ -2,4 +2,4 @@
- verified.svg - Created by Alex Gleason. CC0 - verified.svg - Created by Alex Gleason. CC0
Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg

View file

@ -12,10 +12,18 @@ const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
const FILTER_FETCH_REQUEST = 'FILTER_FETCH_REQUEST';
const FILTER_FETCH_SUCCESS = 'FILTER_FETCH_SUCCESS';
const FILTER_FETCH_FAIL = 'FILTER_FETCH_FAIL';
const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
const FILTERS_UPDATE_REQUEST = 'FILTERS_UPDATE_REQUEST';
const FILTERS_UPDATE_SUCCESS = 'FILTERS_UPDATE_SUCCESS';
const FILTERS_UPDATE_FAIL = 'FILTERS_UPDATE_FAIL';
const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST';
const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS';
const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL';
@ -25,22 +33,16 @@ const messages = defineMessages({
removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' },
}); });
const fetchFilters = () => type FilterKeywords = { keyword: string, whole_word: boolean }[];
const fetchFiltersV1 = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (!features.filters) return;
dispatch({ dispatch({
type: FILTERS_FETCH_REQUEST, type: FILTERS_FETCH_REQUEST,
skipLoading: true, skipLoading: true,
}); });
api(getState) return api(getState)
.get('/api/v1/filters') .get('/api/v1/filters')
.then(({ data }) => dispatch({ .then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS, type: FILTERS_FETCH_SUCCESS,
@ -55,15 +57,105 @@ const fetchFilters = () =>
})); }));
}; };
const createFilter = (phrase: string, expires_at: string, context: Array<string>, whole_word: boolean, irreversible: boolean) => const fetchFiltersV2 = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTERS_FETCH_REQUEST,
skipLoading: true,
});
return api(getState)
.get('/api/v2/filters')
.then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTERS_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilters = (fromFiltersPage = false) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2 && fromFiltersPage) return dispatch(fetchFiltersV2());
if (features.filters) return dispatch(fetchFiltersV1());
};
const fetchFilterV1 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTER_FETCH_REQUEST,
skipLoading: true,
});
return api(getState)
.get(`/api/v1/filters/${id}`)
.then(({ data }) => dispatch({
type: FILTER_FETCH_SUCCESS,
filter: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTER_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilterV2 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTER_FETCH_REQUEST,
skipLoading: true,
});
return api(getState)
.get(`/api/v2/filters/${id}`)
.then(({ data }) => dispatch({
type: FILTER_FETCH_SUCCESS,
filter: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTER_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilter = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(fetchFilterV2(id));
if (features.filters) return dispatch(fetchFilterV1(id));
};
const createFilterV1 = (title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_CREATE_REQUEST }); dispatch({ type: FILTERS_CREATE_REQUEST });
return api(getState).post('/api/v1/filters', { return api(getState).post('/api/v1/filters', {
phrase, phrase: keywords[0].keyword,
context, context,
irreversible, irreversible: hide,
whole_word, whole_word: keywords[0].whole_word,
expires_at, expires_in,
}).then(response => { }).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
toast.success(messages.added); toast.success(messages.added);
@ -72,7 +164,80 @@ const createFilter = (phrase: string, expires_at: string, context: Array<string>
}); });
}; };
const deleteFilter = (id: string) => const createFilterV2 = (title: string, expires_in: string | null, 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_in,
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_in: string | null, 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_in, context, hide, keywords));
return dispatch(createFilterV1(title, expires_in, context, hide, keywords));
};
const updateFilterV1 = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_UPDATE_REQUEST });
return api(getState).patch(`/api/v1/filters/${id}`, {
phrase: keywords[0].keyword,
context,
irreversible: hide,
whole_word: keywords[0].whole_word,
expires_in,
}).then(response => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data });
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_UPDATE_FAIL, error });
});
};
const updateFilterV2 = (id: string, title: string, expires_in: string | null, context: Array<string>, hide: boolean, keywords_attributes: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_UPDATE_REQUEST });
return api(getState).patch(`/api/v2/filters/${id}`, {
title,
context,
filter_action: hide ? 'hide' : 'warn',
expires_in,
keywords_attributes,
}).then(response => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data });
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_UPDATE_FAIL, error });
});
};
const updateFilter = (id: string, title: string, expires_in: string | null, 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(updateFilterV2(id, title, expires_in, context, hide, keywords));
return dispatch(updateFilterV1(id, title, expires_in, context, hide, keywords));
};
const deleteFilterV1 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_DELETE_REQUEST }); dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete(`/api/v1/filters/${id}`).then(response => { return api(getState).delete(`/api/v1/filters/${id}`).then(response => {
@ -83,17 +248,47 @@ 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 { export {
FILTERS_FETCH_REQUEST, FILTERS_FETCH_REQUEST,
FILTERS_FETCH_SUCCESS, FILTERS_FETCH_SUCCESS,
FILTERS_FETCH_FAIL, FILTERS_FETCH_FAIL,
FILTER_FETCH_REQUEST,
FILTER_FETCH_SUCCESS,
FILTER_FETCH_FAIL,
FILTERS_CREATE_REQUEST, FILTERS_CREATE_REQUEST,
FILTERS_CREATE_SUCCESS, FILTERS_CREATE_SUCCESS,
FILTERS_CREATE_FAIL, FILTERS_CREATE_FAIL,
FILTERS_UPDATE_REQUEST,
FILTERS_UPDATE_SUCCESS,
FILTERS_UPDATE_FAIL,
FILTERS_DELETE_REQUEST, FILTERS_DELETE_REQUEST,
FILTERS_DELETE_SUCCESS, FILTERS_DELETE_SUCCESS,
FILTERS_DELETE_FAIL, FILTERS_DELETE_FAIL,
fetchFilters, fetchFilters,
fetchFilter,
createFilter, createFilter,
updateFilter,
deleteFilter, deleteFilter,
}; };

View file

@ -48,6 +48,8 @@ const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const STATUS_UNFILTER = 'STATUS_UNFILTER';
const statusExists = (getState: () => RootState, statusId: string) => { const statusExists = (getState: () => RootState, statusId: string) => {
return (getState().statuses.get(statusId) || null) !== null; return (getState().statuses.get(statusId) || null) !== null;
}; };
@ -335,6 +337,11 @@ const undoStatusTranslation = (id: string) => ({
id, id,
}); });
const unfilterStatus = (id: string) => ({
type: STATUS_UNFILTER,
id,
});
export { export {
STATUS_CREATE_REQUEST, STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS, STATUS_CREATE_SUCCESS,
@ -363,6 +370,7 @@ export {
STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL, STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
createStatus, createStatus,
editStatus, editStatus,
fetchStatus, fetchStatus,
@ -381,4 +389,5 @@ export {
toggleStatusHidden, toggleStatusHidden,
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
unfilterStatus,
}; };

View file

@ -297,7 +297,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/> />
)} )}
{features.filters && ( {(features.filters || features.filtersV2) && (
<SidebarLink <SidebarLink
to='/filters' to='/filters'
icon={require('@tabler/icons/filter.svg')} icon={require('@tabler/icons/filter.svg')}

View file

@ -7,7 +7,7 @@ import { useHistory } from 'react-router-dom';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose'; import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden } from 'soapbox/actions/statuses'; import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import TranslateButton from 'soapbox/components/translate-button'; import TranslateButton from 'soapbox/components/translate-button';
import AccountContainer from 'soapbox/containers/account-container'; import AccountContainer from 'soapbox/containers/account-container';
@ -93,6 +93,8 @@ const Status: React.FC<IStatus> = (props) => {
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`; const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
const group = actualStatus.group as GroupEntity | null; const group = actualStatus.group as GroupEntity | null;
const filtered = (status.filtered.size || actualStatus.filtered.size) > 0;
// Track height changes we know about to compensate scrolling. // Track height changes we know about to compensate scrolling.
useEffect(() => { useEffect(() => {
didShowCard.current = Boolean(!muted && !hidden && status?.card); didShowCard.current = Boolean(!muted && !hidden && status?.card);
@ -202,6 +204,8 @@ const Status: React.FC<IStatus> = (props) => {
_expandEmojiSelector(); _expandEmojiSelector();
}; };
const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.size ? status.id : actualStatus.id));
const _expandEmojiSelector = (): void => { const _expandEmojiSelector = (): void => {
const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji?.focus(); firstEmoji?.focus();
@ -281,7 +285,7 @@ const Status: React.FC<IStatus> = (props) => {
); );
} }
if (status.filtered || actualStatus.filtered) { if (filtered && status.showFiltered) {
const minHandlers = muted ? undefined : { const minHandlers = muted ? undefined : {
moveUp: handleHotkeyMoveUp, moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown, moveDown: handleHotkeyMoveDown,
@ -291,7 +295,11 @@ const Status: React.FC<IStatus> = (props) => {
<HotKeys handlers={minHandlers}> <HotKeys handlers={minHandlers}>
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}> <div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<Text theme='muted'> <Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' /> <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {status.filtered.join(', ')}.
{' '}
<button className='text-primary-600 hover:underline dark:text-accent-blue' onClick={handleUnfilter}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
</button>
</Text> </Text>
</div> </div>
</HotKeys> </HotKeys>

View file

@ -33,6 +33,8 @@ interface IStreamfield {
onChange: (values: any[]) => void onChange: (values: any[]) => void
/** Input to render for each value. */ /** Input to render for each value. */
component: StreamfieldComponent<any> component: StreamfieldComponent<any>
/** Minimum number of allowed inputs. */
minItems?: number
/** Maximum number of allowed inputs. */ /** Maximum number of allowed inputs. */
maxItems?: number maxItems?: number
} }
@ -47,6 +49,7 @@ const Streamfield: React.FC<IStreamfield> = ({
onChange, onChange,
component: Component, component: Component,
maxItems = Infinity, maxItems = Infinity,
minItems = 0,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@ -67,10 +70,10 @@ const Streamfield: React.FC<IStreamfield> = ({
{(values.length > 0) && ( {(values.length > 0) && (
<Stack> <Stack>
{values.map((value, i) => ( {values.map((value, i) => value?._destroy ? null : (
<HStack space={2} alignItems='center'> <HStack space={2} alignItems='center'>
<Component key={i} onChange={handleChange(i)} value={value} /> <Component key={i} onChange={handleChange(i)} value={value} />
{onRemoveItem && ( {values.length > minItems && onRemoveItem && (
<IconButton <IconButton
iconClassName='h-4 w-4' iconClassName='h-4 w-4'
className='bg-transparent text-gray-400 hover:text-gray-600' className='bg-transparent text-gray-400 hover:text-gray-600'

View file

@ -3,7 +3,7 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security'; import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security';
import { Button, Card, CardBody, CardHeader, CardTitle, Column, Spinner, Stack, Text } from 'soapbox/components/ui'; import { Button, Card, CardBody, CardHeader, CardTitle, Column, HStack, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Token } from 'soapbox/reducers/security'; import { Token } from 'soapbox/reducers/security';
@ -59,12 +59,11 @@ const AuthToken: React.FC<IAuthToken> = ({ token, isCurrent }) => {
</Text> </Text>
)} )}
</Stack> </Stack>
<HStack justifyContent='end'>
<div className='flex justify-end'>
<Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}> <Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}>
{intl.formatMessage(messages.revoke)} {intl.formatMessage(messages.revoke)}
</Button> </Button>
</div> </HStack>
</Stack> </Stack>
</div> </div>
); );

View file

@ -0,0 +1,286 @@
import React, { useEffect, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { createFilter, fetchFilter, updateFilter } from 'soapbox/actions/filters';
import List, { ListItem } from 'soapbox/components/list';
import MissingIndicator from 'soapbox/components/missing-indicator';
import { Button, Column, Form, FormActions, FormGroup, HStack, Input, Stack, Streamfield, Text, Toggle } from 'soapbox/components/ui';
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
import { normalizeFilter } from 'soapbox/normalizers';
import toast from 'soapbox/toast';
import { SelectDropdown } from '../forms';
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
interface IFilterField {
id?: string
keyword: string
whole_word: boolean
_destroy?: boolean
}
interface IEditFilter {
params: { id?: string }
}
const messages = defineMessages({
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' },
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' },
add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' },
edit: { id: 'column.filters.edit', defaultMessage: 'Edit Filter' },
create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' },
expiration_never: { id: 'colum.filters.expiration.never', defaultMessage: 'Never' },
expiration_1800: { id: 'colum.filters.expiration.1800', defaultMessage: '30 minutes' },
expiration_3600: { id: 'colum.filters.expiration.3600', defaultMessage: '1 hour' },
expiration_21600: { id: 'colum.filters.expiration.21600', defaultMessage: '6 hours' },
expiration_43200: { id: 'colum.filters.expiration.43200', defaultMessage: '12 hours' },
expiration_86400: { id: 'colum.filters.expiration.86400', defaultMessage: '1 day' },
expiration_604800: { id: 'colum.filters.expiration.604800', defaultMessage: '1 week' },
});
const FilterField: StreamfieldComponent<IFilterField> = ({ value, onChange }) => {
const intl = useIntl();
const handleChange = (key: string): React.ChangeEventHandler<HTMLInputElement> =>
e => 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 EditFilter: React.FC<IEditFilter> = ({ params }) => {
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const features = useFeatures();
const [loading, setLoading] = useState(false);
const [notFound, setNotFound] = useState(false);
const [title, setTitle] = useState('');
const [expiresIn, setExpiresIn] = useState<string | null>(null);
const [homeTimeline, setHomeTimeline] = useState(true);
const [publicTimeline, setPublicTimeline] = useState(false);
const [notifications, setNotifications] = useState(false);
const [conversations, setConversations] = useState(false);
const [accounts, setAccounts] = useState(false);
const [hide, setHide] = useState(false);
const [keywords, setKeywords] = useState<IFilterField[]>([{ keyword: '', whole_word: false }]);
const expirations = useMemo(() => ({
'': intl.formatMessage(messages.expiration_never),
1800: intl.formatMessage(messages.expiration_1800),
3600: intl.formatMessage(messages.expiration_3600),
21600: intl.formatMessage(messages.expiration_21600),
43200: intl.formatMessage(messages.expiration_43200),
86400: intl.formatMessage(messages.expiration_86400),
604800: intl.formatMessage(messages.expiration_604800),
}), []);
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
setExpiresIn(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');
}
if (accounts) {
context.push('account');
}
dispatch(params.id
? updateFilter(params.id, title, expiresIn, context, hide, keywords)
: createFilter(title, expiresIn, context, hide, keywords)).then(() => {
history.push('/filters');
}).catch(() => {
toast.error(intl.formatMessage(messages.create_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[i].id
? keywords.map((keyword, index) => index === i ? { ...keyword, _destroy: true } : keyword)
: keywords.filter((_, index) => index !== i));
useEffect(() => {
if (params.id) {
setLoading(true);
dispatch(fetchFilter(params.id))?.then((res: any) => {
if (res.filter) {
const filter = normalizeFilter(res.filter);
setTitle(filter.title);
setHomeTimeline(filter.context.includes('home'));
setPublicTimeline(filter.context.includes('public'));
setNotifications(filter.context.includes('notifications'));
setConversations(filter.context.includes('thread'));
setAccounts(filter.context.includes('account'));
setHide(filter.filter_action === 'hide');
setKeywords(filter.keywords.toJS());
} else {
setNotFound(true);
}
setLoading(false);
});
}
}, [params.id]);
if (notFound) return <MissingIndicator />;
return (
<Column className='filter-settings-panel' label={intl.formatMessage(messages.subheading_add_new)}>
<Form onSubmit={handleAddNew}>
<FormGroup labelText={intl.formatMessage(messages.title)}>
<Input
required
type='text'
name='title'
value={title}
onChange={({ target }) => setTitle(target.value)}
/>
</FormGroup>
{features.filtersExpiration && (
<FormGroup labelText={intl.formatMessage(messages.expires)}>
<SelectDropdown
items={expirations}
defaultValue={''}
onChange={handleSelectChange}
/>
</FormGroup>
)}
<Stack>
<Text size='sm' weight='medium'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text>
<Text size='xs' theme='muted'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text>
</Stack>
<List>
<ListItem label={intl.formatMessage(messages.home_timeline)}>
<Toggle
name='home_timeline'
checked={homeTimeline}
onChange={({ target }) => setHomeTimeline(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.public_timeline)}>
<Toggle
name='public_timeline'
checked={publicTimeline}
onChange={({ target }) => setPublicTimeline(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.notifications)}>
<Toggle
name='notifications'
checked={notifications}
onChange={({ target }) => setNotifications(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.conversations)}>
<Toggle
name='conversations'
checked={conversations}
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(features.filtersV2 ? messages.hide_header : messages.drop_header)}
hint={intl.formatMessage(features.filtersV2 ? messages.hide_hint : messages.drop_hint)}
>
<Toggle
name='hide'
checked={hide}
onChange={({ target }) => setHide(target.checked)}
/>
</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' disabled={loading}>
{intl.formatMessage(params.id ? messages.edit : messages.add_new)}
</Button>
</FormActions>
</Form>
</Column>
);
};
export default EditFilter;

View file

@ -1,31 +1,23 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters'; import { fetchFilters, deleteFilter } from 'soapbox/actions/filters';
import List, { ListItem } from 'soapbox/components/list'; import RelativeTimestamp from 'soapbox/components/relative-timestamp';
import ScrollableList from 'soapbox/components/scrollable-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 { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.filters', defaultMessage: 'Muted words' }, 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' }, home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' },
public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' },
notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' }, notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' },
conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' }, conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' },
drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' }, accounts: { id: 'column.filters.accounts', defaultMessage: 'Accounts' },
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' }, delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' },
subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' }, edit: { id: 'column.filters.edit', defaultMessage: 'Edit' },
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
}); });
@ -34,167 +26,44 @@ const contexts = {
public: messages.public_timeline, public: messages.public_timeline,
notifications: messages.notifications, notifications: messages.notifications,
thread: messages.conversations, thread: messages.conversations,
account: messages.accounts,
}; };
// const expirations = {
// null: 'Never',
// // 3600: '30 minutes',
// // 21600: '1 hour',
// // 43200: '12 hours',
// // 86400 : '1 day',
// // 604800: '1 week',
// };
const Filters = () => { const Filters = () => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const history = useHistory();
const { filtersV2 } = useFeatures();
const filters = useAppSelector((state) => state.filters); const filters = useAppSelector((state) => state.filters);
const [phrase, setPhrase] = useState(''); const handleFilterEdit = (id: string) => () => history.push(`/filters/${id}`);
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(() => {
return dispatch(fetchFilters());
}).catch(error => {
toast.error(intl.formatMessage(messages.create_error));
});
};
const handleFilterDelete = (id: string) => () => { const handleFilterDelete = (id: string) => () => {
dispatch(deleteFilter(id)).then(() => { dispatch(deleteFilter(id)).then(() => {
return dispatch(fetchFilters()); return dispatch(fetchFilters(true));
}).catch(() => { }).catch(() => {
toast.error(intl.formatMessage(messages.delete_error)); toast.error(intl.formatMessage(messages.delete_error));
}); });
}; };
useEffect(() => { useEffect(() => {
dispatch(fetchFilters()); dispatch(fetchFilters(true));
}, []); }, []);
const emptyMessage = <FormattedMessage id='empty_column.filters' defaultMessage="You haven't created any muted words yet." />; const emptyMessage = <FormattedMessage id='empty_column.filters' defaultMessage="You haven't created any muted words yet." />;
return ( return (
<Column className='filter-settings-panel' label={intl.formatMessage(messages.heading)}> <Column className='filter-settings-panel' label={intl.formatMessage(messages.heading)}>
<CardHeader> <HStack className='mb-4' space={2} justifyContent='end'>
<CardTitle title={intl.formatMessage(messages.subheading_add_new)} /> <Button
</CardHeader> to='/filters/new'
<Form onSubmit={handleAddNew}> theme='primary'
<FormGroup labelText={intl.formatMessage(messages.keyword)}> size='sm'
<Input >
required <FormattedMessage id='filters.create_filter' defaultMessage='Create filter' />
type='text' </Button>
name='phrase' </HStack>
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> */}
<Stack>
<Text size='sm' weight='medium'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text>
<Text size='xs' theme='muted'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text>
</Stack>
<List>
<ListItem label={intl.formatMessage(messages.home_timeline)}>
<Toggle
name='home_timeline'
checked={homeTimeline}
onChange={({ target }) => setHomeTimeline(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.public_timeline)}>
<Toggle
name='public_timeline'
checked={publicTimeline}
onChange={({ target }) => setPublicTimeline(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.notifications)}>
<Toggle
name='notifications'
checked={notifications}
onChange={({ target }) => setNotifications(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.conversations)}>
<Toggle
name='conversations'
checked={conversations}
onChange={({ target }) => setConversations(target.checked)}
/>
</ListItem>
</List>
<List>
<ListItem
label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)}
>
<Toggle
name='irreversible'
checked={irreversible}
onChange={({ target }) => setIrreversible(target.checked)}
/>
</ListItem>
<ListItem
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
>
<Toggle
name='whole_word'
checked={wholeWord}
onChange={({ target }) => setWholeWord(target.checked)}
/>
</ListItem>
</List>
<FormActions>
<Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button>
</FormActions>
</Form>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.subheading_filters)} />
</CardHeader>
<ScrollableList <ScrollableList
scrollKey='filters' scrollKey='filters'
@ -202,38 +71,48 @@ const Filters = () => {
itemClassName='pb-4 last:pb-0' itemClassName='pb-4 last:pb-0'
> >
{filters.map((filter, i) => ( {filters.map((filter, i) => (
<HStack space={1} justifyContent='between'> <div className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
<Stack space={1}> <Stack space={2}>
<Text weight='medium'> <Stack className='grow' space={1}>
<FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' />
{' '}
<Text theme='muted' tag='span'>{filter.phrase}</Text>
</Text>
<Text weight='medium'>
<FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' />
{' '}
<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_phrases_label' defaultMessage='Keywords or phrases:' />
<FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> : {' '}
<FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />} <Text theme='muted' tag='span'>{filter.keywords.map(keyword => keyword.keyword).join(', ')}</Text>
</Text> </Text>
{filter.whole_word && ( <Text weight='medium'>
<FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' />
{' '}
<Text theme='muted' tag='span'>{filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')}</Text>
</Text>
<HStack space={4} wrap>
<Text weight='medium'> <Text weight='medium'>
<FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' /> {filtersV2 ? (
filter.filter_action === 'hide' ?
<FormattedMessage id='filters.filters_list_hide_completely' defaultMessage='Hide content' /> :
<FormattedMessage id='filters.filters_list_warn' defaultMessage='Display warning' />
) : (filter.filter_action === 'hide' ?
<FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> :
<FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />)}
</Text> </Text>
)} {filter.expires_at && (
<Text weight='medium'>
{new Date(filter.expires_at).getTime() <= Date.now()
? <FormattedMessage id='filters.filters_list_expired' defaultMessage='Expired' />
: <RelativeTimestamp timestamp={filter.expires_at} className='whitespace-nowrap' futureDate />}
</Text>
)}
</HStack>
</Stack>
<HStack space={2} justifyContent='end'>
<Button theme='primary' onClick={handleFilterEdit(filter.id)}>
{intl.formatMessage(messages.edit)}
</Button>
<Button theme='danger' onClick={handleFilterDelete(filter.id)}>
{intl.formatMessage(messages.delete)}
</Button>
</HStack> </HStack>
</Stack> </Stack>
<IconButton </div>
iconClassName='h-5 w-5 text-gray-700 hover:text-gray-800 dark:text-gray-600 dark:hover:text-gray-500'
src={require('@tabler/icons/trash.svg')}
onClick={handleFilterDelete(filter.id)}
title={intl.formatMessage(messages.delete)}
/>
</HStack>
))} ))}
</ScrollableList> </ScrollableList>
</Column> </Column>

View file

@ -45,7 +45,7 @@ const LinkFooter: React.FC = (): JSX.Element => {
)} )}
<FooterLink to='/blocks'><FormattedMessage id='navigation_bar.blocks' defaultMessage='Blocks' /></FooterLink> <FooterLink to='/blocks'><FormattedMessage id='navigation_bar.blocks' defaultMessage='Blocks' /></FooterLink>
<FooterLink to='/mutes'><FormattedMessage id='navigation_bar.mutes' defaultMessage='Mutes' /></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> <FooterLink to='/filters'><FormattedMessage id='navigation_bar.filters' defaultMessage='Filters' /></FooterLink>
)} )}
{features.federating && ( {features.federating && (

View file

@ -96,7 +96,7 @@ const EditAnnouncementModal: React.FC<IEditAnnouncementModal> = ({ onClose }) =>
/>)} />)}
</BundleContainer> </BundleContainer>
</FormGroup> </FormGroup>
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2}>
<Toggle <Toggle
icons={false} icons={false}
checked={allDay} checked={allDay}

View file

@ -66,6 +66,7 @@ import {
DomainBlocks, DomainBlocks,
Mutes, Mutes,
Filters, Filters,
EditFilter,
PinnedStatuses, PinnedStatuses,
Search, Search,
ListTimeline, ListTimeline,
@ -267,7 +268,9 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} /> <WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} />
{features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />} {features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />}
<WrappedRoute path='/mutes' page={DefaultPage} component={Mutes} 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/new' page={DefaultPage} component={EditFilter} content={children} />}
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters/:id' page={DefaultPage} component={EditFilter} 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' 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/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} /> <WrappedRoute path='/@:username/followers' publicRoute={!authenticatedProfile} component={Followers} page={ProfilePage} content={children} />

View file

@ -102,6 +102,10 @@ export function Filters() {
return import(/* webpackChunkName: "features/filters" */'../../filters'); return import(/* webpackChunkName: "features/filters" */'../../filters');
} }
export function EditFilter() {
return import(/* webpackChunkName: "features/filters" */'../../filters/edit-filter');
}
export function ReportModal() { export function ReportModal() {
return import(/* webpackChunkName: "modals/report-modal/report-modal" */'../components/modals/report-modal/report-modal'); return import(/* webpackChunkName: "modals/report-modal/report-modal" */'../components/modals/report-modal/report-modal');
} }

View file

@ -283,6 +283,13 @@
"chats.main.blankslate_with_chats.subtitle": "Select from one of your open chats or create a new message.", "chats.main.blankslate_with_chats.subtitle": "Select from one of your open chats or create a new message.",
"chats.main.blankslate_with_chats.title": "Select a chat", "chats.main.blankslate_with_chats.title": "Select a chat",
"chats.search_placeholder": "Start a chat with…", "chats.search_placeholder": "Start a chat with…",
"colum.filters.expiration.1800": "30 minutes",
"colum.filters.expiration.21600": "6 hours",
"colum.filters.expiration.3600": "1 hour",
"colum.filters.expiration.43200": "12 hours",
"colum.filters.expiration.604800": "1 week",
"colum.filters.expiration.86400": "1 day",
"colum.filters.expiration.never": "Never",
"column.admin.announcements": "Announcements", "column.admin.announcements": "Announcements",
"column.admin.awaiting_approval": "Awaiting Approval", "column.admin.awaiting_approval": "Awaiting Approval",
"column.admin.create_announcement": "Create announcement", "column.admin.create_announcement": "Create announcement",
@ -321,6 +328,7 @@
"column.favourites": "Likes", "column.favourites": "Likes",
"column.federation_restrictions": "Federation Restrictions", "column.federation_restrictions": "Federation Restrictions",
"column.filters": "Muted words", "column.filters": "Muted words",
"column.filters.accounts": "Accounts",
"column.filters.add_new": "Add New Filter", "column.filters.add_new": "Add New Filter",
"column.filters.conversations": "Conversations", "column.filters.conversations": "Conversations",
"column.filters.create_error": "Error adding filter", "column.filters.create_error": "Error adding filter",
@ -328,16 +336,18 @@
"column.filters.delete_error": "Error deleting filter", "column.filters.delete_error": "Error deleting filter",
"column.filters.drop_header": "Drop instead of hide", "column.filters.drop_header": "Drop instead of hide",
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed", "column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
"column.filters.edit": "Edit",
"column.filters.expires": "Expire after", "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.home_timeline": "Home timeline",
"column.filters.keyword": "Keyword or phrase", "column.filters.keyword": "Keyword or phrase",
"column.filters.keywords": "Keywords or phrases",
"column.filters.notifications": "Notifications", "column.filters.notifications": "Notifications",
"column.filters.public_timeline": "Public timeline", "column.filters.public_timeline": "Public timeline",
"column.filters.subheading_add_new": "Add New Filter", "column.filters.subheading_add_new": "Add New Filter",
"column.filters.subheading_filters": "Current Filters", "column.filters.title": "Title",
"column.filters.whole_word_header": "Whole word", "column.filters.whole_word": "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", "column.follow_requests": "Follow requests",
"column.followers": "Followers", "column.followers": "Followers",
"column.following": "Following", "column.following": "Following",
@ -740,11 +750,14 @@
"filters.added": "Filter added.", "filters.added": "Filter added.",
"filters.context_header": "Filter contexts", "filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply", "filters.context_hint": "One or multiple contexts where the filter should apply",
"filters.create_filter": "Create filter",
"filters.filters_list_context_label": "Filter contexts:", "filters.filters_list_context_label": "Filter contexts:",
"filters.filters_list_drop": "Drop", "filters.filters_list_drop": "Drop",
"filters.filters_list_expired": "Expired",
"filters.filters_list_hide": "Hide", "filters.filters_list_hide": "Hide",
"filters.filters_list_phrase_label": "Keyword or phrase:", "filters.filters_list_hide_completely": "Hide content",
"filters.filters_list_whole-word": "Whole word", "filters.filters_list_phrases_label": "Keywords or phrases:",
"filters.filters_list_warn": "Display warning",
"filters.removed": "Filter deleted.", "filters.removed": "Filter deleted.",
"followRecommendations.heading": "Suggested Profiles", "followRecommendations.heading": "Suggested Profiles",
"follow_request.authorize": "Authorize", "follow_request.authorize": "Authorize",
@ -1402,6 +1415,7 @@
"status.sensitive_warning": "Sensitive content", "status.sensitive_warning": "Sensitive content",
"status.sensitive_warning.subtitle": "This content may not be suitable for all audiences.", "status.sensitive_warning.subtitle": "This content may not be suitable for all audiences.",
"status.share": "Share", "status.share": "Share",
"status.show_filter_reason": "Show anyway",
"status.show_less_all": "Show less for all", "status.show_less_all": "Show less for all",
"status.show_more_all": "Show more for all", "status.show_more_all": "Show more for all",
"status.show_original": "Show original", "status.show_original": "Show original",

View file

@ -1271,7 +1271,7 @@
"status.embed": "Osadź", "status.embed": "Osadź",
"status.external": "View post on {domain}", "status.external": "View post on {domain}",
"status.favourite": "Zareaguj", "status.favourite": "Zareaguj",
"status.filtered": "Filtrowany(-a)", "status.filtered": "Filtrowany",
"status.interactions.favourites": "{count, plural, one {Polubienie} few {Polubienia} other {Polubień}}", "status.interactions.favourites": "{count, plural, one {Polubienie} few {Polubienia} other {Polubień}}",
"status.interactions.reblogs": "{count, plural, one {Podanie dalej} few {Podania dalej} other {Podań dalej}}", "status.interactions.reblogs": "{count, plural, one {Podanie dalej} few {Podania dalej} other {Podań dalej}}",
"status.load_more": "Załaduj więcej", "status.load_more": "Załaduj więcej",

View file

@ -0,0 +1,18 @@
/**
* Filter normalizer:
* Converts API filters into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/FilterKeyword/}
*/
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
// https://docs.joinmastodon.org/entities/FilterKeyword/
export const FilterKeywordRecord = ImmutableRecord({
id: '',
keyword: '',
whole_word: false,
});
export const normalizeFilterKeyword = (filterKeyword: Record<string, any>) =>
FilterKeywordRecord(
ImmutableMap(fromJS(filterKeyword)),
);

View file

@ -0,0 +1,22 @@
/**
* Filter normalizer:
* Converts API filters into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/FilterResult/}
*/
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
import { normalizeFilter } from './filter';
import type { Filter } from 'soapbox/types/entities';
// https://docs.joinmastodon.org/entities/FilterResult/
export const FilterResultRecord = ImmutableRecord({
filter: null as Filter | null,
keyword_matches: ImmutableList<string>(),
status_matches: ImmutableList<string>(),
});
export const normalizeFilterResult = (filterResult: Record<string, any>) =>
FilterResultRecord(
ImmutableMap(fromJS(filterResult)).update('filter', (filter: any) => normalizeFilter(filter) as any),
);

View file

@ -0,0 +1,17 @@
/**
* Filter normalizer:
* Converts API filters into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/FilterStatus/}
*/
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
// https://docs.joinmastodon.org/entities/FilterStatus/
export const FilterStatusRecord = ImmutableRecord({
id: '',
status_id: '',
});
export const normalizeFilterStatus = (filterStatus: Record<string, any>) =>
FilterStatusRecord(
ImmutableMap(fromJS(filterStatus)),
);

View file

@ -5,20 +5,49 @@
*/ */
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
export type ContextType = 'home' | 'public' | 'notifications' | 'thread'; import { FilterKeyword, FilterStatus } from 'soapbox/types/entities';
import { normalizeFilterKeyword } from './filter-keyword';
import { normalizeFilterStatus } from './filter-status';
export type ContextType = 'home' | 'public' | 'notifications' | 'thread' | 'account';
export type FilterActionType = 'warn' | 'hide';
// https://docs.joinmastodon.org/entities/filter/ // https://docs.joinmastodon.org/entities/filter/
export const FilterRecord = ImmutableRecord({ export const FilterRecord = ImmutableRecord({
id: '', id: '',
phrase: '', title: '',
context: ImmutableList<ContextType>(), context: ImmutableList<ContextType>(),
whole_word: false,
expires_at: '', expires_at: '',
irreversible: false, filter_action: 'warn' as FilterActionType,
keywords: ImmutableList<FilterKeyword>(),
statuses: ImmutableList<FilterStatus>(),
}); });
export const normalizeFilter = (filter: Record<string, any>) => { const normalizeFilterV1 = (filter: ImmutableMap<string, any>) =>
return FilterRecord( filter
ImmutableMap(fromJS(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),
);
const normalizeStatuses = (filter: ImmutableMap<string, any>) =>
filter.update('statuses', ImmutableList(), statuses =>
statuses.map(normalizeFilterStatus),
);
export const normalizeFilter = (filter: Record<string, any>) =>
FilterRecord(
ImmutableMap(fromJS(filter)).withMutations(filter => {
if (filter.has('phrase')) normalizeFilterV1(filter);
normalizeKeywords(filter);
normalizeStatuses(filter);
}),
); );
};

View file

@ -10,6 +10,8 @@ export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
export { EmojiRecord, normalizeEmoji } from './emoji'; export { EmojiRecord, normalizeEmoji } from './emoji';
export { EmojiReactionRecord } from './emoji-reaction'; export { EmojiReactionRecord } from './emoji-reaction';
export { FilterRecord, normalizeFilter } from './filter'; export { FilterRecord, normalizeFilter } from './filter';
export { FilterKeywordRecord, normalizeFilterKeyword } from './filter-keyword';
export { FilterStatusRecord, normalizeFilterStatus } from './filter-status';
export { GroupRecord, normalizeGroup } from './group'; export { GroupRecord, normalizeGroup } from './group';
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship'; export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
export { HistoryRecord, normalizeHistory } from './history'; export { HistoryRecord, normalizeHistory } from './history';

View file

@ -50,6 +50,7 @@ export const StatusRecord = ImmutableRecord({
emojis: ImmutableList<Emoji>(), emojis: ImmutableList<Emoji>(),
favourited: false, favourited: false,
favourites_count: 0, favourites_count: 0,
filtered: ImmutableList<string>(),
group: null as EmbeddedEntity<Group>, group: null as EmbeddedEntity<Group>,
in_reply_to_account_id: null as string | null, in_reply_to_account_id: null as string | null,
in_reply_to_id: null as string | null, in_reply_to_id: null as string | null,
@ -78,9 +79,9 @@ export const StatusRecord = ImmutableRecord({
// Internal fields // Internal fields
contentHtml: '', contentHtml: '',
expectsCard: false, expectsCard: false,
filtered: false,
hidden: false, hidden: false,
search_index: '', search_index: '',
showFiltered: true,
spoilerHtml: '', spoilerHtml: '',
translation: null as ImmutableMap<string, string> | null, translation: null as ImmutableMap<string, string> | null,
}); });
@ -166,11 +167,6 @@ const fixQuote = (status: ImmutableMap<string, any>) => {
}); });
}; };
// Workaround for not yet implemented filtering from Mastodon 3.6
const fixFiltered = (status: ImmutableMap<string, any>) => {
status.delete('filtered');
};
/** If the status contains spoiler text, treat it as sensitive. */ /** If the status contains spoiler text, treat it as sensitive. */
const fixSensitivity = (status: ImmutableMap<string, any>) => { const fixSensitivity = (status: ImmutableMap<string, any>) => {
if (status.get('spoiler_text')) { if (status.get('spoiler_text')) {
@ -214,6 +210,13 @@ const fixContent = (status: ImmutableMap<string, any>) => {
} }
}; };
const normalizeFilterResults = (status: ImmutableMap<string, any>) =>
status.update('filtered', ImmutableList(), filterResults =>
filterResults.map((filterResult: ImmutableMap<string, any>) =>
filterResult.getIn(['filter', 'title']),
),
);
export const normalizeStatus = (status: Record<string, any>) => { export const normalizeStatus = (status: Record<string, any>) => {
return StatusRecord( return StatusRecord(
ImmutableMap(fromJS(status)).withMutations(status => { ImmutableMap(fromJS(status)).withMutations(status => {
@ -225,10 +228,10 @@ export const normalizeStatus = (status: Record<string, any>) => {
fixMentionsOrder(status); fixMentionsOrder(status);
addSelfMention(status); addSelfMention(status);
fixQuote(status); fixQuote(status);
fixFiltered(status);
fixSensitivity(status); fixSensitivity(status);
normalizeEvent(status); normalizeEvent(status);
fixContent(status); fixContent(status);
normalizeFilterResults(status);
}), }),
); );
}; };

View file

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

View file

@ -38,6 +38,7 @@ import {
STATUS_DELETE_FAIL, STATUS_DELETE_FAIL,
STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
} from '../actions/statuses'; } from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
@ -287,6 +288,8 @@ export default function statuses(state = initialState, action: AnyAction): State
return importTranslation(state, action.id, action.translation); return importTranslation(state, action.id, action.translation);
case STATUS_TRANSLATE_UNDO: case STATUS_TRANSLATE_UNDO:
return deleteTranslation(state, action.id); return deleteTranslation(state, action.id);
case STATUS_UNFILTER:
return state.setIn([action.id, 'showFiltered'], false);
case TIMELINE_DELETE: case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references); return deleteStatus(state, action.id, action.references);
case EVENT_JOIN_REQUEST: case EVENT_JOIN_REQUEST:

View file

@ -10,6 +10,7 @@ import { getSettings } from 'soapbox/actions/settings';
import { getDomain } from 'soapbox/utils/accounts'; import { getDomain } from 'soapbox/utils/accounts';
import { validId } from 'soapbox/utils/auth'; import { validId } from 'soapbox/utils/auth';
import ConfigDB from 'soapbox/utils/config-db'; import ConfigDB from 'soapbox/utils/config-db';
import { getFeatures } from 'soapbox/utils/features';
import { shouldFilter } from 'soapbox/utils/timelines'; import { shouldFilter } from 'soapbox/utils/timelines';
import type { ContextType } from 'soapbox/normalizers/filter'; import type { ContextType } from 'soapbox/normalizers/filter';
@ -117,23 +118,63 @@ const escapeRegExp = (string: string) =>
export const regexFromFilters = (filters: ImmutableList<FilterEntity>) => { export const regexFromFilters = (filters: ImmutableList<FilterEntity>) => {
if (filters.size === 0) return null; if (filters.size === 0) return null;
return new RegExp(filters.map(filter => { return new RegExp(filters.map(filter =>
let expr = escapeRegExp(filter.get('phrase')); filter.keywords.map(keyword => {
let expr = escapeRegExp(keyword.keyword);
if (filter.get('whole_word')) { if (keyword.whole_word) {
if (/^[\w]/.test(expr)) { if (/^[\w]/.test(expr)) {
expr = `\\b${expr}`; expr = `\\b${expr}`;
}
if (/[\w]$/.test(expr)) {
expr = `${expr}\\b`;
}
} }
if (/[\w]$/.test(expr)) { return expr;
expr = `${expr}\\b`; }).join('|'),
} ).join('|'), 'i');
}
return expr;
}).join('|'), 'i');
}; };
const checkFiltered = (index: string, filters: ImmutableList<FilterEntity>) =>
filters.reduce((result, filter) =>
result.concat(filter.keywords.reduce((result, keyword) => {
let expr = escapeRegExp(keyword.keyword);
if (keyword.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.concat(filter.title);
return result;
}, ImmutableList<string>())), ImmutableList<string>());
// const results =
// let expr = escapeRegExp(filter.phrase);
// 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 }; type APIStatus = { id: string, username?: string };
export const makeGetStatus = () => { export const makeGetStatus = () => {
@ -147,9 +188,10 @@ export const makeGetStatus = () => {
(_state: RootState, { username }: APIStatus) => username, (_state: RootState, { username }: APIStatus) => username,
getFilters, getFilters,
(state: RootState) => state.me, (state: RootState) => state.me,
(state: RootState) => getFeatures(state.instance),
], ],
(statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me) => { (statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me, features) => {
if (!statusBase || !accountBase) return null; if (!statusBase || !accountBase) return null;
const accountUsername = accountBase.acct; const accountUsername = accountBase.acct;
@ -165,16 +207,18 @@ export const makeGetStatus = () => {
statusReblog = undefined; 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 => { return statusBase.withMutations(map => {
map.set('reblog', statusReblog || null); map.set('reblog', statusReblog || null);
// @ts-ignore :( // @ts-ignore :(
map.set('account', accountBase || null); map.set('account', accountBase || null);
// @ts-ignore // @ts-ignore
map.set('group', group || null); map.set('group', group || null);
map.set('filtered', Boolean(filtered));
if ((features.filters || features.filtersV2) && (accountReblog || accountBase).id !== me) {
const filtered = checkFiltered(statusReblog?.search_index || statusBase.search_index, filters);
map.set('filtered', filtered);
}
}); });
}, },
); );

View file

@ -12,6 +12,8 @@ import {
EmojiReactionRecord, EmojiReactionRecord,
FieldRecord, FieldRecord,
FilterRecord, FilterRecord,
FilterKeywordRecord,
FilterStatusRecord,
GroupRecord, GroupRecord,
GroupRelationshipRecord, GroupRelationshipRecord,
HistoryRecord, HistoryRecord,
@ -44,6 +46,8 @@ type Emoji = ReturnType<typeof EmojiRecord>;
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>; type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
type Field = ReturnType<typeof FieldRecord>; type Field = ReturnType<typeof FieldRecord>;
type Filter = ReturnType<typeof FilterRecord>; type Filter = ReturnType<typeof FilterRecord>;
type FilterKeyword = ReturnType<typeof FilterKeywordRecord>;
type FilterStatus = ReturnType<typeof FilterStatusRecord>;
type Group = ReturnType<typeof GroupRecord>; type Group = ReturnType<typeof GroupRecord>;
type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>; type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>;
type History = ReturnType<typeof HistoryRecord>; type History = ReturnType<typeof HistoryRecord>;
@ -89,6 +93,8 @@ export {
EmojiReaction, EmojiReaction,
Field, Field,
Filter, Filter,
FilterKeyword,
FilterStatus,
Group, Group,
GroupRelationship, GroupRelationship,
History, History,

View file

@ -314,7 +314,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Mastodon's newer solution for direct messaging. * Mastodon's newer solution for direct messaging.
* @see {@link https://docs.joinmastodon.org/methods/timelines/conversations/} * @see {@link https://docs.joinmastodon.org/methods/conversations/}
*/ */
conversations: any([ conversations: any([
v.software === FRIENDICA, v.software === FRIENDICA,
@ -443,16 +443,28 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Can edit and manage timeline filters (aka "muted words"). * Can edit and manage timeline filters (aka "muted words").
* @see {@link https://docs.joinmastodon.org/methods/accounts/filters/} * @see {@link https://docs.joinmastodon.org/methods/filters/#v1}
*/ */
filters: any([ filters: any([
v.software === MASTODON && lt(v.compatVersion, '3.6.0'), v.software === MASTODON && lt(v.compatVersion, '3.6.0'),
v.software === PLEROMA, v.software === PLEROMA,
]), ]),
/** Whether filters can automatically expires. */
filtersExpiration: any([
v.software === MASTODON,
v.software === PLEROMA && gte(v.version, '2.3.0'),
]),
/**
* Can edit and manage timeline filters (aka "muted words").
* @see {@link https://docs.joinmastodon.org/methods/filters/}
*/
filtersV2: v.software === MASTODON && gte(v.compatVersion, '3.6.0'),
/** /**
* Allows setting the focal point of a media attachment. * Allows setting the focal point of a media attachment.
* @see {@link https://docs.joinmastodon.org/methods/statuses/media/} * @see {@link https://docs.joinmastodon.org/methods/media/}
*/ */
focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'), focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'),
@ -528,7 +540,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Can create, view, and manage lists. * Can create, view, and manage lists.
* @see {@link https://docs.joinmastodon.org/methods/timelines/lists/} * @see {@link https://docs.joinmastodon.org/methods/lists/}
* @see GET /api/v1/timelines/list/:list_id * @see GET /api/v1/timelines/list/:list_id
*/ */
lists: any([ lists: any([
@ -643,7 +655,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* A directory of discoverable profiles from the instance. * A directory of discoverable profiles from the instance.
* @see {@link https://docs.joinmastodon.org/methods/instance/directory/} * @see {@link https://docs.joinmastodon.org/methods/directory/}
*/ */
profileDirectory: any([ profileDirectory: any([
v.software === FRIENDICA, v.software === FRIENDICA,
@ -736,7 +748,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Can schedule statuses to be posted at a later time. * Can schedule statuses to be posted at a later time.
* @see POST /api/v1/statuses * @see POST /api/v1/statuses
* @see {@link https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/} * @see {@link https://docs.joinmastodon.org/methods/scheduled_statuses/}
*/ */
scheduledStatuses: any([ scheduledStatuses: any([
v.software === MASTODON && gte(v.version, '2.7.0'), v.software === MASTODON && gte(v.version, '2.7.0'),
@ -788,7 +800,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Can display suggested accounts. * Can display suggested accounts.
* @see {@link https://docs.joinmastodon.org/methods/accounts/suggestions/} * @see {@link https://docs.joinmastodon.org/methods/suggestions/}
*/ */
suggestions: any([ suggestions: any([
v.software === MASTODON && gte(v.compatVersion, '2.4.3'), v.software === MASTODON && gte(v.compatVersion, '2.4.3'),