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 = (
+
+
+
+
+
+
+ );
+ }
+
+ let contextMismatchMessage = null;
+ if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
+ contextMismatchMessage = (
+
+
+
+
+
+
+ );
+ }
+
+ const settings_link = (
+
+
+
+ );
+
+ return (
+
+
+
+
+
+
+ {expiredMessage}
+ {contextMismatchMessage}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
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 = (
+
+ (
+ {filter[3] && }
+ {filter[3] && filter[4] && ', '}
+ {filter[4] && }
+ )
+
+ );
+ }
+
+ return (
+
+ {filter[1]} {warning}
+
+ );
+ }
+
+ renderCreateNew (name) {
+ return (
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
+
+
+
+
+
+ {results.map(this.renderItem)}
+ {isSearching && this.renderCreateNew(searchValue) }
+
+
+
+ );
+ }
+
+}
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 = (
+
+ );
+ break;
+ case 'create':
+ stepComponent = null;
+ break;
+ case 'submitted':
+ stepComponent = (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {stepComponent}
+
+
+ );
+ }
+
+}
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 = (
+
+);
+
+export const deleteIcon = (
+
+);
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
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 2cd4f45ac..596cc1a28 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1181,6 +1181,8 @@ en:
edit:
add_keyword: Add keyword
keywords: Keywords
+ statuses: Individual posts
+ statuses_hint_html: This filter applies to select individual posts regardless of whether they match the keywords below. You can review these posts and remove them from the filter by
clicking here.
title: Edit filter
errors:
deprecated_api_multiple_keywords: These parameters cannot be changed from this application because they apply to more than one filter keyword. Use a more recent application or the web interface.
@@ -1194,10 +1196,23 @@ en:
keywords:
one: "%{count} keyword"
other: "%{count} keywords"
+ statuses:
+ one: "%{count} post"
+ other: "%{count} posts"
+ statuses_long:
+ one: "%{count} individual post hidden"
+ other: "%{count} individual posts hidden"
title: Filters
new:
save: Save new filter
title: Add new filter
+ statuses:
+ back_to_filter: Back to filter
+ batch:
+ remove: Remove from filter
+ index:
+ hint: This filter applies to select individual posts regardless of other criteria. You can add more posts to this filter from the Web interface.
+ title: Filtered posts
footer:
developers: Developers
more: More…
diff --git a/config/routes.rb b/config/routes.rb
index 7dc9f391d..dff0add3a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -177,7 +177,14 @@ Rails.application.routes.draw do
resources :tags, only: [:show]
resources :emojis, only: [:show]
resources :invites, only: [:index, :create, :destroy]
- resources :filters, except: [:show]
+ resources :filters, except: [:show] do
+ resources :statuses, only: [:index], controller: 'filters/statuses' do
+ collection do
+ post :batch
+ end
+ end
+ end
+
resource :relationships, only: [:show, :update]
resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update]
@@ -448,12 +455,14 @@ Rails.application.routes.draw do
resources :trends, only: [:index], controller: 'trends/tags'
resources :filters, only: [:index, :create, :show, :update, :destroy] do
resources :keywords, only: [:index, :create], controller: 'filters/keywords'
+ resources :statuses, only: [:index, :create], controller: 'filters/statuses'
end
resources :endorsements, only: [:index]
resources :markers, only: [:index, :create]
namespace :filters do
resources :keywords, only: [:show, :update, :destroy]
+ resources :statuses, only: [:show, :destroy]
end
namespace :apps do
diff --git a/db/migrate/20220808101323_create_custom_filter_statuses.rb b/db/migrate/20220808101323_create_custom_filter_statuses.rb
new file mode 100644
index 000000000..52f703749
--- /dev/null
+++ b/db/migrate/20220808101323_create_custom_filter_statuses.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class CreateCustomFilterStatuses < ActiveRecord::Migration[6.1]
+ def change
+ create_table :custom_filter_statuses do |t|
+ t.belongs_to :custom_filter, foreign_key: { on_delete: :cascade }, null: false
+ t.belongs_to :status, foreign_key: { on_delete: :cascade }, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2263dc7d7..15ab2e85e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2022_07_14_171049) do
+ActiveRecord::Schema.define(version: 2022_08_08_101323) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -348,6 +348,15 @@ ActiveRecord::Schema.define(version: 2022_07_14_171049) do
t.index ["custom_filter_id"], name: "index_custom_filter_keywords_on_custom_filter_id"
end
+ create_table "custom_filter_statuses", force: :cascade do |t|
+ t.bigint "custom_filter_id", null: false
+ t.bigint "status_id", null: false
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.index ["custom_filter_id"], name: "index_custom_filter_statuses_on_custom_filter_id"
+ t.index ["status_id"], name: "index_custom_filter_statuses_on_status_id"
+ end
+
create_table "custom_filters", force: :cascade do |t|
t.bigint "account_id"
t.datetime "expires_at"
@@ -1113,6 +1122,8 @@ ActiveRecord::Schema.define(version: 2022_07_14_171049) do
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
add_foreign_key "custom_filter_keywords", "custom_filters", on_delete: :cascade
+ add_foreign_key "custom_filter_statuses", "custom_filters", on_delete: :cascade
+ add_foreign_key "custom_filter_statuses", "statuses", on_delete: :cascade
add_foreign_key "custom_filters", "accounts", on_delete: :cascade
add_foreign_key "devices", "accounts", on_delete: :cascade
add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade
diff --git a/spec/controllers/api/v1/filters/statuses_controller_spec.rb b/spec/controllers/api/v1/filters/statuses_controller_spec.rb
new file mode 100644
index 000000000..3b2399dd8
--- /dev/null
+++ b/spec/controllers/api/v1/filters/statuses_controller_spec.rb
@@ -0,0 +1,116 @@
+require 'rails_helper'
+
+RSpec.describe Api::V1::Filters::StatusesController, type: :controller do
+ render_views
+
+ let(:user) { Fabricate(:user) }
+ let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+ let(:filter) { Fabricate(:custom_filter, account: user.account) }
+ let(:other_user) { Fabricate(:user) }
+ let(:other_filter) { Fabricate(:custom_filter, account: other_user.account) }
+
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ end
+
+ describe 'GET #index' do
+ let(:scopes) { 'read:filters' }
+ let!(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) }
+
+ it 'returns http success' do
+ get :index, params: { filter_id: filter.id }
+ expect(response).to have_http_status(200)
+ end
+
+ context "when trying to access another's user filters" do
+ it 'returns http not found' do
+ get :index, params: { filter_id: other_filter.id }
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ describe 'POST #create' do
+ let(:scopes) { 'write:filters' }
+ let(:filter_id) { filter.id }
+ let!(:status) { Fabricate(:status) }
+
+ before do
+ post :create, params: { filter_id: filter_id, status_id: status.id }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns a status filter' do
+ json = body_as_json
+ expect(json[:status_id]).to eq status.id.to_s
+ end
+
+ it 'creates a status filter' do
+ filter = user.account.custom_filters.first
+ expect(filter).to_not be_nil
+ expect(filter.statuses.pluck(:status_id)).to eq [status.id]
+ end
+
+ context "when trying to add to another another's user filters" do
+ let(:filter_id) { other_filter.id }
+
+ it 'returns http not found' do
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ describe 'GET #show' do
+ let(:scopes) { 'read:filters' }
+ let!(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) }
+
+ before do
+ get :show, params: { id: status_filter.id }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns expected data' do
+ json = body_as_json
+ expect(json[:status_id]).to eq status_filter.status_id.to_s
+ end
+
+ context "when trying to access another user's filter keyword" do
+ let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: other_filter) }
+
+ it 'returns http not found' do
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ let(:scopes) { 'write:filters' }
+ let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) }
+
+ before do
+ delete :destroy, params: { id: status_filter.id }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'removes the filter' do
+ expect { status_filter.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
+
+ context "when trying to update another user's filter keyword" do
+ let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: other_filter) }
+
+ it 'returns http not found' do
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb
index 4d104a198..24810a5d2 100644
--- a/spec/controllers/api/v1/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses_controller_spec.rb
@@ -47,6 +47,33 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
end
end
+ context 'when post is explicitly filtered' do
+ let(:status) { Fabricate(:status, text: 'hello world') }
+
+ before do
+ filter = user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide)
+ filter.statuses.create!(status_id: status.id)
+ end
+
+ it 'returns http success' do
+ get :show, params: { id: status.id }
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns filter information' do
+ get :show, params: { id: status.id }
+ json = body_as_json
+ expect(json[:filtered][0]).to include({
+ filter: a_hash_including({
+ id: user.account.custom_filters.first.id.to_s,
+ title: 'filter1',
+ filter_action: 'hide',
+ }),
+ status_matches: [status.id.to_s],
+ })
+ end
+ end
+
context 'when reblog includes filtered terms' do
let(:status) { Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about that banned word')) }
diff --git a/spec/fabricators/custom_filter_status_fabricator.rb b/spec/fabricators/custom_filter_status_fabricator.rb
new file mode 100644
index 000000000..d082b81c5
--- /dev/null
+++ b/spec/fabricators/custom_filter_status_fabricator.rb
@@ -0,0 +1,4 @@
+Fabricator(:custom_filter_status) do
+ custom_filter
+ status
+end
diff --git a/spec/presenters/status_relationships_presenter_spec.rb b/spec/presenters/status_relationships_presenter_spec.rb
index 5cd4929a6..eaab922fd 100644
--- a/spec/presenters/status_relationships_presenter_spec.rb
+++ b/spec/presenters/status_relationships_presenter_spec.rb
@@ -94,5 +94,32 @@ RSpec.describe StatusRelationshipsPresenter do
expect(matched_filters[0].keyword_matches).to eq ['irrelevant']
end
end
+
+ context 'when post includes filtered individual statuses' do
+ let(:statuses) { [Fabricate(:status, text: 'hello world'), Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about an irrelevant word'))] }
+ let(:options) { {} }
+
+ before do
+ filter = Account.find(current_account_id).custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide)
+ filter.statuses.create!(status_id: statuses[0].id)
+ filter.statuses.create!(status_id: statuses[1].reblog_of_id)
+ end
+
+ it 'sets @filters_map to filter top-level status' do
+ matched_filters = presenter.filters_map[statuses[0].id]
+ expect(matched_filters.size).to eq 1
+
+ expect(matched_filters[0].filter.title).to eq 'filter1'
+ expect(matched_filters[0].status_matches).to eq [statuses[0].id]
+ end
+
+ it 'sets @filters_map to filter reblogged status' do
+ matched_filters = presenter.filters_map[statuses[1].reblog_of_id]
+ expect(matched_filters.size).to eq 1
+
+ expect(matched_filters[0].filter.title).to eq 'filter1'
+ expect(matched_filters[0].status_matches).to eq [statuses[1].reblog_of_id]
+ end
+ end
end
end