commit
4be626734d
@ -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
|
@ -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
|
@ -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,
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
};
|
@ -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>
|
||||
);
|
@ -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
|
@ -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
|
@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FilterResultPresenter < ActiveModelSerializers::Model
|
||||
attributes :filter, :keyword_matches
|
||||
attributes :filter, :keyword_matches, :status_matches
|
||||
end
|
||||
|
@ -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
|
@ -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')
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,4 @@
|
||||
Fabricator(:custom_filter_status) do
|
||||
custom_filter
|
||||
status
|
||||
end
|
Loading…
Reference in new issue