mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-11-26 16:01:37 +01:00
Merge remote-tracking branch 'upstream/main' into develop
This commit is contained in:
commit
71b46e3a86
71 changed files with 1434 additions and 216 deletions
|
@ -613,7 +613,7 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
semantic_range (3.0.0)
|
||||
sidekiq (6.5.4)
|
||||
sidekiq (6.5.5)
|
||||
connection_pool (>= 2.2.2)
|
||||
rack (~> 2.0)
|
||||
redis (>= 4.5.0)
|
||||
|
@ -651,7 +651,7 @@ GEM
|
|||
sshkit (1.21.2)
|
||||
net-scp (>= 1.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
stackprof (0.2.20)
|
||||
stackprof (0.2.21)
|
||||
statsd-ruby (1.5.0)
|
||||
stoplight (3.0.0)
|
||||
strong_migrations (0.7.9)
|
||||
|
|
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
|
||||
|
|
|
@ -158,13 +158,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;
|
||||
}
|
||||
|
@ -343,7 +343,7 @@ export function setFilter (filterType) {
|
|||
path: ['notifications', 'quickFilter', 'active'],
|
||||
value: filterType,
|
||||
});
|
||||
dispatch(expandNotifications());
|
||||
dispatch(expandNotifications({ forceLoad: true }));
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -139,17 +139,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>
|
||||
);
|
||||
|
|
|
@ -43,7 +43,7 @@ const initialState = ImmutableMap({
|
|||
unread: 0,
|
||||
lastReadId: '0',
|
||||
readMarkerId: '0',
|
||||
isLoading: false,
|
||||
isLoading: 0,
|
||||
cleaningMode: false,
|
||||
isTabVisible: true,
|
||||
browserSupport: false,
|
||||
|
@ -121,7 +121,7 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
|
|||
}
|
||||
}
|
||||
|
||||
mutable.set('isLoading', false);
|
||||
mutable.update('isLoading', (nbLoading) => nbLoading - 1);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -249,10 +249,10 @@ export default function notifications(state = initialState, action) {
|
|||
return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
|
||||
case NOTIFICATIONS_EXPAND_REQUEST:
|
||||
case NOTIFICATIONS_DELETE_MARKED_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
return state.set('isLoading', (nbLoading) => nbLoading + 1);
|
||||
case NOTIFICATIONS_DELETE_MARKED_FAIL:
|
||||
case NOTIFICATIONS_EXPAND_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
return state.set('isLoading', (nbLoading) => nbLoading - 1);
|
||||
case NOTIFICATIONS_FILTER_SET:
|
||||
return state.set('items', ImmutableList()).set('hasMore', true);
|
||||
case NOTIFICATIONS_SCROLL_TOP:
|
||||
|
@ -287,7 +287,7 @@ export default function notifications(state = initialState, action) {
|
|||
return markForDelete(state, action.id, action.yes);
|
||||
|
||||
case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
|
||||
return deleteMarkedNotifs(state).set('isLoading', false);
|
||||
return deleteMarkedNotifs(state).set('isLoading', (nbLoading) => nbLoading - 1);
|
||||
|
||||
case NOTIFICATIONS_ENTER_CLEARING_MODE:
|
||||
st = state.set('cleaningMode', action.yes);
|
||||
|
|
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
|
|
@ -12,6 +12,10 @@ spec:
|
|||
template:
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-media-remove
|
||||
{{- with .Values.jobAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
{{- if (not .Values.mastodon.s3.enabled) }}
|
||||
|
|
|
@ -70,6 +70,18 @@ spec:
|
|||
key: redis-password
|
||||
- name: "PORT"
|
||||
value: {{ .Values.mastodon.web.port | quote }}
|
||||
{{- if (and .Values.mastodon.s3.enabled .Values.mastodon.s3.existingSecret) }}
|
||||
- name: "AWS_SECRET_ACCESS_KEY"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.mastodon.s3.existingSecret }}
|
||||
key: AWS_SECRET_ACCESS_KEY
|
||||
- name: "AWS_ACCESS_KEY_ID"
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.mastodon.s3.existingSecret }}
|
||||
key: AWS_ACCESS_KEY_ID
|
||||
{{- end -}}
|
||||
{{- if (not .Values.mastodon.s3.enabled) }}
|
||||
volumeMounts:
|
||||
- name: assets
|
||||
|
|
|
@ -12,6 +12,10 @@ spec:
|
|||
template:
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-assets-precompile
|
||||
{{- with .Values.jobAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- if (not .Values.mastodon.s3.enabled) }}
|
||||
|
|
|
@ -13,6 +13,10 @@ spec:
|
|||
template:
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-chewy-upgrade
|
||||
{{- with .Values.jobAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- if (not .Values.mastodon.s3.enabled) }}
|
||||
|
|
|
@ -13,6 +13,10 @@ spec:
|
|||
template:
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-create-admin
|
||||
{{- with .Values.jobAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- if (not .Values.mastodon.s3.enabled) }}
|
||||
|
|
|
@ -12,6 +12,10 @@ spec:
|
|||
template:
|
||||
metadata:
|
||||
name: {{ include "mastodon.fullname" . }}-db-migrate
|
||||
{{- with .Values.jobAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- if (not .Values.mastodon.s3.enabled) }}
|
||||
|
|
|
@ -281,8 +281,14 @@ serviceAccount:
|
|||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
# Kubernetes manages pods for jobs and pods for deployments differently, so you might
|
||||
# need to apply different annotations to the two different sets of pods. The annotations
|
||||
# set with podAnnotations will be added to all deployment-managed pods.
|
||||
podAnnotations: {}
|
||||
|
||||
# The annotations set with jobAnnotations will be added to all job pods.
|
||||
jobAnnotations: {}
|
||||
|
||||
resources: {}
|
||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||
# choice for the user. This also increases chances charts run on environments with little
|
||||
|
|
|
@ -47,7 +47,7 @@ Rails.application.configure do
|
|||
config.force_ssl = true
|
||||
config.ssl_options = {
|
||||
redirect: {
|
||||
exclude: -> request { request.path.start_with?('/health') || request.headers["Host"].end_with?('.onion') }
|
||||
exclude: -> request { request.path.start_with?('/health') || request.headers["Host"].end_with?('.onion') || request.headers["Host"].end_with?('.i2p') }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,5 +18,22 @@ Rails.application.configure do
|
|||
}.compact
|
||||
end
|
||||
|
||||
if ENV['http_hidden_proxy'].present?
|
||||
proxy = URI.parse(ENV['http_hidden_proxy'])
|
||||
|
||||
raise "Unsupported proxy type: #{proxy.scheme}" unless %w(http https).include? proxy.scheme
|
||||
raise "No proxy host" unless proxy.host
|
||||
|
||||
host = proxy.host
|
||||
host = host[1...-1] if host[0] == '[' # for IPv6 address
|
||||
|
||||
config.x.http_client_hidden_proxy[:proxy] = {
|
||||
proxy_address: host,
|
||||
proxy_port: proxy.port,
|
||||
proxy_username: proxy.user,
|
||||
proxy_password: proxy.password,
|
||||
}.compact
|
||||
end
|
||||
|
||||
config.x.access_to_hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
|
||||
end
|
||||
|
|
|
@ -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…
|
||||
|
|
|
@ -85,6 +85,7 @@ en:
|
|||
ip: Enter an IPv4 or IPv6 address. You can block entire ranges using the CIDR syntax. Be careful not to lock yourself out!
|
||||
severities:
|
||||
no_access: Block access to all resources
|
||||
sign_up_block: New sign-ups will not be possible
|
||||
sign_up_requires_approval: New sign-ups will require your approval
|
||||
severity: Choose what will happen with requests from this IP
|
||||
rule:
|
||||
|
@ -219,6 +220,7 @@ en:
|
|||
ip: IP
|
||||
severities:
|
||||
no_access: Block access
|
||||
sign_up_block: Block sign-ups
|
||||
sign_up_requires_approval: Limit sign-ups
|
||||
severity: Rule
|
||||
notification_emails:
|
||||
|
|
|
@ -180,7 +180,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]
|
||||
|
||||
|
@ -470,12 +477,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
|
||||
|
|
12
db/migrate/20220808101323_create_custom_filter_statuses.rb
Normal file
12
db/migrate/20220808101323_create_custom_filter_statuses.rb
Normal 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
|
13
db/schema.rb
13
db/schema.rb
|
@ -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"
|
||||
|
@ -1116,6 +1125,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
|
||||
|
|
|
@ -187,6 +187,7 @@ module Mastodon
|
|||
option :account, type: :string
|
||||
option :domain, type: :string
|
||||
option :status, type: :numeric
|
||||
option :days, type: :numeric
|
||||
option :concurrency, type: :numeric, default: 5, aliases: [:c]
|
||||
option :verbose, type: :boolean, default: false, aliases: [:v]
|
||||
option :dry_run, type: :boolean, default: false
|
||||
|
@ -204,6 +205,8 @@ module Mastodon
|
|||
|
||||
Use the --domain option to download attachments from a specific domain.
|
||||
|
||||
Use the --days option to limit attachments created within days.
|
||||
|
||||
By default, attachments that are believed to be already downloaded will
|
||||
not be re-downloaded. To force re-download of every URL, use --force.
|
||||
DESC
|
||||
|
@ -224,10 +227,16 @@ module Mastodon
|
|||
scope = MediaAttachment.where(account_id: account.id)
|
||||
elsif options[:domain]
|
||||
scope = MediaAttachment.joins(:account).merge(Account.by_domain_and_subdomains(options[:domain]))
|
||||
elsif options[:days].present?
|
||||
scope = MediaAttachment.remote
|
||||
else
|
||||
exit(1)
|
||||
end
|
||||
|
||||
if options[:days].present?
|
||||
scope = scope.where('id > ?', Mastodon::Snowflake.id_at(options[:days].days.ago, with_random: false))
|
||||
end
|
||||
|
||||
processed, aggregate = parallelize_with_progress(scope) do |media_attachment|
|
||||
next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?)
|
||||
next if DomainBlock.reject_media?(media_attachment.account.domain)
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.18.10",
|
||||
"@babel/core": "^7.18.13",
|
||||
"@babel/plugin-proposal-decorators": "^7.18.10",
|
||||
"@babel/plugin-transform-react-inline-elements": "^7.18.6",
|
||||
"@babel/plugin-transform-runtime": "^7.18.10",
|
||||
|
@ -121,7 +121,7 @@
|
|||
"requestidlecallback": "^0.3.0",
|
||||
"reselect": "^4.1.6",
|
||||
"rimraf": "^3.0.2",
|
||||
"sass": "^1.54.4",
|
||||
"sass": "^1.54.5",
|
||||
"sass-loader": "^10.2.0",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"stringz": "^2.1.0",
|
||||
|
@ -134,7 +134,7 @@
|
|||
"uuid": "^8.3.1",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-assets-manifest": "^4.0.6",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack-bundle-analyzer": "^4.6.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-merge": "^5.8.0",
|
||||
"wicg-inert": "^3.1.2",
|
||||
|
@ -157,7 +157,7 @@
|
|||
"raf": "^3.4.1",
|
||||
"react-intl-translations-manager": "^5.0.3",
|
||||
"react-test-renderer": "^16.14.0",
|
||||
"stylelint": "^14.10.0",
|
||||
"stylelint": "^14.11.0",
|
||||
"stylelint-config-standard-scss": "^4.0.0",
|
||||
"webpack-dev-server": "^3.11.3",
|
||||
"yargs": "^17.5.1"
|
||||
|
|
116
spec/controllers/api/v1/filters/statuses_controller_spec.rb
Normal file
116
spec/controllers/api/v1/filters/statuses_controller_spec.rb
Normal 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
|
|
@ -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')) }
|
||||
|
||||
|
|
4
spec/fabricators/custom_filter_status_fabricator.rb
Normal file
4
spec/fabricators/custom_filter_status_fabricator.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
Fabricator(:custom_filter_status) do
|
||||
custom_filter
|
||||
status
|
||||
end
|
|
@ -12,16 +12,29 @@ RSpec.describe EmailDomainBlock, type: :model do
|
|||
let(:input) { nil }
|
||||
|
||||
context 'given an e-mail address' do
|
||||
let(:input) { 'nyarn@example.com' }
|
||||
let(:input) { "foo@#{domain}" }
|
||||
|
||||
it 'returns true if the domain is blocked' do
|
||||
Fabricate(:email_domain_block, domain: 'example.com')
|
||||
expect(EmailDomainBlock.block?(input)).to be true
|
||||
context do
|
||||
let(:domain) { 'example.com' }
|
||||
|
||||
it 'returns true if the domain is blocked' do
|
||||
Fabricate(:email_domain_block, domain: 'example.com')
|
||||
expect(EmailDomainBlock.block?(input)).to be true
|
||||
end
|
||||
|
||||
it 'returns false if the domain is not blocked' do
|
||||
Fabricate(:email_domain_block, domain: 'other-example.com')
|
||||
expect(EmailDomainBlock.block?(input)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns false if the domain is not blocked' do
|
||||
Fabricate(:email_domain_block, domain: 'other-example.com')
|
||||
expect(EmailDomainBlock.block?(input)).to be false
|
||||
context do
|
||||
let(:domain) { 'mail.example.com' }
|
||||
|
||||
it 'returns true if it is a subdomain of a blocked domain' do
|
||||
Fabricate(:email_domain_block, domain: 'example.com')
|
||||
expect(described_class.block?(input)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -11,7 +11,7 @@ RSpec.describe AppSignUpService, type: :service do
|
|||
it 'returns nil when registrations are closed' do
|
||||
tmp = Setting.registrations_mode
|
||||
Setting.registrations_mode = 'none'
|
||||
expect(subject.call(app, remote_ip, good_params)).to be_nil
|
||||
expect { subject.call(app, remote_ip, good_params) }.to raise_error Mastodon::NotPermittedError
|
||||
Setting.registrations_mode = tmp
|
||||
end
|
||||
|
||||
|
|
107
yarn.lock
107
yarn.lock
|
@ -33,21 +33,21 @@
|
|||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d"
|
||||
integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ==
|
||||
|
||||
"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.18.10", "@babel/core@^7.7.2":
|
||||
version "7.18.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8"
|
||||
integrity sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw==
|
||||
"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.18.13", "@babel/core@^7.7.2":
|
||||
version "7.18.13"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.13.tgz#9be8c44512751b05094a4d3ab05fc53a47ce00ac"
|
||||
integrity sha512-ZisbOvRRusFktksHSG6pjj1CSvkPkcZq/KHD45LAkVP/oiHJkNBZWfpvlLmX8OtHDG8IuzsFlVRWo08w7Qxn0A==
|
||||
dependencies:
|
||||
"@ampproject/remapping" "^2.1.0"
|
||||
"@babel/code-frame" "^7.18.6"
|
||||
"@babel/generator" "^7.18.10"
|
||||
"@babel/generator" "^7.18.13"
|
||||
"@babel/helper-compilation-targets" "^7.18.9"
|
||||
"@babel/helper-module-transforms" "^7.18.9"
|
||||
"@babel/helpers" "^7.18.9"
|
||||
"@babel/parser" "^7.18.10"
|
||||
"@babel/parser" "^7.18.13"
|
||||
"@babel/template" "^7.18.10"
|
||||
"@babel/traverse" "^7.18.10"
|
||||
"@babel/types" "^7.18.10"
|
||||
"@babel/traverse" "^7.18.13"
|
||||
"@babel/types" "^7.18.13"
|
||||
convert-source-map "^1.7.0"
|
||||
debug "^4.1.0"
|
||||
gensync "^1.0.0-beta.2"
|
||||
|
@ -63,12 +63,12 @@
|
|||
eslint-visitor-keys "^2.1.0"
|
||||
semver "^6.3.0"
|
||||
|
||||
"@babel/generator@^7.18.10", "@babel/generator@^7.7.2":
|
||||
version "7.18.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.10.tgz#794f328bfabdcbaf0ebf9bf91b5b57b61fa77a2a"
|
||||
integrity sha512-0+sW7e3HjQbiHbj1NeU/vN8ornohYlacAfZIaXhdoGweQqgcNy69COVciYYqEXJ/v+9OBA7Frxm4CVAuNqKeNA==
|
||||
"@babel/generator@^7.18.13", "@babel/generator@^7.7.2":
|
||||
version "7.18.13"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.13.tgz#59550cbb9ae79b8def15587bdfbaa388c4abf212"
|
||||
integrity sha512-CkPg8ySSPuHTYPJYo7IRALdqyjM9HCbt/3uOBEFbzyGVP6Mn8bwFPB0jX6982JVNBlYzM1nnPkfjuXSOPtQeEQ==
|
||||
dependencies:
|
||||
"@babel/types" "^7.18.10"
|
||||
"@babel/types" "^7.18.13"
|
||||
"@jridgewell/gen-mapping" "^0.3.2"
|
||||
jsesc "^2.5.1"
|
||||
|
||||
|
@ -337,10 +337,10 @@
|
|||
chalk "^2.0.0"
|
||||
js-tokens "^4.0.0"
|
||||
|
||||
"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10":
|
||||
version "7.18.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.10.tgz#94b5f8522356e69e8277276adf67ed280c90ecc1"
|
||||
integrity sha512-TYk3OA0HKL6qNryUayb5UUEhM/rkOQozIBEA5ITXh5DWrSp0TlUQXMyZmnWxG/DizSWBeeQ0Zbc5z8UGaaqoeg==
|
||||
"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.13":
|
||||
version "7.18.13"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.13.tgz#5b2dd21cae4a2c5145f1fbd8ca103f9313d3b7e4"
|
||||
integrity sha512-dgXcIfMuQ0kgzLB2b9tRZs7TTFFaGM2AbtA4fJgUUYukzGH4jwsS7hzQHEGs67jdehpm22vkgKwvbU+aEflgwg==
|
||||
|
||||
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
|
||||
version "7.18.6"
|
||||
|
@ -1085,26 +1085,26 @@
|
|||
"@babel/parser" "^7.18.10"
|
||||
"@babel/types" "^7.18.10"
|
||||
|
||||
"@babel/traverse@^7.18.10", "@babel/traverse@^7.18.6", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2":
|
||||
version "7.18.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.10.tgz#37ad97d1cb00efa869b91dd5d1950f8a6cf0cb08"
|
||||
integrity sha512-J7ycxg0/K9XCtLyHf0cz2DqDihonJeIo+z+HEdRe9YuT8TY4A66i+Ab2/xZCEW7Ro60bPCBBfqqboHSamoV3+g==
|
||||
"@babel/traverse@^7.18.10", "@babel/traverse@^7.18.13", "@babel/traverse@^7.18.6", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2":
|
||||
version "7.18.13"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.13.tgz#5ab59ef51a997b3f10c4587d648b9696b6cb1a68"
|
||||
integrity sha512-N6kt9X1jRMLPxxxPYWi7tgvJRH/rtoU+dbKAPDM44RFHiMH8igdsaSBgFeskhSl/kLWLDUvIh1RXCrTmg0/zvA==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.18.6"
|
||||
"@babel/generator" "^7.18.10"
|
||||
"@babel/generator" "^7.18.13"
|
||||
"@babel/helper-environment-visitor" "^7.18.9"
|
||||
"@babel/helper-function-name" "^7.18.9"
|
||||
"@babel/helper-hoist-variables" "^7.18.6"
|
||||
"@babel/helper-split-export-declaration" "^7.18.6"
|
||||
"@babel/parser" "^7.18.10"
|
||||
"@babel/types" "^7.18.10"
|
||||
"@babel/parser" "^7.18.13"
|
||||
"@babel/types" "^7.18.13"
|
||||
debug "^4.1.0"
|
||||
globals "^11.1.0"
|
||||
|
||||
"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
|
||||
version "7.18.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6"
|
||||
integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ==
|
||||
"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.18.10", "@babel/types@^7.18.13", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
|
||||
version "7.18.13"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.13.tgz#30aeb9e514f4100f7c1cb6e5ba472b30e48f519a"
|
||||
integrity sha512-ePqfTihzW0W6XAU+aMw2ykilisStJfDnsejDCXRchCcMJ4O0+8DhPXf2YUbZ6wjBlsEmZwLK/sPweWtu8hcJYQ==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.18.10"
|
||||
"@babel/helper-validator-identifier" "^7.18.6"
|
||||
|
@ -2139,12 +2139,7 @@ acorn@^7.1.1, acorn@^7.4.0:
|
|||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
|
||||
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
|
||||
|
||||
acorn@^8.0.4:
|
||||
version "8.3.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.3.0.tgz#1193f9b96c4e8232f00b11a9edff81b2c8b98b88"
|
||||
integrity sha512-tqPKHZ5CaBJw0Xmy0ZZvLs1qTV+BNFSyvn77ASXkpBNfIRk8ev26fKrD9iLGwGA9zedPao52GSHzq8lyZG0NUw==
|
||||
|
||||
acorn@^8.5.0, acorn@^8.7.1:
|
||||
acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1:
|
||||
version "8.7.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
|
||||
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
|
||||
|
@ -3371,10 +3366,10 @@ color@^3.0.0:
|
|||
color-convert "^1.9.1"
|
||||
color-string "^1.5.2"
|
||||
|
||||
colord@^2.9.2:
|
||||
version "2.9.2"
|
||||
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.2.tgz#25e2bacbbaa65991422c07ea209e2089428effb1"
|
||||
integrity sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ==
|
||||
colord@^2.9.3:
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
|
||||
integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==
|
||||
|
||||
colorette@^1.2.2:
|
||||
version "1.2.2"
|
||||
|
@ -9755,10 +9750,10 @@ sass-loader@^10.2.0:
|
|||
schema-utils "^3.0.0"
|
||||
semver "^7.3.2"
|
||||
|
||||
sass@^1.54.4:
|
||||
version "1.54.4"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.4.tgz#803ff2fef5525f1dd01670c3915b4b68b6cba72d"
|
||||
integrity sha512-3tmF16yvnBwtlPrNBHw/H907j8MlOX8aTBnlNX1yrKx24RKcJGPyLhFUwkoKBKesR3unP93/2z14Ll8NicwQUA==
|
||||
sass@^1.54.5:
|
||||
version "1.54.5"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.5.tgz#93708f5560784f6ff2eab8542ade021a4a947b3a"
|
||||
integrity sha512-p7DTOzxkUPa/63FU0R3KApkRHwcVZYC0PLnLm5iyZACyp15qSi32x7zVUhRdABAATmkALqgGrjCJAcWvobmhHw==
|
||||
dependencies:
|
||||
chokidar ">=3.0.0 <4.0.0"
|
||||
immutable "^4.0.0"
|
||||
|
@ -10524,14 +10519,14 @@ stylelint-scss@^4.0.0:
|
|||
postcss-selector-parser "^6.0.6"
|
||||
postcss-value-parser "^4.1.0"
|
||||
|
||||
stylelint@^14.10.0:
|
||||
version "14.10.0"
|
||||
resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-14.10.0.tgz#c588f5cd47cd214cf1acee5bc165961b6a3ad836"
|
||||
integrity sha512-VAmyKrEK+wNFh9R8mNqoxEFzaa4gsHGhcT4xgkQDuOA5cjF6CaNS8loYV7gpi4tIZBPUyXesotPXzJAMN8VLOQ==
|
||||
stylelint@^14.11.0:
|
||||
version "14.11.0"
|
||||
resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-14.11.0.tgz#e2ecb28bbacab05e1fbeb84cbba23883b27499cc"
|
||||
integrity sha512-OTLjLPxpvGtojEfpESWM8Ir64Z01E89xsisaBMUP/ngOx1+4VG2DPRcUyCCiin9Rd3kPXPsh/uwHd9eqnvhsYA==
|
||||
dependencies:
|
||||
"@csstools/selector-specificity" "^2.0.2"
|
||||
balanced-match "^2.0.0"
|
||||
colord "^2.9.2"
|
||||
colord "^2.9.3"
|
||||
cosmiconfig "^7.0.1"
|
||||
css-functions-list "^3.1.0"
|
||||
debug "^4.3.4"
|
||||
|
@ -10566,7 +10561,7 @@ stylelint@^14.10.0:
|
|||
svg-tags "^1.0.0"
|
||||
table "^6.8.0"
|
||||
v8-compile-cache "^2.3.0"
|
||||
write-file-atomic "^4.0.1"
|
||||
write-file-atomic "^4.0.2"
|
||||
|
||||
stylis@4.0.13:
|
||||
version "4.0.13"
|
||||
|
@ -11353,10 +11348,10 @@ webpack-assets-manifest@^4.0.6:
|
|||
tapable "^1.0"
|
||||
webpack-sources "^1.0"
|
||||
|
||||
webpack-bundle-analyzer@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.5.0.tgz#1b0eea2947e73528754a6f9af3e91b2b6e0f79d5"
|
||||
integrity sha512-GUMZlM3SKwS8Z+CKeIFx7CVoHn3dXFcUAjT/dcZQQmfSZGvitPfMob2ipjai7ovFFqPvTqkEZ/leL4O0YOdAYQ==
|
||||
webpack-bundle-analyzer@^4.6.1:
|
||||
version "4.6.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.6.1.tgz#bee2ee05f4ba4ed430e4831a319126bb4ed9f5a6"
|
||||
integrity sha512-oKz9Oz9j3rUciLNfpGFjOb49/jEpXNmWdVH8Ls//zNcnLlQdTGXQQMsBbb/gR7Zl8WNLxVCq+0Hqbx3zv6twBw==
|
||||
dependencies:
|
||||
acorn "^8.0.4"
|
||||
acorn-walk "^8.0.0"
|
||||
|
@ -11629,10 +11624,10 @@ wrappy@1:
|
|||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
|
||||
|
||||
write-file-atomic@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.1.tgz#9faa33a964c1c85ff6f849b80b42a88c2c537c8f"
|
||||
integrity sha512-nSKUxgAbyioruk6hU87QzVbY279oYT6uiwgDoujth2ju4mJ+TZau7SQBhtbTmUyuNYTuXnSyRn66FV0+eCgcrQ==
|
||||
write-file-atomic@^4.0.1, write-file-atomic@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd"
|
||||
integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==
|
||||
dependencies:
|
||||
imurmurhash "^0.1.4"
|
||||
signal-exit "^3.0.7"
|
||||
|
|
Loading…
Reference in a new issue