--- /dev/null
+# 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
--- /dev/null
+# 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
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
--- /dev/null
+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,
+});
};
};
-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));
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
onBlock: PropTypes.func,
+ onAddFilter: PropTypes.func,
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
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 }) => ({
onPin: PropTypes.func,
onBookmark: PropTypes.func,
onFilter: PropTypes.func,
+ onAddFilter: PropTypes.func,
withDismiss: PropTypes.bool,
withCounters: PropTypes.bool,
scrollKey: PropTypes.string,
this.props.onMuteConversation(this.props.status);
}
- handleFilter = () => {
- this.props.onFilter();
+ handleFilterClick = () => {
+ this.props.onAddFilter(this.props.status);
}
handleCopy = () => {
}
- handleFilterClick = () => {
+ handleHideClick = () => {
this.props.onFilter();
}
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')) {
);
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 (
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';
return mapStateToProps;
};
-const mapDispatchToProps = (dispatch, { intl }) => ({
+const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
onReply (status, router) {
dispatch((_, getState) => {
dispatch(initReport(status.get('account'), status));
},
+ onAddFilter (status) {
+ dispatch(initAddFilter(status, { contextType }));
+ },
+
onMute (account) {
dispatch(initMuteModal(account));
},
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({
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 {
<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}>
--- /dev/null
+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>
+ );
+ }
+
+}
--- /dev/null
+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>
+ );
+ }
+
+}
--- /dev/null
+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>
+ );
+ }
+
+}
ListEditor,
ListAdder,
CompareHistoryModal,
+ FilterModal,
} from 'mastodon/features/ui/util/async-components';
const MODAL_COMPONENTS = {
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder,
'COMPARE_HISTORY': CompareHistoryModal,
+ 'FILTER': FilterModal,
};
export default class ModalRoot extends React.PureComponent {
export function Explore () {
return import(/* webpackChunkName: "features/explore" */'../../explore');
}
+
+export function FilterModal () {
+ return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
+}
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) => {
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)
+ ));
}
};
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:
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);
});
};
-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;
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']));
}
--- /dev/null
+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
+ }
+ }
+};
--- /dev/null
+// 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>
+);
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 {
background: transparent;
margin: 15px 0;
}
+
+ .emoji-mart-search {
+ padding-right: 10px;
+ }
+
+ .emoji-mart-search-icon {
+ right: 10px + 5px;
+ }
}
.report-modal__container {
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
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
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' : ''
/#{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
--- /dev/null
+# 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
--- /dev/null
+# 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
# frozen_string_literal: true
class FilterResultPresenter < ActiveModelSerializers::Model
- attributes :filter, :keyword_matches
+ attributes :filter, :keyword_matches, :status_matches
end
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
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
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
--- /dev/null
+# 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
- 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
%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
--- /dev/null
+- 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')
--- /dev/null
+- 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
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 <a href="%{path}">clicking here</a>.
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.
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…
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]
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
--- /dev/null
+# 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
#
# 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"
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"
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
--- /dev/null
+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
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')) }
--- /dev/null
+Fabricator(:custom_filter_status) do
+ custom_filter
+ status
+end
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