diff options
Diffstat (limited to 'app')
76 files changed, 1371 insertions, 283 deletions
diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb new file mode 100644 index 000000000..838978ddb --- /dev/null +++ b/app/controllers/api/v1/admin/domain_allows_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +class Api::V1::Admin::DomainAllowsController < Api::BaseController + include Authorization + include AccountableConcern + + LIMIT = 100 + + before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show] + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show] + before_action :require_staff! + before_action :set_domain_allows, only: :index + before_action :set_domain_allow, only: [:show, :destroy] + + after_action :insert_pagination_headers, only: :index + + PAGINATION_PARAMS = %i(limit).freeze + + def create + authorize :domain_allow, :create? + + @domain_allow = DomainAllow.find_by(resource_params) + + if @domain_allow.nil? + @domain_allow = DomainAllow.create!(resource_params) + log_action :create, @domain_allow + end + + render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer + end + + def index + authorize :domain_allow, :index? + render json: @domain_allows, each_serializer: REST::Admin::DomainAllowSerializer + end + + def show + authorize @domain_allow, :show? + render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer + end + + def destroy + authorize @domain_allow, :destroy? + UnallowDomainService.new.call(@domain_allow) + log_action :destroy, @domain_allow + render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer + end + + private + + def set_domain_allows + @domain_allows = filtered_domain_allows.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def set_domain_allow + @domain_allow = DomainAllow.find(params[:id]) + end + + def filtered_domain_allows + # TODO: no filtering yet + DomainAllow.all + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty? + end + + def pagination_max_id + @domain_allows.last.id + end + + def pagination_since_id + @domain_allows.first.id + end + + def records_continue? + @domain_allows.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) + end + + def resource_params + params.permit(:domain) + end +end diff --git a/app/controllers/api/v1/filters/keywords_controller.rb b/app/controllers/api/v1/filters/keywords_controller.rb new file mode 100644 index 000000000..d3718a137 --- /dev/null +++ b/app/controllers/api/v1/filters/keywords_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Api::V1::Filters::KeywordsController < 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_keywords, only: :index + before_action :set_keyword, only: [:show, :update, :destroy] + + def index + render json: @keywords, each_serializer: REST::FilterKeywordSerializer + end + + def create + @keyword = current_account.custom_filters.find(params[:filter_id]).keywords.create!(resource_params) + + render json: @keyword, serializer: REST::FilterKeywordSerializer + end + + def show + render json: @keyword, serializer: REST::FilterKeywordSerializer + end + + def update + @keyword.update!(resource_params) + + render json: @keyword, serializer: REST::FilterKeywordSerializer + end + + def destroy + @keyword.destroy! + render_empty + end + + private + + def set_keywords + filter = current_account.custom_filters.includes(:keywords).find(params[:filter_id]) + @keywords = filter.keywords + end + + def set_keyword + @keyword = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id]) + end + + def resource_params + params.permit(:keyword, :whole_word) + end +end diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index b0ace3af0..07cd14147 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -8,21 +8,32 @@ class Api::V1::FiltersController < Api::BaseController before_action :set_filter, only: [:show, :update, :destroy] def index - render json: @filters, each_serializer: REST::FilterSerializer + render json: @filters, each_serializer: REST::V1::FilterSerializer end def create - @filter = current_account.custom_filters.create!(resource_params) - render json: @filter, serializer: REST::FilterSerializer + ApplicationRecord.transaction do + filter_category = current_account.custom_filters.create!(resource_params) + @filter = filter_category.keywords.create!(keyword_params) + end + + render json: @filter, serializer: REST::V1::FilterSerializer end def show - render json: @filter, serializer: REST::FilterSerializer + render json: @filter, serializer: REST::V1::FilterSerializer end def update - @filter.update!(resource_params) - render json: @filter, serializer: REST::FilterSerializer + ApplicationRecord.transaction do + @filter.update!(keyword_params) + @filter.custom_filter.assign_attributes(filter_params) + raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1 + + @filter.custom_filter.save! + end + + render json: @filter, serializer: REST::V1::FilterSerializer end def destroy @@ -33,14 +44,22 @@ class Api::V1::FiltersController < Api::BaseController private def set_filters - @filters = current_account.custom_filters + @filters = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }) end def set_filter - @filter = current_account.custom_filters.find(params[:id]) + @filter = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id]) end def resource_params params.permit(:phrase, :expires_in, :irreversible, :whole_word, context: []) end + + def filter_params + resource_params.slice(:expires_in, :irreversible, :context) + end + + def keyword_params + resource_params.slice(:phrase, :whole_word) + end end diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 47f2e6440..7148d63a4 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController def data_params return {} if params[:data].blank? - params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) + params.require(:data).permit(:policy, alerts: Notification::TYPES) end end diff --git a/app/controllers/api/v2/filters_controller.rb b/app/controllers/api/v2/filters_controller.rb new file mode 100644 index 000000000..8ff3076cf --- /dev/null +++ b/app/controllers/api/v2/filters_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Api::V2::FiltersController < 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_filters, only: :index + before_action :set_filter, only: [:show, :update, :destroy] + + def index + render json: @filters, each_serializer: REST::FilterSerializer, rules_requested: true + end + + def create + @filter = current_account.custom_filters.create!(resource_params) + + render json: @filter, serializer: REST::FilterSerializer, rules_requested: true + end + + def show + render json: @filter, serializer: REST::FilterSerializer, rules_requested: true + end + + def update + @filter.update!(resource_params) + + render json: @filter, serializer: REST::FilterSerializer, rules_requested: true + end + + def destroy + @filter.destroy! + render_empty + end + + private + + def set_filters + @filters = current_account.custom_filters.includes(:keywords) + end + + def set_filter + @filter = current_account.custom_filters.find(params[:id]) + end + + def resource_params + params.permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + end +end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 056f8a9f1..13dfebcdd 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -8,12 +8,18 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :update_user_sign_in prepend_before_action :set_pack + prepend_before_action :check_suspicious!, only: [:create] include TwoFactorAuthenticationConcern before_action :set_instance_presenter, only: [:new] before_action :set_body_classes + def check_suspicious! + user = find_user + @login_is_suspicious = suspicious_sign_in?(user) unless user.nil? + end + def create super do |resource| # We only need to call this if this hasn't already been @@ -148,7 +154,7 @@ class Auth::SessionsController < Devise::SessionsController user_agent: request.user_agent ) - UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if suspicious_sign_in?(user) + UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious end def suspicious_sign_in?(user) diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 0d4c1b97c..6d778312e 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -4,17 +4,17 @@ class FiltersController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_filters, only: :index before_action :set_filter, only: [:edit, :update, :destroy] before_action :set_pack before_action :set_body_classes def index - @filters = current_account.custom_filters.order(:phrase) + @filters = current_account.custom_filters.includes(:keywords).order(:phrase) end def new - @filter = current_account.custom_filters.build + @filter = current_account.custom_filters.build(action: :warn) + @filter.keywords.build end def create @@ -48,16 +48,12 @@ class FiltersController < ApplicationController use_pack 'settings' end - def set_filters - @filters = current_account.custom_filters - end - def set_filter @filter = current_account.custom_filters.find(params[:id]) end def resource_params - params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, :whole_word, context: []) + params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end def set_body_classes diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index f95f46a56..0d5a8505a 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -16,7 +16,11 @@ module RoutingHelper def full_asset_url(source, **options) source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage? - URI.join(root_url, source).to_s + URI.join(asset_host, source).to_s + end + + def asset_host + Rails.configuration.action_controller.asset_host || root_url end def full_pack_url(source, **options) diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 85938867b..3993b1ea5 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -103,6 +103,10 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch(importFetchedStatus(notification.status)); } + if (notification.report) { + dispatch(importFetchedAccount(notification.report.target_account)); + } + dispatch({ type: NOTIFICATIONS_UPDATE, notification, @@ -146,6 +150,7 @@ const excludeTypesFromFilter = filter => { 'status', 'update', 'admin.sign_up', + 'admin.report', ]); return allTypes.filterNot(item => item === filter).toJS(); @@ -191,6 +196,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) { dispatch(importFetchedAccounts(response.data.map(item => item.account))); dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); fetchRelatedRelationships(dispatch, response.data); diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index 9a05badd0..be2468d68 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -140,8 +140,16 @@ export default class IconButton extends React.PureComponent { ); if (href) { - contents = ( - <a href={href} target='_blank' rel='noopener noreferrer'> + return ( + <a + href={href} + aria-label={title} + title={title} + target='_blank' + rel='noopener noreferrer' + className={classes} + style={style} + > {contents} </a> ); diff --git a/app/javascript/flavours/glitch/features/notifications/components/admin_report.js b/app/javascript/flavours/glitch/features/notifications/components/admin_report.js new file mode 100644 index 000000000..80beeb9da --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/admin_report.js @@ -0,0 +1,108 @@ +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; +import classNames from 'classnames'; + +// Our imports. +import Permalink from 'flavours/glitch/components/permalink'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import NotificationOverlayContainer from '../containers/overlay_container'; +import Icon from 'flavours/glitch/components/icon'; +import Report from './report'; + +const messages = defineMessages({ + adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' }, +}); + +export default class AdminReport extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + report: ImmutablePropTypes.map.isRequired, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + this.handleOpenProfile(); + } + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`); + } + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + } + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { intl, account, notification, unread, report } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <bdi><Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/@${account.get('acct')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /></bdi> + ); + + const targetAccount = report.get('target_account'); + const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') }; + const targetLink = <bdi><Permalink className='notification__display-name' href={targetAccount.get('url')} title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>; + + return ( + <HotKeys handlers={this.getHandlers()}> + <div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon id='flag' fixedWidth /> + </div> + + <span title={notification.get('created_at')}> + <FormattedMessage id='notification.admin.report' defaultMessage='{name} reported {target}' values={{ name: link, target: targetLink }} /> + </span> + </div> + + <Report account={account} report={notification.get('report')} hidden={this.props.hidden} /> + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js index 0be2a7e13..a8502f563 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -179,6 +179,19 @@ export default class ColumnSettings extends React.PureComponent { </div> </div> )} + + {isStaff && ( + <div role='group' aria-labelledby='notifications-admin-report'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span> + + <div className='column-settings__pillbar'> + <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} /> + {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'admin.report']} onChange={this.onPushChange} label={pushStr} />} + <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} /> + <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} /> + </div> + </div> + )} </div> ); } diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js index e0cd3c7a6..d676a4207 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification.js +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -9,6 +9,7 @@ import StatusContainer from 'flavours/glitch/containers/status_container'; import NotificationFollow from './follow'; import NotificationFollowRequestContainer from '../containers/follow_request_container'; import NotificationAdminSignup from './admin_signup'; +import NotificationAdminReportContainer from '../containers/admin_report_container'; export default class Notification extends ImmutablePureComponent { @@ -77,6 +78,19 @@ export default class Notification extends ImmutablePureComponent { unread={this.props.unread} /> ); + case 'admin.report': + return ( + <NotificationAdminReportContainer + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + unread={this.props.unread} + /> + ); case 'mention': return ( <StatusContainer diff --git a/app/javascript/flavours/glitch/features/notifications/components/report.js b/app/javascript/flavours/glitch/features/notifications/components/report.js new file mode 100644 index 000000000..46a307250 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/report.js @@ -0,0 +1,62 @@ +import React, { Fragment } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import AvatarOverlay from 'flavours/glitch/components/avatar_overlay'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; + +const messages = defineMessages({ + openReport: { id: 'report_notification.open', defaultMessage: 'Open report' }, + other: { id: 'report_notification.categories.other', defaultMessage: 'Other' }, + spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' }, + violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' }, +}); + +export default @injectIntl +class Report extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + report: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + render () { + const { intl, hidden, report, account } = this.props; + + if (!report) { + return null; + } + + if (hidden) { + return ( + <Fragment> + {report.get('id')} + </Fragment> + ); + } + + return ( + <div className='notification__report'> + <div className='notification__report__avatar'> + <AvatarOverlay account={report.get('target_account')} friend={account} /> + </div> + + <div className='notification__report__details'> + <div> + <RelativeTimestamp timestamp={report.get('created_at')} short={false} /> · <FormattedMessage id='report_notification.attached_statuses' defaultMessage='{count, plural, one {{count} post} other {{count} posts}} attached' values={{ count: report.get('status_ids').size }} /> + <br /> + <strong>{intl.formatMessage(messages[report.get('category')])}</strong> + </div> + + <div className='notification__report__actions'> + <a href={`/admin/reports/${report.get('id')}`} className='button' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.openReport)}</a> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js b/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js new file mode 100644 index 000000000..4198afce8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { makeGetReport } from 'flavours/glitch/selectors'; +import AdminReport from '../components/admin_report'; + +const mapStateToProps = (state, { notification }) => { + const getReport = makeGetReport(); + + return { + report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null, + }; +}; + +export default connect(mapStateToProps)(AdminReport); diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js index 0a53e1c25..de88d4f52 100644 --- a/app/javascript/flavours/glitch/packs/settings.js +++ b/app/javascript/flavours/glitch/packs/settings.js @@ -2,6 +2,7 @@ import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import ready from 'flavours/glitch/util/ready'; import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; +import 'cocoon-js-vanilla'; function main() { const { delegate } = require('@rails/ujs'); diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index 859a8b3a1..f538af7fa 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -31,7 +31,7 @@ import { } from 'flavours/glitch/actions/markers'; import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines'; -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable'; import compareId from 'flavours/glitch/util/compare_id'; const initialState = ImmutableMap({ @@ -58,6 +58,7 @@ const notificationToMap = (state, notification) => ImmutableMap({ account: notification.account.id, markedForDelete: state.get('markNewForDelete'), status: notification.status ? notification.status.id : null, + report: notification.report ? fromJS(notification.report) : null, }); const normalizeNotification = (state, notification, usePendingItems) => { diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index 0c28b2959..1d99441a1 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -43,6 +43,7 @@ const initialState = ImmutableMap({ status: false, update: false, 'admin.sign_up': false, + 'admin.report': false, }), quickFilter: ImmutableMap({ @@ -64,6 +65,7 @@ const initialState = ImmutableMap({ status: true, update: true, 'admin.sign_up': true, + 'admin.report': true, }), sounds: ImmutableMap({ @@ -76,6 +78,7 @@ const initialState = ImmutableMap({ status: true, update: true, 'admin.sign_up': true, + 'admin.report': true, }), }), diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index 99afe5355..d9aa8f140 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -171,14 +171,15 @@ export const getAlerts = createSelector([getAlertsBase], (base) => { return arr; }); -export const makeGetNotification = () => { - return createSelector([ - (_, base) => base, - (state, _, accountId) => state.getIn(['accounts', accountId]), - ], (base, account) => { - return base.set('account', account); - }); -}; +export const makeGetNotification = () => createSelector([ + (_, base) => base, + (state, _, accountId) => state.getIn(['accounts', accountId]), +], (base, account) => base.set('account', account)); + +export const makeGetReport = () => createSelector([ + (_, base) => base, + (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]), +], (base, targetAccount) => base.set('target_account', targetAccount)); export const getAccountGallery = createSelector([ (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 9553aa4ae..6ed67edc4 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -75,6 +75,13 @@ $content-width: 840px; height: 100px; } + .logo--wordmark { + display: inherit; + margin: inherit; + width: inherit; + height: 20px; + } + @media screen and (max-width: $no-columns-breakpoint) { & > a:first-child { display: none; @@ -927,7 +934,8 @@ a.name-tag, text-align: center; } -.applications-list__item { +.applications-list__item, +.filters-list__item { padding: 15px 0; background: $ui-base-color; border: 1px solid lighten($ui-base-color, 4%); @@ -935,7 +943,8 @@ a.name-tag, margin-top: 15px; } -.announcements-list { +.announcements-list, +.filters-list { border: 1px solid lighten($ui-base-color, 4%); border-radius: 4px; @@ -988,6 +997,33 @@ a.name-tag, } } +.filters-list__item { + &__title { + display: flex; + justify-content: space-between; + margin-bottom: 0; + } + + &__permissions { + margin-top: 0; + margin-bottom: 10px; + } + + .expiration { + font-size: 13px; + } + + &.expired { + .expiration { + color: lighten($error-red, 12%); + } + + .permissions-list__item__icon { + color: $dark-text-color; + } + } +} + .dashboard__counters.admin-account-counters { margin-top: 10px; } diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss index 377cdd91f..4e912b18b 100644 --- a/app/javascript/flavours/glitch/styles/components/accounts.scss +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -105,6 +105,8 @@ position: relative; @include avatar-size(48px); + position: relative; + &-base { @include avatar-radius(); @include avatar-size(36px); @@ -243,6 +245,33 @@ margin-right: 10px; } +.notification__report { + padding: 8px 10px; + padding-left: 68px; + position: relative; + border-bottom: 1px solid lighten($ui-base-color, 8%); + min-height: 54px; + + &__details { + display: flex; + justify-content: space-between; + align-items: center; + color: $darker-text-color; + font-size: 15px; + line-height: 22px; + + strong { + font-weight: 500; + } + } + + &__avatar { + position: absolute; + left: 10px; + top: 10px; + } +} + .notification__message { margin-left: 42px; padding: 8px 0 0 26px; diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss index d10fc1d3e..3843bcd68 100644 --- a/app/javascript/flavours/glitch/styles/components/single_column.scss +++ b/app/javascript/flavours/glitch/styles/components/single_column.scss @@ -154,6 +154,16 @@ padding-top: 15px; } + .notification__report { + padding: 15px 15px 15px (48px + 15px * 2); + min-height: 48px + 2px; + + &__avatar { + left: 15px; + top: 17px; + } + } + .status { padding: 15px; min-height: 48px + 2px; diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss index 5aa75b41e..1ce13b874 100644 --- a/app/javascript/flavours/glitch/styles/forms.scss +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -1065,3 +1065,34 @@ code { } } } + +.keywords-table { + thead { + th { + white-space: nowrap; + } + + th:first-child { + width: 100%; + } + } + + tfoot { + td { + border: 0; + } + } + + .input.string { + margin-bottom: 0; + } + + .label_input__wrapper { + margin-top: 10px; + } + + .table-action-link { + margin-top: 10px; + white-space: nowrap; + } +} diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js deleted file mode 100644 index 7fa1c9a70..000000000 --- a/app/javascript/mastodon/actions/filters.js +++ /dev/null @@ -1,26 +0,0 @@ -import api from '../api'; - -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 fetchFilters = () => (dispatch, getState) => { - dispatch({ - type: FILTERS_FETCH_REQUEST, - skipLoading: true, - }); - - api(getState) - .get('/api/v1/filters') - .then(({ data }) => dispatch({ - type: FILTERS_FETCH_SUCCESS, - filters: data, - skipLoading: true, - })) - .catch(err => dispatch({ - type: FILTERS_FETCH_FAIL, - err, - skipLoading: true, - skipAlert: true, - })); -}; diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index f4372fb31..9c69be601 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -5,6 +5,7 @@ export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; export const POLLS_IMPORT = 'POLLS_IMPORT'; +export const FILTERS_IMPORT = 'FILTERS_IMPORT'; function pushUnique(array, object) { if (array.every(element => element.id !== object.id)) { @@ -28,6 +29,10 @@ export function importStatuses(statuses) { return { type: STATUSES_IMPORT, statuses }; } +export function importFilters(filters) { + return { type: FILTERS_IMPORT, filters }; +} + export function importPolls(polls) { return { type: POLLS_IMPORT, polls }; } @@ -61,11 +66,16 @@ export function importFetchedStatuses(statuses) { const accounts = []; const normalStatuses = []; const polls = []; + const filters = []; function processStatus(status) { pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]))); pushUnique(accounts, status.account); + if (status.filtered) { + status.filtered.forEach(result => pushUnique(filters, result.filter)); + } + if (status.reblog && status.reblog.id) { processStatus(status.reblog); } @@ -80,6 +90,7 @@ export function importFetchedStatuses(statuses) { dispatch(importPolls(polls)); dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); + dispatch(importFilters(filters)); }; } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index ca76e3494..8a22f83fa 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -42,6 +42,14 @@ export function normalizeAccount(account) { return account; } +export function normalizeFilterResult(result) { + const normalResult = { ...result }; + + normalResult.filter = normalResult.filter.id; + + return normalResult; +} + export function normalizeStatus(status, normalOldStatus) { const normalStatus = { ...status }; normalStatus.account = status.account.id; @@ -54,6 +62,10 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.poll = status.poll.id; } + if (status.filtered) { + normalStatus.filtered = status.filtered.map(normalizeFilterResult); + } + // Only calculate these values when status first encountered and // when the underlying values change. Otherwise keep the ones // already in the reducer diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 96cf628d6..3c42f71da 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -12,10 +12,8 @@ import { saveSettings } from './settings'; import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from '../utils/html'; -import { getFiltersRegex } from '../selectors'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import compareId from 'mastodon/compare_id'; -import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; import { requestNotificationPermission } from '../utils/notifications'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; @@ -62,20 +60,17 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type; const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); - const filters = getFiltersRegex(getState(), { contextType: 'notifications' }); let filtered = false; - if (['mention', 'status'].includes(notification.type)) { - const dropRegex = filters[0]; - const regex = filters[1]; - const searchIndex = searchTextFromRawStatus(notification.status); + if (['mention', 'status'].includes(notification.type) && notification.status.filtered) { + const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications')); - if (dropRegex && dropRegex.test(searchIndex)) { + if (filters.some(result => result.filter.filter_action === 'hide')) { return; } - filtered = regex && regex.test(searchIndex); + filtered = filters.length > 0; } if (['follow_request'].includes(notification.type)) { @@ -91,6 +86,10 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch(importFetchedStatus(notification.status)); } + if (notification.report) { + dispatch(importFetchedAccount(notification.report.target_account)); + } + dispatch({ type: NOTIFICATIONS_UPDATE, notification, @@ -134,6 +133,7 @@ const excludeTypesFromFilter = filter => { 'status', 'update', 'admin.sign_up', + 'admin.report', ]); return allTypes.filterNot(item => item === filter).toJS(); @@ -179,6 +179,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) { dispatch(importFetchedAccounts(response.data.map(item => item.account))); dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); fetchRelatedRelationships(dispatch, response.data); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index d76f045c8..84709083f 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -21,7 +21,6 @@ import { updateReaction as updateAnnouncementsReaction, deleteAnnouncement, } from './announcements'; -import { fetchFilters } from './filters'; import { getLocale } from '../locales'; const { messages } = getLocale(); @@ -97,9 +96,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'conversation': dispatch(updateConversations(JSON.parse(data.payload))); break; - case 'filters_changed': - dispatch(fetchFilters()); - break; case 'announcement': dispatch(updateAnnouncements(JSON.parse(data.payload))); break; diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 6a653675b..81743a1db 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -132,8 +132,16 @@ export default class IconButton extends React.PureComponent { ); if (href) { - contents = ( - <a href={href} target='_blank' rel='noopener noreferrer'> + return ( + <a + href={href} + aria-label={title} + title={title} + target='_blank' + rel='noopener noreferrer' + className={classes} + style={style} + > {contents} </a> ); diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 7c44669d2..4ca392824 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -116,6 +116,7 @@ class Status extends ImmutablePureComponent { state = { showMedia: defaultMediaVisibility(this.props.status), statusId: undefined, + forceFilter: undefined, }; static getDerivedStateFromProps(nextProps, prevState) { @@ -277,6 +278,15 @@ class Status extends ImmutablePureComponent { this.handleToggleMediaVisibility(); } + handleUnfilterClick = e => { + this.setState({ forceFilter: false }); + e.preventDefault(); + } + + handleFilterClick = () => { + this.setState({ forceFilter: true }); + } + _properStatus () { const { status } = this.props; @@ -328,7 +338,8 @@ class Status extends ImmutablePureComponent { ); } - if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) { + const matchedFilters = status.get('filtered') || status.getIn(['reblog', 'filtered']); + if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { const minHandlers = this.props.muted ? {} : { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, @@ -337,7 +348,11 @@ class Status extends ImmutablePureComponent { return ( <HotKeys handlers={minHandlers}> <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}> - <FormattedMessage id='status.filtered' defaultMessage='Filtered' /> + <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}. + {' '} + <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}> + <FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' /> + </button> </div> </HotKeys> ); @@ -496,7 +511,7 @@ class Status extends ImmutablePureComponent { {media} - <StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} /> + <StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters && this.handleFilterClick} {...other} /> </div> </div> </HotKeys> diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 1d8fe23da..ab8755be0 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -38,6 +38,7 @@ const messages = defineMessages({ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, + hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, @@ -76,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent { onMuteConversation: PropTypes.func, onPin: PropTypes.func, onBookmark: PropTypes.func, + onFilter: PropTypes.func, withDismiss: PropTypes.bool, withCounters: PropTypes.bool, scrollKey: PropTypes.string, @@ -207,6 +209,10 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onMuteConversation(this.props.status); } + handleFilter = () => { + this.props.onFilter(); + } + handleCopy = () => { const url = this.props.status.get('url'); const textarea = document.createElement('textarea'); @@ -226,6 +232,11 @@ class StatusActionBar extends ImmutablePureComponent { } } + + handleFilterClick = () => { + this.props.onFilter(); + } + render () { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; @@ -329,6 +340,10 @@ class StatusActionBar extends ImmutablePureComponent { <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> ); + const filterButton = this.props.onFilter && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} /> + ); + return ( <div className='status__action-bar'> <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount /> @@ -337,6 +352,8 @@ class StatusActionBar extends ImmutablePureComponent { {shareButton} + {filterButton} + <div className='status__action-bar-dropdown'> <DropdownMenuContainer scrollKey={scrollKey} diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 1cdb24086..61df79b46 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -178,6 +178,19 @@ export default class ColumnSettings extends React.PureComponent { </div> </div> )} + + {isStaff && ( + <div role='group' aria-labelledby='notifications-admin-report'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span> + + <div className='column-settings__row'> + <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'admin.report']} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} /> + </div> + </div> + )} </div> ); } diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 9198e9c9d..0af71418c 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from 'mastodon/initial_state'; import StatusContainer from 'mastodon/containers/status_container'; import AccountContainer from 'mastodon/containers/account_container'; +import Report from './report'; import FollowRequestContainer from '../containers/follow_request_container'; import Icon from 'mastodon/components/icon'; import Permalink from 'mastodon/components/permalink'; @@ -21,6 +22,7 @@ const messages = defineMessages({ status: { id: 'notification.status', defaultMessage: '{name} just posted' }, update: { id: 'notification.update', defaultMessage: '{name} edited a post' }, adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' }, + adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' }, }); const notificationForScreenReader = (intl, message, timestamp) => { @@ -367,6 +369,32 @@ class Notification extends ImmutablePureComponent { ); } + renderAdminReport (notification, account, link) { + const { intl, unread, report } = this.props; + + const targetAccount = report.get('target_account'); + const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') }; + const targetLink = <bdi><Permalink className='notification__display-name' href={targetAccount.get('url')} title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>; + + return ( + <HotKeys handlers={this.getHandlers()}> + <div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: account.get('acct'), target: notification.getIn(['report', 'target_account', 'acct']) }), notification.get('created_at'))}> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon id='flag' fixedWidth /> + </div> + + <span title={notification.get('created_at')}> + <FormattedMessage id='notification.admin.report' defaultMessage='{name} reported {target}' values={{ name: link, target: targetLink }} /> + </span> + </div> + + <Report account={account} report={notification.get('report')} hidden={this.props.hidden} /> + </div> + </HotKeys> + ); + } + render () { const { notification } = this.props; const account = notification.get('account'); @@ -392,6 +420,8 @@ class Notification extends ImmutablePureComponent { return this.renderPoll(notification, account); case 'admin.sign_up': return this.renderAdminSignUp(notification, account, link); + case 'admin.report': + return this.renderAdminReport(notification, account, link); } return null; diff --git a/app/javascript/mastodon/features/notifications/components/report.js b/app/javascript/mastodon/features/notifications/components/report.js new file mode 100644 index 000000000..3ce3eb9d3 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/report.js @@ -0,0 +1,62 @@ +import React, { Fragment } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import AvatarOverlay from 'mastodon/components/avatar_overlay'; +import RelativeTimestamp from 'mastodon/components/relative_timestamp'; + +const messages = defineMessages({ + openReport: { id: 'report_notification.open', defaultMessage: 'Open report' }, + other: { id: 'report_notification.categories.other', defaultMessage: 'Other' }, + spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' }, + violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' }, +}); + +export default @injectIntl +class Report extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + report: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + render () { + const { intl, hidden, report, account } = this.props; + + if (!report) { + return null; + } + + if (hidden) { + return ( + <Fragment> + {report.get('id')} + </Fragment> + ); + } + + return ( + <div className='notification__report'> + <div className='notification__report__avatar'> + <AvatarOverlay account={report.get('target_account')} friend={account} /> + </div> + + <div className='notification__report__details'> + <div> + <RelativeTimestamp timestamp={report.get('created_at')} short={false} /> · <FormattedMessage id='report_notification.attached_statuses' defaultMessage='{count, plural, one {{count} post} other {{count} posts}} attached' values={{ count: report.get('status_ids').size }} /> + <br /> + <strong>{intl.formatMessage(messages[report.get('category')])}</strong> + </div> + + <div className='notification__report__actions'> + <a href={`/admin/reports/${report.get('id')}`} className='button' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.openReport)}</a> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 5c984197f..8bd5b3d78 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { makeGetNotification, makeGetStatus } from '../../../selectors'; +import { makeGetNotification, makeGetStatus, makeGetReport } from '../../../selectors'; import Notification from '../components/notification'; import { initBoostModal } from '../../../actions/boosts'; import { mentionCompose } from '../../../actions/compose'; @@ -18,12 +18,14 @@ import { boostModal } from '../../../initial_state'; const makeMapStateToProps = () => { const getNotification = makeGetNotification(); const getStatus = makeGetStatus(); + const getReport = makeGetReport(); const mapStateToProps = (state, props) => { const notification = getNotification(state, props.notification, props.accountId); return { notification: notification, status: notification.get('status') ? getStatus(state, { id: notification.get('status') }) : null, + report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null, }; }; diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 1ee038223..9a901f12a 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -13,7 +13,6 @@ import { debounce } from 'lodash'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { expandHomeTimeline } from '../../actions/timelines'; import { expandNotifications } from '../../actions/notifications'; -import { fetchFilters } from '../../actions/filters'; import { fetchRules } from '../../actions/rules'; import { clearHeight } from '../../actions/height_cache'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; @@ -368,7 +367,7 @@ class UI extends React.PureComponent { this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); - setTimeout(() => this.props.dispatch(fetchFilters()), 500); + setTimeout(() => this.props.dispatch(fetchRules()), 3000); this.hotkeys.__mousetrap__.stopCallback = (e, element) => { diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index d1557f23c..8c46acf6d 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -2532,6 +2532,10 @@ { "defaultMessage": "New sign-ups:", "id": "notifications.column_settings.admin.sign_up" + }, + { + "defaultMessage": "New reports:", + "id": "notifications.column_settings.admin.report" } ], "path": "app/javascript/mastodon/features/notifications/components/column_settings.json" @@ -2626,6 +2630,10 @@ "id": "notification.admin.sign_up" }, { + "defaultMessage": "{name} reported {target}", + "id": "notification.admin.report" + }, + { "defaultMessage": "{name} has requested to follow you", "id": "notification.follow_request" } @@ -2656,6 +2664,31 @@ { "descriptors": [ { + "defaultMessage": "Open report", + "id": "report_notification.open" + }, + { + "defaultMessage": "Other", + "id": "report_notification.categories.other" + }, + { + "defaultMessage": "Spam", + "id": "report_notification.categories.spam" + }, + { + "defaultMessage": "Rule violation", + "id": "report_notification.categories.violation" + }, + { + "defaultMessage": "{count, plural, one {{count} post} other {{count} posts}} attached", + "id": "report_notification.attached_statuses" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/report.json" + }, + { + "descriptors": [ + { "defaultMessage": "Are you sure you want to permanently clear all your notifications?", "id": "notifications.clear_confirmation" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 12ff27452..abe13906b 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -319,6 +319,7 @@ "navigation_bar.preferences": "Preferences", "navigation_bar.public_timeline": "Federated timeline", "navigation_bar.security": "Security", + "notification.admin.report": "{name} reported {target}", "notification.admin.sign_up": "{name} signed up", "notification.favourite": "{name} favourited your post", "notification.follow": "{name} followed you", @@ -331,6 +332,7 @@ "notification.update": "{name} edited a post", "notifications.clear": "Clear notifications", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.admin.report": "New reports:", "notifications.column_settings.admin.sign_up": "New sign-ups:", "notifications.column_settings.alert": "Desktop notifications", "notifications.column_settings.favourite": "Favourites:", @@ -436,6 +438,11 @@ "report.thanks.title_actionable": "Thanks for reporting, we'll look into this.", "report.unfollow": "Unfollow @{name}", "report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.", + "report_notification.attached_statuses": "{count, plural, one {{count} post} other {{count} posts}} attached", + "report_notification.categories.other": "Other", + "report_notification.categories.spam": "Spam", + "report_notification.categories.violation": "Rule violation", + "report_notification.open": "Open report", "search.placeholder": "Search", "search_popout.search_format": "Advanced search format", "search_popout.tips.full_text": "Simple text returns posts you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 236d25496..999f34be6 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -7,13 +7,13 @@ "account.block_domain": "Bloki domajnon {domain}", "account.blocked": "Blokita", "account.browse_more_on_origin_server": "Vidi pli ĉe la originala profilo", - "account.cancel_follow_request": "Nuligi peton de sekvado", + "account.cancel_follow_request": "Nuligi la demandon de sekvado", "account.direct": "Rekte mesaĝi @{name}", "account.disable_notifications": "Ĉesu sciigi min kiam @{name} mesaĝi", "account.domain_blocked": "Domajno blokita", - "account.edit_profile": "Redakti profilon", - "account.enable_notifications": "Sciigi min kiam @{name} mesaĝi", - "account.endorse": "Montri en profilo", + "account.edit_profile": "Redakti la profilon", + "account.enable_notifications": "Sciigi min kiam @{name} mesaĝas", + "account.endorse": "Rekomendi ĉe via profilo", "account.follow": "Sekvi", "account.followers": "Sekvantoj", "account.followers.empty": "Ankoraŭ neniu sekvas tiun uzanton.", @@ -22,7 +22,7 @@ "account.following_counter": "{count, plural, one {{counter} Sekvato} other {{counter} Sekvatoj}}", "account.follows.empty": "Tiu uzanto ankoraŭ ne sekvas iun.", "account.follows_you": "Sekvas vin", - "account.hide_reblogs": "Kaŝi plusendojn de @{name}", + "account.hide_reblogs": "Kaŝi la plusendojn de @{name}", "account.joined": "Kuniĝis {date}", "account.link_verified_on": "La posedanto de tiu ligilo estis kontrolita je {date}", "account.locked_info": "La privateco de tiu konto estas elektita kiel fermita. La posedanto povas mane akcepti tiun, kiu povas sekvi rin.", @@ -34,7 +34,7 @@ "account.muted": "Silentigita", "account.posts": "Mesaĝoj", "account.posts_with_replies": "Mesaĝoj kaj respondoj", - "account.report": "Signali @{name}", + "account.report": "Raporti @{name}", "account.requested": "Atendo de aprobo. Alklaku por nuligi peton de sekvado", "account.share": "Kundividi la profilon de @{name}", "account.show_reblogs": "Montri la plusendojn de @{name}", @@ -42,62 +42,62 @@ "account.unblock": "Malbloki @{name}", "account.unblock_domain": "Malbloki {domain}", "account.unblock_short": "Malbloki", - "account.unendorse": "Ne montri en profilo", + "account.unendorse": "Ne rekomendi ĉe la profilo", "account.unfollow": "Ne plu sekvi", - "account.unmute": "Malsilentigi @{name}", - "account.unmute_notifications": "Malsilentigi sciigojn de @{name}", - "account.unmute_short": "Malsilentigi", - "account_note.placeholder": "Alklaku por aldoni noton", + "account.unmute": "Ne plu silentigi @{name}", + "account.unmute_notifications": "Reebligi la sciigojn de @{name}", + "account.unmute_short": "Ne plu silentigi", + "account_note.placeholder": "Klaku por aldoni noton", "admin.dashboard.daily_retention": "User retention rate by day after sign-up", "admin.dashboard.monthly_retention": "User retention rate by month after sign-up", "admin.dashboard.retention.average": "Averaĝa", - "admin.dashboard.retention.cohort": "Registriĝo monato", + "admin.dashboard.retention.cohort": "Monato de registriĝo", "admin.dashboard.retention.cohort_size": "Novaj uzantoj", "alert.rate_limited.message": "Bonvolu reprovi post {retry_time, time, medium}.", "alert.rate_limited.title": "Mesaĝkvante limigita", "alert.unexpected.message": "Neatendita eraro okazis.", - "alert.unexpected.title": "Ups!", + "alert.unexpected.title": "Aj!", "announcement.announcement": "Anonco", "attachments_list.unprocessed": "(neprilaborita)", "autosuggest_hashtag.per_week": "{count} semajne", "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje", "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.", - "bundle_column_error.retry": "Bonvolu reprovi", - "bundle_column_error.title": "Reta eraro", + "bundle_column_error.retry": "Provu refoje", + "bundle_column_error.title": "Eraro de reto", "bundle_modal_error.close": "Fermi", "bundle_modal_error.message": "Io misfunkciis en la ŝargado de ĉi tiu elemento.", - "bundle_modal_error.retry": "Bonvolu reprovi", + "bundle_modal_error.retry": "Provu refoje", "column.blocks": "Blokitaj uzantoj", "column.bookmarks": "Legosignoj", "column.community": "Loka templinio", "column.direct": "Rektaj mesaĝoj", "column.directory": "Trarigardi profilojn", "column.domain_blocks": "Blokitaj domajnoj", - "column.favourites": "Stelumoj", + "column.favourites": "Preferaĵoj", "column.follow_requests": "Demandoj de sekvado", "column.home": "Hejmo", "column.lists": "Listoj", "column.mutes": "Silentigitaj uzantoj", "column.notifications": "Sciigoj", "column.pins": "Alpinglitaj mesaĝoj", - "column.public": "Fratara templinio", + "column.public": "Federata templinio", "column_back_button.label": "Reveni", - "column_header.hide_settings": "Kaŝi agordojn", + "column_header.hide_settings": "Kaŝi la agordojn", "column_header.moveLeft_settings": "Movi kolumnon maldekstren", "column_header.moveRight_settings": "Movi kolumnon dekstren", "column_header.pin": "Alpingli", - "column_header.show_settings": "Montri agordojn", + "column_header.show_settings": "Montri la agordojn", "column_header.unpin": "Depingli", - "column_subheading.settings": "Agordado", + "column_subheading.settings": "Agordoj", "community.column_settings.local_only": "Nur loka", "community.column_settings.media_only": "Nur aŭdovidaĵoj", - "community.column_settings.remote_only": "Nur malproksima", + "community.column_settings.remote_only": "Nur fora", "compose.language.change": "Ŝanĝi lingvon", "compose.language.search": "Serĉi lingvojn...", "compose_form.direct_message_warning_learn_more": "Lerni pli", - "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.", + "compose_form.encryption_warning": "La mesaĵoj en Mastodono ne estas ĉifrita de tutvojo. Ne kundividu sentemajn informojn ĉe Mastodono.", "compose_form.hashtag_warning": "Ĉi tiu mesaĝo ne estos listigita per ajna kradvorto. Nur publikaj mesaĝoj estas serĉeblaj per kradvortoj.", - "compose_form.lock_disclaimer": "Via konta ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn, kiuj estas nur por sekvantoj.", + "compose_form.lock_disclaimer": "Via konto ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn nur al la sekvantoj.", "compose_form.lock_disclaimer.lock": "ŝlosita", "compose_form.placeholder": "Kion vi pensas?", "compose_form.poll.add_option": "Aldoni elekteblon", @@ -116,7 +116,7 @@ "compose_form.spoiler.unmarked": "Teksto ne kaŝita", "compose_form.spoiler_placeholder": "Skribu vian averton ĉi tie", "confirmation_modal.cancel": "Nuligi", - "confirmations.block.block_and_report": "Bloki kaj signali", + "confirmations.block.block_and_report": "Bloki kaj raporti", "confirmations.block.confirm": "Bloki", "confirmations.block.message": "Ĉu vi certas, ke vi volas bloki {name}?", "confirmations.delete.confirm": "Forigi", @@ -124,7 +124,7 @@ "confirmations.delete_list.confirm": "Forigi", "confirmations.delete_list.message": "Ĉu vi certas, ke vi volas porĉiame forigi ĉi tiun liston?", "confirmations.discard_edit_media.confirm": "Ne konservi", - "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?", + "confirmations.discard_edit_media.message": "Vi havas nekonservitan ŝanĝon de la priskribo aŭ de la antaŭvido de aŭdvidaĵo, ĉu vi forigu ĝin?", "confirmations.domain_block.confirm": "Bloki la tutan domajnon", "confirmations.domain_block.message": "Ĉu vi vere, vere certas, ke vi volas tute bloki {domain}? Plej ofte, trafa blokado kaj silentigado sufiĉas kaj preferindas. Vi ne vidos enhavon de tiu domajno en publika templinio aŭ en viaj sciigoj. Viaj sekvantoj de tiu domajno estos forigitaj.", "confirmations.logout.confirm": "Adiaŭi", @@ -133,7 +133,7 @@ "confirmations.mute.explanation": "Ĉi-tio kaŝos mesaĝojn el ili kaj mesaĝojn kiuj mencias ilin, sed ili ankoraŭ rajtos vidi viajn mesaĝojn kaj sekvi vin.", "confirmations.mute.message": "Ĉu vi certas, ke vi volas silentigi {name}?", "confirmations.redraft.confirm": "Forigi kaj reskribi", - "confirmations.redraft.message": "Ĉu vi certas ke vi volas forigi tiun mesaĝon kaj reskribi ĝin? Ĉiuj diskonigoj kaj stelumoj estos perditaj, kaj respondoj al la originala mesaĝo estos senparentaj.", + "confirmations.redraft.message": "Ĉu vi certas ke vi volas forigi kaj reskribi la mesaĝon? Ĝiaj preferitaĵoj kaj ĝiaj plusendoj estos perditaj, kaj la respondoj al la originala mesaĝo estos orfaj.", "confirmations.reply.confirm": "Respondi", "confirmations.reply.message": "Respondi nun anstataŭigos la mesaĝon, kiun vi nun skribas. Ĉu vi certas, ke vi volas daŭrigi?", "confirmations.unfollow.confirm": "Ne plu sekvi", @@ -172,8 +172,8 @@ "empty_column.direct": "Vi ankoraŭ ne havas rektan mesaĝon. Kiam vi sendos aŭ ricevos iun, ĝi aperos ĉi tie.", "empty_column.domain_blocks": "Ankoraŭ neniu domajno estas blokita.", "empty_column.explore_statuses": "Nenio tendencas nun. Rekontrolu poste!", - "empty_column.favourited_statuses": "Vi ankoraŭ ne stelumis mesaĝon. Kiam vi stelumos iun, tiu aperos ĉi tie.", - "empty_column.favourites": "Ankoraŭ neniu stelumis tiun mesaĝon. Kiam iu faros tion, tiu aperos ĉi tie.", + "empty_column.favourited_statuses": "Vi ankoraŭ ne havas mesaĝon en la preferaĵoj. Kiam vi aldonas ion, ĝi aperos ĉi tie.", + "empty_column.favourites": "Ankoraŭ neniu preferis la mesaĝon. Kiam iu faros ĉi tion, ili aperos ĉi tie.", "empty_column.follow_recommendations": "Ŝajnas, ke neniuj sugestoj povis esti generitaj por vi. Vi povas provi uzi serĉon por serĉi homojn, kiujn vi eble konas, aŭ esplori tendencajn kradvortojn.", "empty_column.follow_requests": "Vi ne ankoraŭ havas iun peton de sekvado. Kiam vi ricevos unu, ĝi aperos ĉi tie.", "empty_column.hashtag": "Ankoraŭ estas nenio per ĉi tiu kradvorto.", @@ -198,10 +198,10 @@ "explore.trending_tags": "Kradvortoj", "follow_recommendations.done": "Farita", "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.", - "follow_recommendations.lead": "La mesaĝoj de personoj kiujn vi sekvas, aperos kronologie en via abonfluo. Ne timu erari, vi povas ĉesi sekvi facile iam ajn!", + "follow_recommendations.lead": "La mesaĝoj de personoj kiujn vi sekvas, kronologie aperos en via hejma templinio. Ne timu erari, vi povas ĉesi sekvi facile iam ajn!", "follow_request.authorize": "Rajtigi", "follow_request.reject": "Rifuzi", - "follow_requests.unlocked_explanation": "Kvankam via konto ne estas ŝlosita, la teamo de {domain} pensis ke vi eble volas kontroli la demandojn de sekvado de ĉi tiuj kontoj permane.", + "follow_requests.unlocked_explanation": "Kvankam via konto ne estas ŝlosita, la teamo de {domain} pensis ke vi eble volas permane kontroli la demandojn de sekvado de ĉi tiuj kontoj.", "generic.saved": "Konservita", "getting_started.developers": "Programistoj", "getting_started.directory": "Profilujo", @@ -237,9 +237,9 @@ "keyboard_shortcuts.direct": "malfermi la kolumnon de rektaj mesaĝoj", "keyboard_shortcuts.down": "iri suben en la listo", "keyboard_shortcuts.enter": "malfermi mesaĝon", - "keyboard_shortcuts.favourite": "stelumi", - "keyboard_shortcuts.favourites": "malfermi la liston de stelumoj", - "keyboard_shortcuts.federated": "Malfermi la frataran templinion", + "keyboard_shortcuts.favourite": "Aldoni la mesaĝon al preferaĵoj", + "keyboard_shortcuts.favourites": "Malfermi la liston de preferaĵoj", + "keyboard_shortcuts.federated": "Malfermi la federatan templinion", "keyboard_shortcuts.heading": "Klavaraj mallongigoj", "keyboard_shortcuts.home": "Malfermi la hejman templinion", "keyboard_shortcuts.hotkey": "Rapidklavo", @@ -279,7 +279,7 @@ "lists.replies_policy.followed": "Iu sekvanta uzanto", "lists.replies_policy.list": "Membroj de la listo", "lists.replies_policy.none": "Neniu", - "lists.replies_policy.title": "Montri respondon al:", + "lists.replies_policy.title": "Montri respondojn al:", "lists.search": "Serĉi inter la homoj, kiujn vi sekvas", "lists.subheading": "Viaj listoj", "load_pending": "{count,plural, one {# nova elemento} other {# novaj elementoj}}", @@ -312,10 +312,10 @@ "navigation_bar.personal": "Persone", "navigation_bar.pins": "Alpinglitaj mesaĝoj", "navigation_bar.preferences": "Preferoj", - "navigation_bar.public_timeline": "Fratara templinio", + "navigation_bar.public_timeline": "Federata templinio", "navigation_bar.security": "Sekureco", "notification.admin.sign_up": "{name} registris", - "notification.favourite": "{name} stelumis vian mesaĝon", + "notification.favourite": "{name} preferis vian mesaĝon", "notification.follow": "{name} eksekvis vin", "notification.follow_request": "{name} petis sekvi vin", "notification.mention": "{name} menciis vin", @@ -328,10 +328,10 @@ "notifications.clear_confirmation": "Ĉu vi certas, ke vi volas porĉiame forviŝi ĉiujn viajn sciigojn?", "notifications.column_settings.admin.sign_up": "Novaj registriĝoj:", "notifications.column_settings.alert": "Retumilaj sciigoj", - "notifications.column_settings.favourite": "Stelumoj:", + "notifications.column_settings.favourite": "Preferaĵoj:", "notifications.column_settings.filter_bar.advanced": "Montri ĉiujn kategoriojn", "notifications.column_settings.filter_bar.category": "Rapida filtra breto", - "notifications.column_settings.filter_bar.show_bar": "Montru filtrilon", + "notifications.column_settings.filter_bar.show_bar": "Montri la breton de filtrilo", "notifications.column_settings.follow": "Novaj sekvantoj:", "notifications.column_settings.follow_request": "Novaj petoj de sekvado:", "notifications.column_settings.mention": "Mencioj:", @@ -346,7 +346,7 @@ "notifications.column_settings.update": "Redaktoj:", "notifications.filter.all": "Ĉiuj", "notifications.filter.boosts": "Plusendoj", - "notifications.filter.favourites": "Stelumoj", + "notifications.filter.favourites": "Preferaĵoj", "notifications.filter.follows": "Sekvoj", "notifications.filter.mentions": "Mencioj", "notifications.filter.polls": "Balotenketaj rezultoj", @@ -381,7 +381,7 @@ "privacy.unlisted.short": "Nelistigita", "refresh": "Refreŝigu", "regeneration_indicator.label": "Ŝargado…", - "regeneration_indicator.sublabel": "Via hejma fluo pretiĝas!", + "regeneration_indicator.sublabel": "Via abonfluo estas preparata!", "relative_time.days": "{number}t", "relative_time.full.days": "{number, plural, one {# day} other {# days}} ago", "relative_time.full.hours": "{number, plural, one {# hour} other {# hours}} ago", @@ -397,18 +397,18 @@ "report.block": "Bloki", "report.block_explanation": "You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.", "report.categories.other": "Aliaj", - "report.categories.spam": "Spamo", + "report.categories.spam": "Trudaĵo", "report.categories.violation": "Content violates one or more server rules", "report.category.subtitle": "Elektu la plej bonan kongruon", "report.category.title": "Diru al ni kio okazas pri ĉi tiu {type}", "report.category.title_account": "profilo", "report.category.title_status": "afiŝo", "report.close": "Farita", - "report.comment.title": "Is there anything else you think we should know?", + "report.comment.title": "Ĉu estas io alia kion vi pensas ke ni devas scii?", "report.forward": "Plusendi al {target}", - "report.forward_hint": "La konto estas en alia servilo. Ĉu sendi sennomigitan kopion de la signalo ankaŭ tien?", + "report.forward_hint": "La konto estas de alia servilo. Ĉu vi volas sendi anoniman kopion de la informo ankaŭ al tie?", "report.mute": "Silentigi", - "report.mute_explanation": "Vi ne vidos iliajn afiŝojn. Ili ankoraŭ povas sekvi vin kaj vidi viajn afiŝojn, kaj ne scios ke si estas silentigitaj.", + "report.mute_explanation": "Vi ne vidos iliajn afiŝojn. Ili ankoraŭ povas sekvi vin kaj vidi viajn afiŝojn, kaj ne scios ke ili estas silentigitaj.", "report.next": "Sekva", "report.placeholder": "Pliaj komentoj", "report.reasons.dislike": "Mi ne ŝatas ĝin", @@ -417,20 +417,20 @@ "report.reasons.other_description": "La problemo ne taŭgas en aliaj kategorioj", "report.reasons.spam": "Ĝi estas trudaĵo", "report.reasons.spam_description": "Malicious links, fake engagement, or repetitive replies", - "report.reasons.violation": "Ĝi malrespektas servilajn regulojn", + "report.reasons.violation": "Ĝi malobservas la regulojn de la servilo", "report.reasons.violation_description": "You are aware that it breaks specific rules", "report.rules.subtitle": "Elektu ĉiujn, kiuj validas", "report.rules.title": "Kiuj reguloj estas malobservataj?", "report.statuses.subtitle": "Elektu ĉiujn, kiuj validas", "report.statuses.title": "Are there any posts that back up this report?", "report.submit": "Sendi", - "report.target": "Signali {target}", + "report.target": "Raporto pri {target}", "report.thanks.take_action": "Here are your options for controlling what you see on Mastodon:", "report.thanks.take_action_actionable": "While we review this, you can take action against @{name}:", "report.thanks.title": "Ĉu vi ne volas vidi ĉi tion?", "report.thanks.title_actionable": "Dankon pro raporti, ni esploros ĉi tion.", "report.unfollow": "Malsekvi @{name}", - "report.unfollow_explanation": "Vi estas sekvanta ĉi tiun konton. Por ne plu vidi ties afiŝojn en via hejma templinio, malsekvu ilin.", + "report.unfollow_explanation": "Vi sekvas ĉi tiun konton. Por ne plu vidi ĝiajn abonfluojn en via hejma templinio, ĉesu sekvi ĝin.", "search.placeholder": "Serĉi", "search_popout.search_format": "Detala serĉo", "search_popout.tips.full_text": "Simplaj tekstoj montras la mesaĝojn, kiujn vi skribis, stelumis, diskonigis, aŭ en kiuj vi estis menciita, sed ankaŭ kongruajn uzantnomojn, montratajn nomojn, kaj kradvortojn.", @@ -459,7 +459,7 @@ "status.edited": "Redaktita {date}", "status.edited_x_times": "Redactita {count, plural, one {{count} fojon} other {{count} fojojn}}", "status.embed": "Enkorpigi", - "status.favourite": "Stelumi", + "status.favourite": "Preferaĵo", "status.filtered": "Filtrita", "status.history.created": "{name} kreis {date}", "status.history.edited": "{name} redaktis {date}", @@ -469,8 +469,8 @@ "status.more": "Pli", "status.mute": "Silentigi @{name}", "status.mute_conversation": "Silentigi konversacion", - "status.open": "Grandigi ĉi tiun mesaĝon", - "status.pin": "Alpingli profile", + "status.open": "Disvolvi la mesaĝon", + "status.pin": "Alpingli al la profilo", "status.pinned": "Alpinglita mesaĝo", "status.read_more": "Legi pli", "status.reblog": "Plusendi", @@ -481,20 +481,20 @@ "status.remove_bookmark": "Forigi legosignon", "status.reply": "Respondi", "status.replyAll": "Respondi al la fadeno", - "status.report": "Signali @{name}", + "status.report": "Raporti @{name}", "status.sensitive_warning": "Tikla enhavo", - "status.share": "Diskonigi", - "status.show_less": "Malgrandigi", - "status.show_less_all": "Malgrandigi ĉiujn", - "status.show_more": "Grandigi", - "status.show_more_all": "Malfoldi ĉiun", - "status.show_thread": "Montri la fadenon", + "status.share": "Kundividi", + "status.show_less": "Montri malpli", + "status.show_less_all": "Montri malpli ĉiun", + "status.show_more": "Montri pli", + "status.show_more_all": "Montri pli ĉiun", + "status.show_thread": "Montri la mesaĝaron", "status.uncached_media_warning": "Nedisponebla", "status.unmute_conversation": "Malsilentigi la konversacion", "status.unpin": "Depingli de profilo", "suggestions.dismiss": "Forigi la proponon", "suggestions.header": "Vi povus interesiĝi pri…", - "tabs_bar.federated_timeline": "Fratara templinio", + "tabs_bar.federated_timeline": "Federata", "tabs_bar.home": "Hejmo", "tabs_bar.local_timeline": "Loka templinio", "tabs_bar.notifications": "Sciigoj", @@ -539,7 +539,7 @@ "video.close": "Fermi la videon", "video.download": "Elŝuti dosieron", "video.exit_fullscreen": "Eksigi plenekrana", - "video.expand": "Grandigi la videon", + "video.expand": "Pligrandigi la videon", "video.fullscreen": "Igi plenekrana", "video.hide": "Kaŝi la videon", "video.mute": "Silentigi", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 0a6aac287..01fd9a567 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -8,7 +8,7 @@ "account.blocked": "Bloqueada", "account.browse_more_on_origin_server": "Busca máis no perfil orixinal", "account.cancel_follow_request": "Desbotar solicitude de seguimento", - "account.direct": "Mensaxe directa @{name}", + "account.direct": "Mensaxe directa a @{name}", "account.disable_notifications": "Deixar de notificarme cando @{name} publica", "account.domain_blocked": "Dominio agochado", "account.edit_profile": "Editar perfil", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index 5bc08533e..b4e85be06 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -106,7 +106,7 @@ "compose_form.poll.remove_option": "เอาตัวเลือกนี้ออก", "compose_form.poll.switch_to_multiple": "เปลี่ยนการสำรวจความคิดเห็นเป็นอนุญาตหลายตัวเลือก", "compose_form.poll.switch_to_single": "เปลี่ยนการสำรวจความคิดเห็นเป็นอนุญาตตัวเลือกเดี่ยว", - "compose_form.publish": "Publish", + "compose_form.publish": "เผยแพร่", "compose_form.publish_loud": "{publish}!", "compose_form.save_changes": "บันทึกการเปลี่ยนแปลง", "compose_form.sensitive.hide": "{count, plural, other {ทำเครื่องหมายสื่อว่าละเอียดอ่อน}}", diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js index 33f0c6732..14b704027 100644 --- a/app/javascript/mastodon/reducers/filters.js +++ b/app/javascript/mastodon/reducers/filters.js @@ -1,10 +1,34 @@ -import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; -import { List as ImmutableList, fromJS } from 'immutable'; +import { FILTERS_IMPORT } from '../actions/importer'; +import { Map as ImmutableMap, is, fromJS } from 'immutable'; -export default function filters(state = ImmutableList(), action) { +const normalizeFilter = (state, filter) => { + const normalizedFilter = fromJS({ + id: filter.id, + title: filter.title, + context: filter.context, + filter_action: filter.filter_action, + 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); + } +}; + +const normalizeFilters = (state, filters) => { + filters.forEach(filter => { + state = normalizeFilter(state, filter); + }); + + return state; +}; + +export default function filters(state = ImmutableMap(), action) { switch(action.type) { - case FILTERS_FETCH_SUCCESS: - return fromJS(action.filters); + case FILTERS_IMPORT: + return normalizeFilters(state, action.filters); default: return state; } diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index b587b6d0f..4b460bc10 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -28,7 +28,7 @@ import { } from '../actions/app'; import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks'; import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines'; -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable'; import compareId from '../compare_id'; const initialState = ImmutableMap({ @@ -52,6 +52,7 @@ const notificationToMap = notification => ImmutableMap({ account: notification.account.id, created_at: notification.created_at, status: notification.status ? notification.status.id : null, + report: notification.report ? fromJS(notification.report) : null, }); const normalizeNotification = (state, notification, usePendingItems) => { diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index afffce917..f9d3236e4 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -39,6 +39,7 @@ const initialState = ImmutableMap({ status: false, update: false, 'admin.sign_up': false, + 'admin.report': false, }), quickFilter: ImmutableMap({ @@ -60,6 +61,7 @@ const initialState = ImmutableMap({ status: true, update: true, 'admin.sign_up': true, + 'admin.report': true, }), sounds: ImmutableMap({ @@ -72,6 +74,7 @@ const initialState = ImmutableMap({ status: true, update: true, 'admin.sign_up': true, + 'admin.report': true, }), }), diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 3121774b3..6aeb8b7bd 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -40,15 +40,15 @@ const toServerSideType = columnType => { const escapeRegExp = string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -const regexFromFilters = filters => { - if (filters.size === 0) { +const regexFromKeywords = keywords => { + if (keywords.size === 0) { return null; } - return new RegExp(filters.map(filter => { - let expr = escapeRegExp(filter.get('phrase')); + return new RegExp(keywords.map(keyword_filter => { + let expr = escapeRegExp(keyword_filter.get('keyword')); - if (filter.get('whole_word')) { + if (keyword_filter.get('whole_word')) { if (/^[\w]/.test(expr)) { expr = `\\b${expr}`; } @@ -62,27 +62,15 @@ const regexFromFilters = filters => { }).join('|'), 'i'); }; -// Memoize the filter regexps for each valid server contextType -const makeGetFiltersRegex = () => { - let memo = {}; +const getFilters = (state, { contextType }) => { + if (!contextType) return null; - return (state, { contextType }) => { - if (!contextType) return ImmutableList(); + const serverSideType = toServerSideType(contextType); + const now = new Date(); - const serverSideType = toServerSideType(contextType); - const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))); - - if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) { - const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible'))); - const regex = regexFromFilters(filters); - memo[serverSideType] = { filters: filters, results: [dropRegex, regex] }; - } - return memo[serverSideType].results; - }; + return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now)); }; -export const getFiltersRegex = makeGetFiltersRegex(); - export const makeGetStatus = () => { return createSelector( [ @@ -90,10 +78,10 @@ export const makeGetStatus = () => { (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), - getFiltersRegex, + getFilters, ], - (statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => { + (statusBase, statusReblog, accountBase, accountReblog, filters) => { if (!statusBase) { return null; } @@ -104,14 +92,17 @@ export const makeGetStatus = () => { statusReblog = null; } - const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0]; - if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) { - return null; + let filtered = false; + if ((accountReblog || accountBase).get('id') !== me && filters) { + let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList(); + if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { + return null; + } + if (!filterResults.isEmpty()) { + filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); + } } - const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1]; - const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index')); - return statusBase.withMutations(map => { map.set('reblog', statusReblog); map.set('account', accountBase); @@ -152,14 +143,15 @@ export const getAlerts = createSelector([getAlertsBase], (base) => { return arr; }); -export const makeGetNotification = () => { - return createSelector([ - (_, base) => base, - (state, _, accountId) => state.getIn(['accounts', accountId]), - ], (base, account) => { - return base.set('account', account); - }); -}; +export const makeGetNotification = () => createSelector([ + (_, base) => base, + (state, _, accountId) => state.getIn(['accounts', accountId]), +], (base, account) => base.set('account', account)); + +export const makeGetReport = () => createSelector([ + (_, base) => base, + (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]), +], (base, targetAccount) => base.set('target_account', targetAccount)); export const getAccountGallery = createSelector([ (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index be467a8e2..4f60f04c1 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -3,6 +3,7 @@ import loadPolyfills from '../mastodon/load_polyfills'; import ready from '../mastodon/ready'; import { start } from '../mastodon/common'; import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; +import 'cocoon-js-vanilla'; start(); diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index f4f5bf752..f606bfa4a 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -75,6 +75,13 @@ $content-width: 840px; height: 100px; } + .logo--wordmark { + display: inherit; + margin: inherit; + width: inherit; + height: 20px; + } + @media screen and (max-width: $no-columns-breakpoint) { & > a:first-child { display: none; @@ -924,7 +931,8 @@ a.name-tag, text-align: center; } -.applications-list__item { +.applications-list__item, +.filters-list__item { padding: 15px 0; background: $ui-base-color; border: 1px solid lighten($ui-base-color, 4%); @@ -932,7 +940,8 @@ a.name-tag, margin-top: 15px; } -.announcements-list { +.announcements-list, +.filters-list { border: 1px solid lighten($ui-base-color, 4%); border-radius: 4px; @@ -985,6 +994,33 @@ a.name-tag, } } +.filters-list__item { + &__title { + display: flex; + justify-content: space-between; + margin-bottom: 0; + } + + &__permissions { + margin-top: 0; + margin-bottom: 10px; + } + + .expiration { + font-size: 13px; + } + + &.expired { + .expiration { + color: lighten($error-red, 12%); + } + + .permissions-list__item__icon { + color: $dark-text-color; + } + } +} + .dashboard__counters.admin-account-counters { margin-top: 10px; } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 7ae20fbd9..26f4c54a3 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -959,6 +959,21 @@ width: 100%; clear: both; border-bottom: 1px solid lighten($ui-base-color, 8%); + + &__button { + display: inline; + color: lighten($ui-highlight-color, 8%); + border: 0; + background: transparent; + padding: 0; + font-size: inherit; + line-height: inherit; + + &:hover, + &:active { + text-decoration: underline; + } + } } .status__prepend-icon-wrapper { @@ -1355,6 +1370,8 @@ a .account__avatar { .account__avatar-overlay { @include avatar-size(48px); + position: relative; + &-base { @include avatar-radius; @include avatar-size(36px); @@ -1620,6 +1637,33 @@ a.account__display-name { } } +.notification__report { + padding: 8px 10px; + padding-left: 68px; + position: relative; + border-bottom: 1px solid lighten($ui-base-color, 8%); + min-height: 54px; + + &__details { + display: flex; + justify-content: space-between; + align-items: center; + color: $darker-text-color; + font-size: 15px; + line-height: 22px; + + strong { + font-weight: 500; + } + } + + &__avatar { + position: absolute; + left: 10px; + top: 10px; + } +} + .notification__message { margin: 0 10px 0 68px; padding: 8px 0 0; @@ -2360,6 +2404,16 @@ a.account__display-name { padding-top: 15px; } + .notification__report { + padding: 15px 15px 15px (48px + 15px * 2); + min-height: 48px + 2px; + + &__avatar { + left: 15px; + top: 17px; + } + } + .status { padding: 15px 15px 15px (48px + 15px * 2); min-height: 48px + 2px; diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index d57eabc09..da699dd25 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -1070,3 +1070,34 @@ code { } } } + +.keywords-table { + thead { + th { + white-space: nowrap; + } + + th:first-child { + width: 100%; + } + } + + tfoot { + td { + border: 0; + } + } + + .input.string { + margin-bottom: 0; + } + + .label_input__wrapper { + margin-top: 10px; + } + + .table-action-link { + margin-top: 10px; + white-space: nowrap; + } +} diff --git a/app/lib/activitypub/parser/media_attachment_parser.rb b/app/lib/activitypub/parser/media_attachment_parser.rb index 30bea1f0e..656be84b7 100644 --- a/app/lib/activitypub/parser/media_attachment_parser.rb +++ b/app/lib/activitypub/parser/media_attachment_parser.rb @@ -50,7 +50,7 @@ class ActivityPub::Parser::MediaAttachmentParser components = begin blurhash = @json['blurhash'] - if blurhash.present? && /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash) + if blurhash.present? && /^[\w#$%*+,-.:;=?@\[\]^{|}~]+$/.match?(blurhash) Blurhash.components(blurhash) end end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 235d6fedd..4633786ca 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -401,7 +401,6 @@ class FeedManager def filter_from_home?(status, receiver_id, crutches) return false if receiver_id == status.account_id return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) - return true if phrase_filtered?(status, receiver_id, :home) check_for_blocks = crutches[:active_mentions][status.id] || [] check_for_blocks.concat([status.account_id]) @@ -437,7 +436,6 @@ class FeedManager # @return [Boolean] def filter_from_mentions?(status, receiver_id) return true if receiver_id == status.account_id - return true if phrase_filtered?(status, receiver_id, :notifications) # This filter is called from NotifyService, but already after the sender of # the notification has been checked for mute/block. Therefore, it's not @@ -476,34 +474,6 @@ class FeedManager false end - # Check if the status hits a phrase filter - # @param [Status] status - # @param [Integer] receiver_id - # @param [Symbol] context - # @return [Boolean] - def phrase_filtered?(status, receiver_id, context) - active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a - - active_filters.select! { |filter| filter.context.include?(context.to_s) && !filter.expired? } - - active_filters.map! do |filter| - if filter.whole_word - sb = /\A[[:word:]]/.match?(filter.phrase) ? '\b' : '' - eb = /[[:word:]]\z/.match?(filter.phrase) ? '\b' : '' - - /(?mix:#{sb}#{Regexp.escape(filter.phrase)}#{eb})/ - else - /#{Regexp.escape(filter.phrase)}/i - end - end - - return false if active_filters.empty? - - combined_regex = Regexp.union(active_filters) - - combined_regex.match?(status.proper.searchable_text) - end - # Adds a status to an account's feed, returning true if a status was # added, and false if it was not added to the feed. Note that this is # an internal helper: callers must call trim or push updates if diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index ad1665dc4..a7401362f 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -247,6 +247,19 @@ module AccountInteractions account_pins.where(target_account: account).exists? end + 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 + end + def followers_for_local_distribution followers.local .joins(:user) diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index 8e3476794..e98ed7df9 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -3,18 +3,22 @@ # # Table name: custom_filters # -# id :bigint(8) not null, primary key -# account_id :bigint(8) -# expires_at :datetime -# phrase :text default(""), not null -# context :string default([]), not null, is an Array -# whole_word :boolean default(TRUE), not null -# irreversible :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint not null, primary key +# account_id :bigint +# expires_at :datetime +# phrase :text default(""), not null +# context :string default([]), not null, is an Array +# created_at :datetime not null +# updated_at :datetime not null +# action :integer default(0), not null # class CustomFilter < ApplicationRecord + self.ignored_columns = %w(whole_word irreversible) + + alias_attribute :title, :phrase + alias_attribute :filter_action, :action + VALID_CONTEXTS = %w( home notifications @@ -26,16 +30,20 @@ class CustomFilter < ApplicationRecord include Expireable include Redisable + enum action: [:warn, :hide], _suffix: :action + belongs_to :account + has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy + accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true - validates :phrase, :context, presence: true + validates :title, :context, presence: true validate :context_must_be_valid - validate :irreversible_must_be_within_context - - scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) } before_validation :clean_up_contexts - after_commit :remove_cache + + before_save :prepare_cache_invalidation! + before_destroy :prepare_cache_invalidation! + after_commit :invalidate_cache! def expires_in return @expires_in if defined?(@expires_in) @@ -44,22 +52,55 @@ class CustomFilter < ApplicationRecord [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at } end - private + def irreversible=(value) + self.action = value ? :hide : :warn + end - def clean_up_contexts - self.context = Array(context).map(&:strip).filter_map(&:presence) + def irreversible? + hide_action? + end + + def self.cached_filters_for(account_id) + active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do + 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| + keywords.map! do |keyword| + if keyword.whole_word + sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : '' + eb = /[[:word:]]\z/.match?(keyword.keyword) ? '\b' : '' + + /(?mix:#{sb}#{Regexp.escape(keyword.keyword)}#{eb})/ + else + /#{Regexp.escape(keyword.keyword)}/i + end + end + [filter, { keywords: Regexp.union(keywords) }] + end + end.to_a + + active_filters.select { |custom_filter, _| !custom_filter.expired? } + end + + def prepare_cache_invalidation! + @should_invalidate_cache = true end - def remove_cache - Rails.cache.delete("filters:#{account_id}") + def invalidate_cache! + return unless @should_invalidate_cache + @should_invalidate_cache = false + + Rails.cache.delete("filters:v3:#{account_id}") redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed)) + redis.publish("timeline:system:#{account_id}", Oj.dump(event: :filters_changed)) end - def context_must_be_valid - errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) } + private + + def clean_up_contexts + self.context = Array(context).map(&:strip).filter_map(&:presence) end - def irreversible_must_be_within_context - errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications') + def context_must_be_valid + errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) } end end diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb new file mode 100644 index 000000000..bf5c55746 --- /dev/null +++ b/app/models/custom_filter_keyword.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: custom_filter_keywords +# +# id :bigint not null, primary key +# custom_filter_id :bigint not null +# keyword :text default(""), not null +# whole_word :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class CustomFilterKeyword < ApplicationRecord + belongs_to :custom_filter + + validates :keyword, presence: true + + alias_attribute :phrase, :keyword + + before_save :prepare_cache_invalidation! + before_destroy :prepare_cache_invalidation! + after_commit :invalidate_cache! + + private + + def prepare_cache_invalidation! + custom_filter.prepare_cache_invalidation! + end + + def invalidate_cache! + custom_filter.invalidate_cache! + end +end diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb index 2e14fce25..7a0acbe32 100644 --- a/app/models/domain_allow.rb +++ b/app/models/domain_allow.rb @@ -11,6 +11,7 @@ # class DomainAllow < ApplicationRecord + include Paginable include DomainNormalizable include DomainMaterializable diff --git a/app/models/notification.rb b/app/models/notification.rb index ba94b54d1..bbc63c1c0 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -37,6 +37,7 @@ class Notification < ApplicationRecord poll update admin.sign_up + admin.report ).freeze TARGET_STATUS_INCLUDES_BY_TYPE = { @@ -46,6 +47,7 @@ class Notification < ApplicationRecord favourite: [favourite: :status], poll: [poll: :status], update: :status, + 'admin.report': [report: :target_account], }.freeze belongs_to :account, optional: true @@ -58,6 +60,7 @@ class Notification < ApplicationRecord belongs_to :follow_request, foreign_key: 'activity_id', optional: true belongs_to :favourite, foreign_key: 'activity_id', optional: true belongs_to :poll, foreign_key: 'activity_id', optional: true + belongs_to :report, foreign_key: 'activity_id', optional: true validates :type, inclusion: { in: TYPES } @@ -146,7 +149,7 @@ class Notification < ApplicationRecord return unless new_record? case activity_type - when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll' + when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report' self.from_account_id = activity&.account_id when 'Mention' self.from_account_id = activity&.status&.account_id diff --git a/app/policies/domain_allow_policy.rb b/app/policies/domain_allow_policy.rb index 5030453bb..7a5b5d780 100644 --- a/app/policies/domain_allow_policy.rb +++ b/app/policies/domain_allow_policy.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true class DomainAllowPolicy < ApplicationPolicy + def index? + admin? + end + + def show? + admin? + end + def create? admin? end diff --git a/app/presenters/filter_result_presenter.rb b/app/presenters/filter_result_presenter.rb new file mode 100644 index 000000000..677225f5e --- /dev/null +++ b/app/presenters/filter_result_presenter.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class FilterResultPresenter < ActiveModelSerializers::Model + attributes :filter, :keyword_matches +end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 4163bb098..d7ffb1954 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -2,7 +2,7 @@ class StatusRelationshipsPresenter attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, - :bookmarks_map + :bookmarks_map, :filters_map def initialize(statuses, current_account_id = nil, **options) if current_account_id.nil? @@ -11,12 +11,14 @@ class StatusRelationshipsPresenter @bookmarks_map = {} @mutes_map = {} @pins_map = {} + @filters_map = {} else statuses = statuses.compact status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact conversation_ids = statuses.filter_map(&:conversation_id).uniq pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) } + @filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {}) @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) @@ -24,4 +26,24 @@ class StatusRelationshipsPresenter @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) end end + + private + + def build_filters_map(statuses, current_account_id) + 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 + + unless filter_matches.empty? + h[status.id] = filter_matches + h[status.reblog_of_id] = filter_matches if status.reblog? + end + end + end end diff --git a/app/serializers/rest/admin/domain_allow_serializer.rb b/app/serializers/rest/admin/domain_allow_serializer.rb new file mode 100644 index 000000000..ebdf33815 --- /dev/null +++ b/app/serializers/rest/admin/domain_allow_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::Admin::DomainAllowSerializer < ActiveModel::Serializer + attributes :id, :domain, :created_at + + def id + object.id.to_s + end +end diff --git a/app/serializers/rest/admin/report_serializer.rb b/app/serializers/rest/admin/report_serializer.rb index 237f41d8e..44b4726e4 100644 --- a/app/serializers/rest/admin/report_serializer.rb +++ b/app/serializers/rest/admin/report_serializer.rb @@ -2,7 +2,7 @@ class REST::Admin::ReportSerializer < ActiveModel::Serializer attributes :id, :action_taken, :action_taken_at, :category, :comment, - :created_at, :updated_at + :forwarded, :created_at, :updated_at has_one :account, serializer: REST::Admin::AccountSerializer has_one :target_account, serializer: REST::Admin::AccountSerializer diff --git a/app/serializers/rest/filter_keyword_serializer.rb b/app/serializers/rest/filter_keyword_serializer.rb new file mode 100644 index 000000000..dd2ebac6e --- /dev/null +++ b/app/serializers/rest/filter_keyword_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::FilterKeywordSerializer < ActiveModel::Serializer + attributes :id, :keyword, :whole_word + + def id + object.id.to_s + end +end diff --git a/app/serializers/rest/filter_result_serializer.rb b/app/serializers/rest/filter_result_serializer.rb new file mode 100644 index 000000000..0ef4db79a --- /dev/null +++ b/app/serializers/rest/filter_result_serializer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class REST::FilterResultSerializer < ActiveModel::Serializer + belongs_to :filter, serializer: REST::FilterSerializer + has_many :keyword_matches +end diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb index 57205630b..98d7edb17 100644 --- a/app/serializers/rest/filter_serializer.rb +++ b/app/serializers/rest/filter_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class REST::FilterSerializer < ActiveModel::Serializer - attributes :id, :phrase, :context, :whole_word, :expires_at, - :irreversible + attributes :id, :title, :context, :expires_at, :filter_action + has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested? def id object.id.to_s end + + def rules_requested? + instance_options[:rules_requested] + end end diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb index 69b81f6de..137fc53dd 100644 --- a/app/serializers/rest/notification_serializer.rb +++ b/app/serializers/rest/notification_serializer.rb @@ -5,6 +5,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer belongs_to :from_account, key: :account, serializer: REST::AccountSerializer belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer + belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer def id object.id.to_s @@ -13,4 +14,8 @@ class REST::NotificationSerializer < ActiveModel::Serializer def status_type? [:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type) end + + def report_type? + object.type == :'admin.report' + end end diff --git a/app/serializers/rest/report_serializer.rb b/app/serializers/rest/report_serializer.rb index ecb88d653..de68dfc6d 100644 --- a/app/serializers/rest/report_serializer.rb +++ b/app/serializers/rest/report_serializer.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true class REST::ReportSerializer < ActiveModel::Serializer - attributes :id, :action_taken + attributes :id, :action_taken, :action_taken_at, :category, :comment, + :forwarded, :created_at, :status_ids, :rule_ids + + has_one :target_account, serializer: REST::AccountSerializer def id object.id.to_s diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index ef2c6c6e5..659c45b83 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -14,6 +14,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :bookmarked, if: :current_user? attribute :pinned, if: :pinnable? attribute :local_only if :local? + has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user? attribute :content, unless: :source_requested? attribute :text, if: :source_requested? @@ -122,6 +123,14 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def filtered + if instance_options && instance_options[:relationships] + instance_options[:relationships].filters_map[object.id] || [] + else + current_user.account.status_matches_filters(object) + end + end + def pinnable? current_user? && current_user.account_id == object.account_id && diff --git a/app/serializers/rest/v1/filter_serializer.rb b/app/serializers/rest/v1/filter_serializer.rb new file mode 100644 index 000000000..455f17efd --- /dev/null +++ b/app/serializers/rest/v1/filter_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class REST::V1::FilterSerializer < ActiveModel::Serializer + attributes :id, :phrase, :context, :whole_word, :expires_at, + :irreversible + + delegate :context, :expires_at, to: :custom_filter + + def id + object.id.to_s + end + + def phrase + object.keyword + end + + def irreversible + custom_filter.irreversible? + end + + private + + def custom_filter + object.custom_filter + end +end diff --git a/app/services/report_service.rb b/app/services/report_service.rb index d251bb33f..70212a6a7 100644 --- a/app/services/report_service.rb +++ b/app/services/report_service.rb @@ -39,8 +39,8 @@ class ReportService < BaseService return if @report.unresolved_siblings? User.staff.includes(:account).each do |u| - next unless u.allows_report_emails? - AdminMailer.new_report(u.account, @report).deliver_later + LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report') + AdminMailer.new_report(u.account, @report).deliver_later if u.allows_report_emails? end end diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml deleted file mode 100644 index 84dcdcca5..000000000 --- a/app/views/filters/_fields.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -.fields-row - .fields-row__column.fields-row__column-6.fields-group - = f.input :phrase, as: :string, wrapper: :with_label, hint: false - .fields-row__column.fields-row__column-6.fields-group - = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt') - -.fields-group - = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false - -%hr.spacer/ - -.fields-group - = f.input :irreversible, wrapper: :with_label - -.fields-group - = f.input :whole_word, wrapper: :with_label diff --git a/app/views/filters/_filter.html.haml b/app/views/filters/_filter.html.haml new file mode 100644 index 000000000..2ab014081 --- /dev/null +++ b/app/views/filters/_filter.html.haml @@ -0,0 +1,32 @@ +.filters-list__item{ class: [filter.expired? && 'expired'] } + = link_to edit_filter_path(filter), class: 'filters-list__item__title' do + = filter.title + + - if filter.expires? + .expiration{ title: t('filters.index.expires_on', date: l(filter.expires_at)) } + - if filter.expired? + = t('invites.expired') + - else + = t('filters.index.expires_in', distance: distance_of_time_in_words_to_now(filter.expires_at)) + + .filters-list__item__permissions + %ul.permissions-list + - unless filter.keywords.empty? + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('paragraph') + .permissions-list__item__text + .permissions-list__item__text__title + = t('filters.index.keywords', count: filter.keywords.size) + .permissions-list__item__text__type + - keywords = filter.keywords.map(&:keyword) + - keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO + = keywords.join(', ') + + .announcements-list__item__action-bar + .announcements-list__item__meta + = t('filters.index.contexts', contexts: filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ')) + + %div + = table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter) + = table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/filters/_filter_fields.html.haml b/app/views/filters/_filter_fields.html.haml new file mode 100644 index 000000000..1a52faa7a --- /dev/null +++ b/app/views/filters/_filter_fields.html.haml @@ -0,0 +1,33 @@ +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :title, as: :string, wrapper: :with_label, hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt') + +.fields-group + = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false + +%hr.spacer/ + +.fields-group + = f.input :filter_action, as: :radio_buttons, collection: %i(warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true + +%hr.spacer/ + +%h4= t('filters.edit.keywords') + +.table-wrapper + %table.table.keywords-table + %thead + %tr + %th= t('simple_form.labels.defaults.phrase') + %th= t('simple_form.labels.defaults.whole_word') + %th + %tbody + = f.simple_fields_for :keywords do |keyword| + = render 'keyword_fields', f: keyword + %tfoot + %tr + %td{ colspan: 3} + = link_to_add_association f, :keywords, class: 'table-action-link', partial: 'keyword_fields', 'data-association-insertion-node': '.keywords-table tbody', 'data-association-insertion-method': 'append' do + = safe_join([fa_icon('plus'), t('filters.edit.add_keyword')]) diff --git a/app/views/filters/_keyword_fields.html.haml b/app/views/filters/_keyword_fields.html.haml new file mode 100644 index 000000000..eedd514ef --- /dev/null +++ b/app/views/filters/_keyword_fields.html.haml @@ -0,0 +1,8 @@ +%tr.nested-fields + %td= f.input :keyword, as: :string + %td + .label_input__wrapper= f.input_field :whole_word + %td + = f.hidden_field :id if f.object&.persisted? # Required so Rails doesn't put the field outside of the <tr/> + = link_to_remove_association(f, class: 'table-action-link') do + = safe_join([fa_icon('times'), t('filters.index.delete')]) diff --git a/app/views/filters/edit.html.haml b/app/views/filters/edit.html.haml index e971215ac..3dc3f07b7 100644 --- a/app/views/filters/edit.html.haml +++ b/app/views/filters/edit.html.haml @@ -2,7 +2,7 @@ = t('filters.edit.title') = simple_form_for @filter, url: filter_path(@filter), method: :put do |f| - = render 'fields', f: f + = render 'filter_fields', f: f .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/filters/index.html.haml b/app/views/filters/index.html.haml index b4d5333aa..0227526a4 100644 --- a/app/views/filters/index.html.haml +++ b/app/views/filters/index.html.haml @@ -7,18 +7,5 @@ - if @filters.empty? %div.muted-hint.center-text= t 'filters.index.empty' - else - .table-wrapper - %table.table - %thead - %tr - %th= t('simple_form.labels.defaults.phrase') - %th= t('simple_form.labels.defaults.context') - %th - %tbody - - @filters.each do |filter| - %tr - %td= filter.phrase - %td= filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ') - %td - = table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter) - = table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete + .applications-list + = render partial: 'filter', collection: @filters diff --git a/app/views/filters/new.html.haml b/app/views/filters/new.html.haml index 05bec343f..5f400e604 100644 --- a/app/views/filters/new.html.haml +++ b/app/views/filters/new.html.haml @@ -2,7 +2,7 @@ = t('filters.new.title') = simple_form_for @filter, url: filters_path do |f| - = render 'fields', f: f + = render 'filter_fields', f: f .actions - = f.button :button, t('filters.new.title'), type: :submit + = f.button :button, t('filters.new.save'), type: :submit |