Merge branch 'filters-v2' into 'develop'
filters v2 See merge request soapbox-pub/soapbox!2321
This commit is contained in:
commit
b1471be142
28 changed files with 818 additions and 262 deletions
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
286
app/soapbox/features/filters/edit-filter.tsx
Normal file
286
app/soapbox/features/filters/edit-filter.tsx
Normal 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;
|
|
@ -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>
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
18
app/soapbox/normalizers/filter-keyword.ts
Normal file
18
app/soapbox/normalizers/filter-keyword.ts
Normal 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)),
|
||||||
|
);
|
22
app/soapbox/normalizers/filter-result.ts
Normal file
22
app/soapbox/normalizers/filter-result.ts
Normal 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),
|
||||||
|
);
|
17
app/soapbox/normalizers/filter-status.ts
Normal file
17
app/soapbox/normalizers/filter-status.ts
Normal 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)),
|
||||||
|
);
|
|
@ -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);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
Loading…
Reference in a new issue