Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master

This commit is contained in:
Jenkins 2018-04-11 00:50:09 +00:00
commit 33c2bbdda7
40 changed files with 488 additions and 110 deletions

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
module Admin
class ChangeEmailsController < BaseController
before_action :set_account
before_action :require_local_account!
def show
authorize @user, :change_email?
end
def update
authorize @user, :change_email?
new_email = resource_params.fetch(:unconfirmed_email)
if new_email != @user.email
@user.update!(
unconfirmed_email: new_email,
# Regenerate the confirmation token:
confirmation_token: nil
)
log_action :change_email, @user
@user.send_confirmation_instructions
end
redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.change_email.changed_msg')
end
private
def set_account
@account = Account.find(params[:account_id])
@user = @account.user
end
def require_local_account!
redirect_to admin_account_path(@account.id) unless @account.local? && @account.user.present?
end
def resource_params
params.require(:user).permit(
:unconfirmed_email
)
end
end
end

View File

@ -8,19 +8,26 @@ module Admin
authorize ReportNote, :create? authorize ReportNote, :create?
@report_note = current_account.report_notes.new(resource_params) @report_note = current_account.report_notes.new(resource_params)
@report = @report_note.report
if @report_note.save if @report_note.save
if params[:create_and_resolve] if params[:create_and_resolve]
@report_note.report.update!(action_taken: true, action_taken_by_account_id: current_account.id) @report.resolve!(current_account)
log_action :resolve, @report_note.report log_action :resolve, @report
redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg') redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
else return
redirect_to admin_report_path(@report_note.report_id), notice: I18n.t('admin.report_notes.created_msg')
end end
if params[:create_and_unresolve]
@report.unresolve!
log_action :reopen, @report
end
redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg')
else else
@report = @report_note.report
@report_notes = @report.notes.latest @report_notes = @report.notes.latest
@report_history = @report.history
@form = Form::StatusBatch.new @form = Form::StatusBatch.new
render template: 'admin/reports/show' render template: 'admin/reports/show'

View File

@ -13,6 +13,7 @@ module Admin
authorize @report, :show? authorize @report, :show?
@report_note = @report.notes.new @report_note = @report.notes.new
@report_notes = @report.notes.latest @report_notes = @report.notes.latest
@report_history = @report.history
@form = Form::StatusBatch.new @form = Form::StatusBatch.new
end end
@ -38,36 +39,33 @@ module Admin
@report.update!(assigned_account_id: nil) @report.update!(assigned_account_id: nil)
log_action :unassigned, @report log_action :unassigned, @report
when 'reopen' when 'reopen'
@report.update!(action_taken: false, action_taken_by_account_id: nil) @report.unresolve!
log_action :reopen, @report log_action :reopen, @report
when 'resolve' when 'resolve'
@report.update!(action_taken_by_current_attributes) @report.resolve!(current_account)
log_action :resolve, @report log_action :resolve, @report
when 'suspend' when 'suspend'
Admin::SuspensionWorker.perform_async(@report.target_account.id) Admin::SuspensionWorker.perform_async(@report.target_account.id)
log_action :resolve, @report log_action :resolve, @report
log_action :suspend, @report.target_account log_action :suspend, @report.target_account
resolve_all_target_account_reports resolve_all_target_account_reports
@report.reload
when 'silence' when 'silence'
@report.target_account.update!(silenced: true) @report.target_account.update!(silenced: true)
log_action :resolve, @report log_action :resolve, @report
log_action :silence, @report.target_account log_action :silence, @report.target_account
resolve_all_target_account_reports resolve_all_target_account_reports
@report.reload
else else
raise ActiveRecord::RecordNotFound raise ActiveRecord::RecordNotFound
end end
end @report.reload
def action_taken_by_current_attributes
{ action_taken: true, action_taken_by_account_id: current_account.id }
end end
def resolve_all_target_account_reports def resolve_all_target_account_reports
unresolved_reports_for_target_account.update_all( unresolved_reports_for_target_account.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
action_taken_by_current_attributes
)
end end
def unresolved_reports_for_target_account def unresolved_reports_for_target_account

View File

@ -45,6 +45,8 @@ module Admin::ActionLogsHelper
log.recorded_changes.slice('domain', 'visible_in_picker') log.recorded_changes.slice('domain', 'visible_in_picker')
elsif log.target_type == 'User' && [:promote, :demote].include?(log.action) elsif log.target_type == 'User' && [:promote, :demote].include?(log.action)
log.recorded_changes.slice('moderator', 'admin') log.recorded_changes.slice('moderator', 'admin')
elsif log.target_type == 'User' && [:change_email].include?(log.action)
log.recorded_changes.slice('email', 'unconfirmed_email')
elsif log.target_type == 'DomainBlock' elsif log.target_type == 'DomainBlock'
log.recorded_changes.slice('severity', 'reject_media') log.recorded_changes.slice('severity', 'reject_media')
elsif log.target_type == 'Status' && log.action == :update elsif log.target_type == 'Status' && log.action == :update
@ -84,7 +86,7 @@ module Admin::ActionLogsHelper
'positive' 'positive'
when :create when :create
opposite_verbs?(log) ? 'negative' : 'positive' opposite_verbs?(log) ? 'negative' : 'positive'
when :update, :reset_password, :disable_2fa, :memorialize when :update, :reset_password, :disable_2fa, :memorialize, :change_email
'neutral' 'neutral'
when :demote, :silence, :disable, :suspend, :remove_avatar, :reopen when :demote, :silence, :disable, :suspend, :remove_avatar, :reopen
'negative' 'negative'

View File

@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
});
@injectIntl
export default class LoadGap extends React.PureComponent {
static propTypes = {
disabled: PropTypes.bool,
maxId: PropTypes.string,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleClick = () => {
this.props.onClick(this.props.maxId);
}
render () {
const { disabled, intl } = this.props;
return (
<button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
<i className='fa fa-ellipsis-h' />
</button>
);
}
}

View File

@ -31,6 +31,8 @@ export default class Status extends ImmutablePureComponent {
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func, onReblog: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
onPin: PropTypes.func, onPin: PropTypes.func,
onOpenMedia: PropTypes.func, onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func, onOpenVideo: PropTypes.func,

View File

@ -9,6 +9,7 @@ import { me } from '../initial_state';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' },
@ -41,6 +42,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func, onReblog: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func, onMention: PropTypes.func,
onMute: PropTypes.func, onMute: PropTypes.func,
onBlock: PropTypes.func, onBlock: PropTypes.func,
@ -92,6 +94,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
this.props.onMention(this.props.status.get('account'), this.context.router.history); this.props.onMention(this.props.status.get('account'), this.context.router.history);
} }
handleDirectClick = () => {
this.props.onDirect(this.props.status.get('account'), this.context.router.history);
}
handleMuteClick = () => { handleMuteClick = () => {
this.props.onMute(this.props.status.get('account')); this.props.onMute(this.props.status.get('account'));
} }
@ -149,6 +155,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else { } else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });

View File

@ -4,28 +4,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusContainer from '../containers/status_container'; import StatusContainer from '../containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import LoadMore from './load_more'; import LoadGap from './load_gap';
import ScrollableList from './scrollable_list'; import ScrollableList from './scrollable_list';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
class LoadGap extends ImmutablePureComponent {
static propTypes = {
disabled: PropTypes.bool,
maxId: PropTypes.string,
onClick: PropTypes.func.isRequired,
};
handleClick = () => {
this.props.onClick(this.props.maxId);
}
render () {
return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />;
}
}
export default class StatusList extends ImmutablePureComponent { export default class StatusList extends ImmutablePureComponent {
static propTypes = { static propTypes = {

View File

@ -5,6 +5,7 @@ import { makeGetStatus } from '../selectors';
import { import {
replyCompose, replyCompose,
mentionCompose, mentionCompose,
directCompose,
} from '../actions/compose'; } from '../actions/compose';
import { import {
reblog, reblog,
@ -102,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onDirect (account, router) {
dispatch(directCompose(account, router));
},
onMention (account, router) { onMention (account, router) {
dispatch(mentionCompose(account, router)); dispatch(mentionCompose(account, router));
}, },

View File

@ -13,7 +13,7 @@ import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import LoadMore from '../../components/load_more'; import LoadGap from '../../components/load_gap';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' }, title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@ -24,24 +24,6 @@ const getNotifications = createSelector([
state => state.getIn(['notifications', 'items']), state => state.getIn(['notifications', 'items']),
], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')))); ], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))));
class LoadGap extends React.PureComponent {
static propTypes = {
disabled: PropTypes.bool,
maxId: PropTypes.string,
onClick: PropTypes.func.isRequired,
};
handleClick = () => {
this.props.onClick(this.props.maxId);
}
render () {
return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />;
}
}
const mapStateToProps = state => ({ const mapStateToProps = state => ({
notifications: getNotifications(state), notifications: getNotifications(state),
isLoading: state.getIn(['notifications', 'isLoading'], true), isLoading: state.getIn(['notifications', 'isLoading'], true),

View File

@ -8,6 +8,7 @@ import { me } from '../../../initial_state';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
@ -37,6 +38,7 @@ export default class ActionBar extends React.PureComponent {
onReblog: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,
onMute: PropTypes.func, onMute: PropTypes.func,
onMuteConversation: PropTypes.func, onMuteConversation: PropTypes.func,
@ -63,6 +65,10 @@ export default class ActionBar extends React.PureComponent {
this.props.onDelete(this.props.status); this.props.onDelete(this.props.status);
} }
handleDirectClick = () => {
this.props.onDirect(this.props.status.get('account'), this.context.router.history);
}
handleMentionClick = () => { handleMentionClick = () => {
this.props.onMention(this.props.status.get('account'), this.context.router.history); this.props.onMention(this.props.status.get('account'), this.context.router.history);
} }
@ -108,6 +114,7 @@ export default class ActionBar extends React.PureComponent {
if (publicStatus) { if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
menu.push(null);
} }
if (me === status.getIn(['account', 'id'])) { if (me === status.getIn(['account', 'id'])) {
@ -121,6 +128,7 @@ export default class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else { } else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });

View File

@ -19,6 +19,7 @@ import {
import { import {
replyCompose, replyCompose,
mentionCompose, mentionCompose,
directCompose,
} from '../../actions/compose'; } from '../../actions/compose';
import { blockAccount } from '../../actions/accounts'; import { blockAccount } from '../../actions/accounts';
import { import {
@ -148,6 +149,10 @@ export default class Status extends ImmutablePureComponent {
} }
} }
handleDirectClick = (account, router) => {
this.props.dispatch(directCompose(account, router));
}
handleMentionClick = (account, router) => { handleMentionClick = (account, router) => {
this.props.dispatch(mentionCompose(account, router)); this.props.dispatch(mentionCompose(account, router));
} }
@ -379,6 +384,7 @@ export default class Status extends ImmutablePureComponent {
onFavourite={this.handleFavouriteClick} onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick} onReblog={this.handleReblogClick}
onDelete={this.handleDeleteClick} onDelete={this.handleDeleteClick}
onDirect={this.handleDirectClick}
onMention={this.handleMentionClick} onMention={this.handleMentionClick}
onMute={this.handleMuteClick} onMute={this.handleMuteClick}
onMuteConversation={this.handleConversationMuteClick} onMuteConversation={this.handleConversationMuteClick}

View File

@ -197,6 +197,10 @@
"defaultMessage": "Delete", "defaultMessage": "Delete",
"id": "status.delete" "id": "status.delete"
}, },
{
"defaultMessage": "Direct message @{name}",
"id": "status.direct"
},
{ {
"defaultMessage": "Mention @{name}", "defaultMessage": "Mention @{name}",
"id": "status.mention" "id": "status.mention"
@ -1370,6 +1374,10 @@
"defaultMessage": "Delete", "defaultMessage": "Delete",
"id": "status.delete" "id": "status.delete"
}, },
{
"defaultMessage": "Direct message @{name}",
"id": "status.direct"
},
{ {
"defaultMessage": "Mention @{name}", "defaultMessage": "Mention @{name}",
"id": "status.mention" "id": "status.mention"

View File

@ -247,6 +247,7 @@
"status.block": "Block @{name}", "status.block": "Block @{name}",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Delete", "status.delete": "Delete",
"status.direct": "Direct message @{name}",
"status.embed": "Embed", "status.embed": "Embed",
"status.favourite": "Favourite", "status.favourite": "Favourite",
"status.load_more": "Load more", "status.load_more": "Load more",

View File

@ -276,6 +276,7 @@
"tabs_bar.home": "Strona główna", "tabs_bar.home": "Strona główna",
"tabs_bar.local_timeline": "Lokalne", "tabs_bar.local_timeline": "Lokalne",
"tabs_bar.notifications": "Powiadomienia", "tabs_bar.notifications": "Powiadomienia",
"tabs_bar.search": "Szukaj",
"ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.", "ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.",
"upload_area.title": "Przeciągnij i upuść aby wysłać", "upload_area.title": "Przeciągnij i upuść aby wysłać",
"upload_button.label": "Dodaj zawartość multimedialną", "upload_button.label": "Dodaj zawartość multimedialną",

View File

@ -259,16 +259,18 @@ export default function compose(state = initialState, action) {
case COMPOSE_UPLOAD_PROGRESS: case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100)); return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION: case COMPOSE_MENTION:
return state return state.withMutations(map => {
.update('text', text => `${text}@${action.account.get('acct')} `) map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
.set('focusDate', new Date()) map.set('focusDate', new Date());
.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
});
case COMPOSE_DIRECT: case COMPOSE_DIRECT:
return state return state.withMutations(map => {
.update('text', text => `@${action.account.get('acct')} `) map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
.set('privacy', 'direct') map.set('privacy', 'direct');
.set('focusDate', new Date()) map.set('focusDate', new Date());
.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
});
case COMPOSE_SUGGESTIONS_CLEAR: case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null); return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
case COMPOSE_SUGGESTIONS_READY: case COMPOSE_SUGGESTIONS_READY:

View File

@ -145,6 +145,11 @@
border: 0; border: 0;
background: transparent; background: transparent;
border-bottom: 1px solid $ui-base-color; border-bottom: 1px solid $ui-base-color;
&.section-break {
margin: 30px 0;
border-bottom: 2px solid $ui-base-lighter-color;
}
} }
.muted-hint { .muted-hint {
@ -330,6 +335,36 @@
} }
} }
.report-note__comment {
margin-bottom: 20px;
}
.report-note__form {
margin-bottom: 20px;
.report-note__textarea {
box-sizing: border-box;
border: 0;
padding: 7px 4px;
margin-bottom: 10px;
font-size: 16px;
color: $ui-base-color;
display: block;
width: 100%;
outline: 0;
font-family: inherit;
resize: vertical;
}
.report-note__buttons {
text-align: right;
}
.report-note__button {
margin: 0 0 5px 5px;
}
}
.batch-form-box { .batch-form-box {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -2455,6 +2455,10 @@ a.status-card {
} }
} }
.load-gap {
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
.regeneration-indicator { .regeneration-indicator {
text-align: center; text-align: center;
font-size: 16px; font-size: 16px;

View File

@ -126,6 +126,7 @@ class Account < ApplicationRecord
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
delegate :email, delegate :email,
:unconfirmed_email,
:current_sign_in_ip, :current_sign_in_ip,
:current_sign_in_at, :current_sign_in_at,
:confirmed?, :confirmed?,

View File

@ -35,6 +35,11 @@ class Admin::ActionLog < ApplicationRecord
self.recorded_changes = target.attributes self.recorded_changes = target.attributes
when :update, :promote, :demote when :update, :promote, :demote
self.recorded_changes = target.previous_changes self.recorded_changes = target.previous_changes
when :change_email
self.recorded_changes = ActiveSupport::HashWithIndifferentAccess.new(
email: [target.email, nil],
unconfirmed_email: [nil, target.unconfirmed_email]
)
end end
end end
end end

View File

@ -15,16 +15,12 @@ module StatusThreadingConcern
def ancestor_ids def ancestor_ids
Rails.cache.fetch("ancestors:#{id}") do Rails.cache.fetch("ancestors:#{id}") do
ancestors_without_self.pluck(:id) ancestor_statuses.pluck(:id)
end end
end end
def ancestors_without_self
ancestor_statuses - [self]
end
def ancestor_statuses def ancestor_statuses
Status.find_by_sql([<<-SQL.squish, id: id]) Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id])
WITH RECURSIVE search_tree(id, in_reply_to_id, path) WITH RECURSIVE search_tree(id, in_reply_to_id, path)
AS ( AS (
SELECT id, in_reply_to_id, ARRAY[id] SELECT id, in_reply_to_id, ARRAY[id]
@ -43,11 +39,7 @@ module StatusThreadingConcern
end end
def descendant_ids def descendant_ids
descendants_without_self.pluck(:id) descendant_statuses.pluck(:id)
end
def descendants_without_self
descendant_statuses - [self]
end end
def descendant_statuses def descendant_statuses
@ -56,7 +48,7 @@ module StatusThreadingConcern
AS ( AS (
SELECT id, ARRAY[id] SELECT id, ARRAY[id]
FROM statuses FROM statuses
WHERE id = :id WHERE in_reply_to_id = :id
UNION ALL UNION ALL
SELECT statuses.id, path || statuses.id SELECT statuses.id, path || statuses.id
FROM search_tree FROM search_tree

View File

@ -58,5 +58,9 @@ class CustomEmoji < ApplicationRecord
where(shortcode: shortcodes, domain: domain, disabled: false) where(shortcode: shortcodes, domain: domain, disabled: false)
end end
def search(shortcode)
where('"custom_emojis"."shortcode" ILIKE ?', "%#{shortcode}%")
end
end end
end end

View File

@ -28,7 +28,7 @@ class CustomEmojiFilter
when 'by_domain' when 'by_domain'
CustomEmoji.where(domain: value) CustomEmoji.where(domain: value)
when 'shortcode' when 'shortcode'
CustomEmoji.where(shortcode: value) CustomEmoji.search(value)
else else
raise "Unknown filter: #{key}" raise "Unknown filter: #{key}"
end end

View File

@ -150,8 +150,9 @@ class MediaAttachment < ApplicationRecord
'pix_fmt' => 'yuv420p', 'pix_fmt' => 'yuv420p',
'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'', 'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
'vsync' => 'cfr', 'vsync' => 'cfr',
'b:v' => '1300K', 'c:v' => 'h264',
'maxrate' => '500K', 'b:v' => '500K',
'maxrate' => '1300K',
'bufsize' => '1300K', 'bufsize' => '1300K',
'crf' => 18, 'crf' => 18,
}, },

View File

@ -39,4 +39,50 @@ class Report < ApplicationRecord
def media_attachments def media_attachments
MediaAttachment.where(status_id: status_ids) MediaAttachment.where(status_id: status_ids)
end end
def assign_to_self!(current_account)
update!(assigned_account_id: current_account.id)
end
def unassign!
update!(assigned_account_id: nil)
end
def resolve!(acting_account)
update!(action_taken: true, action_taken_by_account_id: acting_account.id)
end
def unresolve!
update!(action_taken: false, action_taken_by_account_id: nil)
end
def unresolved?
!action_taken?
end
def history
time_range = created_at..updated_at
sql = [
Admin::ActionLog.where(
target_type: 'Report',
target_id: id,
created_at: time_range
).unscope(:order),
Admin::ActionLog.where(
target_type: 'Account',
target_id: target_account_id,
created_at: time_range
).unscope(:order),
Admin::ActionLog.where(
target_type: 'Status',
target_id: status_ids,
created_at: time_range
).unscope(:order),
].map { |query| "(#{query.to_sql})" }.join(' UNION ALL ')
Admin::ActionLog.from("(#{sql}) AS admin_action_logs")
end
end end

View File

@ -13,7 +13,7 @@
class ReportNote < ApplicationRecord class ReportNote < ApplicationRecord
belongs_to :account belongs_to :account
belongs_to :report, inverse_of: :notes belongs_to :report, inverse_of: :notes, touch: true
scope :latest, -> { reorder('created_at ASC') } scope :latest, -> { reorder('created_at ASC') }

View File

@ -5,6 +5,10 @@ class UserPolicy < ApplicationPolicy
staff? && !record.staff? staff? && !record.staff?
end end
def change_email?
staff? && !record.staff?
end
def disable_2fa? def disable_2fa?
admin? && !record.staff? admin? && !record.staff?
end end

View File

@ -28,7 +28,7 @@ class PostStatusService < BaseService
status = account.statuses.create!(text: text, status = account.statuses.create!(text: text,
media_attachments: media || [], media_attachments: media || [],
thread: in_reply_to, thread: in_reply_to,
sensitive: options[:sensitive], sensitive: (options[:sensitive].nil? ? account.user&.setting_default_sensitive : options[:sensitive]),
spoiler_text: options[:spoiler_text] || '', spoiler_text: options[:spoiler_text] || '',
visibility: options[:visibility] || account.user&.setting_default_privacy, visibility: options[:visibility] || account.user&.setting_default_privacy,
language: LanguageDetector.instance.detect(text, account), language: LanguageDetector.instance.detect(text, account),

View File

@ -36,9 +36,13 @@
%th= t('admin.accounts.email') %th= t('admin.accounts.email')
%td %td
= @account.user_email = @account.user_email
- if @account.user_confirmed? - if @account.user_confirmed?
= fa_icon('check') = fa_icon('check')
= table_link_to 'edit', t('admin.accounts.change_email.label'), admin_account_change_email_path(@account.id) if can?(:change_email, @account.user)
- if @account.user_unconfirmed_email.present?
%th= t('admin.accounts.unconfirmed_email')
%td
= @account.user_unconfirmed_email
%tr %tr
%th= t('admin.accounts.login_status') %th= t('admin.accounts.login_status')
%td %td

View File

@ -0,0 +1,7 @@
- content_for :page_title do
= t('admin.accounts.change_email.title', username: @account.acct)
= simple_form_for @user, url: admin_account_change_email_path(@account.id) do |f|
= f.input :email, wrapper: :with_label, disabled: true, label: t('admin.accounts.change_email.current_email')
= f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email')
= f.button :submit, class: "button", value: t('admin.accounts.change_email.submit')

View File

@ -1,11 +1,9 @@
%tr %li
%td %h4
%p = report_note.account.acct
%strong= report_note.account.acct %div{ style: 'float: right' }
on
%time.formatted{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) } %time.formatted{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) }
= l report_note.created_at = l report_note.created_at
= table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete if can?(:destroy, report_note) = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete if can?(:destroy, report_note)
%br/ %div{ class: 'report-note__comment' }
%br/
= simple_format(h(report_note.content)) = simple_format(h(report_note.content))

View File

@ -2,7 +2,7 @@
= t('admin.reports.report', id: @report.id) = t('admin.reports.report', id: @report.id)
%div{ style: 'overflow: hidden; margin-bottom: 20px' } %div{ style: 'overflow: hidden; margin-bottom: 20px' }
- if !@report.action_taken? - if @report.unresolved?
%div{ style: 'float: right' } %div{ style: 'float: right' }
= link_to t('admin.reports.silence_account'), admin_report_path(@report, outcome: 'silence'), method: :put, class: 'button' = link_to t('admin.reports.silence_account'), admin_report_path(@report, outcome: 'silence'), method: :put, class: 'button'
= link_to t('admin.reports.suspend_account'), admin_report_path(@report, outcome: 'suspend'), method: :put, class: 'button' = link_to t('admin.reports.suspend_account'), admin_report_path(@report, outcome: 'suspend'), method: :put, class: 'button'
@ -14,22 +14,29 @@
.table-wrapper .table-wrapper
%table.table.inline-table %table.table.inline-table
%tbody %tbody
%tr
%th= t('admin.reports.created_at')
%td{colspan: 2}
%time.formatted{ datetime: @report.created_at.iso8601 }
%tr %tr
%th= t('admin.reports.updated_at') %th= t('admin.reports.updated_at')
%td{colspan: 2} %td{colspan: 2}
%time.formatted{ datetime: @report.updated_at.iso8601 } %time.formatted{ datetime: @report.updated_at.iso8601 }
%tr %tr
%th= t('admin.reports.status') %th= t('admin.reports.status')
%td{colspan: 2} %td
- if @report.action_taken? - if @report.action_taken?
= t('admin.reports.resolved') = t('admin.reports.resolved')
= table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put
- else - else
= t('admin.reports.unresolved') = t('admin.reports.unresolved')
%td{style: "text-align: right; overflow: hidden;"}
- if @report.action_taken?
= table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put
- if !@report.action_taken_by_account.nil? - if !@report.action_taken_by_account.nil?
%tr %tr
%th= t('admin.reports.action_taken_by') %th= t('admin.reports.action_taken_by')
%td= @report.action_taken_by_account.acct %td{colspan: 2}
= @report.action_taken_by_account.acct
- else - else
%tr %tr
%th= t('admin.reports.assigned') %th= t('admin.reports.assigned')
@ -44,6 +51,8 @@
- if !@report.assigned_account.nil? - if !@report.assigned_account.nil?
= table_link_to 'trash', t('admin.reports.unassign'), admin_report_path(@report, outcome: 'unassign'), method: :put = table_link_to 'trash', t('admin.reports.unassign'), admin_report_path(@report, outcome: 'unassign'), method: :put
%hr{ class: "section-break"}/
.report-accounts .report-accounts
.report-accounts__item .report-accounts__item
%h3= t('admin.reports.reported_account') %h3= t('admin.reports.reported_account')
@ -85,22 +94,28 @@
= link_to admin_report_reported_status_path(@report, status), method: :delete, class: 'icon-button trash-button', title: t('admin.reports.delete'), data: { confirm: t('admin.reports.are_you_sure') }, remote: true do = link_to admin_report_reported_status_path(@report, status), method: :delete, class: 'icon-button trash-button', title: t('admin.reports.delete'), data: { confirm: t('admin.reports.are_you_sure') }, remote: true do
= fa_icon 'trash' = fa_icon 'trash'
%hr/ %hr{ class: "section-break"}/
%h3= t('admin.reports.notes.label') %h3= t('admin.reports.notes.label')
- if @report_notes.length > 0 - if @report_notes.length > 0
.table-wrapper %ul
%table.table
%thead
%tr
%th
%tbody
= render @report_notes = render @report_notes
= simple_form_for @report_note, url: admin_report_notes_path do |f| %h4= t('admin.reports.notes.new_label')
= form_for @report_note, url: admin_report_notes_path, html: { class: 'report-note__form' } do |f|
= render 'shared/error_messages', object: @report_note = render 'shared/error_messages', object: @report_note
= f.input :content = f.text_area :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6, class: 'report-note__textarea'
= f.hidden_field :report_id = f.hidden_field :report_id
= f.button :button, t('admin.reports.notes.create'), type: :submit %div{ class: 'report-note__buttons' }
= f.button :button, t('admin.reports.notes.create_and_resolve'), type: :submit, name: :create_and_resolve - if @report.unresolved?
= f.submit t('admin.reports.notes.create_and_resolve'), name: :create_and_resolve, class: 'button report-note__button'
- else
= f.submit t('admin.reports.notes.create_and_unresolve'), name: :create_and_unresolve, class: 'button report-note__button'
= f.submit t('admin.reports.notes.create'), class: 'button report-note__button'
- if @report_history.length > 0
%h3= t('admin.reports.history')
%ul
= render @report_history

View File

@ -0,0 +1,4 @@
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, req|
next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
end

View File

@ -4,7 +4,7 @@ namespace = ENV.fetch('REDIS_NAMESPACE') { nil }
redis_params = { url: ENV['REDIS_URL'] } redis_params = { url: ENV['REDIS_URL'] }
if namespace if namespace
redis_params [:namespace] = namespace redis_params[:namespace] = namespace
end end
Sidekiq.configure_server do |config| Sidekiq.configure_server do |config|
@ -18,3 +18,5 @@ end
Sidekiq.configure_client do |config| Sidekiq.configure_client do |config|
config.redis = redis_params config.redis = redis_params
end end
Sidekiq::Logging.logger.level = ::Logger::const_get(ENV.fetch('RAILS_LOG_LEVEL', 'info').upcase.to_s)

View File

@ -63,6 +63,13 @@ en:
are_you_sure: Are you sure? are_you_sure: Are you sure?
avatar: Avatar avatar: Avatar
by_domain: Domain by_domain: Domain
change_email:
changed_msg: Account email successfully changed!
current_email: Current Email
label: Change Email
new_email: New Email
submit: Change Email
title: Change Email for %{username}
confirm: Confirm confirm: Confirm
confirmed: Confirmed confirmed: Confirmed
demote: Demote demote: Demote
@ -131,6 +138,7 @@ en:
statuses: Statuses statuses: Statuses
subscribe: Subscribe subscribe: Subscribe
title: Accounts title: Accounts
unconfirmed_email: Unconfirmed E-mail
undo_silenced: Undo silence undo_silenced: Undo silence
undo_suspension: Undo suspension undo_suspension: Undo suspension
unsubscribe: Unsubscribe unsubscribe: Unsubscribe
@ -139,6 +147,7 @@ en:
action_logs: action_logs:
actions: actions:
assigned_to_self_report: "%{name} assigned report %{target} to themselves" assigned_to_self_report: "%{name} assigned report %{target} to themselves"
change_email_user: "%{name} changed the e-mail address of user %{target}"
confirm_user: "%{name} confirmed e-mail address of user %{target}" confirm_user: "%{name} confirmed e-mail address of user %{target}"
create_custom_emoji: "%{name} uploaded new emoji %{target}" create_custom_emoji: "%{name} uploaded new emoji %{target}"
create_domain_block: "%{name} blocked domain %{target}" create_domain_block: "%{name} blocked domain %{target}"
@ -247,8 +256,8 @@ en:
title: Filter title: Filter
title: Invites title: Invites
report_notes: report_notes:
created_msg: Moderation note successfully created! created_msg: Report note successfully created!
destroyed_msg: Moderation note successfully destroyed! destroyed_msg: Report note successfully deleted!
reports: reports:
action_taken_by: Action taken by action_taken_by: Action taken by
are_you_sure: Are you sure? are_you_sure: Are you sure?
@ -257,15 +266,20 @@ en:
comment: comment:
label: Report Comment label: Report Comment
none: None none: None
created_at: Reported
delete: Delete delete: Delete
history: Moderation History
id: ID id: ID
mark_as_resolved: Mark as resolved mark_as_resolved: Mark as resolved
mark_as_unresolved: Mark as unresolved mark_as_unresolved: Mark as unresolved
notes: notes:
create: Add Note create: Add Note
create_and_resolve: Resolve with Note create_and_resolve: Resolve with Note
create_and_unresolve: Reopen with Note
delete: Delete delete: Delete
label: Notes label: Moderator Notes
new_label: Add Moderator Note
placeholder: Describe what actions have been taken, or any other updates to this report…
nsfw: nsfw:
'false': Unhide media attachments 'false': Unhide media attachments
'true': Hide media attachments 'true': Hide media attachments

View File

@ -711,6 +711,83 @@ pl:
reblogged: podbił reblogged: podbił
sensitive_content: Wrażliwa zawartość sensitive_content: Wrażliwa zawartość
terms: terms:
body_html: |
<h2>Polityka prywatności</h2>
<h3 id="collect">Jakie informacje zbieramy?</h3>
<ul>
<li><em>Podstawowe informacje o koncie</em>: Podczas rejestracji na tym serwerze, możesz zostać poproszony o wprowadzenie nazwy użytkownika, adresu e-mail i hasła. Możesz także wprowadzić dodatkowe informacje o profilu, takie jak nazwa wyświetlana i biografia oraz wysłać awatar i obraz nagłówka. Nazwa użytkownika, nazwa wyświetlana, biografia, awatar i obraz nagłówka są zawsze widoczne dla wszystkich.</li>
<li><em>Wpisy, śledzenie i inne publiczne informacje</em>: Lista osób które śledzisz jest widoczna publicznie, tak jak lista osób, które Cię śledzą. Jeżeli dodasz wpis, data i czas jego utworzenia i aplikacja, z której go wysłano są przechowywane. Wiadomości mogą zawierać załączniki multimedialne, takie jak zdjęcia i filmy. Publiczne i niewidoczne wpisy są dostępne publicznie. Udostępniony wpis również jest widoczny publicznie. Twoje wpisy są dostarczane obserwującym, co oznacza że jego kopie mogą zostać dostarczone i być przechowywane na innych serwerach. Kiedy usuniesz wpis, przestaje być widoczny również dla osób śledzących Cię. „Podbijanie” i dodanie do ulubionych jest zawsze publiczne.</li>
<li><em>Wpisy bezpośrednie i tylko dla śledzących</em>: Wszystkie wpisy są przechowywane i przetwarzane na serwerze. Wpisy przeznaczone tylko dla śledzących są widoczne tylko dla nich i osób wspomnianych we wpisie, a wpisy bezpośrednie tylko dla wspimnianych. W wielu przypadkach oznacza to, że ich kopie są dostarczane i przechowywane na innych serwerach. Staramy się ograniczać zasięg tych wpisów wyłącznie do właściwych odbiorców, ale inne serwery mogą tego nie robić. Ważne jest, aby sprawdzać jakich serwerów używają osoby, które Cię śledzą. Możesz aktywować opcję pozwalającą na ręczne akceptowanie i odrzucanie nowych śledzących. <em>Pamiętaj, że właściciele serwerów mogą zobaczyć te wiadomości</em>, a odbiorcy mogą wykonać zrzut ekranu, skopiować lub udostępniać ten wpis. <em>Nie udostępniaj wrażliwych danych z użyciem Mastodona.</em></li>
<li><em>Adresy IP i inne metadane</em>: Kiedy zalogujesz się, przechowujemy adres IP użyty w trakcie logowania wraz z nazwą używanej przeglądarki. Wszystkie aktywne sesje możesz zobaczyć (i wygasić) w ustawieniach. Ostatnio używany adres IP jest przechowywany przez nas do 12 miesięcy. Możemy również przechowywać adresy IP wykorzystywane przy każdym działaniu na serwerze.</li>
</ul>
<hr class="spacer" />
<h3 id="use">W jakim celu wykorzystujecie informacje?</h3>
<p>Zebrane informacje mogą zostać użyte w następujące sposoby:</p>
<ul>
<li>Aby dostarczyć podstawową funkcjonalność Mastodona. Możesz wchodzić w interakcje z zawartością tworzoną przez innych tylko gdy jesteś zalogowany. Na przykład, możesz śledzić innych, aby widzieć ich wpisy w dostosowanej osi czasu.</li>
<li>Aby wspomóc moderację społeczności, na przykład porównując Twój adres IP ze znanymi, aby rozpoznać próbę obejścia blokady i inne naruszenia.</li>
<li>Adres e-mail może zostać wykorzystany, aby wysyłać Ci informacje, powiadomienia o osobach wchodzących w interakcje z tworzoną przez Ciebie zawartością, wysyłających Ci wiadomości, odpowiadać na zgłoszenia i inne żądania lub zapytania.</li>
</ul>
<hr class="spacer" />
<h3 id="protect">W jaki sposób chronimy Twoje dane?</h3>
<p>Wykorzystujemy różne zabezpieczenia, aby zapewnić bezpieczeństwo informacji, które wprowadzasz, wysyłasz lub do których uzyskujesz dostęp. Poza tym, sesja przeglądarki oraz ruch pomiędzy aplikacją a API jest zabezpieczany z użyciem SSL, a hasło jest hashowane z użyciem silnego algorytmu. Możesz też aktywować uwierzytelnianie dwustopniowe, aby lepiej zabezpieczyć dostęp do konta.</p>
<hr class="spacer" />
<h3 id="data-retention">Jaka jest nasza polityka przechowywania danych?</h3>
<p>Staramy się:</p>
<ul>
<li>Przechowywać logi zawierające adresy IP używane przy każdym żądaniu do serwera przez nie dłużej niż 90 dni.</li>
<li>Przechowywać adresy IP przypisane do użytkowników przez nie dłużej niż 12 miesięcy.</li>
</ul>
<p>Możesz zażądać i pobrać archiwum tworzonej zawartości, wliczając Twoje wpisy, załączniki multimedialne, awatar i zdjęcie nagłówka.</p>
<p>Możesz nieodwracalnie usunąć konto w każdej chwili.</p>
<hr class="spacer"/>
<h3 id="cookies">Czy używany plików cookies?</h3>
<p>Tak. Pliki cookies są małymi plikami, które strona lub dostawca jej usługi dostarcza na dysk twardy komputera z użyciem przeglądarki internetowej (jeżeli na to pozwoli). Pliki cookies pozwalają na rozpoznanie przeglądarki i jeśli jesteś zarejestrowany przypisanie jej do konta.</p>
<p>Wykorzystujemy pliki cookies, aby przechowywać preferencję użytkowników na przyszłe wizyty.</p>
<hr class="spacer" />
<h3 id="disclose">Czy przekazujemy informacje osobom trzecim?</h3>
<p>Nie sprzedajemy, nie wymieniamy i nie przekazujemy osobom trzecim informacji pozwalających na identyfikację Ciebie. Nie dotyczy to zaufanym dostawcom pomagającym w prowadzeniu lub obsługiwaniu użytkowników, jeżeli zgadzają się, aby nie przekazywać dalej tych informacji. Możemy również udostępnić informacje, jeżeli uważany to za wymagane przez prawo, konieczne do wypełnienia polityki strony, przestrzegania naszych lub cudzych praw, własności i bezpieczeństwa.</p>
<p>Twoja publiczna zawartość może zostać pobrana przez inne serwery w sieci. Wpisy publiczne i tylko dla śledzących są dostarczane na serwery, na których znajdują się śledzący Cię, a wiadomości bezpośrednie trafiają na serwery adresatów, jeżeli są oni użytkownikami innego serwera.</p>
<p>Kiedy pozwolisz aplikacji na dostęp do Twojego konta, w zależności od nadanych jej pozwoleń, może uzyskać dostęp do publicznych informacji, listy śledzonych, Twoich list, wszystkich wpisów i ulubionych. Aplikacje nie mogą uzyskać dostępu do Twojego adresu e-mail i hasła.</p>
<hr class="spacer" />
<h3 id="coppa">Children's Online Privacy Protection Act Compliance</h3>
<p>Ta strona, produkty i usługi są przeznaczone dla osób, które ukończyły 13 lat. Jeżeli serwer znajduje się w USA, a nie ukończyłeś 13 roku życia, zgodnie z wymogami COPPA (<a href="https://pl.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act">Prawo o Ochronie Prywatności Dzieci w Internecie</a>), nie używaj tej strony.</p>
<hr class="spacer" />
<h3 id="changes">Zmiany w naszej polityce prywatności</h3>
<p>Jeżeli zdecydujemy się na zmiany w polityce prywatności, pojawią się na tej stronie.</p>
<p>Dokument jest dostępny na licencji CC-BY-SA. Ostatnio zmodyfikowano go 7 marca 2018, przetłumaczono 9 kwietnia 2018. Tłumaczenie (mimo dołożenia wszelkich starań) może nie być w pełni poprawne.</p>
<p>Bazowano na <a href="https://github.com/discourse/discourse">polityce prywatności Discourse</a>.</p>
title: Zasady korzystania i polityka prywatności %{instance} title: Zasady korzystania i polityka prywatności %{instance}
time: time:
formats: formats:

View File

@ -160,6 +160,7 @@ Rails.application.routes.draw do
post :memorialize post :memorialize
end end
resource :change_email, only: [:show, :update]
resource :reset, only: [:create] resource :reset, only: [:create]
resource :silence, only: [:create, :destroy] resource :silence, only: [:create, :destroy]
resource :suspension, only: [:create, :destroy] resource :suspension, only: [:create, :destroy]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 81 B

View File

@ -0,0 +1,47 @@
require 'rails_helper'
RSpec.describe Admin::ChangeEmailsController, type: :controller do
render_views
let(:admin) { Fabricate(:user, admin: true) }
before do
sign_in admin
end
describe "GET #show" do
it "returns http success" do
account = Fabricate(:account)
user = Fabricate(:user, account: account)
get :show, params: { account_id: account.id }
expect(response).to have_http_status(:success)
end
end
describe "GET #update" do
before do
allow(UserMailer).to receive(:confirmation_instructions).and_return(double('email', deliver_later: nil))
end
it "returns http success" do
account = Fabricate(:account)
user = Fabricate(:user, account: account)
previous_email = user.email
post :update, params: { account_id: account.id, user: { unconfirmed_email: 'test@example.com' } }
user.reload
expect(user.email).to eq previous_email
expect(user.unconfirmed_email).to eq 'test@example.com'
expect(user.confirmation_token).not_to be_nil
expect(UserMailer).to have_received(:confirmation_instructions).with(user, user.confirmation_token, { to: 'test@example.com' })
expect(response).to redirect_to(admin_account_path(account.id))
end
end
end

View File

@ -1,6 +1,30 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe CustomEmoji, type: :model do RSpec.describe CustomEmoji, type: :model do
describe '#search' do
let(:custom_emoji) { Fabricate(:custom_emoji, shortcode: shortcode) }
subject { described_class.search(search_term) }
context 'shortcode is exact' do
let(:shortcode) { 'blobpats' }
let(:search_term) { 'blobpats' }
it 'finds emoji' do
is_expected.to include(custom_emoji)
end
end
context 'shortcode is partial' do
let(:shortcode) { 'blobpats' }
let(:search_term) { 'blob' }
it 'finds emoji' do
is_expected.to include(custom_emoji)
end
end
end
describe '#local?' do describe '#local?' do
let(:custom_emoji) { Fabricate(:custom_emoji, domain: domain) } let(:custom_emoji) { Fabricate(:custom_emoji, domain: domain) }