* 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 testsmaster
@@ -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 |
@@ -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 |
@@ -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 SettingText from '../../../components/setting_text'; | |||||
import { injectIntl, FormattedMessage } from 'react-intl'; | |||||
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 () { | ||||
@@ -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', state.getIn(['statuses', id, 'reblog'])]), | |||||
(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(['statuses', id]), | |||||
(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', 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 | ||||
@@ -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 |
@@ -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 | ||||
@@ -0,0 +1,5 @@ | |||||
# frozen_string_literal: true | |||||
class REST::FilterSerializer < ActiveModel::Serializer | |||||
attributes :id, :phrase, :context, :expires_at | |||||
end |
@@ -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') |
@@ -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 |
@@ -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' |
@@ -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' | ||||
@@ -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 |
@@ -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 | ||||
@@ -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 |
@@ -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 | ||||
@@ -0,0 +1,5 @@ | |||||
require 'rails_helper' | |||||
RSpec.describe CustomFilter, type: :model do | |||||
end |