Merge branch 'main' into glitch-soc/merge-upstream
This commit is contained in:
44
app/controllers/api/v1/filters/statuses_controller.rb
Normal file
44
app/controllers/api/v1/filters/statuses_controller.rb
Normal 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
|
@ -83,7 +83,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
end
|
||||
|
||||
def check_enabled_registrations
|
||||
redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations?
|
||||
redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations? || ip_blocked?
|
||||
end
|
||||
|
||||
def allowed_registrations?
|
||||
@ -94,6 +94,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
ENV['OMNIAUTH_ONLY'] == 'true'
|
||||
end
|
||||
|
||||
def ip_blocked?
|
||||
IpBlock.where(severity: :sign_up_block).where('ip >>= ?', request.remote_ip.to_s).exists?
|
||||
end
|
||||
|
||||
def invite_code
|
||||
if params[:user]
|
||||
params[:user][:invite_code]
|
||||
|
49
app/controllers/filters/statuses_controller.rb
Normal file
49
app/controllers/filters/statuses_controller.rb
Normal 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
|
@ -9,7 +9,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
|
||||
|
93
app/javascript/mastodon/actions/filters.js
Normal file
93
app/javascript/mastodon/actions/filters.js
Normal 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,
|
||||
});
|
@ -141,13 +141,13 @@ const excludeTypesFromFilter = filter => {
|
||||
|
||||
const noOp = () => {};
|
||||
|
||||
export function expandNotifications({ maxId } = {}, done = noOp) {
|
||||
export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) {
|
||||
return (dispatch, getState) => {
|
||||
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
|
||||
const notifications = getState().get('notifications');
|
||||
const isLoadingMore = !!maxId;
|
||||
|
||||
if (notifications.get('isLoading')) {
|
||||
if (notifications.get('isLoading') && !forceLoad) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
@ -243,7 +243,7 @@ export function setFilter (filterType) {
|
||||
path: ['notifications', 'quickFilter', 'active'],
|
||||
value: filterType,
|
||||
});
|
||||
dispatch(expandNotifications());
|
||||
dispatch(expandNotifications({ forceLoad: true }));
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
};
|
||||
|
@ -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));
|
||||
|
||||
|
@ -75,18 +75,18 @@ export const unfollowHashtag = name => (dispatch, getState) => {
|
||||
};
|
||||
|
||||
export const unfollowHashtagRequest = name => ({
|
||||
type: HASHTAG_FETCH_REQUEST,
|
||||
type: HASHTAG_UNFOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
export const unfollowHashtagSuccess = (name, tag) => ({
|
||||
type: HASHTAG_FETCH_SUCCESS,
|
||||
type: HASHTAG_UNFOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
export const unfollowHashtagFail = (name, error) => ({
|
||||
type: HASHTAG_FETCH_FAIL,
|
||||
type: HASHTAG_UNFOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
@ -131,17 +131,9 @@ export default class IconButton extends React.PureComponent {
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={classes}
|
||||
style={style}
|
||||
>
|
||||
if (href && !this.prop) {
|
||||
contents = (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer'>
|
||||
{contents}
|
||||
</a>
|
||||
);
|
||||
|
@ -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,
|
||||
@ -515,7 +516,7 @@ class Status extends ImmutablePureComponent {
|
||||
|
||||
{media}
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters && this.handleFilterClick} {...other} />
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
@ -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 (
|
||||
|
@ -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));
|
||||
},
|
||||
|
@ -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}>
|
||||
|
102
app/javascript/mastodon/features/filters/added_to_filter.js
Normal file
102
app/javascript/mastodon/features/filters/added_to_filter.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
192
app/javascript/mastodon/features/filters/select_filter.js
Normal file
192
app/javascript/mastodon/features/filters/select_filter.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
134
app/javascript/mastodon/features/ui/components/filter_modal.js
Normal file
134
app/javascript/mastodon/features/ui/components/filter_modal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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 {
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -41,7 +41,7 @@ const initialState = ImmutableMap({
|
||||
lastReadId: '0',
|
||||
readMarkerId: '0',
|
||||
isTabVisible: true,
|
||||
isLoading: false,
|
||||
isLoading: 0,
|
||||
browserSupport: false,
|
||||
browserPermission: 'default',
|
||||
});
|
||||
@ -115,7 +115,7 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
|
||||
}
|
||||
}
|
||||
|
||||
mutable.set('isLoading', false);
|
||||
mutable.update('isLoading', (nbLoading) => nbLoading - 1);
|
||||
});
|
||||
};
|
||||
|
||||
@ -214,9 +214,9 @@ export default function notifications(state = initialState, action) {
|
||||
case NOTIFICATIONS_LOAD_PENDING:
|
||||
return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
|
||||
case NOTIFICATIONS_EXPAND_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
return state.update('isLoading', (nbLoading) => nbLoading + 1);
|
||||
case NOTIFICATIONS_EXPAND_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
return state.update('isLoading', (nbLoading) => nbLoading - 1);
|
||||
case NOTIFICATIONS_FILTER_SET:
|
||||
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true);
|
||||
case NOTIFICATIONS_SCROLL_TOP:
|
||||
|
@ -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']));
|
||||
}
|
||||
|
16
app/javascript/mastodon/utils/filters.js
Normal file
16
app/javascript/mastodon/utils/filters.js
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
13
app/javascript/mastodon/utils/icons.js
Normal file
13
app/javascript/mastodon/utils/icons.js
Normal 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>
|
||||
);
|
@ -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 {
|
||||
|
@ -31,7 +31,7 @@ class Request
|
||||
@url = Addressable::URI.parse(url).normalize
|
||||
@http_client = options.delete(:http_client)
|
||||
@options = options.merge(socket_class: use_proxy? ? ProxySocket : Socket)
|
||||
@options = @options.merge(Rails.configuration.x.http_client_proxy) if use_proxy?
|
||||
@options = @options.merge(proxy_url) if use_proxy?
|
||||
@headers = {}
|
||||
|
||||
raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service?
|
||||
@ -141,11 +141,23 @@ class Request
|
||||
end
|
||||
|
||||
def use_proxy?
|
||||
Rails.configuration.x.http_client_proxy.present?
|
||||
proxy_url.present?
|
||||
end
|
||||
|
||||
def proxy_url
|
||||
if hidden_service? && Rails.configuration.x.http_client_hidden_proxy.present?
|
||||
Rails.configuration.x.http_client_hidden_proxy
|
||||
else
|
||||
Rails.configuration.x.http_client_proxy
|
||||
end
|
||||
end
|
||||
|
||||
def block_hidden_service?
|
||||
!Rails.configuration.x.access_to_hidden_service && /\.(onion|i2p)$/.match?(@url.host)
|
||||
!Rails.configuration.x.access_to_hidden_service && hidden_service?
|
||||
end
|
||||
|
||||
def hidden_service?
|
||||
/\.(onion|i2p)$/.match?(@url.host)
|
||||
end
|
||||
|
||||
module ClientLimit
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
37
app/models/custom_filter_status.rb
Normal file
37
app/models/custom_filter_status.rb
Normal 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
|
@ -30,32 +30,56 @@ class EmailDomainBlock < ApplicationRecord
|
||||
@history ||= Trends::History.new('email_domain_blocks', id)
|
||||
end
|
||||
|
||||
def self.block?(domain_or_domains, attempt_ip: nil)
|
||||
domains = Array(domain_or_domains).map do |str|
|
||||
domain = begin
|
||||
if str.include?('@')
|
||||
str.split('@', 2).last
|
||||
else
|
||||
str
|
||||
end
|
||||
class Matcher
|
||||
def initialize(domain_or_domains, attempt_ip: nil)
|
||||
@uris = extract_uris(domain_or_domains)
|
||||
@attempt_ip = attempt_ip
|
||||
end
|
||||
|
||||
def match?
|
||||
blocking? || invalid_uri?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invalid_uri?
|
||||
@uris.any?(&:nil?)
|
||||
end
|
||||
|
||||
def blocking?
|
||||
blocks = EmailDomainBlock.where(domain: domains_with_variants).order(Arel.sql('char_length(domain) desc'))
|
||||
blocks.each { |block| block.history.add(@attempt_ip) } if @attempt_ip.present?
|
||||
blocks.any?
|
||||
end
|
||||
|
||||
def domains_with_variants
|
||||
@uris.flat_map do |uri|
|
||||
next if uri.nil?
|
||||
|
||||
segments = uri.normalized_host.split('.')
|
||||
|
||||
segments.map.with_index { |_, i| segments[i..-1].join('.') }
|
||||
end
|
||||
|
||||
TagManager.instance.normalize_domain(domain) if domain.present?
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
# If some of the inputs passed in are invalid, we definitely want to
|
||||
# block the attempt, but we also want to register hits against any
|
||||
# other valid matches
|
||||
def extract_uris(domain_or_domains)
|
||||
Array(domain_or_domains).map do |str|
|
||||
domain = begin
|
||||
if str.include?('@')
|
||||
str.split('@', 2).last
|
||||
else
|
||||
str
|
||||
end
|
||||
end
|
||||
|
||||
blocked = domains.any?(&:nil?)
|
||||
|
||||
where(domain: domains).find_each do |block|
|
||||
blocked = true
|
||||
block.history.add(attempt_ip) if attempt_ip.present?
|
||||
Addressable::URI.new.tap { |u| u.host = domain.strip } if domain.present?
|
||||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
blocked
|
||||
def self.block?(domain_or_domains, attempt_ip: nil)
|
||||
Matcher.new(domain_or_domains, attempt_ip: attempt_ip).match?
|
||||
end
|
||||
end
|
||||
|
34
app/models/form/status_filter_batch_action.rb
Normal file
34
app/models/form/status_filter_batch_action.rb
Normal 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
|
@ -19,6 +19,7 @@ class IpBlock < ApplicationRecord
|
||||
|
||||
enum severity: {
|
||||
sign_up_requires_approval: 5000,
|
||||
sign_up_block: 5500,
|
||||
no_access: 9999,
|
||||
}
|
||||
|
||||
|
@ -94,7 +94,7 @@ class User < ApplicationRecord
|
||||
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
|
||||
|
||||
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
|
||||
validates_with BlacklistedEmailValidator, if: -> { !confirmed? }
|
||||
validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
|
||||
validates_with EmailMxValidator, if: :validate_email_dns?
|
||||
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FilterResultPresenter < ActiveModelSerializers::Model
|
||||
attributes :filter, :keyword_matches
|
||||
attributes :filter, :keyword_matches, :status_matches
|
||||
end
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
13
app/serializers/rest/filter_status_serializer.rb
Normal file
13
app/serializers/rest/filter_status_serializer.rb
Normal 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
|
@ -2,23 +2,67 @@
|
||||
|
||||
class AppSignUpService < BaseService
|
||||
def call(app, remote_ip, params)
|
||||
return unless allowed_registrations?
|
||||
@app = app
|
||||
@remote_ip = remote_ip
|
||||
@params = params
|
||||
|
||||
user_params = params.slice(:email, :password, :agreement, :locale)
|
||||
account_params = params.slice(:username)
|
||||
invite_request_params = { text: params[:reason] }
|
||||
user = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
|
||||
raise Mastodon::NotPermittedError unless allowed_registrations?
|
||||
|
||||
Doorkeeper::AccessToken.create!(application: app,
|
||||
resource_owner_id: user.id,
|
||||
scopes: app.scopes,
|
||||
expires_in: Doorkeeper.configuration.access_token_expires_in,
|
||||
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?)
|
||||
ApplicationRecord.transaction do
|
||||
create_user!
|
||||
create_access_token!
|
||||
end
|
||||
|
||||
@access_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_user!
|
||||
@user = User.create!(
|
||||
user_params.merge(created_by_application: @app, sign_up_ip: @remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params)
|
||||
)
|
||||
end
|
||||
|
||||
def create_access_token!
|
||||
@access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: @app.scopes,
|
||||
expires_in: Doorkeeper.configuration.access_token_expires_in,
|
||||
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?
|
||||
)
|
||||
end
|
||||
|
||||
def user_params
|
||||
@params.slice(:email, :password, :agreement, :locale)
|
||||
end
|
||||
|
||||
def account_params
|
||||
@params.slice(:username)
|
||||
end
|
||||
|
||||
def invite_request_params
|
||||
{ text: @params[:reason] }
|
||||
end
|
||||
|
||||
def allowed_registrations?
|
||||
Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode
|
||||
registrations_open? && !single_user_mode? && !omniauth_only? && !ip_blocked?
|
||||
end
|
||||
|
||||
def registrations_open?
|
||||
Setting.registrations_mode != 'none'
|
||||
end
|
||||
|
||||
def single_user_mode?
|
||||
Rails.configuration.x.single_user_mode
|
||||
end
|
||||
|
||||
def omniauth_only?
|
||||
ENV['OMNIAUTH_ONLY'] == 'true'
|
||||
end
|
||||
|
||||
def ip_blocked?
|
||||
IpBlock.where(severity: :sign_up_block).where('ip >>= ?', @remote_ip.to_s).exists?
|
||||
end
|
||||
end
|
||||
|
@ -13,7 +13,7 @@
|
||||
= f.input :position, wrapper: :with_label, input_html: { max: current_user.role.position - 1 }
|
||||
|
||||
.fields-group
|
||||
= f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000' }
|
||||
= f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000', type: 'color' }
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
37
app/views/filters/statuses/_status_filter.html.haml
Normal file
37
app/views/filters/statuses/_status_filter.html.haml
Normal 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')
|
38
app/views/filters/statuses/index.html.haml
Normal file
38
app/views/filters/statuses/index.html.haml
Normal 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
|
Reference in New Issue
Block a user