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:
parent
fbee9b5ac8
commit
cdb101340a
38 changed files with 530 additions and 72 deletions
48
app/controllers/api/v1/filters_controller.rb
Normal file
48
app/controllers/api/v1/filters_controller.rb
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::FiltersController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :read }, only: [:index, :show]
|
||||||
|
before_action -> { doorkeeper_authorize! :write }, except: [:index, :show]
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_filters, only: :index
|
||||||
|
before_action :set_filter, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @filters, each_serializer: REST::FilterSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@filter = current_account.custom_filters.create!(resource_params)
|
||||||
|
render json: @filter, serializer: REST::FilterSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: @filter, serializer: REST::FilterSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@filter.update!(resource_params)
|
||||||
|
render json: @filter, serializer: REST::FilterSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@filter.destroy!
|
||||||
|
render_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_filters
|
||||||
|
@filters = current_account.custom_filters
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_filter
|
||||||
|
@filter = current_account.custom_filters.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.permit(:phrase, :expires_at, :irreversible, context: [])
|
||||||
|
end
|
||||||
|
end
|
57
app/controllers/filters_controller.rb
Normal file
57
app/controllers/filters_controller.rb
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class FiltersController < ApplicationController
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
layout 'admin'
|
||||||
|
|
||||||
|
before_action :set_filters, only: :index
|
||||||
|
before_action :set_filter, only: [:edit, :update, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@filters = current_account.custom_filters
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@filter = current_account.custom_filters.build
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@filter = current_account.custom_filters.build(resource_params)
|
||||||
|
|
||||||
|
if @filter.save
|
||||||
|
redirect_to filters_path
|
||||||
|
else
|
||||||
|
render action: :new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @filter.update(resource_params)
|
||||||
|
redirect_to filters_path
|
||||||
|
else
|
||||||
|
render action: :edit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@filter.destroy
|
||||||
|
redirect_to filters_path
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_filters
|
||||||
|
@filters = current_account.custom_filters
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_filter
|
||||||
|
@filter = current_account.custom_filters.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, context: [])
|
||||||
|
end
|
||||||
|
end
|
26
app/javascript/mastodon/actions/filters.js
Normal file
26
app/javascript/mastodon/actions/filters.js
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
|
||||||
|
export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
|
||||||
|
export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const fetchFilters = () => (dispatch, getState) => {
|
||||||
|
dispatch({
|
||||||
|
type: FILTERS_FETCH_REQUEST,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
api(getState)
|
||||||
|
.get('/api/v1/filters')
|
||||||
|
.then(({ data }) => dispatch({
|
||||||
|
type: FILTERS_FETCH_SUCCESS,
|
||||||
|
filters: data,
|
||||||
|
skipLoading: true,
|
||||||
|
}))
|
||||||
|
.catch(err => dispatch({
|
||||||
|
type: FILTERS_FETCH_FAIL,
|
||||||
|
err,
|
||||||
|
skipLoading: true,
|
||||||
|
skipAlert: true,
|
||||||
|
}));
|
||||||
|
};
|
|
@ -6,6 +6,7 @@ import {
|
||||||
disconnectTimeline,
|
disconnectTimeline,
|
||||||
} from './timelines';
|
} from './timelines';
|
||||||
import { updateNotifications, expandNotifications } from './notifications';
|
import { updateNotifications, expandNotifications } from './notifications';
|
||||||
|
import { fetchFilters } from './filters';
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
|
|
||||||
const { messages } = getLocale();
|
const { messages } = getLocale();
|
||||||
|
@ -30,6 +31,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
|
||||||
case 'notification':
|
case 'notification':
|
||||||
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
|
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
|
||||||
break;
|
break;
|
||||||
|
case 'filters_changed':
|
||||||
|
dispatch(fetchFilters());
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -157,6 +157,21 @@ export default class Status extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
|
||||||
|
const minHandlers = this.props.muted ? {} : {
|
||||||
|
moveUp: this.handleHotkeyMoveUp,
|
||||||
|
moveDown: this.handleHotkeyMoveDown,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HotKeys handlers={minHandlers}>
|
||||||
|
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0'>
|
||||||
|
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
||||||
|
</div>
|
||||||
|
</HotKeys>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (featured) {
|
if (featured) {
|
||||||
prepend = (
|
prepend = (
|
||||||
<div className='status__prepend'>
|
<div className='status__prepend'>
|
||||||
|
|
|
@ -25,6 +25,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
prepend: PropTypes.node,
|
prepend: PropTypes.node,
|
||||||
emptyMessage: PropTypes.node,
|
emptyMessage: PropTypes.node,
|
||||||
alwaysPrepend: PropTypes.bool,
|
alwaysPrepend: PropTypes.bool,
|
||||||
|
timelineId: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -70,7 +71,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { statusIds, featuredStatusIds, onLoadMore, ...other } = this.props;
|
const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props;
|
||||||
const { isLoading, isPartial } = other;
|
const { isLoading, isPartial } = other;
|
||||||
|
|
||||||
if (isPartial) {
|
if (isPartial) {
|
||||||
|
@ -102,6 +103,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
id={statusId}
|
id={statusId}
|
||||||
onMoveUp={this.handleMoveUp}
|
onMoveUp={this.handleMoveUp}
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={this.handleMoveDown}
|
||||||
|
contextType={timelineId}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : null;
|
) : null;
|
||||||
|
@ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
featured
|
featured
|
||||||
onMoveUp={this.handleMoveUp}
|
onMoveUp={this.handleMoveUp}
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={this.handleMoveDown}
|
||||||
|
contextType={timelineId}
|
||||||
/>
|
/>
|
||||||
)).concat(scrollableContent);
|
)).concat(scrollableContent);
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,7 @@ const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
status: getStatus(state, props.id),
|
status: getStatus(state, props),
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
|
|
|
@ -1,15 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import SettingText from '../../../components/setting_text';
|
|
||||||
import SettingToggle from '../../notifications/components/setting_toggle';
|
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
|
@injectIntl
|
||||||
export default class ColumnSettings extends React.PureComponent {
|
export default class ColumnSettings extends React.PureComponent {
|
||||||
|
|
||||||
|
@ -21,19 +15,13 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { settings, onChange, intl } = this.props;
|
const { settings, onChange } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className='column-settings__row'>
|
<div className='column-settings__row'>
|
||||||
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
|
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
|
status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
|
|
|
@ -7,7 +7,6 @@ import ColumnHeader from '../../components/column_header';
|
||||||
import { expandDirectTimeline } from '../../actions/timelines';
|
import { expandDirectTimeline } from '../../actions/timelines';
|
||||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
|
||||||
import { connectDirectStream } from '../../actions/streaming';
|
import { connectDirectStream } from '../../actions/streaming';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -86,9 +85,7 @@ export default class DirectTimeline extends React.PureComponent {
|
||||||
onClick={this.handleHeaderClick}
|
onClick={this.handleHeaderClick}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
>
|
/>
|
||||||
<ColumnSettingsContainer />
|
|
||||||
</ColumnHeader>
|
|
||||||
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
|
|
|
@ -1,14 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
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 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
|
@injectIntl
|
||||||
export default class ColumnSettings extends React.PureComponent {
|
export default class ColumnSettings extends React.PureComponent {
|
||||||
|
@ -20,7 +14,7 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { settings, onChange, intl } = this.props;
|
const { settings, onChange } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
@ -33,12 +27,6 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
<div className='column-settings__row'>
|
<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' />} />
|
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,7 +58,7 @@ const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
const mapStateToProps = (state, props) => {
|
||||||
const status = getStatus(state, props.params.statusId);
|
const status = getStatus(state, { id: props.params.statusId });
|
||||||
let ancestorsIds = Immutable.List();
|
let ancestorsIds = Immutable.List();
|
||||||
let descendantsIds = Immutable.List();
|
let descendantsIds = Immutable.List();
|
||||||
|
|
||||||
|
@ -336,6 +336,7 @@ export default class Status extends ImmutablePureComponent {
|
||||||
id={id}
|
id={id}
|
||||||
onMoveUp={this.handleMoveUp}
|
onMoveUp={this.handleMoveUp}
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={this.handleMoveDown}
|
||||||
|
contextType='thread'
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,15 +11,6 @@ const makeGetStatusIds = () => createSelector([
|
||||||
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
|
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
|
||||||
(state) => state.get('statuses'),
|
(state) => state.get('statuses'),
|
||||||
], (columnSettings, statusIds, 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 => {
|
return statusIds.filter(id => {
|
||||||
if (id === null) return true;
|
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);
|
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;
|
return showStatus;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { debounce } from 'lodash';
|
||||||
import { uploadCompose, resetCompose } from '../../actions/compose';
|
import { uploadCompose, resetCompose } from '../../actions/compose';
|
||||||
import { expandHomeTimeline } from '../../actions/timelines';
|
import { expandHomeTimeline } from '../../actions/timelines';
|
||||||
import { expandNotifications } from '../../actions/notifications';
|
import { expandNotifications } from '../../actions/notifications';
|
||||||
|
import { fetchFilters } from '../../actions/filters';
|
||||||
import { clearHeight } from '../../actions/height_cache';
|
import { clearHeight } from '../../actions/height_cache';
|
||||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||||
import UploadArea from './components/upload_area';
|
import UploadArea from './components/upload_area';
|
||||||
|
@ -297,6 +298,7 @@ export default class UI extends React.PureComponent {
|
||||||
|
|
||||||
this.props.dispatch(expandHomeTimeline());
|
this.props.dispatch(expandHomeTimeline());
|
||||||
this.props.dispatch(expandNotifications());
|
this.props.dispatch(expandNotifications());
|
||||||
|
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
|
|
11
app/javascript/mastodon/reducers/filters.js
Normal file
11
app/javascript/mastodon/reducers/filters.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
|
||||||
|
import { List as ImmutableList, fromJS } from 'immutable';
|
||||||
|
|
||||||
|
export default function filters(state = ImmutableList(), action) {
|
||||||
|
switch(action.type) {
|
||||||
|
case FILTERS_FETCH_SUCCESS:
|
||||||
|
return fromJS(action.filters);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
|
@ -26,6 +26,7 @@ import height_cache from './height_cache';
|
||||||
import custom_emojis from './custom_emojis';
|
import custom_emojis from './custom_emojis';
|
||||||
import lists from './lists';
|
import lists from './lists';
|
||||||
import listEditor from './list_editor';
|
import listEditor from './list_editor';
|
||||||
|
import filters from './filters';
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
dropdown_menu,
|
dropdown_menu,
|
||||||
|
@ -55,6 +56,7 @@ const reducers = {
|
||||||
custom_emojis,
|
custom_emojis,
|
||||||
lists,
|
lists,
|
||||||
listEditor,
|
listEditor,
|
||||||
|
filters,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
export default combineReducers(reducers);
|
||||||
|
|
|
@ -19,16 +19,44 @@ export const makeGetAccount = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toServerSideType = columnType => {
|
||||||
|
switch (columnType) {
|
||||||
|
case 'home':
|
||||||
|
case 'notifications':
|
||||||
|
case 'public':
|
||||||
|
case 'thread':
|
||||||
|
return columnType;
|
||||||
|
default:
|
||||||
|
if (columnType.indexOf('list:') > -1) {
|
||||||
|
return 'home';
|
||||||
|
} else {
|
||||||
|
return 'public'; // community, account, hashtag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeRegExp = string =>
|
||||||
|
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||||
|
|
||||||
|
const regexFromFilters = filters => {
|
||||||
|
if (filters.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RegExp(filters.map(filter => escapeRegExp(filter.get('phrase'))).join('|'), 'i');
|
||||||
|
};
|
||||||
|
|
||||||
export const makeGetStatus = () => {
|
export const makeGetStatus = () => {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
[
|
[
|
||||||
(state, id) => state.getIn(['statuses', id]),
|
(state, { id }) => state.getIn(['statuses', id]),
|
||||||
(state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
|
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
|
||||||
(state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
|
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
|
||||||
(state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
|
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
|
||||||
|
(state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))),
|
||||||
],
|
],
|
||||||
|
|
||||||
(statusBase, statusReblog, accountBase, accountReblog) => {
|
(statusBase, statusReblog, accountBase, accountReblog, filters) => {
|
||||||
if (!statusBase) {
|
if (!statusBase) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -39,9 +67,13 @@ export const makeGetStatus = () => {
|
||||||
statusReblog = null;
|
statusReblog = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const regex = regexFromFilters(filters);
|
||||||
|
const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'));
|
||||||
|
|
||||||
return statusBase.withMutations(map => {
|
return statusBase.withMutations(map => {
|
||||||
map.set('reblog', statusReblog);
|
map.set('reblog', statusReblog);
|
||||||
map.set('account', accountBase);
|
map.set('account', accountBase);
|
||||||
|
map.set('filtered', filtered);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -725,6 +725,20 @@
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status__wrapper--filtered {
|
||||||
|
color: $dark-text-color;
|
||||||
|
border: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
text-align: center;
|
||||||
|
line-height: inherit;
|
||||||
|
margin: 0;
|
||||||
|
padding: 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
clear: both;
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||||
|
}
|
||||||
|
|
||||||
.status__prepend-icon-wrapper {
|
.status__prepend-icon-wrapper {
|
||||||
left: -26px;
|
left: -26px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -153,6 +153,7 @@ class FeedManager
|
||||||
def filter_from_home?(status, receiver_id)
|
def filter_from_home?(status, receiver_id)
|
||||||
return false if receiver_id == status.account_id
|
return false if receiver_id == status.account_id
|
||||||
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
|
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
|
||||||
|
return true if phrase_filtered?(status, receiver_id, :home)
|
||||||
|
|
||||||
check_for_blocks = status.mentions.pluck(:account_id)
|
check_for_blocks = status.mentions.pluck(:account_id)
|
||||||
check_for_blocks.concat([status.account_id])
|
check_for_blocks.concat([status.account_id])
|
||||||
|
@ -177,6 +178,7 @@ class FeedManager
|
||||||
|
|
||||||
def filter_from_mentions?(status, receiver_id)
|
def filter_from_mentions?(status, receiver_id)
|
||||||
return true if receiver_id == status.account_id
|
return true if receiver_id == status.account_id
|
||||||
|
return true if phrase_filtered?(status, receiver_id, :notifications)
|
||||||
|
|
||||||
# This filter is called from NotifyService, but already after the sender of
|
# This filter is called from NotifyService, but already after the sender of
|
||||||
# the notification has been checked for mute/block. Therefore, it's not
|
# the notification has been checked for mute/block. Therefore, it's not
|
||||||
|
@ -190,6 +192,20 @@ class FeedManager
|
||||||
should_filter
|
should_filter
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def phrase_filtered?(status, receiver_id, context)
|
||||||
|
active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a
|
||||||
|
|
||||||
|
active_filters.select! { |filter| filter.context.include?(context.to_s) && !filter.expired? }
|
||||||
|
active_filters.map! { |filter| Regexp.new(Regexp.escape(filter.phrase), true) }
|
||||||
|
|
||||||
|
return false if active_filters.empty?
|
||||||
|
|
||||||
|
combined_regex = active_filters.reduce { |memo, obj| Regexp.union(memo, obj) }
|
||||||
|
|
||||||
|
!combined_regex.match(status.text).nil? ||
|
||||||
|
(status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?)
|
||||||
|
end
|
||||||
|
|
||||||
# Adds a status to an account's feed, returning true if a status was
|
# Adds a status to an account's feed, returning true if a status was
|
||||||
# added, and false if it was not added to the feed. Note that this is
|
# added, and false if it was not added to the feed. Note that this is
|
||||||
# an internal helper: callers must call trim or push updates if
|
# an internal helper: callers must call trim or push updates if
|
||||||
|
|
|
@ -99,6 +99,7 @@ class Account < ApplicationRecord
|
||||||
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id
|
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id
|
||||||
|
|
||||||
has_many :report_notes, dependent: :destroy
|
has_many :report_notes, dependent: :destroy
|
||||||
|
has_many :custom_filters, inverse_of: :account, dependent: :destroy
|
||||||
|
|
||||||
# Moderation notes
|
# Moderation notes
|
||||||
has_many :account_moderation_notes, dependent: :destroy
|
has_many :account_moderation_notes, dependent: :destroy
|
||||||
|
|
24
app/models/concerns/expireable.rb
Normal file
24
app/models/concerns/expireable.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Expireable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
|
||||||
|
|
||||||
|
attr_reader :expires_in
|
||||||
|
|
||||||
|
def expires_in=(interval)
|
||||||
|
self.expires_at = interval.to_i.seconds.from_now unless interval.blank?
|
||||||
|
@expires_in = interval
|
||||||
|
end
|
||||||
|
|
||||||
|
def expire!
|
||||||
|
touch(:expires_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def expired?
|
||||||
|
!expires_at.nil? && expires_at < Time.now.utc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
55
app/models/custom_filter.rb
Normal file
55
app/models/custom_filter.rb
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: custom_filters
|
||||||
|
#
|
||||||
|
# id :bigint(8) not null, primary key
|
||||||
|
# account_id :bigint(8)
|
||||||
|
# expires_at :datetime
|
||||||
|
# phrase :text default(""), not null
|
||||||
|
# context :string default([]), not null, is an Array
|
||||||
|
# irreversible :boolean default(FALSE), not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
|
||||||
|
class CustomFilter < ApplicationRecord
|
||||||
|
VALID_CONTEXTS = %w(
|
||||||
|
home
|
||||||
|
notifications
|
||||||
|
public
|
||||||
|
thread
|
||||||
|
).freeze
|
||||||
|
|
||||||
|
include Expireable
|
||||||
|
|
||||||
|
belongs_to :account
|
||||||
|
|
||||||
|
validates :phrase, :context, presence: true
|
||||||
|
validate :context_must_be_valid
|
||||||
|
validate :irreversible_must_be_within_context
|
||||||
|
|
||||||
|
scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) }
|
||||||
|
|
||||||
|
before_validation :clean_up_contexts
|
||||||
|
after_commit :remove_cache
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def clean_up_contexts
|
||||||
|
self.context = Array(context).map(&:strip).map(&:presence).compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_cache
|
||||||
|
Rails.cache.delete("filters:#{account_id}")
|
||||||
|
Redis.current.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed))
|
||||||
|
end
|
||||||
|
|
||||||
|
def context_must_be_valid
|
||||||
|
errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def irreversible_must_be_within_context
|
||||||
|
errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications')
|
||||||
|
end
|
||||||
|
end
|
|
@ -15,33 +15,19 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
class Invite < ApplicationRecord
|
class Invite < ApplicationRecord
|
||||||
|
include Expireable
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
has_many :users, inverse_of: :invite
|
has_many :users, inverse_of: :invite
|
||||||
|
|
||||||
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
|
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
|
||||||
scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
|
|
||||||
|
|
||||||
before_validation :set_code
|
before_validation :set_code
|
||||||
|
|
||||||
attr_reader :expires_in
|
|
||||||
|
|
||||||
def expires_in=(interval)
|
|
||||||
self.expires_at = interval.to_i.seconds.from_now unless interval.blank?
|
|
||||||
@expires_in = interval
|
|
||||||
end
|
|
||||||
|
|
||||||
def valid_for_use?
|
def valid_for_use?
|
||||||
(max_uses.nil? || uses < max_uses) && !expired?
|
(max_uses.nil? || uses < max_uses) && !expired?
|
||||||
end
|
end
|
||||||
|
|
||||||
def expire!
|
|
||||||
touch(:expires_at)
|
|
||||||
end
|
|
||||||
|
|
||||||
def expired?
|
|
||||||
!expires_at.nil? && expires_at < Time.now.utc
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_code
|
def set_code
|
||||||
|
|
5
app/serializers/rest/filter_serializer.rb
Normal file
5
app/serializers/rest/filter_serializer.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::FilterSerializer < ActiveModel::Serializer
|
||||||
|
attributes :id, :phrase, :context, :expires_at
|
||||||
|
end
|
11
app/views/filters/_fields.html.haml
Normal file
11
app/views/filters/_fields.html.haml
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
.fields-group
|
||||||
|
= f.input :phrase, as: :string, wrapper: :with_block_label
|
||||||
|
|
||||||
|
.fields-group
|
||||||
|
= f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false
|
||||||
|
|
||||||
|
.fields-group
|
||||||
|
= f.input :irreversible, wrapper: :with_label
|
||||||
|
|
||||||
|
.fields-group
|
||||||
|
= f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
|
8
app/views/filters/edit.html.haml
Normal file
8
app/views/filters/edit.html.haml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
- content_for :page_title do
|
||||||
|
= t('filters.edit.title')
|
||||||
|
|
||||||
|
= simple_form_for @filter, url: filter_path(@filter), method: :put do |f|
|
||||||
|
= render 'fields', f: f
|
||||||
|
|
||||||
|
.actions
|
||||||
|
= f.button :button, t('generic.save_changes'), type: :submit
|
20
app/views/filters/index.html.haml
Normal file
20
app/views/filters/index.html.haml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
- content_for :page_title do
|
||||||
|
= t('filters.index.title')
|
||||||
|
|
||||||
|
.table-wrapper
|
||||||
|
%table.table
|
||||||
|
%thead
|
||||||
|
%tr
|
||||||
|
%th= t('simple_form.labels.defaults.phrase')
|
||||||
|
%th= t('simple_form.labels.defaults.context')
|
||||||
|
%th
|
||||||
|
%tbody
|
||||||
|
- @filters.each do |filter|
|
||||||
|
%tr
|
||||||
|
%td= filter.phrase
|
||||||
|
%td= filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ')
|
||||||
|
%td
|
||||||
|
= table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter)
|
||||||
|
= table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete
|
||||||
|
|
||||||
|
= link_to t('filters.new.title'), new_filter_path, class: 'button'
|
8
app/views/filters/new.html.haml
Normal file
8
app/views/filters/new.html.haml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
- content_for :page_title do
|
||||||
|
= t('filters.new.title')
|
||||||
|
|
||||||
|
= simple_form_for @filter, url: filters_path do |f|
|
||||||
|
= render 'fields', f: f
|
||||||
|
|
||||||
|
.actions
|
||||||
|
= f.button :button, t('filters.new.title'), type: :submit
|
|
@ -474,6 +474,22 @@ en:
|
||||||
follows: You follow
|
follows: You follow
|
||||||
mutes: You mute
|
mutes: You mute
|
||||||
storage: Media storage
|
storage: Media storage
|
||||||
|
filters:
|
||||||
|
contexts:
|
||||||
|
home: Home timeline
|
||||||
|
notifications: Notifications
|
||||||
|
public: Public timelines
|
||||||
|
thread: Conversations
|
||||||
|
edit:
|
||||||
|
title: Edit filter
|
||||||
|
errors:
|
||||||
|
invalid_context: None or invalid context supplied
|
||||||
|
invalid_irreversible: Irreversible filtering only works with home or notifications context
|
||||||
|
index:
|
||||||
|
delete: Delete
|
||||||
|
title: Filters
|
||||||
|
new:
|
||||||
|
title: Add new filter
|
||||||
followers:
|
followers:
|
||||||
domain: Domain
|
domain: Domain
|
||||||
explanation_html: If you want to ensure the privacy of your statuses, you must be aware of who is following you. <strong>Your private statuses are delivered to all instances where you have followers</strong>. You may wish to review them, and remove followers if you do not trust your privacy to be respected by the staff or software of those instances.
|
explanation_html: If you want to ensure the privacy of your statuses, you must be aware of who is following you. <strong>Your private statuses are delivered to all instances where you have followers</strong>. You may wish to review them, and remove followers if you do not trust your privacy to be respected by the staff or software of those instances.
|
||||||
|
|
|
@ -6,17 +6,20 @@ en:
|
||||||
autofollow: People who sign up through the invite will automatically follow you
|
autofollow: People who sign up through the invite will automatically follow you
|
||||||
avatar: PNG, GIF or JPG. At most 2MB. Will be downscaled to 400x400px
|
avatar: PNG, GIF or JPG. At most 2MB. Will be downscaled to 400x400px
|
||||||
bot: This account mainly performs automated actions and might not be monitored
|
bot: This account mainly performs automated actions and might not be monitored
|
||||||
|
context: One or multiple contexts where the filter should apply
|
||||||
digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
|
digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
|
||||||
display_name:
|
display_name:
|
||||||
one: <span class="name-counter">1</span> character left
|
one: <span class="name-counter">1</span> character left
|
||||||
other: <span class="name-counter">%{count}</span> characters left
|
other: <span class="name-counter">%{count}</span> characters left
|
||||||
fields: You can have up to 4 items displayed as a table on your profile
|
fields: You can have up to 4 items displayed as a table on your profile
|
||||||
header: PNG, GIF or JPG. At most 2MB. Will be downscaled to 700x335px
|
header: PNG, GIF or JPG. At most 2MB. Will be downscaled to 700x335px
|
||||||
|
irreversible: Filtered toots will disappear irreversibly, even if filter is later removed
|
||||||
locale: The language of the user interface, e-mails and push notifications
|
locale: The language of the user interface, e-mails and push notifications
|
||||||
locked: Requires you to manually approve followers
|
locked: Requires you to manually approve followers
|
||||||
note:
|
note:
|
||||||
one: <span class="note-counter">1</span> character left
|
one: <span class="note-counter">1</span> character left
|
||||||
other: <span class="note-counter">%{count}</span> characters left
|
other: <span class="note-counter">%{count}</span> characters left
|
||||||
|
phrase: Will be matched regardless of casing in text or content warning of a toot
|
||||||
setting_default_language: The language of your toots can be detected automatically, but it's not always accurate
|
setting_default_language: The language of your toots can be detected automatically, but it's not always accurate
|
||||||
setting_hide_network: Who you follow and who follows you will not be shown on your profile
|
setting_hide_network: Who you follow and who follows you will not be shown on your profile
|
||||||
setting_noindex: Affects your public profile and status pages
|
setting_noindex: Affects your public profile and status pages
|
||||||
|
@ -39,6 +42,7 @@ en:
|
||||||
chosen_languages: Filter languages
|
chosen_languages: Filter languages
|
||||||
confirm_new_password: Confirm new password
|
confirm_new_password: Confirm new password
|
||||||
confirm_password: Confirm password
|
confirm_password: Confirm password
|
||||||
|
context: Filter contexts
|
||||||
current_password: Current password
|
current_password: Current password
|
||||||
data: Data
|
data: Data
|
||||||
display_name: Display name
|
display_name: Display name
|
||||||
|
@ -46,6 +50,7 @@ en:
|
||||||
expires_in: Expire after
|
expires_in: Expire after
|
||||||
fields: Profile metadata
|
fields: Profile metadata
|
||||||
header: Header
|
header: Header
|
||||||
|
irreversible: Drop instead of hide
|
||||||
locale: Interface language
|
locale: Interface language
|
||||||
locked: Lock account
|
locked: Lock account
|
||||||
max_uses: Max number of uses
|
max_uses: Max number of uses
|
||||||
|
@ -53,6 +58,7 @@ en:
|
||||||
note: Bio
|
note: Bio
|
||||||
otp_attempt: Two-factor code
|
otp_attempt: Two-factor code
|
||||||
password: Password
|
password: Password
|
||||||
|
phrase: Keyword or phrase
|
||||||
setting_auto_play_gif: Auto-play animated GIFs
|
setting_auto_play_gif: Auto-play animated GIFs
|
||||||
setting_boost_modal: Show confirmation dialog before boosting
|
setting_boost_modal: Show confirmation dialog before boosting
|
||||||
setting_default_language: Posting language
|
setting_default_language: Posting language
|
||||||
|
|
|
@ -16,6 +16,7 @@ SimpleNavigation::Configuration.run do |navigation|
|
||||||
settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url
|
settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
primary.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}
|
||||||
primary.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' }
|
primary.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' }
|
||||||
|
|
||||||
primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development|
|
primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development|
|
||||||
|
|
|
@ -114,6 +114,7 @@ Rails.application.routes.draw do
|
||||||
resources :tags, only: [:show]
|
resources :tags, only: [:show]
|
||||||
resources :emojis, only: [:show]
|
resources :emojis, only: [:show]
|
||||||
resources :invites, only: [:index, :create, :destroy]
|
resources :invites, only: [:index, :create, :destroy]
|
||||||
|
resources :filters, except: [:show]
|
||||||
|
|
||||||
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy
|
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy
|
||||||
|
|
||||||
|
@ -254,6 +255,7 @@ Rails.application.routes.draw do
|
||||||
resources :mutes, only: [:index]
|
resources :mutes, only: [:index]
|
||||||
resources :favourites, only: [:index]
|
resources :favourites, only: [:index]
|
||||||
resources :reports, only: [:index, :create]
|
resources :reports, only: [:index, :create]
|
||||||
|
resources :filters, only: [:index, :create, :show, :update, :destroy]
|
||||||
|
|
||||||
namespace :apps do
|
namespace :apps do
|
||||||
get :verify_credentials, to: 'credentials#show'
|
get :verify_credentials, to: 'credentials#show'
|
||||||
|
|
13
db/migrate/20180628181026_create_custom_filters.rb
Normal file
13
db/migrate/20180628181026_create_custom_filters.rb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
class CreateCustomFilters < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
create_table :custom_filters do |t|
|
||||||
|
t.belongs_to :account, foreign_key: { on_delete: :cascade }
|
||||||
|
t.datetime :expires_at
|
||||||
|
t.text :phrase, null: false, default: ''
|
||||||
|
t.string :context, array: true, null: false, default: []
|
||||||
|
t.boolean :irreversible, null: false, default: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
14
db/schema.rb
14
db/schema.rb
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2018_06_17_162849) do
|
ActiveRecord::Schema.define(version: 2018_06_28_181026) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -143,6 +143,17 @@ ActiveRecord::Schema.define(version: 2018_06_17_162849) do
|
||||||
t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
|
t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "custom_filters", force: :cascade do |t|
|
||||||
|
t.bigint "account_id"
|
||||||
|
t.datetime "expires_at"
|
||||||
|
t.text "phrase", default: "", null: false
|
||||||
|
t.string "context", default: [], null: false, array: true
|
||||||
|
t.boolean "irreversible", default: false, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id"], name: "index_custom_filters_on_account_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "domain_blocks", force: :cascade do |t|
|
create_table "domain_blocks", force: :cascade do |t|
|
||||||
t.string "domain", default: "", null: false
|
t.string "domain", default: "", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
@ -561,6 +572,7 @@ ActiveRecord::Schema.define(version: 2018_06_17_162849) do
|
||||||
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
|
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
|
||||||
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
|
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
|
||||||
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
|
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
|
||||||
|
add_foreign_key "custom_filters", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
|
add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
|
||||||
add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
|
add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
|
||||||
add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
|
add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
|
||||||
|
|
81
spec/controllers/api/v1/filter_controller_spec.rb
Normal file
81
spec/controllers/api/v1/filter_controller_spec.rb
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Api::V1::FiltersController, type: :controller do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
let(:user) { Fabricate(:user) }
|
||||||
|
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(controller).to receive(:doorkeeper_token) { token }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET #index' do
|
||||||
|
let!(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST #create' do
|
||||||
|
before do
|
||||||
|
post :create, params: { phrase: 'magic', context: %w(home), irreversible: true }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a filter' do
|
||||||
|
filter = user.account.custom_filters.first
|
||||||
|
expect(filter).to_not be_nil
|
||||||
|
expect(filter.phrase).to eq 'magic'
|
||||||
|
expect(filter.context).to eq %w(home)
|
||||||
|
expect(filter.irreversible?).to be true
|
||||||
|
expect(filter.expires_at).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET #show' do
|
||||||
|
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
get :show, params: { id: filter.id }
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PUT #update' do
|
||||||
|
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
put :update, params: { id: filter.id, phrase: 'updated' }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the filter' do
|
||||||
|
expect(filter.reload.phrase).to eq 'updated'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'DELETE #destroy' do
|
||||||
|
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
delete :destroy, params: { id: filter.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes the filter' do
|
||||||
|
expect { filter.reload }.to raise_error ActiveRecord::RecordNotFound
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
6
spec/fabricators/custom_filter_fabricator.rb
Normal file
6
spec/fabricators/custom_filter_fabricator.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
Fabricator(:custom_filter) do
|
||||||
|
account
|
||||||
|
expires_at nil
|
||||||
|
phrase 'discourse'
|
||||||
|
context %w(home notifications)
|
||||||
|
end
|
|
@ -126,6 +126,14 @@ RSpec.describe FeedManager do
|
||||||
reblog = Fabricate(:status, reblog: status, account: jeff)
|
reblog = Fabricate(:status, reblog: status, account: jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
|
expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns true if status contains irreversibly muted phrase' do
|
||||||
|
alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true)
|
||||||
|
alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
|
||||||
|
alice.follow!(jeff)
|
||||||
|
status = Fabricate(:status, text: 'i sure like POP TARts', account: jeff)
|
||||||
|
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'for mentions feed' do
|
context 'for mentions feed' do
|
||||||
|
|
5
spec/models/custom_filter_spec.rb
Normal file
5
spec/models/custom_filter_spec.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe CustomFilter, type: :model do
|
||||||
|
|
||||||
|
end
|
Loading…
Reference in a new issue