From 02851848e964675bb59919fa5fd1bdee2c1c29db Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 28 Jun 2022 09:42:13 +0200 Subject: Revamp post filtering system (#18058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../api/v1/filters/keywords_controller.rb | 50 +++++++++++++ app/controllers/api/v1/filters_controller.rb | 35 +++++++-- app/controllers/api/v2/filters_controller.rb | 48 ++++++++++++ app/controllers/filters_controller.rb | 12 +-- app/javascript/mastodon/actions/filters.js | 26 ------- app/javascript/mastodon/actions/importer/index.js | 11 +++ .../mastodon/actions/importer/normalizer.js | 12 +++ app/javascript/mastodon/actions/notifications.js | 13 +--- app/javascript/mastodon/actions/streaming.js | 4 - app/javascript/mastodon/components/status.js | 21 +++++- .../mastodon/components/status_action_bar.js | 17 +++++ app/javascript/mastodon/features/ui/index.js | 3 +- app/javascript/mastodon/reducers/filters.js | 34 +++++++-- app/javascript/mastodon/selectors/index.js | 51 ++++++------- app/javascript/packs/public.js | 1 + app/javascript/styles/mastodon/admin.scss | 33 +++++++- app/javascript/styles/mastodon/components.scss | 15 ++++ app/javascript/styles/mastodon/forms.scss | 31 ++++++++ app/lib/feed_manager.rb | 30 -------- app/models/concerns/account_interactions.rb | 13 ++++ app/models/custom_filter.rb | 87 ++++++++++++++++------ app/models/custom_filter_keyword.rb | 34 +++++++++ app/presenters/filter_result_presenter.rb | 5 ++ app/presenters/status_relationships_presenter.rb | 24 +++++- app/serializers/rest/filter_keyword_serializer.rb | 9 +++ app/serializers/rest/filter_result_serializer.rb | 6 ++ app/serializers/rest/filter_serializer.rb | 8 +- app/serializers/rest/status_serializer.rb | 9 +++ app/serializers/rest/v1/filter_serializer.rb | 26 +++++++ app/views/filters/_fields.html.haml | 16 ---- app/views/filters/_filter.html.haml | 32 ++++++++ app/views/filters/_filter_fields.html.haml | 33 ++++++++ app/views/filters/_keyword_fields.html.haml | 8 ++ app/views/filters/edit.html.haml | 2 +- app/views/filters/index.html.haml | 17 +---- app/views/filters/new.html.haml | 4 +- 36 files changed, 593 insertions(+), 187 deletions(-) create mode 100644 app/controllers/api/v1/filters/keywords_controller.rb create mode 100644 app/controllers/api/v2/filters_controller.rb delete mode 100644 app/javascript/mastodon/actions/filters.js create mode 100644 app/models/custom_filter_keyword.rb create mode 100644 app/presenters/filter_result_presenter.rb create mode 100644 app/serializers/rest/filter_keyword_serializer.rb create mode 100644 app/serializers/rest/filter_result_serializer.rb create mode 100644 app/serializers/rest/v1/filter_serializer.rb delete mode 100644 app/views/filters/_fields.html.haml create mode 100644 app/views/filters/_filter.html.haml create mode 100644 app/views/filters/_filter_fields.html.haml create mode 100644 app/views/filters/_keyword_fields.html.haml (limited to 'app') 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 (
- + : {matchedFilters.join(', ')}. + {' '} +
); @@ -496,7 +511,7 @@ class Status extends ImmutablePureComponent { {media} - + 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 { ); + const filterButton = this.props.onFilter && ( + + ); + return (
@@ -337,6 +352,8 @@ class StatusActionBar extends ImmutablePureComponent { {shareButton} + {filterButton} +
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 + = 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 -- cgit