Merge pull request #2087 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changeslolsob-rspec
commit
dbacfa28b5
|
@ -21,7 +21,7 @@ module Admin
|
|||
account_action.save!
|
||||
|
||||
if account_action.with_report?
|
||||
redirect_to admin_reports_path
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id])
|
||||
else
|
||||
redirect_to admin_account_path(@account.id)
|
||||
end
|
||||
|
|
|
@ -23,9 +23,7 @@ module Admin
|
|||
@import = Admin::Import.new(import_params)
|
||||
return render :new unless @import.validate
|
||||
|
||||
parse_import_data!(export_headers)
|
||||
|
||||
@data.take(Admin::Import::ROWS_PROCESSING_LIMIT).each do |row|
|
||||
@import.csv_rows.each do |row|
|
||||
domain = row['#domain'].strip
|
||||
next if DomainAllow.allowed?(domain)
|
||||
|
||||
|
|
|
@ -23,24 +23,30 @@ module Admin
|
|||
@import = Admin::Import.new(import_params)
|
||||
return render :new unless @import.validate
|
||||
|
||||
parse_import_data!(export_headers)
|
||||
|
||||
@global_private_comment = I18n.t('admin.export_domain_blocks.import.private_comment_template', source: @import.data_file_name, date: I18n.l(Time.now.utc))
|
||||
|
||||
@form = Form::DomainBlockBatch.new
|
||||
@domain_blocks = @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).filter_map do |row|
|
||||
@domain_blocks = @import.csv_rows.filter_map do |row|
|
||||
domain = row['#domain'].strip
|
||||
next if DomainBlock.rule_for(domain).present?
|
||||
|
||||
domain_block = DomainBlock.new(domain: domain,
|
||||
severity: row['#severity'].strip,
|
||||
reject_media: row['#reject_media'].strip,
|
||||
reject_reports: row['#reject_reports'].strip,
|
||||
severity: row.fetch('#severity', :suspend),
|
||||
reject_media: row.fetch('#reject_media', false),
|
||||
reject_reports: row.fetch('#reject_reports', false),
|
||||
private_comment: @global_private_comment,
|
||||
public_comment: row['#public_comment']&.strip,
|
||||
obfuscate: row['#obfuscate'].strip)
|
||||
public_comment: row['#public_comment'],
|
||||
obfuscate: row.fetch('#obfuscate', false))
|
||||
|
||||
domain_block if domain_block.valid?
|
||||
if domain_block.invalid?
|
||||
flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: domain_block.errors.full_messages.join(', '))
|
||||
next
|
||||
end
|
||||
|
||||
domain_block
|
||||
rescue ArgumentError => e
|
||||
flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: e.message)
|
||||
next
|
||||
end
|
||||
|
||||
@warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain)
|
||||
|
|
|
@ -3,6 +3,11 @@
|
|||
class Admin::Reports::ActionsController < Admin::BaseController
|
||||
before_action :set_report
|
||||
|
||||
def preview
|
||||
authorize @report, :show?
|
||||
@moderation_action = action_from_button
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @report, :show?
|
||||
|
||||
|
@ -13,7 +18,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||
status_ids: @report.status_ids,
|
||||
current_account: current_account,
|
||||
report_id: @report.id,
|
||||
send_email_notification: !@report.spam?
|
||||
send_email_notification: !@report.spam?,
|
||||
text: params[:text]
|
||||
)
|
||||
|
||||
status_batch_action.save!
|
||||
|
@ -23,13 +29,16 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||
report_id: @report.id,
|
||||
target_account: @report.target_account,
|
||||
current_account: current_account,
|
||||
send_email_notification: !@report.spam?
|
||||
send_email_notification: !@report.spam?,
|
||||
text: params[:text]
|
||||
)
|
||||
|
||||
account_action.save!
|
||||
else
|
||||
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
||||
end
|
||||
|
||||
redirect_to admin_reports_path
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: @report.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -47,6 +56,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||
'silence'
|
||||
elsif params[:suspend]
|
||||
'suspend'
|
||||
elsif params[:moderation_action]
|
||||
params[:moderation_action]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,6 +3,14 @@
|
|||
class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
|
||||
before_action -> { authorize_if_got_token! :'admin:read' }
|
||||
|
||||
def index
|
||||
if current_user&.can?(:manage_taxonomies)
|
||||
render json: @tags, each_serializer: REST::Admin::TagSerializer
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enabled?
|
||||
|
|
|
@ -80,6 +80,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
current_account.id,
|
||||
text: status_params[:status],
|
||||
media_ids: status_params[:media_ids],
|
||||
media_attributes: status_params[:media_attributes],
|
||||
sensitive: status_params[:sensitive],
|
||||
language: status_params[:language],
|
||||
spoiler_text: status_params[:spoiler_text],
|
||||
|
@ -131,6 +132,12 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
:scheduled_at,
|
||||
:content_type,
|
||||
media_ids: [],
|
||||
media_attributes: [
|
||||
:id,
|
||||
:thumbnail,
|
||||
:description,
|
||||
:focus,
|
||||
],
|
||||
poll: [
|
||||
:multiple,
|
||||
:hide_totals,
|
||||
|
|
|
@ -26,14 +26,4 @@ module AdminExportControllerConcern
|
|||
def import_params
|
||||
params.require(:admin_import).permit(:data)
|
||||
end
|
||||
|
||||
def import_data_path
|
||||
params[:admin_import][:data].path
|
||||
end
|
||||
|
||||
def parse_import_data!(default_headers)
|
||||
data = CSV.read(import_data_path, headers: true, encoding: 'UTF-8')
|
||||
data = CSV.read(import_data_path, headers: default_headers, encoding: 'UTF-8') unless data.headers&.first&.strip&.include?(default_headers[0])
|
||||
@data = data.reject(&:blank?)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -46,11 +46,11 @@ module SignatureVerification
|
|||
end
|
||||
|
||||
def require_account_signature!
|
||||
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
||||
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
||||
end
|
||||
|
||||
def require_actor_signature!
|
||||
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor
|
||||
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor
|
||||
end
|
||||
|
||||
def signed_request?
|
||||
|
@ -97,11 +97,11 @@ module SignatureVerification
|
|||
|
||||
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
|
||||
|
||||
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
||||
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
||||
|
||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
||||
rescue SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue HTTP::Error, OpenSSL::SSL::SSLError => e
|
||||
|
@ -118,8 +118,8 @@ module SignatureVerification
|
|||
|
||||
private
|
||||
|
||||
def fail_with!(message)
|
||||
@signature_verification_failure_reason = message
|
||||
def fail_with!(message, **options)
|
||||
@signature_verification_failure_reason = { error: message }.merge(options)
|
||||
@signed_request_actor = nil
|
||||
end
|
||||
|
||||
|
@ -209,8 +209,8 @@ module SignatureVerification
|
|||
end
|
||||
|
||||
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
||||
rescue ArgumentError
|
||||
return false
|
||||
rescue ArgumentError => e
|
||||
raise SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||
end
|
||||
|
||||
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
||||
|
|
|
@ -181,6 +181,18 @@ export function submitCompose(routerHistory) {
|
|||
|
||||
dispatch(submitComposeRequest());
|
||||
|
||||
// If we're editing a post with media attachments, those have not
|
||||
// necessarily been changed on the server. Do it now in the same
|
||||
// API call.
|
||||
let media_attributes;
|
||||
if (statusId !== null) {
|
||||
media_attributes = media.map(item => ({
|
||||
id: item.get('id'),
|
||||
description: item.get('description'),
|
||||
focus: item.get('focus'),
|
||||
}));
|
||||
}
|
||||
|
||||
api(getState).request({
|
||||
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
||||
method: statusId === null ? 'post' : 'put',
|
||||
|
@ -189,6 +201,7 @@ export function submitCompose(routerHistory) {
|
|||
content_type: getState().getIn(['compose', 'content_type']),
|
||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||
media_ids: media.map(item => item.get('id')),
|
||||
media_attributes,
|
||||
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
|
||||
spoiler_text: spoilerText,
|
||||
visibility: getState().getIn(['compose', 'privacy']),
|
||||
|
@ -415,11 +428,31 @@ export function changeUploadCompose(id, params) {
|
|||
return (dispatch, getState) => {
|
||||
dispatch(changeUploadComposeRequest());
|
||||
|
||||
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
|
||||
dispatch(changeUploadComposeSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(changeUploadComposeFail(id, error));
|
||||
});
|
||||
let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
|
||||
|
||||
// Editing already-attached media is deferred to editing the post itself.
|
||||
// For simplicity's sake, fake an API reply.
|
||||
if (media && !media.get('unattached')) {
|
||||
let { description, focus } = params;
|
||||
const data = media.toJS();
|
||||
|
||||
if (description) {
|
||||
data.description = description;
|
||||
}
|
||||
|
||||
if (focus) {
|
||||
focus = focus.split(',');
|
||||
data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
|
||||
}
|
||||
|
||||
dispatch(changeUploadComposeSuccess(data, true));
|
||||
} else {
|
||||
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
|
||||
dispatch(changeUploadComposeSuccess(response.data, false));
|
||||
}).catch(error => {
|
||||
dispatch(changeUploadComposeFail(id, error));
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -430,10 +463,11 @@ export function changeUploadComposeRequest() {
|
|||
};
|
||||
};
|
||||
|
||||
export function changeUploadComposeSuccess(media) {
|
||||
export function changeUploadComposeSuccess(media, attached) {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||
media: media,
|
||||
attached: attached,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
import api from '../api';
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||
|
@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
|
|||
error,
|
||||
});
|
||||
|
||||
export const fetchFollowedHashtags = () => (dispatch, getState) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get('/api/v1/followed_tags').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchFollowedHashtagsFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowedHashtags() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['followed_tags', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowedHashtagsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export const followHashtag = name => (dispatch, getState) => {
|
||||
dispatch(followHashtagRequest(name));
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
|
|||
<Hashtag
|
||||
key={hashtag.name}
|
||||
name={hashtag.name}
|
||||
href={`/admin/tags/${hashtag.id}`}
|
||||
href={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
|
||||
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
|
||||
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
|
||||
history={hashtag.history.reverse().map(day => day.uses)}
|
||||
|
|
|
@ -45,6 +45,7 @@ const messages = defineMessages({
|
|||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
|
@ -188,7 +189,7 @@ class Header extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
|
||||
bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
|
||||
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
|
||||
}
|
||||
|
||||
if (me !== account.get('id')) {
|
||||
|
@ -245,6 +246,7 @@ class Header extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
|
||||
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
||||
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||
|
|
|
@ -12,6 +12,7 @@ const messages = defineMessages({
|
|||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
|
@ -46,6 +47,7 @@ class ActionBar extends React.PureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
||||
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
||||
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||
|
|
|
@ -43,13 +43,13 @@ export default class Upload extends ImmutablePureComponent {
|
|||
{({ scale }) => (
|
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
||||
<div className='compose-form__upload__actions'>
|
||||
<button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
|
||||
{!!media.get('unattached') && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
|
||||
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
|
||||
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
||||
</div>
|
||||
|
||||
{(media.get('description') || '').length === 0 && !!media.get('unattached') && (
|
||||
{(media.get('description') || '').length === 0 && (
|
||||
<div className='compose-form__upload__warning'>
|
||||
<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
|
||||
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
import { debounce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Hashtag from 'flavours/glitch/components/hashtag';
|
||||
import { expandFollowedHashtags, fetchFollowedHashtags } from 'flavours/glitch/actions/tags';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
hashtags: state.getIn(['followed_tags', 'items']),
|
||||
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['followed_tags', 'next']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class FollowedTags extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hashtags: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchFollowedHashtags());
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFollowedHashtags());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{hashtags.map((hashtag) => (
|
||||
<Hashtag
|
||||
key={hashtag.get('name')}
|
||||
name={hashtag.get('name')}
|
||||
to={`/tags/${hashtag.get('name')}`}
|
||||
withGraph={false}
|
||||
// Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
|
||||
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
|
||||
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||
<React.Fragment>
|
||||
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
|
||||
|
||||
<Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
|
||||
<Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
|
||||
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
||||
|
|
|
@ -30,7 +30,7 @@ const SignInBanner = () => {
|
|||
|
||||
return (
|
||||
<div className='sign-in-banner'>
|
||||
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
|
||||
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
|
||||
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
|
||||
{signupButton}
|
||||
</div>
|
||||
|
|
|
@ -42,6 +42,7 @@ import {
|
|||
FollowRequests,
|
||||
FavouritedStatuses,
|
||||
BookmarkedStatuses,
|
||||
FollowedTags,
|
||||
ListTimeline,
|
||||
Blocks,
|
||||
DomainBlocks,
|
||||
|
@ -56,7 +57,7 @@ import {
|
|||
PrivacyPolicy,
|
||||
} from './util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state';
|
||||
import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
|
||||
import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
@ -177,7 +178,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
}
|
||||
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
|
||||
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
|
||||
} else if (showTrends) {
|
||||
} else if (showTrends && trendsAsLanding) {
|
||||
redirect = <Redirect from='/' to='/explore' exact />;
|
||||
} else {
|
||||
redirect = <Redirect from='/' to='/about' exact />;
|
||||
|
@ -230,6 +231,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
|
||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
|
||||
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
|
||||
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
||||
<WrappedRoute path='/lists' component={Lists} content={children} />
|
||||
<WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
|
||||
|
|
|
@ -98,6 +98,10 @@ export function FavouritedStatuses () {
|
|||
return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses');
|
||||
}
|
||||
|
||||
export function FollowedTags () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/followed_tags" */'flavours/glitch/features/followed_tags');
|
||||
}
|
||||
|
||||
export function BookmarkedStatuses () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses');
|
||||
}
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
* @property {boolean} timeline_preview
|
||||
* @property {string} title
|
||||
* @property {boolean} trends
|
||||
* @property {boolean} trends_as_landing_page
|
||||
* @property {boolean} unfollow_modal
|
||||
* @property {boolean} use_blurhash
|
||||
* @property {boolean=} use_pending_items
|
||||
|
@ -134,6 +135,7 @@ export const singleUserMode = getMeta('single_user_mode');
|
|||
export const source_url = getMeta('source_url');
|
||||
export const timelinePreview = getMeta('timeline_preview');
|
||||
export const title = getMeta('title');
|
||||
export const trendsAsLanding = getMeta('trends_as_landing_page');
|
||||
export const unfollowModal = getMeta('unfollow_modal');
|
||||
export const useBlurhash = getMeta('use_blurhash');
|
||||
export const usePendingItems = getMeta('use_pending_items');
|
||||
|
|
|
@ -551,7 +551,7 @@ export default function compose(state = initialState, action) {
|
|||
.setIn(['media_modal', 'dirty'], false)
|
||||
.update('media_attachments', list => list.map(item => {
|
||||
if (item.get('id') === action.media.id) {
|
||||
return fromJS(action.media).set('unattached', true);
|
||||
return fromJS(action.media).set('unattached', !action.attached);
|
||||
}
|
||||
|
||||
return item;
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
} from 'flavours/glitch/actions/tags';
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
items: ImmutableList(),
|
||||
isLoading: false,
|
||||
next: null,
|
||||
});
|
||||
|
||||
export default function followed_tags(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case FOLLOWED_HASHTAGS_FETCH_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('items', fromJS(action.followed_tags));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_FETCH_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.update('items', set => set.concat(fromJS(action.followed_tags)));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_EXPAND_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
|
@ -42,6 +42,7 @@ import picture_in_picture from './picture_in_picture';
|
|||
import accounts_map from './accounts_map';
|
||||
import history from './history';
|
||||
import tags from './tags';
|
||||
import followed_tags from './followed_tags';
|
||||
|
||||
const reducers = {
|
||||
announcements,
|
||||
|
@ -87,6 +88,7 @@ const reducers = {
|
|||
picture_in_picture,
|
||||
history,
|
||||
tags,
|
||||
followed_tags,
|
||||
};
|
||||
|
||||
export default combineReducers(reducers);
|
||||
|
|
|
@ -1588,6 +1588,15 @@ a.sparkline {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: $highlight-text-color;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
|
|
|
@ -118,7 +118,7 @@
|
|||
|
||||
&.active {
|
||||
border-color: $highlight-text-color;
|
||||
background: $highlight-text-color;
|
||||
background: $highlight-text-color url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") center center no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -160,6 +160,18 @@ export function submitCompose(routerHistory) {
|
|||
|
||||
dispatch(submitComposeRequest());
|
||||
|
||||
// If we're editing a post with media attachments, those have not
|
||||
// necessarily been changed on the server. Do it now in the same
|
||||
// API call.
|
||||
let media_attributes;
|
||||
if (statusId !== null) {
|
||||
media_attributes = media.map(item => ({
|
||||
id: item.get('id'),
|
||||
description: item.get('description'),
|
||||
focus: item.get('focus'),
|
||||
}));
|
||||
}
|
||||
|
||||
api(getState).request({
|
||||
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
||||
method: statusId === null ? 'post' : 'put',
|
||||
|
@ -167,6 +179,7 @@ export function submitCompose(routerHistory) {
|
|||
status,
|
||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||
media_ids: media.map(item => item.get('id')),
|
||||
media_attributes,
|
||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||
visibility: getState().getIn(['compose', 'privacy']),
|
||||
|
@ -377,11 +390,31 @@ export function changeUploadCompose(id, params) {
|
|||
return (dispatch, getState) => {
|
||||
dispatch(changeUploadComposeRequest());
|
||||
|
||||
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
|
||||
dispatch(changeUploadComposeSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(changeUploadComposeFail(id, error));
|
||||
});
|
||||
let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
|
||||
|
||||
// Editing already-attached media is deferred to editing the post itself.
|
||||
// For simplicity's sake, fake an API reply.
|
||||
if (media && !media.get('unattached')) {
|
||||
let { description, focus } = params;
|
||||
const data = media.toJS();
|
||||
|
||||
if (description) {
|
||||
data.description = description;
|
||||
}
|
||||
|
||||
if (focus) {
|
||||
focus = focus.split(',');
|
||||
data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
|
||||
}
|
||||
|
||||
dispatch(changeUploadComposeSuccess(data, true));
|
||||
} else {
|
||||
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
|
||||
dispatch(changeUploadComposeSuccess(response.data, false));
|
||||
}).catch(error => {
|
||||
dispatch(changeUploadComposeFail(id, error));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -392,10 +425,11 @@ export function changeUploadComposeRequest() {
|
|||
};
|
||||
}
|
||||
|
||||
export function changeUploadComposeSuccess(media) {
|
||||
export function changeUploadComposeSuccess(media, attached) {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||
media: media,
|
||||
attached: attached,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
import api from '../api';
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||
|
@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
|
|||
error,
|
||||
});
|
||||
|
||||
export const fetchFollowedHashtags = () => (dispatch, getState) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get('/api/v1/followed_tags').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchFollowedHashtagsFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowedHashtags() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['followed_tags', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowedHashtagsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export const followHashtag = name => (dispatch, getState) => {
|
||||
dispatch(followHashtagRequest(name));
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
|
|||
<Hashtag
|
||||
key={hashtag.name}
|
||||
name={hashtag.name}
|
||||
to={`/admin/tags/${hashtag.id}`}
|
||||
to={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
|
||||
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
|
||||
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
|
||||
history={hashtag.history.reverse().map(day => day.uses)}
|
||||
|
|
|
@ -46,6 +46,7 @@ const messages = defineMessages({
|
|||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
|
@ -193,7 +194,7 @@ class Header extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
|
||||
bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
|
||||
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
|
||||
}
|
||||
|
||||
if (me !== account.get('id')) {
|
||||
|
@ -242,6 +243,7 @@ class Header extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
|
||||
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
||||
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||
|
|
|
@ -11,6 +11,7 @@ const messages = defineMessages({
|
|||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
|
@ -45,6 +46,7 @@ class ActionBar extends React.PureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
||||
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
||||
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||
|
|
|
@ -43,10 +43,10 @@ export default class Upload extends ImmutablePureComponent {
|
|||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
||||
<div className='compose-form__upload__actions'>
|
||||
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
|
||||
{!!media.get('unattached') && (<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
|
||||
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
||||
</div>
|
||||
|
||||
{(media.get('description') || '').length === 0 && !!media.get('unattached') && (
|
||||
{(media.get('description') || '').length === 0 && (
|
||||
<div className='compose-form__upload__warning'>
|
||||
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
import { debounce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Hashtag from 'mastodon/components/hashtag';
|
||||
import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
hashtags: state.getIn(['followed_tags', 'items']),
|
||||
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['followed_tags', 'next']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class FollowedTags extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hashtags: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchFollowedHashtags());
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandFollowedHashtags());
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{hashtags.map((hashtag) => (
|
||||
<Hashtag
|
||||
key={hashtag.get('name')}
|
||||
name={hashtag.get('name')}
|
||||
to={`/tags/${hashtag.get('name')}`}
|
||||
withGraph={false}
|
||||
// Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
|
||||
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
|
||||
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||
<React.Fragment>
|
||||
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
|
||||
|
||||
<Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
|
||||
<Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
|
||||
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
||||
|
|
|
@ -30,7 +30,7 @@ const SignInBanner = () => {
|
|||
|
||||
return (
|
||||
<div className='sign-in-banner'>
|
||||
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
|
||||
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
|
||||
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
|
||||
{signupButton}
|
||||
</div>
|
||||
|
|
|
@ -42,6 +42,7 @@ import {
|
|||
FollowRequests,
|
||||
FavouritedStatuses,
|
||||
BookmarkedStatuses,
|
||||
FollowedTags,
|
||||
ListTimeline,
|
||||
Blocks,
|
||||
DomainBlocks,
|
||||
|
@ -54,7 +55,7 @@ import {
|
|||
About,
|
||||
PrivacyPolicy,
|
||||
} from './util/async-components';
|
||||
import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state';
|
||||
import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
|
||||
import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
||||
import Header from './components/header';
|
||||
|
||||
|
@ -163,7 +164,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
}
|
||||
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
|
||||
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
|
||||
} else if (showTrends) {
|
||||
} else if (showTrends && trendsAsLanding) {
|
||||
redirect = <Redirect from='/' to='/explore' exact />;
|
||||
} else {
|
||||
redirect = <Redirect from='/' to='/about' exact />;
|
||||
|
@ -216,6 +217,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
|
||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
|
||||
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
|
||||
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
||||
<WrappedRoute path='/lists' component={Lists} content={children} />
|
||||
|
||||
|
|
|
@ -90,6 +90,10 @@ export function FavouritedStatuses () {
|
|||
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
|
||||
}
|
||||
|
||||
export function FollowedTags () {
|
||||
return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags');
|
||||
}
|
||||
|
||||
export function BookmarkedStatuses () {
|
||||
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
|
||||
}
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
* @property {boolean} timeline_preview
|
||||
* @property {string} title
|
||||
* @property {boolean} trends
|
||||
* @property {boolean} trends_as_landing_page
|
||||
* @property {boolean} unfollow_modal
|
||||
* @property {boolean} use_blurhash
|
||||
* @property {boolean=} use_pending_items
|
||||
|
@ -126,6 +127,7 @@ export const singleUserMode = getMeta('single_user_mode');
|
|||
export const source_url = getMeta('source_url');
|
||||
export const timelinePreview = getMeta('timeline_preview');
|
||||
export const title = getMeta('title');
|
||||
export const trendsAsLanding = getMeta('trends_as_landing_page');
|
||||
export const unfollowModal = getMeta('unfollow_modal');
|
||||
export const useBlurhash = getMeta('use_blurhash');
|
||||
export const usePendingItems = getMeta('use_pending_items');
|
||||
|
|
|
@ -1391,6 +1391,10 @@
|
|||
"defaultMessage": "Lists",
|
||||
"id": "navigation_bar.lists"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Followed hashtags",
|
||||
"id": "navigation_bar.followed_tags"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Blocked users",
|
||||
"id": "navigation_bar.blocks"
|
||||
|
@ -4220,7 +4224,7 @@
|
|||
"id": "sign_in_banner.create_account"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.",
|
||||
"defaultMessage": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
|
||||
"id": "sign_in_banner.text"
|
||||
},
|
||||
{
|
||||
|
|
|
@ -383,6 +383,7 @@
|
|||
"navigation_bar.favourites": "Favourites",
|
||||
"navigation_bar.filters": "Muted words",
|
||||
"navigation_bar.follow_requests": "Follow requests",
|
||||
"navigation_bar.followed_tags": "Followed hashtags",
|
||||
"navigation_bar.follows_and_followers": "Follows and followers",
|
||||
"navigation_bar.lists": "Lists",
|
||||
"navigation_bar.misc": "Misc",
|
||||
|
@ -545,7 +546,7 @@
|
|||
"server_banner.server_stats": "Server stats:",
|
||||
"sign_in_banner.create_account": "Create account",
|
||||
"sign_in_banner.sign_in": "Sign in",
|
||||
"sign_in_banner.text": "Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.",
|
||||
"sign_in_banner.text": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
|
||||
"status.admin_account": "Open moderation interface for @{name}",
|
||||
"status.admin_domain": "Open moderation interface for {domain}",
|
||||
"status.admin_status": "Open this post in the moderation interface",
|
||||
|
|
|
@ -444,7 +444,7 @@ export default function compose(state = initialState, action) {
|
|||
.setIn(['media_modal', 'dirty'], false)
|
||||
.update('media_attachments', list => list.map(item => {
|
||||
if (item.get('id') === action.media.id) {
|
||||
return fromJS(action.media).set('unattached', true);
|
||||
return fromJS(action.media).set('unattached', !action.attached);
|
||||
}
|
||||
|
||||
return item;
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
} from 'mastodon/actions/tags';
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
items: ImmutableList(),
|
||||
isLoading: false,
|
||||
next: null,
|
||||
});
|
||||
|
||||
export default function followed_tags(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case FOLLOWED_HASHTAGS_FETCH_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('items', fromJS(action.followed_tags));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_FETCH_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.update('items', set => set.concat(fromJS(action.followed_tags)));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_EXPAND_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
|
@ -40,6 +40,7 @@ import picture_in_picture from './picture_in_picture';
|
|||
import accounts_map from './accounts_map';
|
||||
import history from './history';
|
||||
import tags from './tags';
|
||||
import followed_tags from './followed_tags';
|
||||
|
||||
const reducers = {
|
||||
announcements,
|
||||
|
@ -83,6 +84,7 @@ const reducers = {
|
|||
picture_in_picture,
|
||||
history,
|
||||
tags,
|
||||
followed_tags,
|
||||
};
|
||||
|
||||
export default combineReducers(reducers);
|
||||
|
|
|
@ -1588,6 +1588,15 @@ a.sparkline {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: $highlight-text-color;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
|
|
|
@ -423,7 +423,7 @@ body > [data-popper-placement] {
|
|||
|
||||
&.active {
|
||||
border-color: $highlight-text-color;
|
||||
background: $highlight-text-color;
|
||||
background: $highlight-text-color url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") center center no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,19 +30,24 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
|||
|
||||
def running_version
|
||||
@running_version ||= begin
|
||||
Chewy.client.info['version']['minimum_wire_compatibility_version'] ||
|
||||
Chewy.client.info['version']['number']
|
||||
Chewy.client.info['version']['number']
|
||||
rescue Faraday::ConnectionFailed
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def compatible_wire_version
|
||||
Chewy.client.info['version']['minimum_wire_compatibility_version']
|
||||
end
|
||||
|
||||
def required_version
|
||||
'7.x'
|
||||
end
|
||||
|
||||
def compatible_version?
|
||||
return false if running_version.nil?
|
||||
Gem::Version.new(running_version) >= Gem::Version.new(required_version)
|
||||
|
||||
Gem::Version.new(running_version) >= Gem::Version.new(required_version) ||
|
||||
Gem::Version.new(compatible_wire_version) >= Gem::Version.new(required_version)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -88,8 +88,8 @@ class Account < ApplicationRecord
|
|||
validates :username, presence: true
|
||||
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
|
||||
|
||||
# Remote user validations
|
||||
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { !local? && will_save_change_to_username? }
|
||||
# Remote user validations, also applies to internal actors
|
||||
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? }
|
||||
|
||||
# Local user validations
|
||||
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
|
||||
# A non-activerecord helper class for csv upload
|
||||
class Admin::Import
|
||||
include ActiveModel::Model
|
||||
|
@ -15,17 +17,46 @@ class Admin::Import
|
|||
data.original_filename
|
||||
end
|
||||
|
||||
def csv_rows
|
||||
csv_data.rewind
|
||||
|
||||
csv_data.take(ROWS_PROCESSING_LIMIT + 1)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def csv_data
|
||||
return @csv_data if defined?(@csv_data)
|
||||
|
||||
csv_converter = lambda do |field, field_info|
|
||||
case field_info.header
|
||||
when '#domain', '#public_comment'
|
||||
field&.strip
|
||||
when '#severity'
|
||||
field&.strip&.to_sym
|
||||
when '#reject_media', '#reject_reports', '#obfuscate'
|
||||
ActiveModel::Type::Boolean.new.cast(field)
|
||||
else
|
||||
field
|
||||
end
|
||||
end
|
||||
|
||||
@csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: true, converters: csv_converter)
|
||||
@csv_data.take(1) # Ensure the headers are read
|
||||
@csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: ['#domain'], converters: csv_converter) unless @csv_data.headers&.first == '#domain'
|
||||
@csv_data
|
||||
end
|
||||
|
||||
def csv_row_count
|
||||
return @csv_row_count if defined?(@csv_row_count)
|
||||
|
||||
csv_data.rewind
|
||||
@csv_row_count = csv_data.take(ROWS_PROCESSING_LIMIT + 2).count
|
||||
end
|
||||
|
||||
def validate_data
|
||||
return if data.blank?
|
||||
|
||||
csv_data = CSV.read(data.path, encoding: 'UTF-8')
|
||||
|
||||
row_count = csv_data.size
|
||||
row_count -= 1 if csv_data.first&.first == '#domain'
|
||||
|
||||
errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if row_count > ROWS_PROCESSING_LIMIT
|
||||
return if data.nil?
|
||||
errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if csv_row_count > ROWS_PROCESSING_LIMIT
|
||||
rescue CSV::MalformedCSVError => e
|
||||
errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
|
||||
end
|
||||
|
|
|
@ -6,7 +6,8 @@ class Admin::StatusBatchAction
|
|||
include Authorization
|
||||
|
||||
attr_accessor :current_account, :type,
|
||||
:status_ids, :report_id
|
||||
:status_ids, :report_id,
|
||||
:text
|
||||
|
||||
attr_reader :send_email_notification
|
||||
|
||||
|
@ -57,7 +58,8 @@ class Admin::StatusBatchAction
|
|||
action: :delete_statuses,
|
||||
account: current_account,
|
||||
report: report,
|
||||
status_ids: status_ids
|
||||
status_ids: status_ids,
|
||||
text: text
|
||||
)
|
||||
|
||||
statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
|
||||
|
@ -95,7 +97,8 @@ class Admin::StatusBatchAction
|
|||
action: :mark_statuses_as_sensitive,
|
||||
account: current_account,
|
||||
report: report,
|
||||
status_ids: status_ids
|
||||
status_ids: status_ids,
|
||||
text: text
|
||||
)
|
||||
|
||||
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
|
||||
|
|
|
@ -13,9 +13,11 @@ module AccountFinderConcern
|
|||
end
|
||||
|
||||
def representative
|
||||
Account.find(-99).tap(&:ensure_keys!)
|
||||
actor = Account.find(-99).tap(&:ensure_keys!)
|
||||
actor.update!(username: 'mastodon.internal') if actor.username.include?(':')
|
||||
actor
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
Account.create!(id: -99, actor_type: 'Application', locked: true, username: Rails.configuration.x.local_domain)
|
||||
Account.create!(id: -99, actor_type: 'Application', locked: true, username: 'mastodon.internal')
|
||||
end
|
||||
|
||||
def find_local(username)
|
||||
|
|
|
@ -28,6 +28,7 @@ class Form::AdminSettings
|
|||
show_reblogs_in_public_timelines
|
||||
show_replies_in_public_timelines
|
||||
trends
|
||||
trends_as_landing_page
|
||||
trendable_by_default
|
||||
trending_status_cw
|
||||
show_domain_blocks
|
||||
|
@ -57,6 +58,7 @@ class Form::AdminSettings
|
|||
show_reblogs_in_public_timelines
|
||||
show_replies_in_public_timelines
|
||||
trends
|
||||
trends_as_landing_page
|
||||
trendable_by_default
|
||||
trending_status_cw
|
||||
noindex
|
||||
|
|
|
@ -46,6 +46,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
activity_api_enabled: Setting.activity_api_enabled,
|
||||
single_user_mode: Rails.configuration.x.single_user_mode,
|
||||
translation_enabled: TranslationService.configured?,
|
||||
trends_as_landing_page: Setting.trends_as_landing_page,
|
||||
}
|
||||
|
||||
if object.current_account
|
||||
|
|
|
@ -16,6 +16,16 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
|||
attribute :silenced, key: :limited, if: :silenced?
|
||||
attribute :noindex, if: :local?
|
||||
|
||||
class AccountDecorator < SimpleDelegator
|
||||
def self.model_name
|
||||
Account.model_name
|
||||
end
|
||||
|
||||
def moved?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
class FieldSerializer < ActiveModel::Serializer
|
||||
include FormattingHelper
|
||||
|
||||
|
@ -89,7 +99,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
|||
end
|
||||
|
||||
def moved_to_account
|
||||
object.suspended? ? nil : object.moved_to_account
|
||||
object.suspended? ? nil : AccountDecorator.new(object.moved_to_account)
|
||||
end
|
||||
|
||||
def emojis
|
||||
|
@ -115,6 +125,6 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
|||
delegate :suspended?, :silenced?, :local?, to: :object
|
||||
|
||||
def moved_and_not_nested?
|
||||
object.moved? && object.moved_to_account.moved_to_account_id.nil?
|
||||
object.moved?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -28,6 +28,7 @@ class ActivityPub::FetchRemoteActorService < BaseService
|
|||
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?
|
||||
raise Error, "Unexpected object type for actor #{uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_type?
|
||||
raise Error, "Actor #{uri} has moved to #{@json['movedTo']}" if break_on_redirect && @json['movedTo'].present?
|
||||
raise Error, "Actor #{uri} has no 'preferredUsername', which is a requirement for Mastodon compatibility" unless @json['preferredUsername'].present?
|
||||
|
||||
@uri = @json['id']
|
||||
@username = @json['preferredUsername']
|
||||
|
|
|
@ -10,6 +10,7 @@ class UpdateStatusService < BaseService
|
|||
# @param [Integer] account_id
|
||||
# @param [Hash] options
|
||||
# @option options [Array<Integer>] :media_ids
|
||||
# @option options [Array<Hash>] :media_attributes
|
||||
# @option options [Hash] :poll
|
||||
# @option options [String] :text
|
||||
# @option options [String] :spoiler_text
|
||||
|
@ -51,10 +52,18 @@ class UpdateStatusService < BaseService
|
|||
next_media_attachments = validate_media!
|
||||
added_media_attachments = next_media_attachments - previous_media_attachments
|
||||
|
||||
(@options[:media_attributes] || []).each do |attributes|
|
||||
media = next_media_attachments.find { |attachment| attachment.id == attributes[:id].to_i }
|
||||
next if media.nil?
|
||||
|
||||
media.update!(attributes.slice(:thumbnail, :description, :focus))
|
||||
@media_attachments_changed ||= media.significantly_changed?
|
||||
end
|
||||
|
||||
MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
|
||||
|
||||
@status.ordered_media_attachment_ids = (@options[:media_ids] || []).map(&:to_i) & next_media_attachments.map(&:id)
|
||||
@media_attachments_changed = previous_media_attachments.map(&:id) != @status.ordered_media_attachment_ids
|
||||
@media_attachments_changed ||= previous_media_attachments.map(&:id) != @status.ordered_media_attachment_ids
|
||||
@status.media_attachments.reload
|
||||
end
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
- unless @announcement.published?
|
||||
.fields-group
|
||||
= f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
|
||||
= f.input :scheduled_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') }
|
||||
|
||||
.actions
|
||||
= f.button :button, t('generic.save_changes'), type: :submit
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
= l report_note.created_at.to_date
|
||||
|
||||
.report-notes__item__content
|
||||
= simple_format(h(report_note.content))
|
||||
= linkify(report_note.content)
|
||||
|
||||
- if can?(:destroy, report_note)
|
||||
.report-notes__item__actions
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
= form_tag admin_report_actions_path(@report), method: :post do
|
||||
= form_tag preview_admin_report_actions_path(@report), method: :post do
|
||||
.report-actions
|
||||
.report-actions__item
|
||||
.report-actions__item__button
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
- target_acct = @report.target_account.acct
|
||||
- warning_action = { 'delete' => 'delete_statuses', 'mark_as_sensitive' => 'mark_statuses_as_sensitive' }.fetch(@moderation_action, @moderation_action)
|
||||
|
||||
- content_for :page_title do
|
||||
= t('admin.reports.confirm_action', acct: target_acct)
|
||||
|
||||
= form_tag admin_report_actions_path(@report), class: 'simple_form', method: :post do
|
||||
= hidden_field_tag :moderation_action, @moderation_action
|
||||
|
||||
%p.hint= t("admin.reports.summary.action_preambles.#{@moderation_action}_html", acct: target_acct)
|
||||
%ul.hint
|
||||
%li.warning-hint= t("admin.reports.summary.actions.#{@moderation_action}_html", acct: target_acct)
|
||||
- if @moderation_action == 'suspend'
|
||||
%li.warning-hint= t('admin.reports.summary.delete_data_html', acct: target_acct)
|
||||
- if %w(silence suspend).include?(@moderation_action)
|
||||
%li.warning-hint= t('admin.reports.summary.close_reports_html', acct: target_acct)
|
||||
- else
|
||||
%li= t('admin.reports.summary.close_report', id: @report.id)
|
||||
%li= t('admin.reports.summary.record_strike_html', acct: target_acct)
|
||||
- if @report.target_account.local? && !@report.spam?
|
||||
%li= t('admin.reports.summary.send_email_html', acct: target_acct)
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
- if @report.target_account.local?
|
||||
%p.hint= t('admin.reports.summary.preview_preamble_html', acct: target_acct)
|
||||
|
||||
.strike-card
|
||||
- unless warning_action == 'none'
|
||||
%p= t "user_mailer.warning.explanation.#{warning_action}", instance: Rails.configuration.x.local_domain
|
||||
|
||||
.fields-group
|
||||
= text_area_tag :text, nil, placeholder: t('admin.reports.summary.warning_placeholder')
|
||||
|
||||
- if !@report.other?
|
||||
%p
|
||||
%strong= t('user_mailer.warning.reason')
|
||||
= t("user_mailer.warning.categories.#{@report.category}")
|
||||
|
||||
- if @report.violation? && @report.rule_ids.present?
|
||||
%ul.strike-card__rules
|
||||
- @report.rules.each do |rule|
|
||||
%li
|
||||
%span.strike-card__rules__text= rule.text
|
||||
|
||||
- if @report.status_ids.present? && !@report.status_ids.empty?
|
||||
%p
|
||||
%strong= t('user_mailer.warning.statuses')
|
||||
|
||||
.strike-card__statuses-list
|
||||
- status_map = @report.statuses.includes(:application, :media_attachments).index_by(&:id)
|
||||
|
||||
- @report.status_ids.each do |status_id|
|
||||
.strike-card__statuses-list__item
|
||||
- if (status = status_map[status_id.to_i])
|
||||
.one-liner
|
||||
= link_to short_account_status_url(@report.target_account, status_id), class: 'emojify' do
|
||||
= one_line_preview(status)
|
||||
|
||||
- status.ordered_media_attachments.each do |media_attachment|
|
||||
%abbr{ title: media_attachment.description }
|
||||
= fa_icon 'link'
|
||||
= media_attachment.file_file_name
|
||||
.strike-card__statuses-list__item__meta
|
||||
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
|
||||
- unless status.application.nil?
|
||||
·
|
||||
= status.application.name
|
||||
- else
|
||||
.one-liner= t('disputes.strikes.status', id: status_id)
|
||||
.strike-card__statuses-list__item__meta
|
||||
= t('disputes.strikes.status_removed')
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
.actions
|
||||
= link_to t('admin.reports.cancel'), admin_report_path(@report), class: 'button button-tertiary'
|
||||
= button_tag t('admin.reports.confirm'), name: :confirm, class: 'button', type: :submit
|
|
@ -15,6 +15,9 @@
|
|||
.fields-group
|
||||
= f.input :trends, as: :boolean, wrapper: :with_label
|
||||
|
||||
.fields-group
|
||||
= f.input :trends_as_landing_page, as: :boolean, wrapper: :with_label
|
||||
|
||||
.fields-group
|
||||
= f.input :trendable_by_default, as: :boolean, wrapper: :with_label, recommended: :not_recommended
|
||||
|
||||
|
|
|
@ -65,9 +65,11 @@ ignore_unused:
|
|||
- 'errors.429'
|
||||
- 'admin.accounts.roles.*'
|
||||
- 'admin.action_logs.actions.*'
|
||||
- 'themes.*'
|
||||
- 'admin.reports.summary.action_preambles.*'
|
||||
- 'admin.reports.summary.actions.*'
|
||||
- 'admin_mailer.new_appeal.actions.*'
|
||||
- 'statuses.attached.*'
|
||||
- 'themes.*'
|
||||
- 'move_handler.carry_{mutes,blocks}_over_text'
|
||||
- 'notification_mailer.*'
|
||||
|
||||
|
|
|
@ -441,6 +441,7 @@ en:
|
|||
private_comment_description_html: 'To help you track where imported blocks come from, imported blocks will be created with the following private comment: <q>%{comment}</q>'
|
||||
private_comment_template: Imported from %{source} on %{date}
|
||||
title: Import domain blocks
|
||||
invalid_domain_block: 'One or more domain blocks were skipped because of the following error(s): %{error}'
|
||||
new:
|
||||
title: Import domain blocks
|
||||
no_file: No file selected
|
||||
|
@ -589,6 +590,7 @@ en:
|
|||
comment:
|
||||
none: None
|
||||
comment_description_html: 'To provide more information, %{name} wrote:'
|
||||
confirm_action: Confirm moderation action against @%{acct}
|
||||
created_at: Reported
|
||||
delete_and_resolve: Delete posts
|
||||
forwarded: Forwarded
|
||||
|
@ -605,6 +607,7 @@ en:
|
|||
placeholder: Describe what actions have been taken, or any other related updates...
|
||||
title: Notes
|
||||
notes_description_html: View and leave notes to other moderators and your future self
|
||||
processed_msg: 'Report #%{id} successfully processed'
|
||||
quick_actions_description_html: 'Take a quick action or scroll down to see reported content:'
|
||||
remote_user_placeholder: the remote user from %{instance}
|
||||
reopen: Reopen report
|
||||
|
@ -617,9 +620,28 @@ en:
|
|||
status: Status
|
||||
statuses: Reported content
|
||||
statuses_description_html: Offending content will be cited in communication with the reported account
|
||||
summary:
|
||||
action_preambles:
|
||||
delete_html: 'You are about to <strong>remove</strong> some of <strong>@%{acct}</strong>''s posts. This will:'
|
||||
mark_as_sensitive_html: 'You are about to <strong>mark</strong> some of <strong>@%{acct}</strong>''s posts as <strong>sensitive</strong>. This will:'
|
||||
silence_html: 'You are about to <strong>limit</strong> <strong>@%{acct}</strong>''s account. This will:'
|
||||
suspend_html: 'You are about to <strong>suspend</strong> <strong>@%{acct}</strong>''s account. This will:'
|
||||
actions:
|
||||
delete_html: Remove the offending posts
|
||||
mark_as_sensitive_html: Mark the offending posts' media as sensitive
|
||||
silence_html: Severely limit <strong>@%{acct}</strong>'s reach by making their profile and contents only visible to people already following them or manually looking it profile up
|
||||
suspend_html: Suspend <strong>@%{acct}</strong>, making their profile and contents inaccessible and impossible to interact with
|
||||
close_report: 'Mark report #%{id} as resolved'
|
||||
close_reports_html: Mark <strong>all</strong> reports against <strong>@%{acct}</strong> as resolved
|
||||
delete_data_html: Delete <strong>@%{acct}</strong>'s profile and contents 30 days from now unless they get unsuspended in the meantime
|
||||
preview_preamble_html: "<strong>@%{acct}</strong> will receive a warning with the following contents:"
|
||||
record_strike_html: Record a strike against <strong>@%{acct}</strong> to help you escalate on future violations from this account
|
||||
send_email_html: Send <strong>@%{acct}</strong> a warning e-mail
|
||||
warning_placeholder: Optional additional reasoning for the moderation action.
|
||||
target_origin: Origin of reported account
|
||||
title: Reports
|
||||
unassign: Unassign
|
||||
unknown_action_msg: 'Unknown action: %{action}'
|
||||
unresolved: Unresolved
|
||||
updated_at: Updated
|
||||
view_profile: View profile
|
||||
|
|
|
@ -96,6 +96,7 @@ en:
|
|||
timeline_preview: Logged out visitors will be able to browse the most recent public posts available on the server.
|
||||
trendable_by_default: Skip manual review of trending content. Individual items can still be removed from trends after the fact.
|
||||
trends: Trends show which posts, hashtags and news stories are gaining traction on your server.
|
||||
trends_as_landing_page: Show trending content to logged-out users and visitors instead of a description of this server. Requires trends to be enabled.
|
||||
form_challenge:
|
||||
current_password: You are entering a secure area
|
||||
imports:
|
||||
|
@ -256,6 +257,7 @@ en:
|
|||
timeline_preview: Allow unauthenticated access to public timelines
|
||||
trendable_by_default: Allow trends without prior review
|
||||
trends: Enable trends
|
||||
trends_as_landing_page: Use trends as the landing page
|
||||
interactions:
|
||||
must_be_follower: Block notifications from non-followers
|
||||
must_be_following: Block notifications from people you don't follow
|
||||
|
|
|
@ -27,6 +27,7 @@ Rails.application.routes.draw do
|
|||
/blocks
|
||||
/domain_blocks
|
||||
/mutes
|
||||
/followed_tags
|
||||
/statuses/(*any)
|
||||
).freeze
|
||||
|
||||
|
@ -314,7 +315,11 @@ Rails.application.routes.draw do
|
|||
end
|
||||
|
||||
resources :reports, only: [:index, :show] do
|
||||
resources :actions, only: [:create], controller: 'reports/actions'
|
||||
resources :actions, only: [:create], controller: 'reports/actions' do
|
||||
collection do
|
||||
post :preview
|
||||
end
|
||||
end
|
||||
|
||||
member do
|
||||
post :assign_to_self
|
||||
|
|
|
@ -39,6 +39,7 @@ defaults: &defaults
|
|||
use_blurhash: true
|
||||
use_pending_items: false
|
||||
trends: true
|
||||
trends_as_landing_page: true
|
||||
trendable_by_default: false
|
||||
trending_status_cw: true
|
||||
crop_images: true
|
||||
|
|
|
@ -1 +1 @@
|
|||
Account.create_with(actor_type: 'Application', locked: true, username: ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain).find_or_create_by(id: -99)
|
||||
Account.create_with(actor_type: 'Application', locked: true, username: 'mastodon.internal').find_or_create_by(id: -99)
|
||||
|
|
|
@ -18,6 +18,8 @@ module Mastodon
|
|||
option :dry_run, type: :boolean
|
||||
option :limited_federation_mode, type: :boolean
|
||||
option :by_uri, type: :boolean
|
||||
option :include_subdomains, type: :boolean
|
||||
option :purge_domain_blocks, type: :boolean
|
||||
desc 'purge [DOMAIN...]', 'Remove accounts from a DOMAIN without a trace'
|
||||
long_desc <<-LONG_DESC
|
||||
Remove all accounts from a given DOMAIN without leaving behind any
|
||||
|
@ -33,40 +35,75 @@ module Mastodon
|
|||
that has the handle `foo@bar.com` but whose profile is at the URL
|
||||
`https://mastodon-bar.com/users/foo`, would be purged by either
|
||||
`tootctl domains purge bar.com` or `tootctl domains purge --by-uri mastodon-bar.com`.
|
||||
|
||||
When the --include-subdomains option is given, not only DOMAIN is deleted, but all
|
||||
subdomains as well. Note that this may be considerably slower.
|
||||
|
||||
When the --purge-domain-blocks option is given, also purge matching domain blocks.
|
||||
LONG_DESC
|
||||
def purge(*domains)
|
||||
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
|
||||
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
|
||||
domains = domains.map { |domain| TagManager.instance.normalize_domain(domain) }
|
||||
account_scope = Account.none
|
||||
domain_block_scope = DomainBlock.none
|
||||
emoji_scope = CustomEmoji.none
|
||||
|
||||
scope = begin
|
||||
if options[:limited_federation_mode]
|
||||
Account.remote.where.not(domain: DomainAllow.pluck(:domain))
|
||||
elsif !domains.empty?
|
||||
if options[:by_uri]
|
||||
domains.map { |domain| Account.remote.where(Account.arel_table[:uri].matches("https://#{domain}/%", false, true)) }.reduce(:or)
|
||||
else
|
||||
Account.remote.where(domain: domains)
|
||||
end
|
||||
# Sanity check on command arguments
|
||||
if options[:limited_federation_mode] && !domains.empty?
|
||||
say('DOMAIN parameter not supported with --limited-federation-mode', :red)
|
||||
exit(1)
|
||||
elsif domains.empty? && !options[:limited_federation_mode]
|
||||
say('No domain(s) given', :red)
|
||||
exit(1)
|
||||
end
|
||||
|
||||
# Build scopes from command arguments
|
||||
if options[:limited_federation_mode]
|
||||
account_scope = Account.remote.where.not(domain: DomainAllow.select(:domain))
|
||||
emoji_scope = CustomEmoji.remote.where.not(domain: DomainAllow.select(:domain))
|
||||
else
|
||||
# Handle wildcard subdomains
|
||||
subdomain_patterns = domains.filter_map { |domain| "%.#{Account.sanitize_sql_like(domain[2..])}" if domain.start_with?('*.') }
|
||||
domains = domains.filter { |domain| !domain.start_with?('*.') }
|
||||
# Handle --include-subdomains
|
||||
subdomain_patterns += domains.map { |domain| "%.#{Account.sanitize_sql_like(domain)}" } if options[:include_subdomains]
|
||||
uri_patterns = (domains.map { |domain| Account.sanitize_sql_like(domain) } + subdomain_patterns).map { |pattern| "https://#{pattern}/%" }
|
||||
|
||||
if options[:purge_domain_blocks]
|
||||
domain_block_scope = DomainBlock.where(domain: domains)
|
||||
domain_block_scope = domain_block_scope.or(DomainBlock.where(DomainBlock.arel_table[:domain].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
|
||||
end
|
||||
|
||||
if options[:by_uri]
|
||||
account_scope = Account.remote.where(Account.arel_table[:uri].matches_any(uri_patterns, false, true))
|
||||
emoji_scope = CustomEmoji.remote.where(CustomEmoji.arel_table[:uri].matches_any(uri_patterns, false, true))
|
||||
else
|
||||
say('No domain(s) given', :red)
|
||||
exit(1)
|
||||
account_scope = Account.remote.where(domain: domains)
|
||||
account_scope = account_scope.or(Account.remote.where(Account.arel_table[:domain].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
|
||||
emoji_scope = CustomEmoji.where(domain: domains)
|
||||
emoji_scope = emoji_scope.or(CustomEmoji.remote.where(CustomEmoji.arel_table[:uri].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
|
||||
end
|
||||
end
|
||||
|
||||
processed, = parallelize_with_progress(scope) do |account|
|
||||
# Actually perform the deletions
|
||||
processed, = parallelize_with_progress(account_scope) do |account|
|
||||
DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run]
|
||||
end
|
||||
|
||||
DomainBlock.where(domain: domains).destroy_all unless options[:dry_run]
|
||||
|
||||
say("Removed #{processed} accounts#{dry_run}", :green)
|
||||
|
||||
custom_emojis = CustomEmoji.where(domain: domains)
|
||||
custom_emojis_count = custom_emojis.count
|
||||
custom_emojis.destroy_all unless options[:dry_run]
|
||||
if options[:purge_domain_blocks]
|
||||
domain_block_count = domain_block_scope.count
|
||||
domain_block_scope.in_batches.destroy_all unless options[:dry_run]
|
||||
say("Removed #{domain_block_count} domain blocks#{dry_run}", :green)
|
||||
end
|
||||
|
||||
custom_emojis_count = emoji_scope.count
|
||||
emoji_scope.in_batches.destroy_all unless options[:dry_run]
|
||||
|
||||
Instance.refresh unless options[:dry_run]
|
||||
|
||||
say("Removed #{custom_emojis_count} custom emojis", :green)
|
||||
say("Removed #{custom_emojis_count} custom emojis#{dry_run}", :green)
|
||||
end
|
||||
|
||||
option :concurrency, type: :numeric, default: 50, aliases: [:c]
|
||||
|
|
|
@ -9,9 +9,9 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
|
|||
|
||||
describe 'GET #export' do
|
||||
it 'renders instances' do
|
||||
Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad')
|
||||
Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse', obfuscate: true)
|
||||
Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media')
|
||||
Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad server')
|
||||
Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse server', obfuscate: true)
|
||||
Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media and test unicode characters ♥')
|
||||
Fabricate(:domain_block, domain: 'no.op', severity: 'noop', public_comment: 'noop')
|
||||
|
||||
get :export, params: { format: :csv }
|
||||
|
@ -21,10 +21,32 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
|
|||
end
|
||||
|
||||
describe 'POST #import' do
|
||||
it 'blocks imported domains' do
|
||||
post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } }
|
||||
context 'with complete domain blocks CSV' do
|
||||
before do
|
||||
post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } }
|
||||
end
|
||||
|
||||
expect(assigns(:domain_blocks).map(&:domain)).to match_array ['bad.domain', 'worse.domain', 'reject.media']
|
||||
it 'renders page with expected domain blocks' do
|
||||
expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :silence], ['worse.domain', :suspend], ['reject.media', :noop]]
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a list of only domains' do
|
||||
before do
|
||||
post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks_list.txt') } }
|
||||
end
|
||||
|
||||
it 'renders page with expected domain blocks' do
|
||||
expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :suspend], ['worse.domain', :suspend], ['reject.media', :suspend]]
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -4,39 +4,131 @@ describe Admin::Reports::ActionsController do
|
|||
render_views
|
||||
|
||||
let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
|
||||
let(:account) { Fabricate(:account) }
|
||||
let!(:status) { Fabricate(:status, account: account) }
|
||||
let(:media_attached_status) { Fabricate(:status, account: account) }
|
||||
let!(:media_attachment) { Fabricate(:media_attachment, account: account, status: media_attached_status) }
|
||||
let(:media_attached_deleted_status) { Fabricate(:status, account: account, deleted_at: 1.day.ago) }
|
||||
let!(:media_attachment2) { Fabricate(:media_attachment, account: account, status: media_attached_deleted_status) }
|
||||
let(:last_media_attached_status) { Fabricate(:status, account: account) }
|
||||
let!(:last_media_attachment) { Fabricate(:media_attachment, account: account, status: last_media_attached_status) }
|
||||
let!(:last_status) { Fabricate(:status, account: account) }
|
||||
|
||||
before do
|
||||
sign_in user, scope: :user
|
||||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
let(:report) { Fabricate(:report, status_ids: status_ids, account: user.account, target_account: account) }
|
||||
let(:status_ids) { [media_attached_status.id, media_attached_deleted_status.id] }
|
||||
describe 'POST #preview' do
|
||||
let(:report) { Fabricate(:report) }
|
||||
|
||||
before do
|
||||
post :create, params: { report_id: report.id, action => '' }
|
||||
post :preview, params: { report_id: report.id, action => '' }
|
||||
end
|
||||
|
||||
context 'when action is mark_as_sensitive' do
|
||||
context 'when the action is "suspend"' do
|
||||
let(:action) { 'suspend' }
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the action is "silence"' do
|
||||
let(:action) { 'silence' }
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the action is "delete"' do
|
||||
let(:action) { 'delete' }
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the action is "mark_as_sensitive"' do
|
||||
let(:action) { 'mark_as_sensitive' }
|
||||
|
||||
it 'resolves the report' do
|
||||
expect(report.reload.action_taken_at).to_not be_nil
|
||||
end
|
||||
|
||||
it 'marks the non-deleted as sensitive' do
|
||||
expect(media_attached_status.reload.sensitive).to eq true
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
let(:target_account) { Fabricate(:account) }
|
||||
let(:statuses) { [Fabricate(:status, account: target_account), Fabricate(:status, account: target_account)] }
|
||||
let!(:media) { Fabricate(:media_attachment, account: target_account, status: statuses[0]) }
|
||||
let(:report) { Fabricate(:report, target_account: target_account, status_ids: statuses.map(&:id)) }
|
||||
let(:text) { 'hello' }
|
||||
|
||||
shared_examples 'common behavior' do
|
||||
it 'closes the report' do
|
||||
expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
|
||||
end
|
||||
|
||||
it 'creates a strike with the expected text' do
|
||||
expect { subject }.to change { report.target_account.strikes.count }.by(1)
|
||||
expect(report.target_account.strikes.last.text).to eq text
|
||||
end
|
||||
|
||||
it 'redirects' do
|
||||
subject
|
||||
expect(response).to redirect_to(admin_reports_path)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'all action types' do
|
||||
context 'when the action is "suspend"' do
|
||||
let(:action) { 'suspend' }
|
||||
|
||||
it_behaves_like 'common behavior'
|
||||
|
||||
it 'suspends the target account' do
|
||||
expect { subject }.to change { report.target_account.reload.suspended? }.from(false).to(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the action is "silence"' do
|
||||
let(:action) { 'silence' }
|
||||
|
||||
it_behaves_like 'common behavior'
|
||||
|
||||
it 'suspends the target account' do
|
||||
expect { subject }.to change { report.target_account.reload.silenced? }.from(false).to(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the action is "delete"' do
|
||||
let(:action) { 'delete' }
|
||||
|
||||
it_behaves_like 'common behavior'
|
||||
end
|
||||
|
||||
context 'when the action is "mark_as_sensitive"' do
|
||||
let(:action) { 'mark_as_sensitive' }
|
||||
let(:statuses) { [media_attached_status, media_attached_deleted_status] }
|
||||
|
||||
let!(:status) { Fabricate(:status, account: target_account) }
|
||||
let(:media_attached_status) { Fabricate(:status, account: target_account) }
|
||||
let!(:media_attachment) { Fabricate(:media_attachment, account: target_account, status: media_attached_status) }
|
||||
let(:media_attached_deleted_status) { Fabricate(:status, account: target_account, deleted_at: 1.day.ago) }
|
||||
let!(:media_attachment2) { Fabricate(:media_attachment, account: target_account, status: media_attached_deleted_status) }
|
||||
let(:last_media_attached_status) { Fabricate(:status, account: target_account) }
|
||||
let!(:last_media_attachment) { Fabricate(:media_attachment, account: target_account, status: last_media_attached_status) }
|
||||
let!(:last_status) { Fabricate(:status, account: target_account) }
|
||||
|
||||
it_behaves_like 'common behavior'
|
||||
|
||||
it 'marks the non-deleted as sensitive' do
|
||||
subject
|
||||
expect(media_attached_status.reload.sensitive).to eq true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'action as submit button' do
|
||||
subject { post :create, params: { report_id: report.id, text: text, action => '' } }
|
||||
it_behaves_like 'all action types'
|
||||
end
|
||||
|
||||
context 'action as submit button' do
|
||||
subject { post :create, params: { report_id: report.id, text: text, moderation_action: action } }
|
||||
it_behaves_like 'all action types'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -16,6 +16,8 @@ describe ApplicationController, type: :controller do
|
|||
controller do
|
||||
include SignatureVerification
|
||||
|
||||
before_action :require_actor_signature!, only: [:signature_required]
|
||||
|
||||
def success
|
||||
head 200
|
||||
end
|
||||
|
@ -23,10 +25,17 @@ describe ApplicationController, type: :controller do
|
|||
def alternative_success
|
||||
head 200
|
||||
end
|
||||
|
||||
def signature_required
|
||||
head 200
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
routes.draw { match via: [:get, :post], 'success' => 'anonymous#success' }
|
||||
routes.draw do
|
||||
match via: [:get, :post], 'success' => 'anonymous#success'
|
||||
match via: [:get, :post], 'signature_required' => 'anonymous#signature_required'
|
||||
end
|
||||
end
|
||||
|
||||
context 'without signature header' do
|
||||
|
@ -118,6 +127,37 @@ describe ApplicationController, type: :controller do
|
|||
end
|
||||
end
|
||||
|
||||
context 'with request with unparseable Date header' do
|
||||
before do
|
||||
get :success
|
||||
|
||||
fake_request = Request.new(:get, request.url)
|
||||
fake_request.add_headers({ 'Date' => 'wrong date' })
|
||||
fake_request.on_behalf_of(author)
|
||||
|
||||
request.headers.merge!(fake_request.headers)
|
||||
end
|
||||
|
||||
describe '#signed_request?' do
|
||||
it 'returns true' do
|
||||
expect(controller.signed_request?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signature_verification_failure_reason' do
|
||||
it 'contains an error description' do
|
||||
controller.signed_request_account
|
||||
expect(controller.signature_verification_failure_reason[:error]).to eq 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with request older than a day' do
|
||||
before do
|
||||
get :success
|
||||
|
@ -140,6 +180,13 @@ describe ApplicationController, type: :controller do
|
|||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signature_verification_failure_reason' do
|
||||
it 'contains an error description' do
|
||||
controller.signed_request_account
|
||||
expect(controller.signature_verification_failure_reason[:error]).to eq 'Signed request date outside acceptable time window'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with inaccessible key' do
|
||||
|
@ -171,6 +218,7 @@ describe ApplicationController, type: :controller do
|
|||
|
||||
context 'with body' do
|
||||
before do
|
||||
allow(controller).to receive(:actor_refresh_key!).and_return(author)
|
||||
post :success, body: 'Hello world'
|
||||
|
||||
fake_request = Request.new(:post, request.url, body: 'Hello world')
|
||||
|
@ -189,22 +237,67 @@ describe ApplicationController, type: :controller do
|
|||
it 'returns an account' do
|
||||
expect(controller.signed_request_account).to eq author
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns nil when path does not match' do
|
||||
context 'when path does not match' do
|
||||
before do
|
||||
request.path = '/alternative-path'
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when method does not match' do
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#signature_verification_failure_reason' do
|
||||
it 'contains an error description' do
|
||||
controller.signed_request_account
|
||||
expect(controller.signature_verification_failure_reason[:error]).to include('using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)')
|
||||
expect(controller.signature_verification_failure_reason[:signed_string]).to include("(request-target): post /alternative-path\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when method does not match' do
|
||||
before do
|
||||
get :success
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when body has been tampered' do
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when body has been tampered' do
|
||||
before do
|
||||
post :success, body: 'doo doo doo'
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
|
||||
describe '#signed_request_account' do
|
||||
it 'returns nil when body has been tampered' do
|
||||
expect(controller.signed_request_account).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a signature is required' do
|
||||
before do
|
||||
get :signature_required
|
||||
end
|
||||
|
||||
context 'without signature header' do
|
||||
it 'returns HTTP 401' do
|
||||
expect(response).to have_http_status(401)
|
||||
end
|
||||
|
||||
it 'returns an error' do
|
||||
expect(Oj.load(response.body)['error']).to eq 'Request not signed'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate
|
||||
bad.domain,silence,false,false,bad,false
|
||||
worse.domain,suspend,true,true,worse,true
|
||||
reject.media,noop,true,false,reject media,false
|
||||
bad.domain,silence,false,false,bad server,false
|
||||
worse.domain,suspend,true,true,worse server,true
|
||||
reject.media,noop,true,false,reject media and test unicode characters ♥,false
|
||||
|
|
|
|
@ -0,0 +1,3 @@
|
|||
bad.domain
|
||||
worse.domain
|
||||
reject.media
|
|
@ -87,6 +87,28 @@ RSpec.describe UpdateStatusService, type: :service do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when already-attached media changes' do
|
||||
let!(:status) { Fabricate(:status, text: 'Foo') }
|
||||
let!(:media_attachment) { Fabricate(:media_attachment, account: status.account, description: 'Old description') }
|
||||
|
||||
before do
|
||||
status.media_attachments << media_attachment
|
||||
subject.call(status, status.account_id, text: 'Foo', media_ids: [media_attachment.id], media_attributes: [{ id: media_attachment.id, description: 'New description' }])
|
||||
end
|
||||
|
||||
it 'does not detach media attachment' do
|
||||
expect(media_attachment.reload.status_id).to eq status.id
|
||||
end
|
||||
|
||||
it 'updates the media attachment description' do
|
||||
expect(media_attachment.reload.description).to eq 'New description'
|
||||
end
|
||||
|
||||
it 'saves edit history' do
|
||||
expect(status.edits.map { |edit| edit.ordered_media_attachments.map(&:description) }).to eq [['Old description'], ['New description']]
|
||||
end
|
||||
end
|
||||
|
||||
context 'when poll changes' do
|
||||
let(:account) { Fabricate(:account) }
|
||||
let!(:status) { Fabricate(:status, text: 'Foo', account: account, poll_attributes: {options: %w(Foo Bar), account: account, multiple: false, hide_totals: false, expires_at: 7.days.from_now }) }
|
||||
|
|
Loading…
Reference in New Issue