about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/api/v1/filters_controller.rb48
-rw-r--r--app/controllers/filters_controller.rb57
-rw-r--r--app/javascript/mastodon/actions/filters.js26
-rw-r--r--app/javascript/mastodon/actions/streaming.js4
-rw-r--r--app/javascript/mastodon/components/status.js15
-rw-r--r--app/javascript/mastodon/components/status_list.js5
-rw-r--r--app/javascript/mastodon/containers/status_container.js2
-rw-r--r--app/javascript/mastodon/features/community_timeline/components/column_settings.js16
-rw-r--r--app/javascript/mastodon/features/compose/containers/reply_indicator_container.js2
-rw-r--r--app/javascript/mastodon/features/direct_timeline/index.js5
-rw-r--r--app/javascript/mastodon/features/home_timeline/components/column_settings.js16
-rw-r--r--app/javascript/mastodon/features/status/index.js3
-rw-r--r--app/javascript/mastodon/features/ui/containers/status_list_container.js14
-rw-r--r--app/javascript/mastodon/features/ui/index.js2
-rw-r--r--app/javascript/mastodon/reducers/filters.js11
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/selectors/index.js42
-rw-r--r--app/javascript/styles/mastodon/components.scss14
-rw-r--r--app/lib/feed_manager.rb16
-rw-r--r--app/models/account.rb1
-rw-r--r--app/models/concerns/expireable.rb24
-rw-r--r--app/models/custom_filter.rb55
-rw-r--r--app/models/invite.rb18
-rw-r--r--app/serializers/rest/filter_serializer.rb5
-rw-r--r--app/views/filters/_fields.html.haml11
-rw-r--r--app/views/filters/edit.html.haml8
-rw-r--r--app/views/filters/index.html.haml20
-rw-r--r--app/views/filters/new.html.haml8
-rw-r--r--config/locales/en.yml16
-rw-r--r--config/locales/simple_form.en.yml6
-rw-r--r--config/navigation.rb1
-rw-r--r--config/routes.rb2
-rw-r--r--db/migrate/20180628181026_create_custom_filters.rb13
-rw-r--r--db/schema.rb14
-rw-r--r--spec/controllers/api/v1/filter_controller_spec.rb81
-rw-r--r--spec/fabricators/custom_filter_fabricator.rb6
-rw-r--r--spec/lib/feed_manager_spec.rb8
-rw-r--r--spec/models/custom_filter_spec.rb5
38 files changed, 530 insertions, 72 deletions
diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb
new file mode 100644
index 000000000..c89722b85
--- /dev/null
+++ b/app/controllers/api/v1/filters_controller.rb
@@ -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
diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb
new file mode 100644
index 000000000..03403a1ba
--- /dev/null
+++ b/app/controllers/filters_controller.rb
@@ -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
diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js
new file mode 100644
index 000000000..7fa1c9a70
--- /dev/null
+++ b/app/javascript/mastodon/actions/filters.js
@@ -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,
+    }));
+};
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index f56853bff..32fc67e67 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -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;
         }
       },
     };
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index fd08ff3b7..922b609ec 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -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'>
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 1c34d0640..68c9eef54 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -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);
     }
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 3e7b5215b..eb6329fdc 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -42,7 +42,7 @@ const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
 
   const mapStateToProps = (state, props) => ({
-    status: getStatus(state, props.id),
+    status: getStatus(state, props),
   });
 
   return mapStateToProps;
diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.js b/app/javascript/mastodon/features/community_timeline/components/column_settings.js
index 3a1d19aa8..f4325f58d 100644
--- a/app/javascript/mastodon/features/community_timeline/components/column_settings.js
+++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.js
@@ -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>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
index 73f394c1a..5eb1eb72a 100644
--- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
+++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
@@ -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;
diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js
index fda57f69a..63dc41d9e 100644
--- a/app/javascript/mastodon/features/direct_timeline/index.js
+++ b/app/javascript/mastodon/features/direct_timeline/index.js
@@ -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}
diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
index 0c0c4fa36..932ac2049 100644
--- a/app/javascript/mastodon/features/home_timeline/components/column_settings.js
+++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
@@ -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>
     );
   }
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index ca792043f..3c66536d4 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -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'
       />
     ));
   }
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
index e5b1edc4a..3df5b7bea 100644
--- a/app/javascript/mastodon/features/ui/containers/status_list_container.js
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -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;
   });
 });
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 3c1a266e3..56a856230 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -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 () {
diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js
new file mode 100644
index 000000000..33f0c6732
--- /dev/null
+++ b/app/javascript/mastodon/reducers/filters.js
@@ -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;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3d9a6a132..4a981fada 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -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);
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index e47ec5183..56eca1f02 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -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);
       });
     }
   );
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index c16cf3437..5fa73d58a 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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;
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index c18c07b33..ee9185d34 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -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
diff --git a/app/models/account.rb b/app/models/account.rb
index c3eea79cc..40a45b1f8 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -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
diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb
new file mode 100644
index 000000000..444ccdfdb
--- /dev/null
+++ b/app/models/concerns/expireable.rb
@@ -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
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
new file mode 100644
index 000000000..2c1a54375
--- /dev/null
+++ b/app/models/custom_filter.rb
@@ -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
diff --git a/app/models/invite.rb b/app/models/invite.rb
index d0cc427c4..fe2322462 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -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
diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb
new file mode 100644
index 000000000..07f2516f8
--- /dev/null
+++ b/app/serializers/rest/filter_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::FilterSerializer < ActiveModel::Serializer
+  attributes :id, :phrase, :context, :expires_at
+end
diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml
new file mode 100644
index 000000000..af5d648b8
--- /dev/null
+++ b/app/views/filters/_fields.html.haml
@@ -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')
diff --git a/app/views/filters/edit.html.haml b/app/views/filters/edit.html.haml
new file mode 100644
index 000000000..e971215ac
--- /dev/null
+++ b/app/views/filters/edit.html.haml
@@ -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
diff --git a/app/views/filters/index.html.haml b/app/views/filters/index.html.haml
new file mode 100644
index 000000000..18ebee570
--- /dev/null
+++ b/app/views/filters/index.html.haml
@@ -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'
diff --git a/app/views/filters/new.html.haml b/app/views/filters/new.html.haml
new file mode 100644
index 000000000..05bec343f
--- /dev/null
+++ b/app/views/filters/new.html.haml
@@ -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
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 01e5dd2f8..5cb81ebe9 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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.
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 6783f0045..59133ea73 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -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
diff --git a/config/navigation.rb b/config/navigation.rb
index 2bee5a4f9..3f2e913c6 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -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|
diff --git a/config/routes.rb b/config/routes.rb
index a3cba24fc..5fdd3b390 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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'
diff --git a/db/migrate/20180628181026_create_custom_filters.rb b/db/migrate/20180628181026_create_custom_filters.rb
new file mode 100644
index 000000000..d19cf2e9d
--- /dev/null
+++ b/db/migrate/20180628181026_create_custom_filters.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 2853aef94..661fc8179 100644
--- a/db/schema.rb
+++ b/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
diff --git a/spec/controllers/api/v1/filter_controller_spec.rb b/spec/controllers/api/v1/filter_controller_spec.rb
new file mode 100644
index 000000000..3ffd8f784
--- /dev/null
+++ b/spec/controllers/api/v1/filter_controller_spec.rb
@@ -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
diff --git a/spec/fabricators/custom_filter_fabricator.rb b/spec/fabricators/custom_filter_fabricator.rb
new file mode 100644
index 000000000..64297a7e3
--- /dev/null
+++ b/spec/fabricators/custom_filter_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:custom_filter) do
+  account
+  expires_at nil
+  phrase     'discourse'
+  context    %w(home notifications)
+end
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 6ead5bbd9..d1b847675 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -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
diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb
new file mode 100644
index 000000000..1024542e7
--- /dev/null
+++ b/spec/models/custom_filter_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe CustomFilter, type: :model do
+
+end