about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/api/v1/filters/statuses_controller.rb44
-rw-r--r--app/controllers/filters/statuses_controller.rb49
-rw-r--r--app/controllers/filters_controller.rb2
-rw-r--r--app/javascript/mastodon/actions/filters.js93
-rw-r--r--app/javascript/mastodon/actions/statuses.js4
-rw-r--r--app/javascript/mastodon/components/status.js1
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js16
-rw-r--r--app/javascript/mastodon/containers/status_container.js9
-rw-r--r--app/javascript/mastodon/features/compose/components/language_dropdown.js19
-rw-r--r--app/javascript/mastodon/features/filters/added_to_filter.js102
-rw-r--r--app/javascript/mastodon/features/filters/select_filter.js192
-rw-r--r--app/javascript/mastodon/features/ui/components/filter_modal.js134
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js4
-rw-r--r--app/javascript/mastodon/reducers/filters.js11
-rw-r--r--app/javascript/mastodon/selectors/index.js19
-rw-r--r--app/javascript/mastodon/utils/filters.js16
-rw-r--r--app/javascript/mastodon/utils/icons.js13
-rw-r--r--app/javascript/styles/mastodon/components.scss18
-rw-r--r--app/models/concerns/account_interactions.rb10
-rw-r--r--app/models/custom_filter.rb28
-rw-r--r--app/models/custom_filter_status.rb37
-rw-r--r--app/models/form/status_filter_batch_action.rb34
-rw-r--r--app/presenters/filter_result_presenter.rb2
-rw-r--r--app/presenters/status_relationships_presenter.rb7
-rw-r--r--app/serializers/rest/filter_result_serializer.rb5
-rw-r--r--app/serializers/rest/filter_serializer.rb1
-rw-r--r--app/serializers/rest/filter_status_serializer.rb13
-rw-r--r--app/views/filters/_filter.html.haml9
-rw-r--r--app/views/filters/_filter_fields.html.haml7
-rw-r--r--app/views/filters/statuses/_status_filter.html.haml37
-rw-r--r--app/views/filters/statuses/index.html.haml38
32 files changed, 915 insertions, 61 deletions
diff --git a/app/controllers/api/v1/filters/statuses_controller.rb b/app/controllers/api/v1/filters/statuses_controller.rb
new file mode 100644
index 000000000..b6bed306f
--- /dev/null
+++ b/app/controllers/api/v1/filters/statuses_controller.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class Api::V1::Filters::StatusesController < 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_status_filters, only: :index
+  before_action :set_status_filter, only: [:show, :destroy]
+
+  def index
+    render json: @status_filters, each_serializer: REST::FilterStatusSerializer
+  end
+
+  def create
+    @status_filter = current_account.custom_filters.find(params[:filter_id]).statuses.create!(resource_params)
+
+    render json: @status_filter, serializer: REST::FilterStatusSerializer
+  end
+
+  def show
+    render json: @status_filter, serializer: REST::FilterStatusSerializer
+  end
+
+  def destroy
+    @status_filter.destroy!
+    render_empty
+  end
+
+  private
+
+  def set_status_filters
+    filter = current_account.custom_filters.includes(:statuses).find(params[:filter_id])
+    @status_filters = filter.statuses
+  end
+
+  def set_status_filter
+    @status_filter = CustomFilterStatus.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id])
+  end
+
+  def resource_params
+    params.permit(:status_id)
+  end
+end
diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb
new file mode 100644
index 000000000..cc493c22c
--- /dev/null
+++ b/app/controllers/filters/statuses_controller.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class Filters::StatusesController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_filter
+  before_action :set_status_filters
+  before_action :set_body_classes
+
+  PER_PAGE = 20
+
+  def index
+    @status_filter_batch_action = Form::StatusFilterBatchAction.new
+  end
+
+  def batch
+    @status_filter_batch_action = Form::StatusFilterBatchAction.new(status_filter_batch_action_params.merge(current_account: current_account, filter_id: params[:filter_id], type: action_from_button))
+    @status_filter_batch_action.save!
+  rescue ActionController::ParameterMissing
+    flash[:alert] = I18n.t('admin.statuses.no_status_selected')
+  ensure
+    redirect_to edit_filter_path(@filter)
+  end
+
+  private
+
+  def set_filter
+    @filter = current_account.custom_filters.find(params[:filter_id])
+  end
+
+  def set_status_filters
+    @status_filters = @filter.statuses.preload(:status).page(params[:page]).per(PER_PAGE)
+  end
+
+  def status_filter_batch_action_params
+    params.require(:form_status_filter_batch_action).permit(status_filter_ids: [])
+  end
+
+  def action_from_button
+    if params[:remove]
+      'remove'
+    end
+  end
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
+end
diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb
index 5ed53bce1..cc5cb5d9f 100644
--- a/app/controllers/filters_controller.rb
+++ b/app/controllers/filters_controller.rb
@@ -8,7 +8,7 @@ class FiltersController < ApplicationController
   before_action :set_body_classes
 
   def index
-    @filters = current_account.custom_filters.includes(:keywords).order(:phrase)
+    @filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase)
   end
 
   def new
diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js
new file mode 100644
index 000000000..76326802e
--- /dev/null
+++ b/app/javascript/mastodon/actions/filters.js
@@ -0,0 +1,93 @@
+import api from '../api';
+import { openModal } from './modal';
+
+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 FILTERS_STATUS_CREATE_REQUEST = 'FILTERS_STATUS_CREATE_REQUEST';
+export const FILTERS_STATUS_CREATE_SUCCESS = 'FILTERS_STATUS_CREATE_SUCCESS';
+export const FILTERS_STATUS_CREATE_FAIL    = 'FILTERS_STATUS_CREATE_FAIL';
+
+export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
+export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
+export const FILTERS_CREATE_FAIL    = 'FILTERS_CREATE_FAIL';
+
+export const initAddFilter = (status, { contextType }) => dispatch =>
+  dispatch(openModal('FILTER', {
+    statusId: status?.get('id'),
+    contextType: contextType,
+  }));
+
+export const fetchFilters = () => (dispatch, getState) => {
+  dispatch({
+    type: FILTERS_FETCH_REQUEST,
+    skipLoading: true,
+  });
+
+  api(getState)
+    .get('/api/v2/filters')
+    .then(({ data }) => dispatch({
+      type: FILTERS_FETCH_SUCCESS,
+      filters: data,
+      skipLoading: true,
+    }))
+    .catch(err => dispatch({
+      type: FILTERS_FETCH_FAIL,
+      err,
+      skipLoading: true,
+      skipAlert: true,
+    }));
+};
+
+export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => {
+  dispatch(createFilterStatusRequest());
+
+  api(getState).post(`/api/v1/filters/${params.filter_id}/statuses`, params).then(response => {
+    dispatch(createFilterStatusSuccess(response.data));
+    if (onSuccess) onSuccess();
+  }).catch(error => {
+    dispatch(createFilterStatusFail(error));
+    if (onFail) onFail();
+  });
+};
+
+export const createFilterStatusRequest = () => ({
+  type: FILTERS_STATUS_CREATE_REQUEST,
+});
+
+export const createFilterStatusSuccess = filter_status => ({
+  type: FILTERS_STATUS_CREATE_SUCCESS,
+  filter_status,
+});
+
+export const createFilterStatusFail = error => ({
+  type: FILTERS_STATUS_CREATE_FAIL,
+  error,
+});
+
+export const createFilter = (params, onSuccess, onFail) => (dispatch, getState) => {
+  dispatch(createFilterRequest());
+
+  api(getState).post('/api/v2/filters', params).then(response => {
+    dispatch(createFilterSuccess(response.data));
+    if (onSuccess) onSuccess(response.data);
+  }).catch(error => {
+    dispatch(createFilterFail(error));
+    if (onFail) onFail();
+  });
+};
+
+export const createFilterRequest = () => ({
+  type: FILTERS_CREATE_REQUEST,
+});
+
+export const createFilterSuccess = filter => ({
+  type: FILTERS_CREATE_SUCCESS,
+  filter,
+});
+
+export const createFilterFail = error => ({
+  type: FILTERS_CREATE_FAIL,
+  error,
+});
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index adc24eabf..32a4f1f85 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -42,9 +42,9 @@ export function fetchStatusRequest(id, skipLoading) {
   };
 };
 
-export function fetchStatus(id) {
+export function fetchStatus(id, forceFetch = false) {
   return (dispatch, getState) => {
-    const skipLoading = getState().getIn(['statuses', id], null) !== null;
+    const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
 
     dispatch(fetchContext(id));
 
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index dee935a6c..d36311c67 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -80,6 +80,7 @@ class Status extends ImmutablePureComponent {
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
     onBlock: PropTypes.func,
+    onAddFilter: PropTypes.func,
     onEmbed: PropTypes.func,
     onHeightChange: PropTypes.func,
     onToggleHidden: PropTypes.func,
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index d44da482d..4b384e6e5 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -44,6 +44,7 @@ const messages = defineMessages({
   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
 });
 
 const mapStateToProps = (state, { status }) => ({
@@ -80,6 +81,7 @@ class StatusActionBar extends ImmutablePureComponent {
     onPin: PropTypes.func,
     onBookmark: PropTypes.func,
     onFilter: PropTypes.func,
+    onAddFilter: PropTypes.func,
     withDismiss: PropTypes.bool,
     withCounters: PropTypes.bool,
     scrollKey: PropTypes.string,
@@ -211,8 +213,8 @@ class StatusActionBar extends ImmutablePureComponent {
     this.props.onMuteConversation(this.props.status);
   }
 
-  handleFilter = () => {
-    this.props.onFilter();
+  handleFilterClick = () => {
+    this.props.onAddFilter(this.props.status);
   }
 
   handleCopy = () => {
@@ -235,7 +237,7 @@ class StatusActionBar extends ImmutablePureComponent {
   }
 
 
-  handleFilterClick = () => {
+  handleHideClick = () => {
     this.props.onFilter();
   }
 
@@ -294,6 +296,12 @@ class StatusActionBar extends ImmutablePureComponent {
         menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
       }
 
+      if (!this.props.onFilter) {
+        menu.push(null);
+        menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick });
+        menu.push(null);
+      }
+
       menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport });
 
       if (account.get('acct') !== account.get('username')) {
@@ -343,7 +351,7 @@ class StatusActionBar extends ImmutablePureComponent {
     );
 
     const filterButton = this.props.onFilter && (
-      <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
+      <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
     );
 
     return (
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index ef0aca13a..28698b082 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -34,6 +34,9 @@ import {
   blockDomain,
   unblockDomain,
 } from '../actions/domain_blocks';
+import {
+  initAddFilter,
+} from '../actions/filters';
 import { initMuteModal } from '../actions/mutes';
 import { initBlockModal } from '../actions/blocks';
 import { initBoostModal } from '../actions/boosts';
@@ -66,7 +69,7 @@ const makeMapStateToProps = () => {
   return mapStateToProps;
 };
 
-const mapDispatchToProps = (dispatch, { intl }) => ({
+const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
 
   onReply (status, router) {
     dispatch((_, getState) => {
@@ -176,6 +179,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(initReport(status.get('account'), status));
   },
 
+  onAddFilter (status) {
+    dispatch(initAddFilter(status, { contextType }));
+  },
+
   onMute (account) {
     dispatch(initMuteModal(account));
   },
diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.js b/app/javascript/mastodon/features/compose/components/language_dropdown.js
index d76490c77..0af3db7a4 100644
--- a/app/javascript/mastodon/features/compose/components/language_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/language_dropdown.js
@@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring';
 import { supportsPassiveEvents } from 'detect-passive-events';
 import classNames from 'classnames';
 import { languages as preloadedLanguages } from 'mastodon/initial_state';
+import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
 import fuzzysort from 'fuzzysort';
 
 const messages = defineMessages({
@@ -16,22 +17,6 @@ const messages = defineMessages({
   clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
 });
 
-// Copied from emoji-mart for consistency with emoji picker and since
-// they don't export the icons in the package
-const icons = {
-  loupe: (
-    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
-      <path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
-    </svg>
-  ),
-
-  delete: (
-    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
-      <path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
-    </svg>
-  ),
-};
-
 const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 class LanguageDropdownMenu extends React.PureComponent {
@@ -242,7 +227,7 @@ class LanguageDropdownMenu extends React.PureComponent {
           <div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
             <div className='emoji-mart-search'>
               <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
-              <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? icons.loupe : icons.delete}</button>
+              <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
             </div>
 
             <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
diff --git a/app/javascript/mastodon/features/filters/added_to_filter.js b/app/javascript/mastodon/features/filters/added_to_filter.js
new file mode 100644
index 000000000..3785eb3c5
--- /dev/null
+++ b/app/javascript/mastodon/features/filters/added_to_filter.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import { toServerSideType } from 'mastodon/utils/filters';
+import Button from 'mastodon/components/button';
+import { connect } from 'react-redux';
+
+const mapStateToProps = (state, { filterId }) => ({
+  filter: state.getIn(['filters', filterId]),
+});
+
+export default @connect(mapStateToProps)
+class AddedToFilter extends React.PureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    contextType: PropTypes.string,
+    filter: ImmutablePropTypes.map.isRequired,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleCloseClick = () => {
+    const { onClose } = this.props;
+    onClose();
+  };
+
+  render () {
+    const { filter, contextType } = this.props;
+
+    let expiredMessage = null;
+    if (filter.get('expires_at') && filter.get('expires_at') < new Date()) {
+      expiredMessage = (
+        <React.Fragment>
+          <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.expired_title' defaultMessage='Expired filter!' /></h4>
+          <p className='report-dialog-modal__lead'>
+            <FormattedMessage
+              id='filter_modal.added.expired_explanation'
+              defaultMessage='This filter category has expired, you will need to change the expiration date for it to apply.'
+            />
+          </p>
+        </React.Fragment>
+      );
+    }
+
+    let contextMismatchMessage = null;
+    if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
+      contextMismatchMessage = (
+        <React.Fragment>
+          <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.context_mismatch_title' defaultMessage='Context mismatch!' /></h4>
+          <p className='report-dialog-modal__lead'>
+            <FormattedMessage
+              id='filter_modal.added.context_mismatch_explanation'
+              defaultMessage='This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.'
+            />
+          </p>
+        </React.Fragment>
+      );
+    }
+
+    const settings_link = (
+      <a href={`/filters/${filter.get('id')}/edit`}>
+        <FormattedMessage
+          id='filter_modal.added.settings_link'
+          defaultMessage='settings page'
+        />
+      </a>
+    );
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' /></h3>
+        <p className='report-dialog-modal__lead'>
+          <FormattedMessage
+            id='filter_modal.added.short_explanation'
+            defaultMessage='This post has been added to the following filter category: {title}.'
+            values={{ title: filter.get('title') }}
+          />
+        </p>
+
+        {expiredMessage}
+        {contextMismatchMessage}
+
+        <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.review_and_configure_title' defaultMessage='Filter settings' /></h4>
+        <p className='report-dialog-modal__lead'>
+          <FormattedMessage
+            id='filter_modal.added.review_and_configure'
+            defaultMessage='To review and further configure this filter category, go to the {settings_link}.'
+            values={{ settings_link }}
+          />
+        </p>
+
+        <div className='flex-spacer' />
+
+        <div className='report-dialog-modal__actions'>
+          <Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/filters/select_filter.js b/app/javascript/mastodon/features/filters/select_filter.js
new file mode 100644
index 000000000..b5b354529
--- /dev/null
+++ b/app/javascript/mastodon/features/filters/select_filter.js
@@ -0,0 +1,192 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { toServerSideType } from 'mastodon/utils/filters';
+import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
+import Icon from 'mastodon/components/icon';
+import fuzzysort from 'fuzzysort';
+
+const messages = defineMessages({
+  search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
+  clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
+});
+
+const mapStateToProps = (state, { contextType }) => ({
+  filters: Array.from(state.get('filters').values()).map((filter) => [
+    filter.get('id'),
+    filter.get('title'),
+    filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
+    filter.get('expires_at') && filter.get('expires_at') < new Date(),
+    contextType && !filter.get('context').includes(toServerSideType(contextType)),
+  ]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class SelectFilter extends React.PureComponent {
+
+  static propTypes = {
+    onSelectFilter: PropTypes.func.isRequired,
+    onNewFilter: PropTypes.func.isRequired,
+    filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    searchValue: '',
+  };
+
+  search () {
+    const { filters } = this.props;
+    const { searchValue } = this.state;
+
+    if (searchValue === '') {
+      return filters;
+    }
+
+    return fuzzysort.go(searchValue, filters, {
+      keys: ['1', '2'],
+      limit: 5,
+      threshold: -10000,
+    }).map(result => result.obj);
+  }
+
+  renderItem = filter => {
+    let warning = null;
+    if (filter[3] || filter[4]) {
+      warning = (
+        <span className='language-dropdown__dropdown__results__item__common-name'>
+          (
+          {filter[3] && <FormattedMessage id='filter_modal.select_filter.expired' defaultMessage='expired' />}
+          {filter[3] && filter[4] && ', '}
+          {filter[4] && <FormattedMessage id='filter_modal.select_filter.context_mismatch' defaultMessage='does not apply to this context' />}
+          )
+        </span>
+      );
+    }
+
+    return (
+      <div key={filter[0]} role='button' tabIndex='0' data-index={filter[0]} className='language-dropdown__dropdown__results__item' onClick={this.handleItemClick} onKeyDown={this.handleKeyDown}>
+        <span className='language-dropdown__dropdown__results__item__native-name'>{filter[1]}</span> {warning}
+      </div>
+    );
+  }
+
+  renderCreateNew (name) {
+    return (
+      <div key='add-new-filter' role='button' tabIndex='0' className='language-dropdown__dropdown__results__item' onClick={this.handleNewFilterClick} onKeyDown={this.handleKeyDown}>
+        <Icon id='plus' fixedWidth /> <FormattedMessage id='filter_modal.select_filter.prompt_new' defaultMessage='New category: {name}' values={{ name }} />
+      </div>
+    );
+  }
+
+  handleSearchChange = ({ target }) => {
+    this.setState({ searchValue: target.value });
+  }
+
+  setListRef = c => {
+    this.listNode = c;
+  }
+
+  handleKeyDown = e => {
+    const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
+
+    let element = null;
+
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      e.currentTarget.click();
+      break;
+    case 'ArrowDown':
+      element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      break;
+    case 'ArrowUp':
+      element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      } else {
+        element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      }
+      break;
+    case 'Home':
+      element = this.listNode.firstChild;
+      break;
+    case 'End':
+      element = this.listNode.lastChild;
+      break;
+    }
+
+    if (element) {
+      element.focus();
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  }
+
+  handleSearchKeyDown = e => {
+    let element = null;
+
+    switch(e.key) {
+    case 'Tab':
+    case 'ArrowDown':
+      element = this.listNode.firstChild;
+
+      if (element) {
+        element.focus();
+        e.preventDefault();
+        e.stopPropagation();
+      }
+
+      break;
+    }
+  }
+
+  handleClear = () => {
+    this.setState({ searchValue: '' });
+  }
+
+  handleItemClick = e => {
+    const value = e.currentTarget.getAttribute('data-index');
+
+    e.preventDefault();
+
+    this.props.onSelectFilter(value);
+  }
+
+  handleNewFilterClick = e => {
+    e.preventDefault();
+
+    this.props.onNewFilter(this.state.searchValue);
+  };
+
+  render () {
+    const { intl } = this.props;
+
+    const { searchValue } = this.state;
+    const isSearching = searchValue !== '';
+    const results = this.search();
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3>
+        <p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
+
+        <div className='emoji-mart-search'>
+          <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
+          <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
+        </div>
+
+        <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
+          {results.map(this.renderItem)}
+          {isSearching && this.renderCreateNew(searchValue) }
+        </div>
+
+      </React.Fragment>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/filter_modal.js b/app/javascript/mastodon/features/ui/components/filter_modal.js
new file mode 100644
index 000000000..376db961d
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/filter_modal.js
@@ -0,0 +1,134 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { fetchStatus } from 'mastodon/actions/statuses';
+import { fetchFilters, createFilter, createFilterStatus } from 'mastodon/actions/filters';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import IconButton from 'mastodon/components/icon_button';
+import SelectFilter from 'mastodon/features/filters/select_filter';
+import AddedToFilter from 'mastodon/features/filters/added_to_filter';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export default @connect(undefined)
+@injectIntl
+class FilterModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    contextType: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    step: 'select',
+    filterId: null,
+    isSubmitting: false,
+    isSubmitted: false,
+  };
+
+  handleNewFilterSuccess = (result) => {
+    this.handleSelectFilter(result.id);
+  };
+
+  handleSuccess = () => {
+    const { dispatch, statusId } = this.props;
+    dispatch(fetchStatus(statusId, true));
+    this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
+  };
+
+  handleFail = () => {
+    this.setState({ isSubmitting: false });
+  };
+
+  handleNextStep = step => {
+    this.setState({ step });
+  };
+
+  handleSelectFilter = (filterId) => {
+    const { dispatch, statusId } = this.props;
+
+    this.setState({ isSubmitting: true, filterId });
+
+    dispatch(createFilterStatus({
+      filter_id: filterId,
+      status_id: statusId,
+    }, this.handleSuccess, this.handleFail));
+  };
+
+  handleNewFilter = (title) => {
+    const { dispatch } = this.props;
+
+    this.setState({ isSubmitting: true });
+
+    dispatch(createFilter({
+      title,
+      context: ['home', 'notifications', 'public', 'thread', 'account'],
+      action: 'warn',
+    }, this.handleNewFilterSuccess, this.handleFail));
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(fetchFilters());
+  }
+
+  render () {
+    const {
+      intl,
+      statusId,
+      contextType,
+      onClose,
+    } = this.props;
+
+    const {
+      step,
+      filterId,
+    } = this.state;
+
+    let stepComponent;
+
+    switch(step) {
+    case 'select':
+      stepComponent = (
+        <SelectFilter
+          contextType={contextType}
+          onSelectFilter={this.handleSelectFilter}
+          onNewFilter={this.handleNewFilter}
+        />
+      );
+      break;
+    case 'create':
+      stepComponent = null;
+      break;
+    case 'submitted':
+      stepComponent = (
+        <AddedToFilter
+          contextType={contextType}
+          filterId={filterId}
+          statusId={statusId}
+          onClose={onClose}
+        />
+      );
+    }
+
+    return (
+      <div className='modal-root__modal report-dialog-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
+          <FormattedMessage id='filter_modal.title.status' defaultMessage='Filter a post' />
+        </div>
+
+        <div className='report-dialog-modal__container'>
+          {stepComponent}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 3fc235849..b2c30e079 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -20,6 +20,7 @@ import {
   ListEditor,
   ListAdder,
   CompareHistoryModal,
+  FilterModal,
 } from 'mastodon/features/ui/util/async-components';
 
 const MODAL_COMPONENTS = {
@@ -37,6 +38,7 @@ const MODAL_COMPONENTS = {
   'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
   'LIST_ADDER': ListAdder,
   'COMPARE_HISTORY': CompareHistoryModal,
+  'FILTER': FilterModal,
 };
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 92c683e2f..29b06206a 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -161,3 +161,7 @@ export function CompareHistoryModal () {
 export function Explore () {
   return import(/* webpackChunkName: "features/explore" */'../../explore');
 }
+
+export function FilterModal () {
+  return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
+}
diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js
index 14b704027..cc1d3349c 100644
--- a/app/javascript/mastodon/reducers/filters.js
+++ b/app/javascript/mastodon/reducers/filters.js
@@ -1,4 +1,5 @@
 import { FILTERS_IMPORT } from '../actions/importer';
+import { FILTERS_FETCH_SUCCESS, FILTERS_CREATE_SUCCESS } from '../actions/filters';
 import { Map as ImmutableMap, is, fromJS } from 'immutable';
 
 const normalizeFilter = (state, filter) => {
@@ -7,13 +8,17 @@ const normalizeFilter = (state, filter) => {
     title: filter.title,
     context: filter.context,
     filter_action: filter.filter_action,
+    keywords: filter.keywords,
     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);
+    // Do not overwrite keywords when receiving a partial filter
+    return state.update(filter.id, ImmutableMap(), (old) => (
+      old.mergeWith(((old_value, new_value) => (new_value === undefined ? old_value : new_value)), normalizedFilter)
+    ));
   }
 };
 
@@ -27,6 +32,10 @@ const normalizeFilters = (state, filters) => {
 
 export default function filters(state = ImmutableMap(), action) {
   switch(action.type) {
+  case FILTERS_CREATE_SUCCESS:
+    return normalizeFilter(state, action.filter);
+  case FILTERS_FETCH_SUCCESS:
+    //TODO: handle deleting obsolete filters
   case FILTERS_IMPORT:
     return normalizeFilters(state, action.filters);
   default:
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 187e3306d..3dd7f4897 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -1,5 +1,6 @@
 import { createSelector } from 'reselect';
 import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
+import { toServerSideType } from 'mastodon/utils/filters';
 import { me } from '../initial_state';
 
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
@@ -20,23 +21,6 @@ export const makeGetAccount = () => {
   });
 };
 
-const toServerSideType = columnType => {
-  switch (columnType) {
-  case 'home':
-  case 'notifications':
-  case 'public':
-  case 'thread':
-  case 'account':
-    return columnType;
-  default:
-    if (columnType.indexOf('list:') > -1) {
-      return 'home';
-    } else {
-      return 'public'; // community, account, hashtag
-    }
-  }
-};
-
 const getFilters = (state, { contextType }) => {
   if (!contextType) return null;
 
@@ -73,6 +57,7 @@ export const makeGetStatus = () => {
         if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
           return null;
         }
+        filterResults = filterResults.filter(result => filters.has(result.get('filter')));
         if (!filterResults.isEmpty()) {
           filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
         }
diff --git a/app/javascript/mastodon/utils/filters.js b/app/javascript/mastodon/utils/filters.js
new file mode 100644
index 000000000..97b433a99
--- /dev/null
+++ b/app/javascript/mastodon/utils/filters.js
@@ -0,0 +1,16 @@
+export const toServerSideType = columnType => {
+  switch (columnType) {
+  case 'home':
+  case 'notifications':
+  case 'public':
+  case 'thread':
+  case 'account':
+    return columnType;
+  default:
+    if (columnType.indexOf('list:') > -1) {
+      return 'home';
+    } else {
+      return 'public'; // community, account, hashtag
+    }
+  }
+};
diff --git a/app/javascript/mastodon/utils/icons.js b/app/javascript/mastodon/utils/icons.js
new file mode 100644
index 000000000..be566032e
--- /dev/null
+++ b/app/javascript/mastodon/utils/icons.js
@@ -0,0 +1,13 @@
+// Copied from emoji-mart for consistency with emoji picker and since
+// they don't export the icons in the package
+export const loupeIcon = (
+  <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+    <path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
+  </svg>
+);
+
+export const deleteIcon = (
+  <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+    <path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
+  </svg>
+);
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index a0a39812b..f5377a858 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -5233,6 +5233,16 @@ a.status-card.compact:hover {
     line-height: 22px;
     color: lighten($inverted-text-color, 16%);
     margin-bottom: 30px;
+
+    a {
+      text-decoration: none;
+      color: $inverted-text-color;
+      font-weight: 500;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
   }
 
   &__actions {
@@ -5379,6 +5389,14 @@ a.status-card.compact:hover {
     background: transparent;
     margin: 15px 0;
   }
+
+  .emoji-mart-search {
+    padding-right: 10px;
+  }
+
+  .emoji-mart-search-icon {
+    right: 10px + 5px;
+  }
 }
 
 .report-modal__container {
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index a7401362f..9b358d338 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -249,15 +249,7 @@ module AccountInteractions
 
   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
+    CustomFilter.apply_cached_filters(active_filters, status)
   end
 
   def followers_for_local_distribution
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 985eab125..da2a91493 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -34,6 +34,7 @@ class CustomFilter < ApplicationRecord
 
   belongs_to :account
   has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
+  has_many :statuses, class_name: 'CustomFilterStatus', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
   accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
 
   validates :title, :context, presence: true
@@ -62,8 +63,10 @@ class CustomFilter < ApplicationRecord
 
   def self.cached_filters_for(account_id)
     active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do
+      filters_hash = {}
+
       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|
+      scope.to_a.group_by(&:custom_filter).each do |filter, keywords|
         keywords.map! do |keyword|
           if keyword.whole_word
             sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : ''
@@ -74,13 +77,34 @@ class CustomFilter < ApplicationRecord
             /#{Regexp.escape(keyword.keyword)}/i
           end
         end
-        [filter, { keywords: Regexp.union(keywords) }]
+
+        filters_hash[filter.id] = { keywords: Regexp.union(keywords), filter: filter }
+      end.to_h
+
+      scope = CustomFilterStatus.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).each do |filter, statuses|
+        filters_hash[filter.id] ||= { filter: filter }
+        filters_hash[filter.id].merge!(status_ids: statuses.map(&:status_id))
       end
+
+      filters_hash.values.map { |cache| [cache.delete(:filter), cache] }
     end.to_a
 
     active_filters.select { |custom_filter, _| !custom_filter.expired? }
   end
 
+  def self.apply_cached_filters(cached_filters, status)
+    cached_filters.filter_map do |filter, rules|
+      match = rules[:keywords].match(status.proper.searchable_text) if rules[:keywords].present?
+      keyword_matches = [match.to_s] unless match.nil?
+
+      status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present?
+
+      next if keyword_matches.blank? && status_matches.blank?
+      FilterResultPresenter.new(filter: filter, keyword_matches: keyword_matches, status_matches: status_matches)
+    end
+  end
+
   def prepare_cache_invalidation!
     @should_invalidate_cache = true
   end
diff --git a/app/models/custom_filter_status.rb b/app/models/custom_filter_status.rb
new file mode 100644
index 000000000..b6bea1394
--- /dev/null
+++ b/app/models/custom_filter_status.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: custom_filter_statuses
+#
+#  id               :bigint(8)        not null, primary key
+#  custom_filter_id :bigint(8)        not null
+#  status_id        :bigint(8)        default(""), not null
+#  created_at       :datetime         not null
+#  updated_at       :datetime         not null
+#
+
+class CustomFilterStatus < ApplicationRecord
+  belongs_to :custom_filter
+  belongs_to :status
+
+  validates :status, uniqueness: { scope: :custom_filter }
+  validate :validate_status_access
+
+  before_save :prepare_cache_invalidation!
+  before_destroy :prepare_cache_invalidation!
+  after_commit :invalidate_cache!
+
+  private
+
+  def validate_status_access
+    errors.add(:status_id, :invalid) unless StatusPolicy.new(custom_filter.account, status).show?
+  end
+
+  def prepare_cache_invalidation!
+    custom_filter.prepare_cache_invalidation!
+  end
+
+  def invalidate_cache!
+    custom_filter.invalidate_cache!
+  end
+end
diff --git a/app/models/form/status_filter_batch_action.rb b/app/models/form/status_filter_batch_action.rb
new file mode 100644
index 000000000..d87bd5cc4
--- /dev/null
+++ b/app/models/form/status_filter_batch_action.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class Form::StatusFilterBatchAction
+  include ActiveModel::Model
+  include AccountableConcern
+  include Authorization
+
+  attr_accessor :current_account, :type,
+                :status_filter_ids, :filter_id
+
+  def save!
+    process_action!
+  end
+
+  private
+
+  def status_filters
+    filter = current_account.custom_filters.find(filter_id)
+    filter.statuses.where(id: status_filter_ids)
+  end
+
+  def process_action!
+    return if status_filter_ids.empty?
+
+    case type
+    when 'remove'
+      handle_remove!
+    end
+  end
+
+  def handle_remove!
+    status_filters.destroy_all
+  end
+end
diff --git a/app/presenters/filter_result_presenter.rb b/app/presenters/filter_result_presenter.rb
index 677225f5e..1e9e8f3c1 100644
--- a/app/presenters/filter_result_presenter.rb
+++ b/app/presenters/filter_result_presenter.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 class FilterResultPresenter < ActiveModelSerializers::Model
-  attributes :filter, :keyword_matches
+  attributes :filter, :keyword_matches, :status_matches
 end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index d7ffb1954..be818a2de 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -33,12 +33,7 @@ class StatusRelationshipsPresenter
     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
+      filter_matches = CustomFilter.apply_cached_filters(active_filters, status)
 
       unless filter_matches.empty?
         h[status.id] = filter_matches
diff --git a/app/serializers/rest/filter_result_serializer.rb b/app/serializers/rest/filter_result_serializer.rb
index 0ef4db79a..54ead2f1f 100644
--- a/app/serializers/rest/filter_result_serializer.rb
+++ b/app/serializers/rest/filter_result_serializer.rb
@@ -3,4 +3,9 @@
 class REST::FilterResultSerializer < ActiveModel::Serializer
   belongs_to :filter, serializer: REST::FilterSerializer
   has_many :keyword_matches
+  has_many :status_matches
+
+  def status_matches
+    object.status_matches&.map(&:to_s)
+  end
 end
diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb
index 98d7edb17..8816dd807 100644
--- a/app/serializers/rest/filter_serializer.rb
+++ b/app/serializers/rest/filter_serializer.rb
@@ -3,6 +3,7 @@
 class REST::FilterSerializer < ActiveModel::Serializer
   attributes :id, :title, :context, :expires_at, :filter_action
   has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested?
+  has_many :statuses, serializer: REST::FilterStatusSerializer, if: :rules_requested?
 
   def id
     object.id.to_s
diff --git a/app/serializers/rest/filter_status_serializer.rb b/app/serializers/rest/filter_status_serializer.rb
new file mode 100644
index 000000000..6bcbaa249
--- /dev/null
+++ b/app/serializers/rest/filter_status_serializer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class REST::FilterStatusSerializer < ActiveModel::Serializer
+  attributes :id, :status_id
+
+  def id
+    object.id.to_s
+  end
+
+  def status_id
+    object.status_id.to_s
+  end
+end
diff --git a/app/views/filters/_filter.html.haml b/app/views/filters/_filter.html.haml
index 2ab014081..9993ad2ee 100644
--- a/app/views/filters/_filter.html.haml
+++ b/app/views/filters/_filter.html.haml
@@ -22,6 +22,15 @@
               - keywords = filter.keywords.map(&:keyword)
               - keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO
               = keywords.join(', ')
+      - unless filter.statuses.empty?
+        %li.permissions-list__item
+          .permissions-list__item__icon
+            = fa_icon('comment')
+          .permissions-list__item__text
+            .permissions-list__item__text__title
+              = t('filters.index.statuses', count: filter.statuses.size)
+            .permissions-list__item__text__type
+              = t('filters.index.statuses_long', count: filter.statuses.size)
 
   .announcements-list__item__action-bar
     .announcements-list__item__meta
diff --git a/app/views/filters/_filter_fields.html.haml b/app/views/filters/_filter_fields.html.haml
index 1a52faa7a..c58978f5a 100644
--- a/app/views/filters/_filter_fields.html.haml
+++ b/app/views/filters/_filter_fields.html.haml
@@ -14,6 +14,13 @@
 
 %hr.spacer/
 
+- unless f.object.statuses.empty?
+  %h4= t('filters.edit.statuses')
+
+  %p.muted-hint= t('filters.edit.statuses_hint_html', path: filter_statuses_path(f.object))
+
+  %hr.spacer/
+
 %h4= t('filters.edit.keywords')
 
 .table-wrapper
diff --git a/app/views/filters/statuses/_status_filter.html.haml b/app/views/filters/statuses/_status_filter.html.haml
new file mode 100644
index 000000000..ba1170cf9
--- /dev/null
+++ b/app/views/filters/statuses/_status_filter.html.haml
@@ -0,0 +1,37 @@
+- status = status_filter.status.proper
+
+.batch-table__row
+  %label.batch-table__row__select.batch-checkbox
+    = f.check_box :status_filter_ids, { multiple: true, include_hidden: false }, status_filter.id
+  .batch-table__row__content
+    .status__content><
+      - if status.spoiler_text.blank?
+        = prerender_custom_emojis(status_content_format(status), status.emojis)
+      - else
+        %details<
+          %summary><
+            %strong> Content warning: #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
+          = prerender_custom_emojis(status_content_format(status), status.emojis)
+
+    - status.ordered_media_attachments.each do |media_attachment|
+      %abbr{ title: media_attachment.description }
+        = fa_icon 'link'
+        = media_attachment.file_file_name
+
+    .detailed-status__meta
+      = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'name-tag', target: '_blank', rel: 'noopener noreferrer' do
+        = image_tag(status.account.avatar.url, width: 15, height: 15, alt: display_name(status.account), class: 'avatar')
+        .username= status.account.acct
+      ·
+      = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do
+        %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+      - if status.edited?
+        ·
+        = t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted'))
+      ·
+      = fa_visibility_icon(status)
+      = t("statuses.visibilities.#{status.visibility}")
+      - if status.sensitive?
+        ·
+        = fa_icon('eye-slash fw')
+        = t('stream_entries.sensitive_content')
diff --git a/app/views/filters/statuses/index.html.haml b/app/views/filters/statuses/index.html.haml
new file mode 100644
index 000000000..886de58fa
--- /dev/null
+++ b/app/views/filters/statuses/index.html.haml
@@ -0,0 +1,38 @@
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+- content_for :page_title do
+  = t('filters.statuses.index.title')
+  \-
+  = @filter.title
+
+.filters
+  .back-link
+    = link_to edit_filter_path(@filter) do
+      = fa_icon 'chevron-left fw'
+      = t('filters.statuses.back_to_filter')
+
+%p.hint= t('filters.statuses.index.hint')
+
+%hr.spacer/
+
+= form_for(@status_filter_batch_action, url: batch_filter_statuses_path(@filter.id)) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  - Admin::StatusFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        - unless @status_filters.empty?
+          = f.button safe_join([fa_icon('times'), t('filters.statuses.batch.remove')]), name: :remove, class: 'table-action-link', type: :submit
+    .batch-table__body
+      - if @status_filters.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'status_filter', collection: @status_filters, locals: { f: f }
+
+= paginate @status_filters