forked from cybrespace/mastodon
		
	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, | ||||
| } from './timelines'; | ||||
| import { updateNotifications, expandNotifications } from './notifications'; | ||||
| import { fetchFilters } from './filters'; | ||||
| import { getLocale } from '../locales'; | ||||
| 
 | ||||
| const { messages } = getLocale(); | ||||
|  | @ -30,6 +31,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) | |||
|         case 'notification': | ||||
|           dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); | ||||
|           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) { | ||||
|       prepend = ( | ||||
|         <div className='status__prepend'> | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ export default class StatusList extends ImmutablePureComponent { | |||
|     prepend: PropTypes.node, | ||||
|     emptyMessage: PropTypes.node, | ||||
|     alwaysPrepend: PropTypes.bool, | ||||
|     timelineId: PropTypes.string.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   static defaultProps = { | ||||
|  | @ -70,7 +71,7 @@ export default class StatusList extends ImmutablePureComponent { | |||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { statusIds, featuredStatusIds, onLoadMore, ...other }  = this.props; | ||||
|     const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other }  = this.props; | ||||
|     const { isLoading, isPartial } = other; | ||||
| 
 | ||||
|     if (isPartial) { | ||||
|  | @ -102,6 +103,7 @@ export default class StatusList extends ImmutablePureComponent { | |||
|           id={statusId} | ||||
|           onMoveUp={this.handleMoveUp} | ||||
|           onMoveDown={this.handleMoveDown} | ||||
|           contextType={timelineId} | ||||
|         /> | ||||
|       )) | ||||
|     ) : null; | ||||
|  | @ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent { | |||
|           featured | ||||
|           onMoveUp={this.handleMoveUp} | ||||
|           onMoveDown={this.handleMoveDown} | ||||
|           contextType={timelineId} | ||||
|         /> | ||||
|       )).concat(scrollableContent); | ||||
|     } | ||||
|  |  | |||
|  | @ -42,7 +42,7 @@ const makeMapStateToProps = () => { | |||
|   const getStatus = makeGetStatus(); | ||||
| 
 | ||||
|   const mapStateToProps = (state, props) => ({ | ||||
|     status: getStatus(state, props.id), | ||||
|     status: getStatus(state, props), | ||||
|   }); | ||||
| 
 | ||||
|   return mapStateToProps; | ||||
|  |  | |||
|  | @ -1,15 +1,9 @@ | |||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import SettingText from '../../../components/setting_text'; | ||||
| import { injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import SettingToggle from '../../notifications/components/setting_toggle'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, | ||||
|   settings: { id: 'home.settings', defaultMessage: 'Column settings' }, | ||||
| }); | ||||
| 
 | ||||
| @injectIntl | ||||
| export default class ColumnSettings extends React.PureComponent { | ||||
| 
 | ||||
|  | @ -21,19 +15,13 @@ export default class ColumnSettings extends React.PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { settings, onChange, intl } = this.props; | ||||
|     const { settings, onChange } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <div> | ||||
|         <div className='column-settings__row'> | ||||
|           <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} /> | ||||
|         </div> | ||||
| 
 | ||||
|         <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> | ||||
| 
 | ||||
|         <div className='column-settings__row'> | ||||
|           <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ const makeMapStateToProps = () => { | |||
|   const getStatus = makeGetStatus(); | ||||
| 
 | ||||
|   const mapStateToProps = state => ({ | ||||
|     status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), | ||||
|     status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }), | ||||
|   }); | ||||
| 
 | ||||
|   return mapStateToProps; | ||||
|  |  | |||
|  | @ -7,7 +7,6 @@ import ColumnHeader from '../../components/column_header'; | |||
| import { expandDirectTimeline } from '../../actions/timelines'; | ||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import ColumnSettingsContainer from './containers/column_settings_container'; | ||||
| import { connectDirectStream } from '../../actions/streaming'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|  | @ -86,9 +85,7 @@ export default class DirectTimeline extends React.PureComponent { | |||
|           onClick={this.handleHeaderClick} | ||||
|           pinned={pinned} | ||||
|           multiColumn={multiColumn} | ||||
|         > | ||||
|           <ColumnSettingsContainer /> | ||||
|         </ColumnHeader> | ||||
|         /> | ||||
| 
 | ||||
|         <StatusListContainer | ||||
|           trackScroll={!pinned} | ||||
|  |  | |||
|  | @ -1,14 +1,8 @@ | |||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import { injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import SettingToggle from '../../notifications/components/setting_toggle'; | ||||
| import SettingText from '../../../components/setting_text'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, | ||||
|   settings: { id: 'home.settings', defaultMessage: 'Column settings' }, | ||||
| }); | ||||
| 
 | ||||
| @injectIntl | ||||
| export default class ColumnSettings extends React.PureComponent { | ||||
|  | @ -20,7 +14,7 @@ export default class ColumnSettings extends React.PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { settings, onChange, intl } = this.props; | ||||
|     const { settings, onChange } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <div> | ||||
|  | @ -33,12 +27,6 @@ export default class ColumnSettings extends React.PureComponent { | |||
|         <div className='column-settings__row'> | ||||
|           <SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> | ||||
|         </div> | ||||
| 
 | ||||
|         <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> | ||||
| 
 | ||||
|         <div className='column-settings__row'> | ||||
|           <SettingText prefix='home_timeline' settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  |  | |||
|  | @ -58,7 +58,7 @@ const makeMapStateToProps = () => { | |||
|   const getStatus = makeGetStatus(); | ||||
| 
 | ||||
|   const mapStateToProps = (state, props) => { | ||||
|     const status = getStatus(state, props.params.statusId); | ||||
|     const status = getStatus(state, { id: props.params.statusId }); | ||||
|     let ancestorsIds = Immutable.List(); | ||||
|     let descendantsIds = Immutable.List(); | ||||
| 
 | ||||
|  | @ -336,6 +336,7 @@ export default class Status extends ImmutablePureComponent { | |||
|         id={id} | ||||
|         onMoveUp={this.handleMoveUp} | ||||
|         onMoveDown={this.handleMoveDown} | ||||
|         contextType='thread' | ||||
|       /> | ||||
|     )); | ||||
|   } | ||||
|  |  | |||
|  | @ -11,15 +11,6 @@ const makeGetStatusIds = () => createSelector([ | |||
|   (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), | ||||
|   (state)           => state.get('statuses'), | ||||
| ], (columnSettings, statusIds, statuses) => { | ||||
|   const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim(); | ||||
|   let regex      = null; | ||||
| 
 | ||||
|   try { | ||||
|     regex = rawRegex && new RegExp(rawRegex, 'i'); | ||||
|   } catch (e) { | ||||
|     // Bad regex, don't affect filters
 | ||||
|   } | ||||
| 
 | ||||
|   return statusIds.filter(id => { | ||||
|     if (id === null) return true; | ||||
| 
 | ||||
|  | @ -34,11 +25,6 @@ const makeGetStatusIds = () => createSelector([ | |||
|       showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me); | ||||
|     } | ||||
| 
 | ||||
|     if (showStatus && regex && statusForId.get('account') !== me) { | ||||
|       const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index'); | ||||
|       showStatus = !regex.test(searchIndex); | ||||
|     } | ||||
| 
 | ||||
|     return showStatus; | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -12,6 +12,7 @@ import { debounce } from 'lodash'; | |||
| import { uploadCompose, resetCompose } from '../../actions/compose'; | ||||
| import { expandHomeTimeline } from '../../actions/timelines'; | ||||
| import { expandNotifications } from '../../actions/notifications'; | ||||
| import { fetchFilters } from '../../actions/filters'; | ||||
| import { clearHeight } from '../../actions/height_cache'; | ||||
| import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | ||||
| import UploadArea from './components/upload_area'; | ||||
|  | @ -297,6 +298,7 @@ export default class UI extends React.PureComponent { | |||
| 
 | ||||
|     this.props.dispatch(expandHomeTimeline()); | ||||
|     this.props.dispatch(expandNotifications()); | ||||
|     setTimeout(() => this.props.dispatch(fetchFilters()), 500); | ||||
|   } | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|  |  | |||
							
								
								
									
										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 lists from './lists'; | ||||
| import listEditor from './list_editor'; | ||||
| import filters from './filters'; | ||||
| 
 | ||||
| const reducers = { | ||||
|   dropdown_menu, | ||||
|  | @ -55,6 +56,7 @@ const reducers = { | |||
|   custom_emojis, | ||||
|   lists, | ||||
|   listEditor, | ||||
|   filters, | ||||
| }; | ||||
| 
 | ||||
| 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 = () => { | ||||
|   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) { | ||||
|         return null; | ||||
|       } | ||||
|  | @ -39,9 +67,13 @@ export const makeGetStatus = () => { | |||
|         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 => { | ||||
|         map.set('reblog', statusReblog); | ||||
|         map.set('account', accountBase); | ||||
|         map.set('filtered', filtered); | ||||
|       }); | ||||
|     } | ||||
|   ); | ||||
|  |  | |||
|  | @ -725,6 +725,20 @@ | |||
|   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 { | ||||
|   left: -26px; | ||||
|   position: absolute; | ||||
|  |  | |||
|  | @ -153,6 +153,7 @@ class FeedManager | |||
|   def filter_from_home?(status, receiver_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 phrase_filtered?(status, receiver_id, :home) | ||||
| 
 | ||||
|     check_for_blocks = status.mentions.pluck(:account_id) | ||||
|     check_for_blocks.concat([status.account_id]) | ||||
|  | @ -177,6 +178,7 @@ class FeedManager | |||
| 
 | ||||
|   def filter_from_mentions?(status, receiver_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 | ||||
|     # the notification has been checked for mute/block. Therefore, it's not | ||||
|  | @ -190,6 +192,20 @@ class FeedManager | |||
|     should_filter | ||||
|   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 | ||||
|   # 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 | ||||
|  |  | |||
|  | @ -99,6 +99,7 @@ class Account < ApplicationRecord | |||
|   has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id | ||||
| 
 | ||||
|   has_many :report_notes, dependent: :destroy | ||||
|   has_many :custom_filters, inverse_of: :account, dependent: :destroy | ||||
| 
 | ||||
|   # Moderation notes | ||||
|   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 | ||||
|   include Expireable | ||||
| 
 | ||||
|   belongs_to :user | ||||
|   has_many :users, inverse_of: :invite | ||||
| 
 | ||||
|   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 | ||||
| 
 | ||||
|   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? | ||||
|     (max_uses.nil? || uses < max_uses) && !expired? | ||||
|   end | ||||
| 
 | ||||
|   def expire! | ||||
|     touch(:expires_at) | ||||
|   end | ||||
| 
 | ||||
|   def expired? | ||||
|     !expires_at.nil? && expires_at < Time.now.utc | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   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 | ||||
|     mutes: You mute | ||||
|     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: | ||||
|     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. | ||||
|  |  | |||
|  | @ -6,17 +6,20 @@ en: | |||
|         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 | ||||
|         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 | ||||
|         display_name: | ||||
|           one: <span class="name-counter">1</span> character 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 | ||||
|         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 | ||||
|         locked: Requires you to manually approve followers | ||||
|         note: | ||||
|           one: <span class="note-counter">1</span> character 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_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 | ||||
|  | @ -39,6 +42,7 @@ en: | |||
|         chosen_languages: Filter languages | ||||
|         confirm_new_password: Confirm new password | ||||
|         confirm_password: Confirm password | ||||
|         context: Filter contexts | ||||
|         current_password: Current password | ||||
|         data: Data | ||||
|         display_name: Display name | ||||
|  | @ -46,6 +50,7 @@ en: | |||
|         expires_in: Expire after | ||||
|         fields: Profile metadata | ||||
|         header: Header | ||||
|         irreversible: Drop instead of hide | ||||
|         locale: Interface language | ||||
|         locked: Lock account | ||||
|         max_uses: Max number of uses | ||||
|  | @ -53,6 +58,7 @@ en: | |||
|         note: Bio | ||||
|         otp_attempt: Two-factor code | ||||
|         password: Password | ||||
|         phrase: Keyword or phrase | ||||
|         setting_auto_play_gif: Auto-play animated GIFs | ||||
|         setting_boost_modal: Show confirmation dialog before boosting | ||||
|         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 | ||||
|     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 :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 :emojis, only: [:show] | ||||
|   resources :invites, only: [:index, :create, :destroy] | ||||
|   resources :filters, except: [:show] | ||||
| 
 | ||||
|   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 :favourites, only: [:index] | ||||
|       resources :reports,    only: [:index, :create] | ||||
|       resources :filters,    only: [:index, :create, :show, :update, :destroy] | ||||
| 
 | ||||
|       namespace :apps do | ||||
|         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. | ||||
| 
 | ||||
| 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 | ||||
|   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 | ||||
|   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| | ||||
|     t.string "domain", default: "", 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 "conversation_mutes", "accounts", name: "fk_225b4212bb", 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", "statuses", name: "fk_b0e856845e", 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) | ||||
|         expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true | ||||
|       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 | ||||
| 
 | ||||
|     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…
	
	Add table
		
		Reference in a new issue