Keyword/phrase filtering (#7905)

* Add keyword filtering

    GET|POST       /api/v1/filters
    GET|PUT|DELETE /api/v1/filters/:id

- Irreversible filters can drop toots from home or notifications
- Other filters can hide toots through the client app
- Filters use a phrase valid in particular contexts, expiration

* Make sure expired filters don't get applied client-side

* Add missing API methods

* Remove "regex filter" from column settings

* Add tests

* Add test for FeedManager

* Add CustomFilter test

* Add UI for managing filters

* Add streaming API event to allow syncing filters

* Fix tests
This commit is contained in:
Eugen Rochko
2018-06-29 15:34:36 +02:00
committed by GitHub
parent fbee9b5ac8
commit cdb101340a
38 changed files with 530 additions and 72 deletions

View File

@ -1,15 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import SettingText from '../../../components/setting_text';
import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
settings: { id: 'home.settings', defaultMessage: 'Column settings' },
});
@injectIntl
export default class ColumnSettings extends React.PureComponent {
@ -21,19 +15,13 @@ export default class ColumnSettings extends React.PureComponent {
};
render () {
const { settings, onChange, intl } = this.props;
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
</div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div className='column-settings__row'>
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div>
</div>
);
}

View File

@ -7,7 +7,7 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => ({
status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }),
});
return mapStateToProps;

View File

@ -7,7 +7,6 @@ import ColumnHeader from '../../components/column_header';
import { expandDirectTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from '../../actions/streaming';
const messages = defineMessages({
@ -86,9 +85,7 @@ export default class DirectTimeline extends React.PureComponent {
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
/>
<StatusListContainer
trackScroll={!pinned}

View File

@ -1,14 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../notifications/components/setting_toggle';
import SettingText from '../../../components/setting_text';
const messages = defineMessages({
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
settings: { id: 'home.settings', defaultMessage: 'Column settings' },
});
@injectIntl
export default class ColumnSettings extends React.PureComponent {
@ -20,7 +14,7 @@ export default class ColumnSettings extends React.PureComponent {
};
render () {
const { settings, onChange, intl } = this.props;
const { settings, onChange } = this.props;
return (
<div>
@ -33,12 +27,6 @@ export default class ColumnSettings extends React.PureComponent {
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div className='column-settings__row'>
<SettingText prefix='home_timeline' settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div>
</div>
);
}

View File

@ -58,7 +58,7 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => {
const status = getStatus(state, props.params.statusId);
const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
@ -336,6 +336,7 @@ export default class Status extends ImmutablePureComponent {
id={id}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType='thread'
/>
));
}

View File

@ -11,15 +11,6 @@ const makeGetStatusIds = () => createSelector([
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
(state) => state.get('statuses'),
], (columnSettings, statusIds, statuses) => {
const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
let regex = null;
try {
regex = rawRegex && new RegExp(rawRegex, 'i');
} catch (e) {
// Bad regex, don't affect filters
}
return statusIds.filter(id => {
if (id === null) return true;
@ -34,11 +25,6 @@ const makeGetStatusIds = () => createSelector([
showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
}
if (showStatus && regex && statusForId.get('account') !== me) {
const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
showStatus = !regex.test(searchIndex);
}
return showStatus;
});
});

View File

@ -12,6 +12,7 @@ import { debounce } from 'lodash';
import { uploadCompose, resetCompose } from '../../actions/compose';
import { expandHomeTimeline } from '../../actions/timelines';
import { expandNotifications } from '../../actions/notifications';
import { fetchFilters } from '../../actions/filters';
import { clearHeight } from '../../actions/height_cache';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
@ -297,6 +298,7 @@ export default class UI extends React.PureComponent {
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
}
componentDidMount () {