Add ability to filter individual posts (#18945)

* Add database table for status-specific filters

* Add REST endpoints, entities and attributes

* Show status filters in /filters interface

* Perform server-side filtering for individual posts filters

* Fix filtering on context mismatch

* Refactor `toServerSideType` by moving it to its own module

* Move loupe and delete icons to their own module

* Add ability to filter individual posts from WebUI

* Replace keyword list by warnings (expired, context mismatch)

* Refactor server-side filtering code

* Add tests
This commit is contained in:
Claire 2022-08-25 04:27:47 +02:00 committed by GitHub
parent 67f86afc0a
commit 522fbf9d13
40 changed files with 1138 additions and 63 deletions

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
class Api::V1::Filters::StatusesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
before_action :require_user!
before_action :set_status_filters, only: :index
before_action :set_status_filter, only: [:show, :destroy]
def index
render json: @status_filters, each_serializer: REST::FilterStatusSerializer
end
def create
@status_filter = current_account.custom_filters.find(params[:filter_id]).statuses.create!(resource_params)
render json: @status_filter, serializer: REST::FilterStatusSerializer
end
def show
render json: @status_filter, serializer: REST::FilterStatusSerializer
end
def destroy
@status_filter.destroy!
render_empty
end
private
def set_status_filters
filter = current_account.custom_filters.includes(:statuses).find(params[:filter_id])
@status_filters = filter.statuses
end
def set_status_filter
@status_filter = CustomFilterStatus.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id])
end
def resource_params
params.permit(:status_id)
end
end

View file

@ -0,0 +1,49 @@
# frozen_string_literal: true
class Filters::StatusesController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_filter
before_action :set_status_filters
before_action :set_body_classes
PER_PAGE = 20
def index
@status_filter_batch_action = Form::StatusFilterBatchAction.new
end
def batch
@status_filter_batch_action = Form::StatusFilterBatchAction.new(status_filter_batch_action_params.merge(current_account: current_account, filter_id: params[:filter_id], type: action_from_button))
@status_filter_batch_action.save!
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.statuses.no_status_selected')
ensure
redirect_to edit_filter_path(@filter)
end
private
def set_filter
@filter = current_account.custom_filters.find(params[:filter_id])
end
def set_status_filters
@status_filters = @filter.statuses.preload(:status).page(params[:page]).per(PER_PAGE)
end
def status_filter_batch_action_params
params.require(:form_status_filter_batch_action).permit(status_filter_ids: [])
end
def action_from_button
if params[:remove]
'remove'
end
end
def set_body_classes
@body_classes = 'admin'
end
end

View file

@ -8,7 +8,7 @@ class FiltersController < ApplicationController
before_action :set_body_classes
def index
@filters = current_account.custom_filters.includes(:keywords).order(:phrase)
@filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase)
end
def new

View file

@ -0,0 +1,93 @@
import api from '../api';
import { openModal } from './modal';
export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
export const FILTERS_STATUS_CREATE_REQUEST = 'FILTERS_STATUS_CREATE_REQUEST';
export const FILTERS_STATUS_CREATE_SUCCESS = 'FILTERS_STATUS_CREATE_SUCCESS';
export const FILTERS_STATUS_CREATE_FAIL = 'FILTERS_STATUS_CREATE_FAIL';
export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
export const initAddFilter = (status, { contextType }) => dispatch =>
dispatch(openModal('FILTER', {
statusId: status?.get('id'),
contextType: contextType,
}));
export const fetchFilters = () => (dispatch, getState) => {
dispatch({
type: FILTERS_FETCH_REQUEST,
skipLoading: true,
});
api(getState)
.get('/api/v2/filters')
.then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTERS_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => {
dispatch(createFilterStatusRequest());
api(getState).post(`/api/v1/filters/${params.filter_id}/statuses`, params).then(response => {
dispatch(createFilterStatusSuccess(response.data));
if (onSuccess) onSuccess();
}).catch(error => {
dispatch(createFilterStatusFail(error));
if (onFail) onFail();
});
};
export const createFilterStatusRequest = () => ({
type: FILTERS_STATUS_CREATE_REQUEST,
});
export const createFilterStatusSuccess = filter_status => ({
type: FILTERS_STATUS_CREATE_SUCCESS,
filter_status,
});
export const createFilterStatusFail = error => ({
type: FILTERS_STATUS_CREATE_FAIL,
error,
});
export const createFilter = (params, onSuccess, onFail) => (dispatch, getState) => {
dispatch(createFilterRequest());
api(getState).post('/api/v2/filters', params).then(response => {
dispatch(createFilterSuccess(response.data));
if (onSuccess) onSuccess(response.data);
}).catch(error => {
dispatch(createFilterFail(error));
if (onFail) onFail();
});
};
export const createFilterRequest = () => ({
type: FILTERS_CREATE_REQUEST,
});
export const createFilterSuccess = filter => ({
type: FILTERS_CREATE_SUCCESS,
filter,
});
export const createFilterFail = error => ({
type: FILTERS_CREATE_FAIL,
error,
});

View file

@ -42,9 +42,9 @@ export function fetchStatusRequest(id, skipLoading) {
};
};
export function fetchStatus(id) {
export function fetchStatus(id, forceFetch = false) {
return (dispatch, getState) => {
const skipLoading = getState().getIn(['statuses', id], null) !== null;
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
dispatch(fetchContext(id));

View file

@ -80,6 +80,7 @@ class Status extends ImmutablePureComponent {
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
onBlock: PropTypes.func,
onAddFilter: PropTypes.func,
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,

View file

@ -44,6 +44,7 @@ const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
});
const mapStateToProps = (state, { status }) => ({
@ -80,6 +81,7 @@ class StatusActionBar extends ImmutablePureComponent {
onPin: PropTypes.func,
onBookmark: PropTypes.func,
onFilter: PropTypes.func,
onAddFilter: PropTypes.func,
withDismiss: PropTypes.bool,
withCounters: PropTypes.bool,
scrollKey: PropTypes.string,
@ -211,8 +213,8 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onMuteConversation(this.props.status);
}
handleFilter = () => {
this.props.onFilter();
handleFilterClick = () => {
this.props.onAddFilter(this.props.status);
}
handleCopy = () => {
@ -235,7 +237,7 @@ class StatusActionBar extends ImmutablePureComponent {
}
handleFilterClick = () => {
handleHideClick = () => {
this.props.onFilter();
}
@ -294,6 +296,12 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
}
if (!this.props.onFilter) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick });
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport });
if (account.get('acct') !== account.get('username')) {
@ -343,7 +351,7 @@ class StatusActionBar extends ImmutablePureComponent {
);
const filterButton = this.props.onFilter && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
);
return (

View file

@ -34,6 +34,9 @@ import {
blockDomain,
unblockDomain,
} from '../actions/domain_blocks';
import {
initAddFilter,
} from '../actions/filters';
import { initMuteModal } from '../actions/mutes';
import { initBlockModal } from '../actions/blocks';
import { initBoostModal } from '../actions/boosts';
@ -66,7 +69,7 @@ const makeMapStateToProps = () => {
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
onReply (status, router) {
dispatch((_, getState) => {
@ -176,6 +179,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(initReport(status.get('account'), status));
},
onAddFilter (status) {
dispatch(initAddFilter(status, { contextType }));
},
onMute (account) {
dispatch(initMuteModal(account));
},

View file

@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames';
import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
import fuzzysort from 'fuzzysort';
const messages = defineMessages({
@ -16,22 +17,6 @@ const messages = defineMessages({
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
});
// Copied from emoji-mart for consistency with emoji picker and since
// they don't export the icons in the package
const icons = {
loupe: (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
</svg>
),
delete: (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
</svg>
),
};
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
class LanguageDropdownMenu extends React.PureComponent {
@ -242,7 +227,7 @@ class LanguageDropdownMenu extends React.PureComponent {
<div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? icons.loupe : icons.delete}</button>
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
</div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>

View file

@ -0,0 +1,102 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import { toServerSideType } from 'mastodon/utils/filters';
import Button from 'mastodon/components/button';
import { connect } from 'react-redux';
const mapStateToProps = (state, { filterId }) => ({
filter: state.getIn(['filters', filterId]),
});
export default @connect(mapStateToProps)
class AddedToFilter extends React.PureComponent {
static propTypes = {
onClose: PropTypes.func.isRequired,
contextType: PropTypes.string,
filter: ImmutablePropTypes.map.isRequired,
dispatch: PropTypes.func.isRequired,
};
handleCloseClick = () => {
const { onClose } = this.props;
onClose();
};
render () {
const { filter, contextType } = this.props;
let expiredMessage = null;
if (filter.get('expires_at') && filter.get('expires_at') < new Date()) {
expiredMessage = (
<React.Fragment>
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.expired_title' defaultMessage='Expired filter!' /></h4>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.expired_explanation'
defaultMessage='This filter category has expired, you will need to change the expiration date for it to apply.'
/>
</p>
</React.Fragment>
);
}
let contextMismatchMessage = null;
if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
contextMismatchMessage = (
<React.Fragment>
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.context_mismatch_title' defaultMessage='Context mismatch!' /></h4>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.context_mismatch_explanation'
defaultMessage='This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.'
/>
</p>
</React.Fragment>
);
}
const settings_link = (
<a href={`/filters/${filter.get('id')}/edit`}>
<FormattedMessage
id='filter_modal.added.settings_link'
defaultMessage='settings page'
/>
</a>
);
return (
<React.Fragment>
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' /></h3>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.short_explanation'
defaultMessage='This post has been added to the following filter category: {title}.'
values={{ title: filter.get('title') }}
/>
</p>
{expiredMessage}
{contextMismatchMessage}
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.review_and_configure_title' defaultMessage='Filter settings' /></h4>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.review_and_configure'
defaultMessage='To review and further configure this filter category, go to the {settings_link}.'
values={{ settings_link }}
/>
</p>
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button>
</div>
</React.Fragment>
);
}
}

View file

@ -0,0 +1,192 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { toServerSideType } from 'mastodon/utils/filters';
import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
import Icon from 'mastodon/components/icon';
import fuzzysort from 'fuzzysort';
const messages = defineMessages({
search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
});
const mapStateToProps = (state, { contextType }) => ({
filters: Array.from(state.get('filters').values()).map((filter) => [
filter.get('id'),
filter.get('title'),
filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
filter.get('expires_at') && filter.get('expires_at') < new Date(),
contextType && !filter.get('context').includes(toServerSideType(contextType)),
]),
});
export default @connect(mapStateToProps)
@injectIntl
class SelectFilter extends React.PureComponent {
static propTypes = {
onSelectFilter: PropTypes.func.isRequired,
onNewFilter: PropTypes.func.isRequired,
filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
intl: PropTypes.object.isRequired,
};
state = {
searchValue: '',
};
search () {
const { filters } = this.props;
const { searchValue } = this.state;
if (searchValue === '') {
return filters;
}
return fuzzysort.go(searchValue, filters, {
keys: ['1', '2'],
limit: 5,
threshold: -10000,
}).map(result => result.obj);
}
renderItem = filter => {
let warning = null;
if (filter[3] || filter[4]) {
warning = (
<span className='language-dropdown__dropdown__results__item__common-name'>
(
{filter[3] && <FormattedMessage id='filter_modal.select_filter.expired' defaultMessage='expired' />}
{filter[3] && filter[4] && ', '}
{filter[4] && <FormattedMessage id='filter_modal.select_filter.context_mismatch' defaultMessage='does not apply to this context' />}
)
</span>
);
}
return (
<div key={filter[0]} role='button' tabIndex='0' data-index={filter[0]} className='language-dropdown__dropdown__results__item' onClick={this.handleItemClick} onKeyDown={this.handleKeyDown}>
<span className='language-dropdown__dropdown__results__item__native-name'>{filter[1]}</span> {warning}
</div>
);
}
renderCreateNew (name) {
return (
<div key='add-new-filter' role='button' tabIndex='0' className='language-dropdown__dropdown__results__item' onClick={this.handleNewFilterClick} onKeyDown={this.handleKeyDown}>
<Icon id='plus' fixedWidth /> <FormattedMessage id='filter_modal.select_filter.prompt_new' defaultMessage='New category: {name}' values={{ name }} />
</div>
);
}
handleSearchChange = ({ target }) => {
this.setState({ searchValue: target.value });
}
setListRef = c => {
this.listNode = c;
}
handleKeyDown = e => {
const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
let element = null;
switch(e.key) {
case ' ':
case 'Enter':
e.currentTarget.click();
break;
case 'ArrowDown':
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
break;
case 'ArrowUp':
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
} else {
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
}
break;
case 'Home':
element = this.listNode.firstChild;
break;
case 'End':
element = this.listNode.lastChild;
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
}
handleSearchKeyDown = e => {
let element = null;
switch(e.key) {
case 'Tab':
case 'ArrowDown':
element = this.listNode.firstChild;
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
}
}
handleClear = () => {
this.setState({ searchValue: '' });
}
handleItemClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onSelectFilter(value);
}
handleNewFilterClick = e => {
e.preventDefault();
this.props.onNewFilter(this.state.searchValue);
};
render () {
const { intl } = this.props;
const { searchValue } = this.state;
const isSearching = searchValue !== '';
const results = this.search();
return (
<React.Fragment>
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3>
<p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
<div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
</div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
{results.map(this.renderItem)}
{isSearching && this.renderCreateNew(searchValue) }
</div>
</React.Fragment>
);
}
}

View file

@ -0,0 +1,134 @@
import React from 'react';
import { connect } from 'react-redux';
import { fetchStatus } from 'mastodon/actions/statuses';
import { fetchFilters, createFilter, createFilterStatus } from 'mastodon/actions/filters';
import PropTypes from 'prop-types';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import IconButton from 'mastodon/components/icon_button';
import SelectFilter from 'mastodon/features/filters/select_filter';
import AddedToFilter from 'mastodon/features/filters/added_to_filter';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @connect(undefined)
@injectIntl
class FilterModal extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
contextType: PropTypes.string,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
step: 'select',
filterId: null,
isSubmitting: false,
isSubmitted: false,
};
handleNewFilterSuccess = (result) => {
this.handleSelectFilter(result.id);
};
handleSuccess = () => {
const { dispatch, statusId } = this.props;
dispatch(fetchStatus(statusId, true));
this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
};
handleFail = () => {
this.setState({ isSubmitting: false });
};
handleNextStep = step => {
this.setState({ step });
};
handleSelectFilter = (filterId) => {
const { dispatch, statusId } = this.props;
this.setState({ isSubmitting: true, filterId });
dispatch(createFilterStatus({
filter_id: filterId,
status_id: statusId,
}, this.handleSuccess, this.handleFail));
};
handleNewFilter = (title) => {
const { dispatch } = this.props;
this.setState({ isSubmitting: true });
dispatch(createFilter({
title,
context: ['home', 'notifications', 'public', 'thread', 'account'],
action: 'warn',
}, this.handleNewFilterSuccess, this.handleFail));
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchFilters());
}
render () {
const {
intl,
statusId,
contextType,
onClose,
} = this.props;
const {
step,
filterId,
} = this.state;
let stepComponent;
switch(step) {
case 'select':
stepComponent = (
<SelectFilter
contextType={contextType}
onSelectFilter={this.handleSelectFilter}
onNewFilter={this.handleNewFilter}
/>
);
break;
case 'create':
stepComponent = null;
break;
case 'submitted':
stepComponent = (
<AddedToFilter
contextType={contextType}
filterId={filterId}
statusId={statusId}
onClose={onClose}
/>
);
}
return (
<div className='modal-root__modal report-dialog-modal'>
<div className='report-modal__target'>
<IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
<FormattedMessage id='filter_modal.title.status' defaultMessage='Filter a post' />
</div>
<div className='report-dialog-modal__container'>
{stepComponent}
</div>
</div>
);
}
}

View file

@ -20,6 +20,7 @@ import {
ListEditor,
ListAdder,
CompareHistoryModal,
FilterModal,
} from 'mastodon/features/ui/util/async-components';
const MODAL_COMPONENTS = {
@ -37,6 +38,7 @@ const MODAL_COMPONENTS = {
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder,
'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,
};
export default class ModalRoot extends React.PureComponent {

View file

@ -161,3 +161,7 @@ export function CompareHistoryModal () {
export function Explore () {
return import(/* webpackChunkName: "features/explore" */'../../explore');
}
export function FilterModal () {
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
}

View file

@ -1,4 +1,5 @@
import { FILTERS_IMPORT } from '../actions/importer';
import { FILTERS_FETCH_SUCCESS, FILTERS_CREATE_SUCCESS } from '../actions/filters';
import { Map as ImmutableMap, is, fromJS } from 'immutable';
const normalizeFilter = (state, filter) => {
@ -7,13 +8,17 @@ const normalizeFilter = (state, filter) => {
title: filter.title,
context: filter.context,
filter_action: filter.filter_action,
keywords: filter.keywords,
expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null,
});
if (is(state.get(filter.id), normalizedFilter)) {
return state;
} else {
return state.set(filter.id, normalizedFilter);
// Do not overwrite keywords when receiving a partial filter
return state.update(filter.id, ImmutableMap(), (old) => (
old.mergeWith(((old_value, new_value) => (new_value === undefined ? old_value : new_value)), normalizedFilter)
));
}
};
@ -27,6 +32,10 @@ const normalizeFilters = (state, filters) => {
export default function filters(state = ImmutableMap(), action) {
switch(action.type) {
case FILTERS_CREATE_SUCCESS:
return normalizeFilter(state, action.filter);
case FILTERS_FETCH_SUCCESS:
//TODO: handle deleting obsolete filters
case FILTERS_IMPORT:
return normalizeFilters(state, action.filters);
default:

View file

@ -1,5 +1,6 @@
import { createSelector } from 'reselect';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { toServerSideType } from 'mastodon/utils/filters';
import { me } from '../initial_state';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
@ -20,23 +21,6 @@ export const makeGetAccount = () => {
});
};
const toServerSideType = columnType => {
switch (columnType) {
case 'home':
case 'notifications':
case 'public':
case 'thread':
case 'account':
return columnType;
default:
if (columnType.indexOf('list:') > -1) {
return 'home';
} else {
return 'public'; // community, account, hashtag
}
}
};
const getFilters = (state, { contextType }) => {
if (!contextType) return null;
@ -73,6 +57,7 @@ export const makeGetStatus = () => {
if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
return null;
}
filterResults = filterResults.filter(result => filters.has(result.get('filter')));
if (!filterResults.isEmpty()) {
filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
}

View file

@ -0,0 +1,16 @@
export const toServerSideType = columnType => {
switch (columnType) {
case 'home':
case 'notifications':
case 'public':
case 'thread':
case 'account':
return columnType;
default:
if (columnType.indexOf('list:') > -1) {
return 'home';
} else {
return 'public'; // community, account, hashtag
}
}
};

View file

@ -0,0 +1,13 @@
// Copied from emoji-mart for consistency with emoji picker and since
// they don't export the icons in the package
export const loupeIcon = (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
</svg>
);
export const deleteIcon = (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
</svg>
);

View file

@ -5233,6 +5233,16 @@ a.status-card.compact:hover {
line-height: 22px;
color: lighten($inverted-text-color, 16%);
margin-bottom: 30px;
a {
text-decoration: none;
color: $inverted-text-color;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
}
&__actions {
@ -5379,6 +5389,14 @@ a.status-card.compact:hover {
background: transparent;
margin: 15px 0;
}
.emoji-mart-search {
padding-right: 10px;
}
.emoji-mart-search-icon {
right: 10px + 5px;
}
}
.report-modal__container {

View file

@ -249,15 +249,7 @@ module AccountInteractions
def status_matches_filters(status)
active_filters = CustomFilter.cached_filters_for(id)
filter_matches = active_filters.filter_map do |filter, rules|
next if rules[:keywords].blank?
match = rules[:keywords].match(status.proper.searchable_text)
FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil?
end
filter_matches
CustomFilter.apply_cached_filters(active_filters, status)
end
def followers_for_local_distribution

View file

@ -34,6 +34,7 @@ class CustomFilter < ApplicationRecord
belongs_to :account
has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
has_many :statuses, class_name: 'CustomFilterStatus', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
validates :title, :context, presence: true
@ -62,8 +63,10 @@ class CustomFilter < ApplicationRecord
def self.cached_filters_for(account_id)
active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do
filters_hash = {}
scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
scope.to_a.group_by(&:custom_filter).map do |filter, keywords|
scope.to_a.group_by(&:custom_filter).each do |filter, keywords|
keywords.map! do |keyword|
if keyword.whole_word
sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : ''
@ -74,13 +77,34 @@ class CustomFilter < ApplicationRecord
/#{Regexp.escape(keyword.keyword)}/i
end
end
[filter, { keywords: Regexp.union(keywords) }]
filters_hash[filter.id] = { keywords: Regexp.union(keywords), filter: filter }
end.to_h
scope = CustomFilterStatus.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
scope.to_a.group_by(&:custom_filter).each do |filter, statuses|
filters_hash[filter.id] ||= { filter: filter }
filters_hash[filter.id].merge!(status_ids: statuses.map(&:status_id))
end
filters_hash.values.map { |cache| [cache.delete(:filter), cache] }
end.to_a
active_filters.select { |custom_filter, _| !custom_filter.expired? }
end
def self.apply_cached_filters(cached_filters, status)
cached_filters.filter_map do |filter, rules|
match = rules[:keywords].match(status.proper.searchable_text) if rules[:keywords].present?
keyword_matches = [match.to_s] unless match.nil?
status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present?
next if keyword_matches.blank? && status_matches.blank?
FilterResultPresenter.new(filter: filter, keyword_matches: keyword_matches, status_matches: status_matches)
end
end
def prepare_cache_invalidation!
@should_invalidate_cache = true
end

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: custom_filter_statuses
#
# id :bigint(8) not null, primary key
# custom_filter_id :bigint(8) not null
# status_id :bigint(8) default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class CustomFilterStatus < ApplicationRecord
belongs_to :custom_filter
belongs_to :status
validates :status, uniqueness: { scope: :custom_filter }
validate :validate_status_access
before_save :prepare_cache_invalidation!
before_destroy :prepare_cache_invalidation!
after_commit :invalidate_cache!
private
def validate_status_access
errors.add(:status_id, :invalid) unless StatusPolicy.new(custom_filter.account, status).show?
end
def prepare_cache_invalidation!
custom_filter.prepare_cache_invalidation!
end
def invalidate_cache!
custom_filter.invalidate_cache!
end
end

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
class Form::StatusFilterBatchAction
include ActiveModel::Model
include AccountableConcern
include Authorization
attr_accessor :current_account, :type,
:status_filter_ids, :filter_id
def save!
process_action!
end
private
def status_filters
filter = current_account.custom_filters.find(filter_id)
filter.statuses.where(id: status_filter_ids)
end
def process_action!
return if status_filter_ids.empty?
case type
when 'remove'
handle_remove!
end
end
def handle_remove!
status_filters.destroy_all
end
end

View file

@ -1,5 +1,5 @@
# frozen_string_literal: true
class FilterResultPresenter < ActiveModelSerializers::Model
attributes :filter, :keyword_matches
attributes :filter, :keyword_matches, :status_matches
end

View file

@ -33,12 +33,7 @@ class StatusRelationshipsPresenter
active_filters = CustomFilter.cached_filters_for(current_account_id)
@filters_map = statuses.each_with_object({}) do |status, h|
filter_matches = active_filters.filter_map do |filter, rules|
next if rules[:keywords].blank?
match = rules[:keywords].match(status.proper.searchable_text)
FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil?
end
filter_matches = CustomFilter.apply_cached_filters(active_filters, status)
unless filter_matches.empty?
h[status.id] = filter_matches

View file

@ -3,4 +3,9 @@
class REST::FilterResultSerializer < ActiveModel::Serializer
belongs_to :filter, serializer: REST::FilterSerializer
has_many :keyword_matches
has_many :status_matches
def status_matches
object.status_matches&.map(&:to_s)
end
end

View file

@ -3,6 +3,7 @@
class REST::FilterSerializer < ActiveModel::Serializer
attributes :id, :title, :context, :expires_at, :filter_action
has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested?
has_many :statuses, serializer: REST::FilterStatusSerializer, if: :rules_requested?
def id
object.id.to_s

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class REST::FilterStatusSerializer < ActiveModel::Serializer
attributes :id, :status_id
def id
object.id.to_s
end
def status_id
object.status_id.to_s
end
end

View file

@ -22,6 +22,15 @@
- keywords = filter.keywords.map(&:keyword)
- keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO
= keywords.join(', ')
- unless filter.statuses.empty?
%li.permissions-list__item
.permissions-list__item__icon
= fa_icon('comment')
.permissions-list__item__text
.permissions-list__item__text__title
= t('filters.index.statuses', count: filter.statuses.size)
.permissions-list__item__text__type
= t('filters.index.statuses_long', count: filter.statuses.size)
.announcements-list__item__action-bar
.announcements-list__item__meta

View file

@ -14,6 +14,13 @@
%hr.spacer/
- unless f.object.statuses.empty?
%h4= t('filters.edit.statuses')
%p.muted-hint= t('filters.edit.statuses_hint_html', path: filter_statuses_path(f.object))
%hr.spacer/
%h4= t('filters.edit.keywords')
.table-wrapper

View file

@ -0,0 +1,37 @@
- status = status_filter.status.proper
.batch-table__row
%label.batch-table__row__select.batch-checkbox
= f.check_box :status_filter_ids, { multiple: true, include_hidden: false }, status_filter.id
.batch-table__row__content
.status__content><
- if status.spoiler_text.blank?
= prerender_custom_emojis(status_content_format(status), status.emojis)
- else
%details<
%summary><
%strong> Content warning: #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
= prerender_custom_emojis(status_content_format(status), status.emojis)
- status.ordered_media_attachments.each do |media_attachment|
%abbr{ title: media_attachment.description }
= fa_icon 'link'
= media_attachment.file_file_name
.detailed-status__meta
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'name-tag', target: '_blank', rel: 'noopener noreferrer' do
= image_tag(status.account.avatar.url, width: 15, height: 15, alt: display_name(status.account), class: 'avatar')
.username= status.account.acct
·
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
- if status.edited?
·
= t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted'))
·
= fa_visibility_icon(status)
= t("statuses.visibilities.#{status.visibility}")
- if status.sensitive?
·
= fa_icon('eye-slash fw')
= t('stream_entries.sensitive_content')

View file

@ -0,0 +1,38 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :page_title do
= t('filters.statuses.index.title')
\-
= @filter.title
.filters
.back-link
= link_to edit_filter_path(@filter) do
= fa_icon 'chevron-left fw'
= t('filters.statuses.back_to_filter')
%p.hint= t('filters.statuses.index.hint')
%hr.spacer/
= form_for(@status_filter_batch_action, url: batch_filter_statuses_path(@filter.id)) do |f|
= hidden_field_tag :page, params[:page] || 1
- Admin::StatusFilter::KEYS.each do |key|
= hidden_field_tag key, params[key] if params[key].present?
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
- unless @status_filters.empty?
= f.button safe_join([fa_icon('times'), t('filters.statuses.batch.remove')]), name: :remove, class: 'table-action-link', type: :submit
.batch-table__body
- if @status_filters.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'status_filter', collection: @status_filters, locals: { f: f }
= paginate @status_filters

View file

@ -1181,6 +1181,8 @@ en:
edit:
add_keyword: Add keyword
keywords: Keywords
statuses: Individual posts
statuses_hint_html: This filter applies to select individual posts regardless of whether they match the keywords below. You can review these posts and remove them from the filter by <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.
@ -1194,10 +1196,23 @@ en:
keywords:
one: "%{count} keyword"
other: "%{count} keywords"
statuses:
one: "%{count} post"
other: "%{count} posts"
statuses_long:
one: "%{count} individual post hidden"
other: "%{count} individual posts hidden"
title: Filters
new:
save: Save new filter
title: Add new filter
statuses:
back_to_filter: Back to filter
batch:
remove: Remove from filter
index:
hint: This filter applies to select individual posts regardless of other criteria. You can add more posts to this filter from the Web interface.
title: Filtered posts
footer:
developers: Developers
more: More…

View file

@ -177,7 +177,14 @@ Rails.application.routes.draw do
resources :tags, only: [:show]
resources :emojis, only: [:show]
resources :invites, only: [:index, :create, :destroy]
resources :filters, except: [:show]
resources :filters, except: [:show] do
resources :statuses, only: [:index], controller: 'filters/statuses' do
collection do
post :batch
end
end
end
resource :relationships, only: [:show, :update]
resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update]
@ -448,12 +455,14 @@ Rails.application.routes.draw do
resources :trends, only: [:index], controller: 'trends/tags'
resources :filters, only: [:index, :create, :show, :update, :destroy] do
resources :keywords, only: [:index, :create], controller: 'filters/keywords'
resources :statuses, only: [:index, :create], controller: 'filters/statuses'
end
resources :endorsements, only: [:index]
resources :markers, only: [:index, :create]
namespace :filters do
resources :keywords, only: [:show, :update, :destroy]
resources :statuses, only: [:show, :destroy]
end
namespace :apps do

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateCustomFilterStatuses < ActiveRecord::Migration[6.1]
def change
create_table :custom_filter_statuses do |t|
t.belongs_to :custom_filter, foreign_key: { on_delete: :cascade }, null: false
t.belongs_to :status, foreign_key: { on_delete: :cascade }, null: false
t.timestamps
end
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_07_14_171049) do
ActiveRecord::Schema.define(version: 2022_08_08_101323) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -348,6 +348,15 @@ ActiveRecord::Schema.define(version: 2022_07_14_171049) do
t.index ["custom_filter_id"], name: "index_custom_filter_keywords_on_custom_filter_id"
end
create_table "custom_filter_statuses", force: :cascade do |t|
t.bigint "custom_filter_id", null: false
t.bigint "status_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["custom_filter_id"], name: "index_custom_filter_statuses_on_custom_filter_id"
t.index ["status_id"], name: "index_custom_filter_statuses_on_status_id"
end
create_table "custom_filters", force: :cascade do |t|
t.bigint "account_id"
t.datetime "expires_at"
@ -1113,6 +1122,8 @@ ActiveRecord::Schema.define(version: 2022_07_14_171049) do
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
add_foreign_key "custom_filter_keywords", "custom_filters", on_delete: :cascade
add_foreign_key "custom_filter_statuses", "custom_filters", on_delete: :cascade
add_foreign_key "custom_filter_statuses", "statuses", on_delete: :cascade
add_foreign_key "custom_filters", "accounts", on_delete: :cascade
add_foreign_key "devices", "accounts", on_delete: :cascade
add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade

View file

@ -0,0 +1,116 @@
require 'rails_helper'
RSpec.describe Api::V1::Filters::StatusesController, type: :controller do
render_views
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:filter) { Fabricate(:custom_filter, account: user.account) }
let(:other_user) { Fabricate(:user) }
let(:other_filter) { Fabricate(:custom_filter, account: other_user.account) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'GET #index' do
let(:scopes) { 'read:filters' }
let!(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) }
it 'returns http success' do
get :index, params: { filter_id: filter.id }
expect(response).to have_http_status(200)
end
context "when trying to access another's user filters" do
it 'returns http not found' do
get :index, params: { filter_id: other_filter.id }
expect(response).to have_http_status(404)
end
end
end
describe 'POST #create' do
let(:scopes) { 'write:filters' }
let(:filter_id) { filter.id }
let!(:status) { Fabricate(:status) }
before do
post :create, params: { filter_id: filter_id, status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns a status filter' do
json = body_as_json
expect(json[:status_id]).to eq status.id.to_s
end
it 'creates a status filter' do
filter = user.account.custom_filters.first
expect(filter).to_not be_nil
expect(filter.statuses.pluck(:status_id)).to eq [status.id]
end
context "when trying to add to another another's user filters" do
let(:filter_id) { other_filter.id }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
describe 'GET #show' do
let(:scopes) { 'read:filters' }
let!(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) }
before do
get :show, params: { id: status_filter.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns expected data' do
json = body_as_json
expect(json[:status_id]).to eq status_filter.status_id.to_s
end
context "when trying to access another user's filter keyword" do
let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: other_filter) }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
describe 'DELETE #destroy' do
let(:scopes) { 'write:filters' }
let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) }
before do
delete :destroy, params: { id: status_filter.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'removes the filter' do
expect { status_filter.reload }.to raise_error ActiveRecord::RecordNotFound
end
context "when trying to update another user's filter keyword" do
let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: other_filter) }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
end

View file

@ -47,6 +47,33 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
end
end
context 'when post is explicitly filtered' do
let(:status) { Fabricate(:status, text: 'hello world') }
before do
filter = user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide)
filter.statuses.create!(status_id: status.id)
end
it 'returns http success' do
get :show, params: { id: status.id }
expect(response).to have_http_status(200)
end
it 'returns filter information' do
get :show, params: { id: status.id }
json = body_as_json
expect(json[:filtered][0]).to include({
filter: a_hash_including({
id: user.account.custom_filters.first.id.to_s,
title: 'filter1',
filter_action: 'hide',
}),
status_matches: [status.id.to_s],
})
end
end
context 'when reblog includes filtered terms' do
let(:status) { Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about that banned word')) }

View file

@ -0,0 +1,4 @@
Fabricator(:custom_filter_status) do
custom_filter
status
end

View file

@ -94,5 +94,32 @@ RSpec.describe StatusRelationshipsPresenter do
expect(matched_filters[0].keyword_matches).to eq ['irrelevant']
end
end
context 'when post includes filtered individual statuses' do
let(:statuses) { [Fabricate(:status, text: 'hello world'), Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about an irrelevant word'))] }
let(:options) { {} }
before do
filter = Account.find(current_account_id).custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide)
filter.statuses.create!(status_id: statuses[0].id)
filter.statuses.create!(status_id: statuses[1].reblog_of_id)
end
it 'sets @filters_map to filter top-level status' do
matched_filters = presenter.filters_map[statuses[0].id]
expect(matched_filters.size).to eq 1
expect(matched_filters[0].filter.title).to eq 'filter1'
expect(matched_filters[0].status_matches).to eq [statuses[0].id]
end
it 'sets @filters_map to filter reblogged status' do
matched_filters = presenter.filters_map[statuses[1].reblog_of_id]
expect(matched_filters.size).to eq 1
expect(matched_filters[0].filter.title).to eq 'filter1'
expect(matched_filters[0].status_matches).to eq [statuses[1].reblog_of_id]
end
end
end
end