about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorClaire <claire.github-309c@sitedethib.com>2022-06-28 09:42:13 +0200
committerGitHub <noreply@github.com>2022-06-28 09:42:13 +0200
commit02851848e964675bb59919fa5fd1bdee2c1c29db (patch)
tree55f0836a4dda9f8de3a9c9511d59e5b726fd77a7 /app
parent5823ae70c4c7c297c8d69ecd0be8df65019411e3 (diff)
Revamp post filtering system (#18058)
* Add model for custom filter keywords

* Use CustomFilterKeyword internally

Does not change the API

* Fix /filters/edit and /filters/new

* Add migration tests

* Remove whole_word column from custom_filters (covered by custom_filter_keywords)

* Redesign /filters

Instead of a list, present a card that displays more information and handles
multiple keywords per filter.

* Redesign /filters/new and /filters/edit to add and remove keywords

This adds a new gem dependency: cocoon, as well as a npm dependency:
cocoon-js-vanilla. Those are used to easily populate and remove form fields
from the user interface when manipulating multiple keyword filters at once.

* Add /api/v2/filters to edit filter with multiple keywords

Entities:
- `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context`
  `keywords`
- `FilterKeyword`: `id`, `keyword`, `whole_word`

API endpoits:
- `GET /api/v2/filters` to list filters (including keywords)
- `POST /api/v2/filters` to create a new filter
  `keywords_attributes` can also be passed to create keywords in one request
- `GET /api/v2/filters/:id` to read a particular filter
- `PUT /api/v2/filters/:id` to update a new filter
  `keywords_attributes` can also be passed to edit, delete or add keywords in
   one request
- `DELETE /api/v2/filters/:id` to delete a particular filter
- `GET /api/v2/filters/:id/keywords` to list keywords for a filter
- `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a
   filter
- `GET /api/v2/filter_keywords/:id` to read a particular keyword
- `PUT /api/v2/filter_keywords/:id` to edit a particular keyword
- `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword

* Change from `irreversible` boolean to `action` enum

* Remove irrelevent `irreversible_must_be_within_context` check

* Fix /filters/new and /filters/edit with update for filter_action

* Fix Rubocop/Codeclimate complaining about task names

* Refactor FeedManager#phrase_filtered?

This moves regexp building and filter caching to the `CustomFilter` class.

This does not change the functional behavior yet, but this changes how the
cache is built, doing per-custom_filter regexps so that filters can be matched
independently, while still offering caching.

* Perform server-side filtering and output result in REST API

* Fix numerous filters_changed events being sent when editing multiple keywords at once

* Add some tests

* Use the new API in the WebUI

- use client-side logic for filters we have fetched rules for.
  This is so that filter changes can be retroactively applied without
  reloading the UI.
- use server-side logic for filters we haven't fetched rules for yet
  (e.g. network error, or initial timeline loading)

* Minor optimizations and refactoring

* Perform server-side filtering on the streaming server

* Change the wording of filter action labels

* Fix issues pointed out by linter

* Change design of “Show anyway” link in accordence to review comments

* Drop “irreversible” filtering behavior

* Move /api/v2/filter_keywords to /api/v1/filters/keywords

* Rename `filter_results` attribute to `filtered`

* Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer

* Fix systemChannelId value in streaming server

* Simplify code by removing client-side filtering code

The simplifcation comes at a cost though: filters aren't retroactively
applied anymore.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/api/v1/filters/keywords_controller.rb50
-rw-r--r--app/controllers/api/v1/filters_controller.rb35
-rw-r--r--app/controllers/api/v2/filters_controller.rb48
-rw-r--r--app/controllers/filters_controller.rb12
-rw-r--r--app/javascript/mastodon/actions/filters.js26
-rw-r--r--app/javascript/mastodon/actions/importer/index.js11
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js12
-rw-r--r--app/javascript/mastodon/actions/notifications.js13
-rw-r--r--app/javascript/mastodon/actions/streaming.js4
-rw-r--r--app/javascript/mastodon/components/status.js21
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js17
-rw-r--r--app/javascript/mastodon/features/ui/index.js3
-rw-r--r--app/javascript/mastodon/reducers/filters.js34
-rw-r--r--app/javascript/mastodon/selectors/index.js51
-rw-r--r--app/javascript/packs/public.js1
-rw-r--r--app/javascript/styles/mastodon/admin.scss33
-rw-r--r--app/javascript/styles/mastodon/components.scss15
-rw-r--r--app/javascript/styles/mastodon/forms.scss31
-rw-r--r--app/lib/feed_manager.rb30
-rw-r--r--app/models/concerns/account_interactions.rb13
-rw-r--r--app/models/custom_filter.rb87
-rw-r--r--app/models/custom_filter_keyword.rb34
-rw-r--r--app/presenters/filter_result_presenter.rb5
-rw-r--r--app/presenters/status_relationships_presenter.rb24
-rw-r--r--app/serializers/rest/filter_keyword_serializer.rb9
-rw-r--r--app/serializers/rest/filter_result_serializer.rb6
-rw-r--r--app/serializers/rest/filter_serializer.rb8
-rw-r--r--app/serializers/rest/status_serializer.rb9
-rw-r--r--app/serializers/rest/v1/filter_serializer.rb26
-rw-r--r--app/views/filters/_fields.html.haml16
-rw-r--r--app/views/filters/_filter.html.haml32
-rw-r--r--app/views/filters/_filter_fields.html.haml33
-rw-r--r--app/views/filters/_keyword_fields.html.haml8
-rw-r--r--app/views/filters/edit.html.haml2
-rw-r--r--app/views/filters/index.html.haml17
-rw-r--r--app/views/filters/new.html.haml4
36 files changed, 593 insertions, 187 deletions
diff --git a/app/controllers/api/v1/filters/keywords_controller.rb b/app/controllers/api/v1/filters/keywords_controller.rb
new file mode 100644
index 000000000..d3718a137
--- /dev/null
+++ b/app/controllers/api/v1/filters/keywords_controller.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+class Api::V1::Filters::KeywordsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
+  before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
+  before_action :require_user!
+
+  before_action :set_keywords, only: :index
+  before_action :set_keyword, only: [:show, :update, :destroy]
+
+  def index
+    render json: @keywords, each_serializer: REST::FilterKeywordSerializer
+  end
+
+  def create
+    @keyword = current_account.custom_filters.find(params[:filter_id]).keywords.create!(resource_params)
+
+    render json: @keyword, serializer: REST::FilterKeywordSerializer
+  end
+
+  def show
+    render json: @keyword, serializer: REST::FilterKeywordSerializer
+  end
+
+  def update
+    @keyword.update!(resource_params)
+
+    render json: @keyword, serializer: REST::FilterKeywordSerializer
+  end
+
+  def destroy
+    @keyword.destroy!
+    render_empty
+  end
+
+  private
+
+  def set_keywords
+    filter = current_account.custom_filters.includes(:keywords).find(params[:filter_id])
+    @keywords = filter.keywords
+  end
+
+  def set_keyword
+    @keyword = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id])
+  end
+
+  def resource_params
+    params.permit(:keyword, :whole_word)
+  end
+end
diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb
index b0ace3af0..07cd14147 100644
--- a/app/controllers/api/v1/filters_controller.rb
+++ b/app/controllers/api/v1/filters_controller.rb
@@ -8,21 +8,32 @@ class Api::V1::FiltersController < Api::BaseController
   before_action :set_filter, only: [:show, :update, :destroy]
 
   def index
-    render json: @filters, each_serializer: REST::FilterSerializer
+    render json: @filters, each_serializer: REST::V1::FilterSerializer
   end
 
   def create
-    @filter = current_account.custom_filters.create!(resource_params)
-    render json: @filter, serializer: REST::FilterSerializer
+    ApplicationRecord.transaction do
+      filter_category = current_account.custom_filters.create!(resource_params)
+      @filter = filter_category.keywords.create!(keyword_params)
+    end
+
+    render json: @filter, serializer: REST::V1::FilterSerializer
   end
 
   def show
-    render json: @filter, serializer: REST::FilterSerializer
+    render json: @filter, serializer: REST::V1::FilterSerializer
   end
 
   def update
-    @filter.update!(resource_params)
-    render json: @filter, serializer: REST::FilterSerializer
+    ApplicationRecord.transaction do
+      @filter.update!(keyword_params)
+      @filter.custom_filter.assign_attributes(filter_params)
+      raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1
+
+      @filter.custom_filter.save!
+    end
+
+    render json: @filter, serializer: REST::V1::FilterSerializer
   end
 
   def destroy
@@ -33,14 +44,22 @@ class Api::V1::FiltersController < Api::BaseController
   private
 
   def set_filters
-    @filters = current_account.custom_filters
+    @filters = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account })
   end
 
   def set_filter
-    @filter = current_account.custom_filters.find(params[:id])
+    @filter = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id])
   end
 
   def resource_params
     params.permit(:phrase, :expires_in, :irreversible, :whole_word, context: [])
   end
+
+  def filter_params
+    resource_params.slice(:expires_in, :irreversible, :context)
+  end
+
+  def keyword_params
+    resource_params.slice(:phrase, :whole_word)
+  end
 end
diff --git a/app/controllers/api/v2/filters_controller.rb b/app/controllers/api/v2/filters_controller.rb
new file mode 100644
index 000000000..8ff3076cf
--- /dev/null
+++ b/app/controllers/api/v2/filters_controller.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class Api::V2::FiltersController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
+  before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
+  before_action :require_user!
+  before_action :set_filters, only: :index
+  before_action :set_filter, only: [:show, :update, :destroy]
+
+  def index
+    render json: @filters, each_serializer: REST::FilterSerializer, rules_requested: true
+  end
+
+  def create
+    @filter = current_account.custom_filters.create!(resource_params)
+
+    render json: @filter, serializer: REST::FilterSerializer, rules_requested: true
+  end
+
+  def show
+    render json: @filter, serializer: REST::FilterSerializer, rules_requested: true
+  end
+
+  def update
+    @filter.update!(resource_params)
+
+    render json: @filter, serializer: REST::FilterSerializer, rules_requested: true
+  end
+
+  def destroy
+    @filter.destroy!
+    render_empty
+  end
+
+  private
+
+  def set_filters
+    @filters = current_account.custom_filters.includes(:keywords)
+  end
+
+  def set_filter
+    @filter = current_account.custom_filters.find(params[:id])
+  end
+
+  def resource_params
+    params.permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
+  end
+end
diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb
index 79a1ab02b..5ed53bce1 100644
--- a/app/controllers/filters_controller.rb
+++ b/app/controllers/filters_controller.rb
@@ -4,16 +4,16 @@ class FiltersController < ApplicationController
   layout 'admin'
 
   before_action :authenticate_user!
-  before_action :set_filters, only: :index
   before_action :set_filter, only: [:edit, :update, :destroy]
   before_action :set_body_classes
 
   def index
-    @filters = current_account.custom_filters.order(:phrase)
+    @filters = current_account.custom_filters.includes(:keywords).order(:phrase)
   end
 
   def new
-    @filter = current_account.custom_filters.build
+    @filter = current_account.custom_filters.build(action: :warn)
+    @filter.keywords.build
   end
 
   def create
@@ -43,16 +43,12 @@ class FiltersController < ApplicationController
 
   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, :whole_word, context: [])
+    params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
   end
 
   def set_body_classes
diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js
deleted file mode 100644
index 7fa1c9a70..000000000
--- a/app/javascript/mastodon/actions/filters.js
+++ /dev/null
@@ -1,26 +0,0 @@
-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/importer/index.js b/app/javascript/mastodon/actions/importer/index.js
index f4372fb31..9c69be601 100644
--- a/app/javascript/mastodon/actions/importer/index.js
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -5,6 +5,7 @@ export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
 export const STATUS_IMPORT   = 'STATUS_IMPORT';
 export const STATUSES_IMPORT = 'STATUSES_IMPORT';
 export const POLLS_IMPORT    = 'POLLS_IMPORT';
+export const FILTERS_IMPORT  = 'FILTERS_IMPORT';
 
 function pushUnique(array, object) {
   if (array.every(element => element.id !== object.id)) {
@@ -28,6 +29,10 @@ export function importStatuses(statuses) {
   return { type: STATUSES_IMPORT, statuses };
 }
 
+export function importFilters(filters) {
+  return { type: FILTERS_IMPORT, filters };
+}
+
 export function importPolls(polls) {
   return { type: POLLS_IMPORT, polls };
 }
@@ -61,11 +66,16 @@ export function importFetchedStatuses(statuses) {
     const accounts = [];
     const normalStatuses = [];
     const polls = [];
+    const filters = [];
 
     function processStatus(status) {
       pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
       pushUnique(accounts, status.account);
 
+      if (status.filtered) {
+        status.filtered.forEach(result => pushUnique(filters, result.filter));
+      }
+
       if (status.reblog && status.reblog.id) {
         processStatus(status.reblog);
       }
@@ -80,6 +90,7 @@ export function importFetchedStatuses(statuses) {
     dispatch(importPolls(polls));
     dispatch(importFetchedAccounts(accounts));
     dispatch(importStatuses(normalStatuses));
+    dispatch(importFilters(filters));
   };
 }
 
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index ca76e3494..8a22f83fa 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -42,6 +42,14 @@ export function normalizeAccount(account) {
   return account;
 }
 
+export function normalizeFilterResult(result) {
+  const normalResult = { ...result };
+
+  normalResult.filter = normalResult.filter.id;
+
+  return normalResult;
+}
+
 export function normalizeStatus(status, normalOldStatus) {
   const normalStatus   = { ...status };
   normalStatus.account = status.account.id;
@@ -54,6 +62,10 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.poll = status.poll.id;
   }
 
+  if (status.filtered) {
+    normalStatus.filtered = status.filtered.map(normalizeFilterResult);
+  }
+
   // Only calculate these values when status first encountered and
   // when the underlying values change. Otherwise keep the ones
   // already in the reducer
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 84dfbeef3..3c42f71da 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -12,10 +12,8 @@ import { saveSettings } from './settings';
 import { defineMessages } from 'react-intl';
 import { List as ImmutableList } from 'immutable';
 import { unescapeHTML } from '../utils/html';
-import { getFiltersRegex } from '../selectors';
 import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
 import compareId from 'mastodon/compare_id';
-import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
 import { requestNotificationPermission } from '../utils/notifications';
 
 export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE';
@@ -62,20 +60,17 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
     const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type;
     const showAlert    = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
     const playSound    = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
-    const filters      = getFiltersRegex(getState(), { contextType: 'notifications' });
 
     let filtered = false;
 
-    if (['mention', 'status'].includes(notification.type)) {
-      const dropRegex   = filters[0];
-      const regex       = filters[1];
-      const searchIndex = searchTextFromRawStatus(notification.status);
+    if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
+      const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
 
-      if (dropRegex && dropRegex.test(searchIndex)) {
+      if (filters.some(result => result.filter.filter_action === 'hide')) {
         return;
       }
 
-      filtered = regex && regex.test(searchIndex);
+      filtered = filters.length > 0;
     }
 
     if (['follow_request'].includes(notification.type)) {
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index d76f045c8..84709083f 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -21,7 +21,6 @@ import {
   updateReaction as updateAnnouncementsReaction,
   deleteAnnouncement,
 } from './announcements';
-import { fetchFilters } from './filters';
 import { getLocale } from '../locales';
 
 const { messages } = getLocale();
@@ -97,9 +96,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         case 'conversation':
           dispatch(updateConversations(JSON.parse(data.payload)));
           break;
-        case 'filters_changed':
-          dispatch(fetchFilters());
-          break;
         case 'announcement':
           dispatch(updateAnnouncements(JSON.parse(data.payload)));
           break;
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 7c44669d2..4ca392824 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -116,6 +116,7 @@ class Status extends ImmutablePureComponent {
   state = {
     showMedia: defaultMediaVisibility(this.props.status),
     statusId: undefined,
+    forceFilter: undefined,
   };
 
   static getDerivedStateFromProps(nextProps, prevState) {
@@ -277,6 +278,15 @@ class Status extends ImmutablePureComponent {
     this.handleToggleMediaVisibility();
   }
 
+  handleUnfilterClick = e => {
+    this.setState({ forceFilter: false });
+    e.preventDefault();
+  }
+
+  handleFilterClick = () => {
+    this.setState({ forceFilter: true });
+  }
+
   _properStatus () {
     const { status } = this.props;
 
@@ -328,7 +338,8 @@ class Status extends ImmutablePureComponent {
       );
     }
 
-    if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
+    const matchedFilters = status.get('filtered') || status.getIn(['reblog', 'filtered']);
+    if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
       const minHandlers = this.props.muted ? {} : {
         moveUp: this.handleHotkeyMoveUp,
         moveDown: this.handleHotkeyMoveDown,
@@ -337,7 +348,11 @@ class Status extends ImmutablePureComponent {
       return (
         <HotKeys handlers={minHandlers}>
           <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
-            <FormattedMessage id='status.filtered' defaultMessage='Filtered' />
+            <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
+            {' '}
+            <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
+              <FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
+            </button>
           </div>
         </HotKeys>
       );
@@ -496,7 +511,7 @@ class Status extends ImmutablePureComponent {
 
             {media}
 
-            <StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
+            <StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters && this.handleFilterClick} {...other} />
           </div>
         </div>
       </HotKeys>
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 1d8fe23da..ab8755be0 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -38,6 +38,7 @@ const messages = defineMessages({
   admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
+  hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
   blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
@@ -76,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent {
     onMuteConversation: PropTypes.func,
     onPin: PropTypes.func,
     onBookmark: PropTypes.func,
+    onFilter: PropTypes.func,
     withDismiss: PropTypes.bool,
     withCounters: PropTypes.bool,
     scrollKey: PropTypes.string,
@@ -207,6 +209,10 @@ class StatusActionBar extends ImmutablePureComponent {
     this.props.onMuteConversation(this.props.status);
   }
 
+  handleFilter = () => {
+    this.props.onFilter();
+  }
+
   handleCopy = () => {
     const url      = this.props.status.get('url');
     const textarea = document.createElement('textarea');
@@ -226,6 +232,11 @@ class StatusActionBar extends ImmutablePureComponent {
     }
   }
 
+
+  handleFilterClick = () => {
+    this.props.onFilter();
+  }
+
   render () {
     const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
 
@@ -329,6 +340,10 @@ class StatusActionBar extends ImmutablePureComponent {
       <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
     );
 
+    const filterButton = this.props.onFilter && (
+      <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
+    );
+
     return (
       <div className='status__action-bar'>
         <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
@@ -337,6 +352,8 @@ class StatusActionBar extends ImmutablePureComponent {
 
         {shareButton}
 
+        {filterButton}
+
         <div className='status__action-bar-dropdown'>
           <DropdownMenuContainer
             scrollKey={scrollKey}
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 1ee038223..9a901f12a 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -13,7 +13,6 @@ import { debounce } from 'lodash';
 import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
 import { expandHomeTimeline } from '../../actions/timelines';
 import { expandNotifications } from '../../actions/notifications';
-import { fetchFilters } from '../../actions/filters';
 import { fetchRules } from '../../actions/rules';
 import { clearHeight } from '../../actions/height_cache';
 import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
@@ -368,7 +367,7 @@ class UI extends React.PureComponent {
     this.props.dispatch(fetchMarkers());
     this.props.dispatch(expandHomeTimeline());
     this.props.dispatch(expandNotifications());
-    setTimeout(() => this.props.dispatch(fetchFilters()), 500);
+
     setTimeout(() => this.props.dispatch(fetchRules()), 3000);
 
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js
index 33f0c6732..14b704027 100644
--- a/app/javascript/mastodon/reducers/filters.js
+++ b/app/javascript/mastodon/reducers/filters.js
@@ -1,10 +1,34 @@
-import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
-import { List as ImmutableList, fromJS } from 'immutable';
+import { FILTERS_IMPORT } from '../actions/importer';
+import { Map as ImmutableMap, is, fromJS } from 'immutable';
 
-export default function filters(state = ImmutableList(), action) {
+const normalizeFilter = (state, filter) => {
+  const normalizedFilter = fromJS({
+    id: filter.id,
+    title: filter.title,
+    context: filter.context,
+    filter_action: filter.filter_action,
+    expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null,
+  });
+
+  if (is(state.get(filter.id), normalizedFilter)) {
+    return state;
+  } else {
+    return state.set(filter.id, normalizedFilter);
+  }
+};
+
+const normalizeFilters = (state, filters) => {
+  filters.forEach(filter => {
+    state = normalizeFilter(state, filter);
+  });
+
+  return state;
+};
+
+export default function filters(state = ImmutableMap(), action) {
   switch(action.type) {
-  case FILTERS_FETCH_SUCCESS:
-    return fromJS(action.filters);
+  case FILTERS_IMPORT:
+    return normalizeFilters(state, action.filters);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index fbd25b605..6aeb8b7bd 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -40,15 +40,15 @@ const toServerSideType = columnType => {
 const escapeRegExp = string =>
   string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
 
-const regexFromFilters = filters => {
-  if (filters.size === 0) {
+const regexFromKeywords = keywords => {
+  if (keywords.size === 0) {
     return null;
   }
 
-  return new RegExp(filters.map(filter => {
-    let expr = escapeRegExp(filter.get('phrase'));
+  return new RegExp(keywords.map(keyword_filter => {
+    let expr = escapeRegExp(keyword_filter.get('keyword'));
 
-    if (filter.get('whole_word')) {
+    if (keyword_filter.get('whole_word')) {
       if (/^[\w]/.test(expr)) {
         expr = `\\b${expr}`;
       }
@@ -62,27 +62,15 @@ const regexFromFilters = filters => {
   }).join('|'), 'i');
 };
 
-// Memoize the filter regexps for each valid server contextType
-const makeGetFiltersRegex = () => {
-  let memo = {};
+const getFilters = (state, { contextType }) => {
+  if (!contextType) return null;
 
-  return (state, { contextType }) => {
-    if (!contextType) return ImmutableList();
+  const serverSideType = toServerSideType(contextType);
+  const now = new Date();
 
-    const serverSideType = toServerSideType(contextType);
-    const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
-
-    if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) {
-      const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
-      const regex = regexFromFilters(filters);
-      memo[serverSideType] = { filters: filters, results: [dropRegex, regex] };
-    }
-    return memo[serverSideType].results;
-  };
+  return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now));
 };
 
-export const getFiltersRegex = makeGetFiltersRegex();
-
 export const makeGetStatus = () => {
   return createSelector(
     [
@@ -90,10 +78,10 @@ export const makeGetStatus = () => {
       (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'])]),
-      getFiltersRegex,
+      getFilters,
     ],
 
-    (statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
+    (statusBase, statusReblog, accountBase, accountReblog, filters) => {
       if (!statusBase) {
         return null;
       }
@@ -104,14 +92,17 @@ export const makeGetStatus = () => {
         statusReblog = null;
       }
 
-      const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
-      if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
-        return null;
+      let filtered = false;
+      if ((accountReblog || accountBase).get('id') !== me && filters) {
+        let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList();
+        if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
+          return null;
+        }
+        if (!filterResults.isEmpty()) {
+          filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
+        }
       }
 
-      const regex     = (accountReblog || accountBase).get('id') !== me && filtersRegex[1];
-      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);
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 3d0a937e1..e42468e0c 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -4,6 +4,7 @@ import loadPolyfills from '../mastodon/load_polyfills';
 import ready from '../mastodon/ready';
 import { start } from '../mastodon/common';
 import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
+import 'cocoon-js-vanilla';
 
 start();
 
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 66e2997f1..4ce5cd101 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -915,7 +915,8 @@ a.name-tag,
   text-align: center;
 }
 
-.applications-list__item {
+.applications-list__item,
+.filters-list__item {
   padding: 15px 0;
   background: $ui-base-color;
   border: 1px solid lighten($ui-base-color, 4%);
@@ -923,7 +924,8 @@ a.name-tag,
   margin-top: 15px;
 }
 
-.announcements-list {
+.announcements-list,
+.filters-list {
   border: 1px solid lighten($ui-base-color, 4%);
   border-radius: 4px;
 
@@ -976,6 +978,33 @@ a.name-tag,
   }
 }
 
+.filters-list__item {
+  &__title {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 0;
+  }
+
+  &__permissions {
+    margin-top: 0;
+    margin-bottom: 10px;
+  }
+
+  .expiration {
+    font-size: 13px;
+  }
+
+  &.expired {
+    .expiration {
+      color: lighten($error-red, 12%);
+    }
+
+    .permissions-list__item__icon {
+      color: $dark-text-color;
+    }
+  }
+}
+
 .dashboard__counters.admin-account-counters {
   margin-top: 10px;
 }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 7e3ce3de2..592ce91f3 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -959,6 +959,21 @@
   width: 100%;
   clear: both;
   border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+  &__button {
+    display: inline;
+    color: lighten($ui-highlight-color, 8%);
+    border: 0;
+    background: transparent;
+    padding: 0;
+    font-size: inherit;
+    line-height: inherit;
+
+    &:hover,
+    &:active {
+      text-decoration: underline;
+    }
+  }
 }
 
 .status__prepend-icon-wrapper {
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index d57eabc09..da699dd25 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -1070,3 +1070,34 @@ code {
     }
   }
 }
+
+.keywords-table {
+  thead {
+    th {
+      white-space: nowrap;
+    }
+
+    th:first-child {
+      width: 100%;
+    }
+  }
+
+  tfoot {
+    td {
+      border: 0;
+    }
+  }
+
+  .input.string {
+    margin-bottom: 0;
+  }
+
+  .label_input__wrapper {
+    margin-top: 10px;
+  }
+
+  .table-action-link {
+    margin-top: 10px;
+    white-space: nowrap;
+  }
+}
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 4811ebbcc..2eb4ba2f4 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -352,7 +352,6 @@ class FeedManager
   def filter_from_home?(status, receiver_id, crutches)
     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 = crutches[:active_mentions][status.id] || []
     check_for_blocks.concat([status.account_id])
@@ -388,7 +387,6 @@ class FeedManager
   # @return [Boolean]
   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
@@ -418,34 +416,6 @@ class FeedManager
     false
   end
 
-  # Check if the status hits a phrase filter
-  # @param [Status] status
-  # @param [Integer] receiver_id
-  # @param [Symbol] context
-  # @return [Boolean]
-  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! do |filter|
-      if filter.whole_word
-        sb = /\A[[:word:]]/.match?(filter.phrase) ? '\b' : ''
-        eb = /[[:word:]]\z/.match?(filter.phrase) ? '\b' : ''
-
-        /(?mix:#{sb}#{Regexp.escape(filter.phrase)}#{eb})/
-      else
-        /#{Regexp.escape(filter.phrase)}/i
-      end
-    end
-
-    return false if active_filters.empty?
-
-    combined_regex = Regexp.union(active_filters)
-
-    combined_regex.match?(status.proper.searchable_text)
-  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/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index ad1665dc4..a7401362f 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -247,6 +247,19 @@ module AccountInteractions
     account_pins.where(target_account: account).exists?
   end
 
+  def status_matches_filters(status)
+    active_filters = CustomFilter.cached_filters_for(id)
+
+    filter_matches = active_filters.filter_map do |filter, rules|
+      next if rules[:keywords].blank?
+
+      match = rules[:keywords].match(status.proper.searchable_text)
+      FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil?
+    end
+
+    filter_matches
+  end
+
   def followers_for_local_distribution
     followers.local
              .joins(:user)
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 8e3476794..e98ed7df9 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -3,18 +3,22 @@
 #
 # 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
-#  whole_word   :boolean          default(TRUE), not null
-#  irreversible :boolean          default(FALSE), not null
-#  created_at   :datetime         not null
-#  updated_at   :datetime         not null
+#  id         :bigint           not null, primary key
+#  account_id :bigint
+#  expires_at :datetime
+#  phrase     :text             default(""), not null
+#  context    :string           default([]), not null, is an Array
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#  action     :integer          default(0), not null
 #
 
 class CustomFilter < ApplicationRecord
+  self.ignored_columns = %w(whole_word irreversible)
+
+  alias_attribute :title, :phrase
+  alias_attribute :filter_action, :action
+
   VALID_CONTEXTS = %w(
     home
     notifications
@@ -26,16 +30,20 @@ class CustomFilter < ApplicationRecord
   include Expireable
   include Redisable
 
+  enum action: [:warn, :hide], _suffix: :action
+
   belongs_to :account
+  has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
+  accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
 
-  validates :phrase, :context, presence: true
+  validates :title, :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
+
+  before_save :prepare_cache_invalidation!
+  before_destroy :prepare_cache_invalidation!
+  after_commit :invalidate_cache!
 
   def expires_in
     return @expires_in if defined?(@expires_in)
@@ -44,22 +52,55 @@ class CustomFilter < ApplicationRecord
     [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at }
   end
 
-  private
+  def irreversible=(value)
+    self.action = value ? :hide : :warn
+  end
 
-  def clean_up_contexts
-    self.context = Array(context).map(&:strip).filter_map(&:presence)
+  def irreversible?
+    hide_action?
+  end
+
+  def self.cached_filters_for(account_id)
+    active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do
+      scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
+      scope.to_a.group_by(&:custom_filter).map do |filter, keywords|
+        keywords.map! do |keyword|
+          if keyword.whole_word
+            sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : ''
+            eb = /[[:word:]]\z/.match?(keyword.keyword) ? '\b' : ''
+
+            /(?mix:#{sb}#{Regexp.escape(keyword.keyword)}#{eb})/
+          else
+            /#{Regexp.escape(keyword.keyword)}/i
+          end
+        end
+        [filter, { keywords: Regexp.union(keywords) }]
+      end
+    end.to_a
+
+    active_filters.select { |custom_filter, _| !custom_filter.expired? }
+  end
+
+  def prepare_cache_invalidation!
+    @should_invalidate_cache = true
   end
 
-  def remove_cache
-    Rails.cache.delete("filters:#{account_id}")
+  def invalidate_cache!
+    return unless @should_invalidate_cache
+    @should_invalidate_cache = false
+
+    Rails.cache.delete("filters:v3:#{account_id}")
     redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed))
+    redis.publish("timeline:system:#{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) }
+  private
+
+  def clean_up_contexts
+    self.context = Array(context).map(&:strip).filter_map(&:presence)
   end
 
-  def irreversible_must_be_within_context
-    errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications')
+  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
 end
diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb
new file mode 100644
index 000000000..bf5c55746
--- /dev/null
+++ b/app/models/custom_filter_keyword.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: custom_filter_keywords
+#
+#  id               :bigint           not null, primary key
+#  custom_filter_id :bigint           not null
+#  keyword          :text             default(""), not null
+#  whole_word       :boolean          default(TRUE), not null
+#  created_at       :datetime         not null
+#  updated_at       :datetime         not null
+#
+
+class CustomFilterKeyword < ApplicationRecord
+  belongs_to :custom_filter
+
+  validates :keyword, presence: true
+
+  alias_attribute :phrase, :keyword
+
+  before_save :prepare_cache_invalidation!
+  before_destroy :prepare_cache_invalidation!
+  after_commit :invalidate_cache!
+
+  private
+
+  def prepare_cache_invalidation!
+    custom_filter.prepare_cache_invalidation!
+  end
+
+  def invalidate_cache!
+    custom_filter.invalidate_cache!
+  end
+end
diff --git a/app/presenters/filter_result_presenter.rb b/app/presenters/filter_result_presenter.rb
new file mode 100644
index 000000000..677225f5e
--- /dev/null
+++ b/app/presenters/filter_result_presenter.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class FilterResultPresenter < ActiveModelSerializers::Model
+  attributes :filter, :keyword_matches
+end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 4163bb098..d7ffb1954 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -2,7 +2,7 @@
 
 class StatusRelationshipsPresenter
   attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
-              :bookmarks_map
+              :bookmarks_map, :filters_map
 
   def initialize(statuses, current_account_id = nil, **options)
     if current_account_id.nil?
@@ -11,12 +11,14 @@ class StatusRelationshipsPresenter
       @bookmarks_map  = {}
       @mutes_map      = {}
       @pins_map       = {}
+      @filters_map    = {}
     else
       statuses            = statuses.compact
       status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
       conversation_ids    = statuses.filter_map(&:conversation_id).uniq
       pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) }
 
+      @filters_map     = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {})
       @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
       @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
       @bookmarks_map   = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
@@ -24,4 +26,24 @@ class StatusRelationshipsPresenter
       @pins_map        = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
     end
   end
+
+  private
+
+  def build_filters_map(statuses, current_account_id)
+    active_filters = CustomFilter.cached_filters_for(current_account_id)
+
+    @filters_map = statuses.each_with_object({}) do |status, h|
+      filter_matches = active_filters.filter_map do |filter, rules|
+        next if rules[:keywords].blank?
+
+        match = rules[:keywords].match(status.proper.searchable_text)
+        FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil?
+      end
+
+      unless filter_matches.empty?
+        h[status.id] = filter_matches
+        h[status.reblog_of_id] = filter_matches if status.reblog?
+      end
+    end
+  end
 end
diff --git a/app/serializers/rest/filter_keyword_serializer.rb b/app/serializers/rest/filter_keyword_serializer.rb
new file mode 100644
index 000000000..dd2ebac6e
--- /dev/null
+++ b/app/serializers/rest/filter_keyword_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class REST::FilterKeywordSerializer < ActiveModel::Serializer
+  attributes :id, :keyword, :whole_word
+
+  def id
+    object.id.to_s
+  end
+end
diff --git a/app/serializers/rest/filter_result_serializer.rb b/app/serializers/rest/filter_result_serializer.rb
new file mode 100644
index 000000000..0ef4db79a
--- /dev/null
+++ b/app/serializers/rest/filter_result_serializer.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class REST::FilterResultSerializer < ActiveModel::Serializer
+  belongs_to :filter, serializer: REST::FilterSerializer
+  has_many :keyword_matches
+end
diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb
index 57205630b..98d7edb17 100644
--- a/app/serializers/rest/filter_serializer.rb
+++ b/app/serializers/rest/filter_serializer.rb
@@ -1,10 +1,14 @@
 # frozen_string_literal: true
 
 class REST::FilterSerializer < ActiveModel::Serializer
-  attributes :id, :phrase, :context, :whole_word, :expires_at,
-             :irreversible
+  attributes :id, :title, :context, :expires_at, :filter_action
+  has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested?
 
   def id
     object.id.to_s
   end
+
+  def rules_requested?
+    instance_options[:rules_requested]
+  end
 end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 6bd6a23e5..e0b8f32a6 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -13,6 +13,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :muted, if: :current_user?
   attribute :bookmarked, if: :current_user?
   attribute :pinned, if: :pinnable?
+  has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user?
 
   attribute :content, unless: :source_requested?
   attribute :text, if: :source_requested?
@@ -120,6 +121,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
     end
   end
 
+  def filtered
+    if instance_options && instance_options[:relationships]
+      instance_options[:relationships].filters_map[object.id] || []
+    else
+      current_user.account.status_matches_filters(object)
+    end
+  end
+
   def pinnable?
     current_user? &&
       current_user.account_id == object.account_id &&
diff --git a/app/serializers/rest/v1/filter_serializer.rb b/app/serializers/rest/v1/filter_serializer.rb
new file mode 100644
index 000000000..455f17efd
--- /dev/null
+++ b/app/serializers/rest/v1/filter_serializer.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class REST::V1::FilterSerializer < ActiveModel::Serializer
+  attributes :id, :phrase, :context, :whole_word, :expires_at,
+             :irreversible
+
+  delegate :context, :expires_at, to: :custom_filter
+
+  def id
+    object.id.to_s
+  end
+
+  def phrase
+    object.keyword
+  end
+
+  def irreversible
+    custom_filter.irreversible?
+  end
+
+  private
+
+  def custom_filter
+    object.custom_filter
+  end
+end
diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml
deleted file mode 100644
index 84dcdcca5..000000000
--- a/app/views/filters/_fields.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-.fields-row
-  .fields-row__column.fields-row__column-6.fields-group
-    = f.input :phrase, as: :string, wrapper: :with_label, hint: false
-  .fields-row__column.fields-row__column-6.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}") }, include_blank: I18n.t('invites.expires_in_prompt')
-
-.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
-
-%hr.spacer/
-
-.fields-group
-  = f.input :irreversible, wrapper: :with_label
-
-.fields-group
-  = f.input :whole_word, wrapper: :with_label
diff --git a/app/views/filters/_filter.html.haml b/app/views/filters/_filter.html.haml
new file mode 100644
index 000000000..2ab014081
--- /dev/null
+++ b/app/views/filters/_filter.html.haml
@@ -0,0 +1,32 @@
+.filters-list__item{ class: [filter.expired? && 'expired'] }
+  = link_to edit_filter_path(filter), class: 'filters-list__item__title' do
+    = filter.title
+
+    - if filter.expires?
+      .expiration{ title: t('filters.index.expires_on', date: l(filter.expires_at)) }
+        - if filter.expired?
+          = t('invites.expired')
+        - else
+          = t('filters.index.expires_in', distance: distance_of_time_in_words_to_now(filter.expires_at))
+
+  .filters-list__item__permissions
+    %ul.permissions-list
+      - unless filter.keywords.empty?
+        %li.permissions-list__item
+          .permissions-list__item__icon
+            = fa_icon('paragraph')
+          .permissions-list__item__text
+            .permissions-list__item__text__title
+              = t('filters.index.keywords', count: filter.keywords.size)
+            .permissions-list__item__text__type
+              - keywords = filter.keywords.map(&:keyword)
+              - keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO
+              = keywords.join(', ')
+
+  .announcements-list__item__action-bar
+    .announcements-list__item__meta
+      = t('filters.index.contexts', contexts: filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', '))
+
+    %div
+      = 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, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/filters/_filter_fields.html.haml b/app/views/filters/_filter_fields.html.haml
new file mode 100644
index 000000000..1a52faa7a
--- /dev/null
+++ b/app/views/filters/_filter_fields.html.haml
@@ -0,0 +1,33 @@
+.fields-row
+  .fields-row__column.fields-row__column-6.fields-group
+    = f.input :title, as: :string, wrapper: :with_label, hint: false
+  .fields-row__column.fields-row__column-6.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}") }, include_blank: I18n.t('invites.expires_in_prompt')
+
+.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
+
+%hr.spacer/
+
+.fields-group
+  = f.input :filter_action, as: :radio_buttons, collection: %i(warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true
+
+%hr.spacer/
+
+%h4= t('filters.edit.keywords')
+
+.table-wrapper
+  %table.table.keywords-table
+    %thead
+      %tr
+        %th= t('simple_form.labels.defaults.phrase')
+        %th= t('simple_form.labels.defaults.whole_word')
+        %th
+    %tbody
+      = f.simple_fields_for :keywords do |keyword|
+        = render 'keyword_fields', f: keyword
+    %tfoot
+      %tr
+        %td{ colspan: 3}
+          = link_to_add_association f, :keywords, class: 'table-action-link', partial: 'keyword_fields', 'data-association-insertion-node': '.keywords-table tbody', 'data-association-insertion-method': 'append' do
+            = safe_join([fa_icon('plus'), t('filters.edit.add_keyword')])
diff --git a/app/views/filters/_keyword_fields.html.haml b/app/views/filters/_keyword_fields.html.haml
new file mode 100644
index 000000000..eedd514ef
--- /dev/null
+++ b/app/views/filters/_keyword_fields.html.haml
@@ -0,0 +1,8 @@
+%tr.nested-fields
+  %td= f.input :keyword, as: :string
+  %td
+    .label_input__wrapper= f.input_field :whole_word
+  %td
+    = f.hidden_field :id if f.object&.persisted? # Required so Rails doesn't put the field outside of the <tr/>
+    = link_to_remove_association(f, class: 'table-action-link') do
+      = safe_join([fa_icon('times'), t('filters.index.delete')])
diff --git a/app/views/filters/edit.html.haml b/app/views/filters/edit.html.haml
index e971215ac..3dc3f07b7 100644
--- a/app/views/filters/edit.html.haml
+++ b/app/views/filters/edit.html.haml
@@ -2,7 +2,7 @@
   = t('filters.edit.title')
 
 = simple_form_for @filter, url: filter_path(@filter), method: :put do |f|
-  = render 'fields', f: f
+  = render 'filter_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
index b4d5333aa..0227526a4 100644
--- a/app/views/filters/index.html.haml
+++ b/app/views/filters/index.html.haml
@@ -7,18 +7,5 @@
 - if @filters.empty?
   %div.muted-hint.center-text= t 'filters.index.empty'
 - else
-  .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
+  .applications-list
+    = render partial: 'filter', collection: @filters
diff --git a/app/views/filters/new.html.haml b/app/views/filters/new.html.haml
index 05bec343f..5f400e604 100644
--- a/app/views/filters/new.html.haml
+++ b/app/views/filters/new.html.haml
@@ -2,7 +2,7 @@
   = t('filters.new.title')
 
 = simple_form_for @filter, url: filters_path do |f|
-  = render 'fields', f: f
+  = render 'filter_fields', f: f
 
   .actions
-    = f.button :button, t('filters.new.title'), type: :submit
+    = f.button :button, t('filters.new.save'), type: :submit