mirror of https://github.com/Siphonay/mastodon
Merge pull request #2087 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
commit
01405bc6f8
|
@ -21,7 +21,7 @@ module Admin
|
||||||
account_action.save!
|
account_action.save!
|
||||||
|
|
||||||
if account_action.with_report?
|
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
|
else
|
||||||
redirect_to admin_account_path(@account.id)
|
redirect_to admin_account_path(@account.id)
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,9 +23,7 @@ module Admin
|
||||||
@import = Admin::Import.new(import_params)
|
@import = Admin::Import.new(import_params)
|
||||||
return render :new unless @import.validate
|
return render :new unless @import.validate
|
||||||
|
|
||||||
parse_import_data!(export_headers)
|
@import.csv_rows.each do |row|
|
||||||
|
|
||||||
@data.take(Admin::Import::ROWS_PROCESSING_LIMIT).each do |row|
|
|
||||||
domain = row['#domain'].strip
|
domain = row['#domain'].strip
|
||||||
next if DomainAllow.allowed?(domain)
|
next if DomainAllow.allowed?(domain)
|
||||||
|
|
||||||
|
|
|
@ -23,24 +23,30 @@ module Admin
|
||||||
@import = Admin::Import.new(import_params)
|
@import = Admin::Import.new(import_params)
|
||||||
return render :new unless @import.validate
|
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))
|
@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
|
@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
|
domain = row['#domain'].strip
|
||||||
next if DomainBlock.rule_for(domain).present?
|
next if DomainBlock.rule_for(domain).present?
|
||||||
|
|
||||||
domain_block = DomainBlock.new(domain: domain,
|
domain_block = DomainBlock.new(domain: domain,
|
||||||
severity: row['#severity'].strip,
|
severity: row.fetch('#severity', :suspend),
|
||||||
reject_media: row['#reject_media'].strip,
|
reject_media: row.fetch('#reject_media', false),
|
||||||
reject_reports: row['#reject_reports'].strip,
|
reject_reports: row.fetch('#reject_reports', false),
|
||||||
private_comment: @global_private_comment,
|
private_comment: @global_private_comment,
|
||||||
public_comment: row['#public_comment']&.strip,
|
public_comment: row['#public_comment'],
|
||||||
obfuscate: row['#obfuscate'].strip)
|
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
|
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)
|
@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
|
class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
before_action :set_report
|
before_action :set_report
|
||||||
|
|
||||||
|
def preview
|
||||||
|
authorize @report, :show?
|
||||||
|
@moderation_action = action_from_button
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
authorize @report, :show?
|
authorize @report, :show?
|
||||||
|
|
||||||
|
@ -13,7 +18,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
status_ids: @report.status_ids,
|
status_ids: @report.status_ids,
|
||||||
current_account: current_account,
|
current_account: current_account,
|
||||||
report_id: @report.id,
|
report_id: @report.id,
|
||||||
send_email_notification: !@report.spam?
|
send_email_notification: !@report.spam?,
|
||||||
|
text: params[:text]
|
||||||
)
|
)
|
||||||
|
|
||||||
status_batch_action.save!
|
status_batch_action.save!
|
||||||
|
@ -23,13 +29,16 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
report_id: @report.id,
|
report_id: @report.id,
|
||||||
target_account: @report.target_account,
|
target_account: @report.target_account,
|
||||||
current_account: current_account,
|
current_account: current_account,
|
||||||
send_email_notification: !@report.spam?
|
send_email_notification: !@report.spam?,
|
||||||
|
text: params[:text]
|
||||||
)
|
)
|
||||||
|
|
||||||
account_action.save!
|
account_action.save!
|
||||||
|
else
|
||||||
|
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to admin_reports_path
|
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: @report.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -47,6 +56,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
'silence'
|
'silence'
|
||||||
elsif params[:suspend]
|
elsif params[:suspend]
|
||||||
'suspend'
|
'suspend'
|
||||||
|
elsif params[:moderation_action]
|
||||||
|
params[:moderation_action]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,6 +3,14 @@
|
||||||
class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
|
class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
|
||||||
before_action -> { authorize_if_got_token! :'admin:read' }
|
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
|
private
|
||||||
|
|
||||||
def enabled?
|
def enabled?
|
||||||
|
|
|
@ -80,6 +80,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
current_account.id,
|
current_account.id,
|
||||||
text: status_params[:status],
|
text: status_params[:status],
|
||||||
media_ids: status_params[:media_ids],
|
media_ids: status_params[:media_ids],
|
||||||
|
media_attributes: status_params[:media_attributes],
|
||||||
sensitive: status_params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
language: status_params[:language],
|
language: status_params[:language],
|
||||||
spoiler_text: status_params[:spoiler_text],
|
spoiler_text: status_params[:spoiler_text],
|
||||||
|
@ -131,6 +132,12 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
:scheduled_at,
|
:scheduled_at,
|
||||||
:content_type,
|
:content_type,
|
||||||
media_ids: [],
|
media_ids: [],
|
||||||
|
media_attributes: [
|
||||||
|
:id,
|
||||||
|
:thumbnail,
|
||||||
|
:description,
|
||||||
|
:focus,
|
||||||
|
],
|
||||||
poll: [
|
poll: [
|
||||||
:multiple,
|
:multiple,
|
||||||
:hide_totals,
|
:hide_totals,
|
||||||
|
|
|
@ -26,14 +26,4 @@ module AdminExportControllerConcern
|
||||||
def import_params
|
def import_params
|
||||||
params.require(:admin_import).permit(:data)
|
params.require(:admin_import).permit(:data)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -46,11 +46,11 @@ module SignatureVerification
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_account_signature!
|
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
|
end
|
||||||
|
|
||||||
def require_actor_signature!
|
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
|
end
|
||||||
|
|
||||||
def signed_request?
|
def signed_request?
|
||||||
|
@ -97,11 +97,11 @@ module SignatureVerification
|
||||||
|
|
||||||
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
|
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?
|
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
|
rescue SignatureVerificationError => e
|
||||||
fail_with! e.message
|
fail_with! e.message
|
||||||
rescue HTTP::Error, OpenSSL::SSL::SSLError => e
|
rescue HTTP::Error, OpenSSL::SSL::SSLError => e
|
||||||
|
@ -118,8 +118,8 @@ module SignatureVerification
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fail_with!(message)
|
def fail_with!(message, **options)
|
||||||
@signature_verification_failure_reason = message
|
@signature_verification_failure_reason = { error: message }.merge(options)
|
||||||
@signed_request_actor = nil
|
@signed_request_actor = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -209,8 +209,8 @@ module SignatureVerification
|
||||||
end
|
end
|
||||||
|
|
||||||
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
||||||
rescue ArgumentError
|
rescue ArgumentError => e
|
||||||
return false
|
raise SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
||||||
|
|
|
@ -181,6 +181,18 @@ export function submitCompose(routerHistory) {
|
||||||
|
|
||||||
dispatch(submitComposeRequest());
|
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({
|
api(getState).request({
|
||||||
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
||||||
method: statusId === null ? 'post' : 'put',
|
method: statusId === null ? 'post' : 'put',
|
||||||
|
@ -189,6 +201,7 @@ export function submitCompose(routerHistory) {
|
||||||
content_type: getState().getIn(['compose', 'content_type']),
|
content_type: getState().getIn(['compose', 'content_type']),
|
||||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||||
media_ids: media.map(item => item.get('id')),
|
media_ids: media.map(item => item.get('id')),
|
||||||
|
media_attributes,
|
||||||
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
|
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
|
||||||
spoiler_text: spoilerText,
|
spoiler_text: spoilerText,
|
||||||
visibility: getState().getIn(['compose', 'privacy']),
|
visibility: getState().getIn(['compose', 'privacy']),
|
||||||
|
@ -415,11 +428,31 @@ export function changeUploadCompose(id, params) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(changeUploadComposeRequest());
|
dispatch(changeUploadComposeRequest());
|
||||||
|
|
||||||
|
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 => {
|
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
|
||||||
dispatch(changeUploadComposeSuccess(response.data));
|
dispatch(changeUploadComposeSuccess(response.data, false));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(changeUploadComposeFail(id, error));
|
dispatch(changeUploadComposeFail(id, error));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -430,10 +463,11 @@ export function changeUploadComposeRequest() {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function changeUploadComposeSuccess(media) {
|
export function changeUploadComposeSuccess(media, attached) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||||
media: media,
|
media: media,
|
||||||
|
attached: attached,
|
||||||
skipLoading: true,
|
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_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||||
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||||
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
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_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||||
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||||
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||||
|
@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
|
||||||
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) => {
|
export const followHashtag = name => (dispatch, getState) => {
|
||||||
dispatch(followHashtagRequest(name));
|
dispatch(followHashtagRequest(name));
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
|
||||||
<Hashtag
|
<Hashtag
|
||||||
key={hashtag.name}
|
key={hashtag.name}
|
||||||
name={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}
|
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
|
||||||
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
|
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
|
||||||
history={hashtag.history.reverse().map(day => day.uses)}
|
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' },
|
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
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' },
|
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
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'])) {
|
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')) {
|
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.follow_requests), to: '/follow_requests' });
|
||||||
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
||||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
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(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
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' },
|
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
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' },
|
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
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.favourites), to: '/favourites' });
|
||||||
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
||||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
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(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||||
|
|
|
@ -43,13 +43,13 @@ export default class Upload extends ImmutablePureComponent {
|
||||||
{({ scale }) => (
|
{({ 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-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
||||||
<div className='compose-form__upload__actions'>
|
<div className='compose-form__upload__actions'>
|
||||||
<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.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.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(media.get('description') || '').length === 0 && !!media.get('unattached') && (
|
{(media.get('description') || '').length === 0 && (
|
||||||
<div className='compose-form__upload__warning'>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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>
|
<React.Fragment>
|
||||||
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
|
<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>
|
<label>
|
||||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
||||||
|
|
|
@ -30,7 +30,7 @@ const SignInBanner = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='sign-in-banner'>
|
<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>
|
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
|
||||||
{signupButton}
|
{signupButton}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -42,6 +42,7 @@ import {
|
||||||
FollowRequests,
|
FollowRequests,
|
||||||
FavouritedStatuses,
|
FavouritedStatuses,
|
||||||
BookmarkedStatuses,
|
BookmarkedStatuses,
|
||||||
|
FollowedTags,
|
||||||
ListTimeline,
|
ListTimeline,
|
||||||
Blocks,
|
Blocks,
|
||||||
DomainBlocks,
|
DomainBlocks,
|
||||||
|
@ -56,7 +57,7 @@ import {
|
||||||
PrivacyPolicy,
|
PrivacyPolicy,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
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 { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
|
||||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
|
@ -177,7 +178,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||||
}
|
}
|
||||||
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
|
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
|
||||||
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
|
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
|
||||||
} else if (showTrends) {
|
} else if (showTrends && trendsAsLanding) {
|
||||||
redirect = <Redirect from='/' to='/explore' exact />;
|
redirect = <Redirect from='/' to='/explore' exact />;
|
||||||
} else {
|
} else {
|
||||||
redirect = <Redirect from='/' to='/about' exact />;
|
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='/follow_requests' component={FollowRequests} content={children} />
|
||||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||||
<WrappedRoute path='/domain_blocks' component={DomainBlocks} 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='/mutes' component={Mutes} content={children} />
|
||||||
<WrappedRoute path='/lists' component={Lists} content={children} />
|
<WrappedRoute path='/lists' component={Lists} content={children} />
|
||||||
<WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} 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');
|
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 () {
|
export function BookmarkedStatuses () {
|
||||||
return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses');
|
return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses');
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,6 +75,7 @@
|
||||||
* @property {boolean} timeline_preview
|
* @property {boolean} timeline_preview
|
||||||
* @property {string} title
|
* @property {string} title
|
||||||
* @property {boolean} trends
|
* @property {boolean} trends
|
||||||
|
* @property {boolean} trends_as_landing_page
|
||||||
* @property {boolean} unfollow_modal
|
* @property {boolean} unfollow_modal
|
||||||
* @property {boolean} use_blurhash
|
* @property {boolean} use_blurhash
|
||||||
* @property {boolean=} use_pending_items
|
* @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 source_url = getMeta('source_url');
|
||||||
export const timelinePreview = getMeta('timeline_preview');
|
export const timelinePreview = getMeta('timeline_preview');
|
||||||
export const title = getMeta('title');
|
export const title = getMeta('title');
|
||||||
|
export const trendsAsLanding = getMeta('trends_as_landing_page');
|
||||||
export const unfollowModal = getMeta('unfollow_modal');
|
export const unfollowModal = getMeta('unfollow_modal');
|
||||||
export const useBlurhash = getMeta('use_blurhash');
|
export const useBlurhash = getMeta('use_blurhash');
|
||||||
export const usePendingItems = getMeta('use_pending_items');
|
export const usePendingItems = getMeta('use_pending_items');
|
||||||
|
|
|
@ -551,7 +551,7 @@ export default function compose(state = initialState, action) {
|
||||||
.setIn(['media_modal', 'dirty'], false)
|
.setIn(['media_modal', 'dirty'], false)
|
||||||
.update('media_attachments', list => list.map(item => {
|
.update('media_attachments', list => list.map(item => {
|
||||||
if (item.get('id') === action.media.id) {
|
if (item.get('id') === action.media.id) {
|
||||||
return fromJS(action.media).set('unattached', true);
|
return fromJS(action.media).set('unattached', !action.attached);
|
||||||
}
|
}
|
||||||
|
|
||||||
return item;
|
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 accounts_map from './accounts_map';
|
||||||
import history from './history';
|
import history from './history';
|
||||||
import tags from './tags';
|
import tags from './tags';
|
||||||
|
import followed_tags from './followed_tags';
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
announcements,
|
announcements,
|
||||||
|
@ -87,6 +88,7 @@ const reducers = {
|
||||||
picture_in_picture,
|
picture_in_picture,
|
||||||
history,
|
history,
|
||||||
tags,
|
tags,
|
||||||
|
followed_tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
export default combineReducers(reducers);
|
||||||
|
|
|
@ -1588,6 +1588,15 @@ a.sparkline {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
|
|
|
@ -118,7 +118,7 @@
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: $highlight-text-color;
|
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());
|
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({
|
api(getState).request({
|
||||||
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
||||||
method: statusId === null ? 'post' : 'put',
|
method: statusId === null ? 'post' : 'put',
|
||||||
|
@ -167,6 +179,7 @@ export function submitCompose(routerHistory) {
|
||||||
status,
|
status,
|
||||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||||
media_ids: media.map(item => item.get('id')),
|
media_ids: media.map(item => item.get('id')),
|
||||||
|
media_attributes,
|
||||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||||
visibility: getState().getIn(['compose', 'privacy']),
|
visibility: getState().getIn(['compose', 'privacy']),
|
||||||
|
@ -377,11 +390,31 @@ export function changeUploadCompose(id, params) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(changeUploadComposeRequest());
|
dispatch(changeUploadComposeRequest());
|
||||||
|
|
||||||
|
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 => {
|
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
|
||||||
dispatch(changeUploadComposeSuccess(response.data));
|
dispatch(changeUploadComposeSuccess(response.data, false));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(changeUploadComposeFail(id, error));
|
dispatch(changeUploadComposeFail(id, error));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -392,10 +425,11 @@ export function changeUploadComposeRequest() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeUploadComposeSuccess(media) {
|
export function changeUploadComposeSuccess(media, attached) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||||
media: media,
|
media: media,
|
||||||
|
attached: attached,
|
||||||
skipLoading: true,
|
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_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||||
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||||
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
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_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||||
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||||
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||||
|
@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
|
||||||
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) => {
|
export const followHashtag = name => (dispatch, getState) => {
|
||||||
dispatch(followHashtagRequest(name));
|
dispatch(followHashtagRequest(name));
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
|
||||||
<Hashtag
|
<Hashtag
|
||||||
key={hashtag.name}
|
key={hashtag.name}
|
||||||
name={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}
|
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
|
||||||
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
|
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
|
||||||
history={hashtag.history.reverse().map(day => day.uses)}
|
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' },
|
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
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' },
|
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
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'])) {
|
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')) {
|
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.follow_requests), to: '/follow_requests' });
|
||||||
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
||||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
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(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
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' },
|
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
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' },
|
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
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.favourites), to: '/favourites' });
|
||||||
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
||||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
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(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
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-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
||||||
<div className='compose-form__upload__actions'>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{(media.get('description') || '').length === 0 && !!media.get('unattached') && (
|
{(media.get('description') || '').length === 0 && (
|
||||||
<div className='compose-form__upload__warning'>
|
<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>
|
<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 '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>
|
<React.Fragment>
|
||||||
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
|
<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>
|
<label>
|
||||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
||||||
|
|
|
@ -30,7 +30,7 @@ const SignInBanner = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='sign-in-banner'>
|
<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>
|
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
|
||||||
{signupButton}
|
{signupButton}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -42,6 +42,7 @@ import {
|
||||||
FollowRequests,
|
FollowRequests,
|
||||||
FavouritedStatuses,
|
FavouritedStatuses,
|
||||||
BookmarkedStatuses,
|
BookmarkedStatuses,
|
||||||
|
FollowedTags,
|
||||||
ListTimeline,
|
ListTimeline,
|
||||||
Blocks,
|
Blocks,
|
||||||
DomainBlocks,
|
DomainBlocks,
|
||||||
|
@ -54,7 +55,7 @@ import {
|
||||||
About,
|
About,
|
||||||
PrivacyPolicy,
|
PrivacyPolicy,
|
||||||
} from './util/async-components';
|
} 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 { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
||||||
import Header from './components/header';
|
import Header from './components/header';
|
||||||
|
|
||||||
|
@ -163,7 +164,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||||
}
|
}
|
||||||
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
|
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
|
||||||
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
|
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
|
||||||
} else if (showTrends) {
|
} else if (showTrends && trendsAsLanding) {
|
||||||
redirect = <Redirect from='/' to='/explore' exact />;
|
redirect = <Redirect from='/' to='/explore' exact />;
|
||||||
} else {
|
} else {
|
||||||
redirect = <Redirect from='/' to='/about' exact />;
|
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='/follow_requests' component={FollowRequests} content={children} />
|
||||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||||
<WrappedRoute path='/domain_blocks' component={DomainBlocks} 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='/mutes' component={Mutes} content={children} />
|
||||||
<WrappedRoute path='/lists' component={Lists} content={children} />
|
<WrappedRoute path='/lists' component={Lists} content={children} />
|
||||||
|
|
||||||
|
|
|
@ -90,6 +90,10 @@ export function FavouritedStatuses () {
|
||||||
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
|
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FollowedTags () {
|
||||||
|
return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags');
|
||||||
|
}
|
||||||
|
|
||||||
export function BookmarkedStatuses () {
|
export function BookmarkedStatuses () {
|
||||||
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
|
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,6 +75,7 @@
|
||||||
* @property {boolean} timeline_preview
|
* @property {boolean} timeline_preview
|
||||||
* @property {string} title
|
* @property {string} title
|
||||||
* @property {boolean} trends
|
* @property {boolean} trends
|
||||||
|
* @property {boolean} trends_as_landing_page
|
||||||
* @property {boolean} unfollow_modal
|
* @property {boolean} unfollow_modal
|
||||||
* @property {boolean} use_blurhash
|
* @property {boolean} use_blurhash
|
||||||
* @property {boolean=} use_pending_items
|
* @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 source_url = getMeta('source_url');
|
||||||
export const timelinePreview = getMeta('timeline_preview');
|
export const timelinePreview = getMeta('timeline_preview');
|
||||||
export const title = getMeta('title');
|
export const title = getMeta('title');
|
||||||
|
export const trendsAsLanding = getMeta('trends_as_landing_page');
|
||||||
export const unfollowModal = getMeta('unfollow_modal');
|
export const unfollowModal = getMeta('unfollow_modal');
|
||||||
export const useBlurhash = getMeta('use_blurhash');
|
export const useBlurhash = getMeta('use_blurhash');
|
||||||
export const usePendingItems = getMeta('use_pending_items');
|
export const usePendingItems = getMeta('use_pending_items');
|
||||||
|
|
|
@ -1391,6 +1391,10 @@
|
||||||
"defaultMessage": "Lists",
|
"defaultMessage": "Lists",
|
||||||
"id": "navigation_bar.lists"
|
"id": "navigation_bar.lists"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"defaultMessage": "Followed hashtags",
|
||||||
|
"id": "navigation_bar.followed_tags"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"defaultMessage": "Blocked users",
|
"defaultMessage": "Blocked users",
|
||||||
"id": "navigation_bar.blocks"
|
"id": "navigation_bar.blocks"
|
||||||
|
@ -4220,7 +4224,7 @@
|
||||||
"id": "sign_in_banner.create_account"
|
"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"
|
"id": "sign_in_banner.text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -383,6 +383,7 @@
|
||||||
"navigation_bar.favourites": "Favourites",
|
"navigation_bar.favourites": "Favourites",
|
||||||
"navigation_bar.filters": "Muted words",
|
"navigation_bar.filters": "Muted words",
|
||||||
"navigation_bar.follow_requests": "Follow requests",
|
"navigation_bar.follow_requests": "Follow requests",
|
||||||
|
"navigation_bar.followed_tags": "Followed hashtags",
|
||||||
"navigation_bar.follows_and_followers": "Follows and followers",
|
"navigation_bar.follows_and_followers": "Follows and followers",
|
||||||
"navigation_bar.lists": "Lists",
|
"navigation_bar.lists": "Lists",
|
||||||
"navigation_bar.misc": "Misc",
|
"navigation_bar.misc": "Misc",
|
||||||
|
@ -545,7 +546,7 @@
|
||||||
"server_banner.server_stats": "Server stats:",
|
"server_banner.server_stats": "Server stats:",
|
||||||
"sign_in_banner.create_account": "Create account",
|
"sign_in_banner.create_account": "Create account",
|
||||||
"sign_in_banner.sign_in": "Sign in",
|
"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_account": "Open moderation interface for @{name}",
|
||||||
"status.admin_domain": "Open moderation interface for {domain}",
|
"status.admin_domain": "Open moderation interface for {domain}",
|
||||||
"status.admin_status": "Open this post in the moderation interface",
|
"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)
|
.setIn(['media_modal', 'dirty'], false)
|
||||||
.update('media_attachments', list => list.map(item => {
|
.update('media_attachments', list => list.map(item => {
|
||||||
if (item.get('id') === action.media.id) {
|
if (item.get('id') === action.media.id) {
|
||||||
return fromJS(action.media).set('unattached', true);
|
return fromJS(action.media).set('unattached', !action.attached);
|
||||||
}
|
}
|
||||||
|
|
||||||
return item;
|
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 accounts_map from './accounts_map';
|
||||||
import history from './history';
|
import history from './history';
|
||||||
import tags from './tags';
|
import tags from './tags';
|
||||||
|
import followed_tags from './followed_tags';
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
announcements,
|
announcements,
|
||||||
|
@ -83,6 +84,7 @@ const reducers = {
|
||||||
picture_in_picture,
|
picture_in_picture,
|
||||||
history,
|
history,
|
||||||
tags,
|
tags,
|
||||||
|
followed_tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
export default combineReducers(reducers);
|
||||||
|
|
|
@ -1588,6 +1588,15 @@ a.sparkline {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
|
|
|
@ -423,7 +423,7 @@ body > [data-popper-placement] {
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: $highlight-text-color;
|
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
|
def running_version
|
||||||
@running_version ||= begin
|
@running_version ||= begin
|
||||||
Chewy.client.info['version']['minimum_wire_compatibility_version'] ||
|
|
||||||
Chewy.client.info['version']['number']
|
Chewy.client.info['version']['number']
|
||||||
rescue Faraday::ConnectionFailed
|
rescue Faraday::ConnectionFailed
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def compatible_wire_version
|
||||||
|
Chewy.client.info['version']['minimum_wire_compatibility_version']
|
||||||
|
end
|
||||||
|
|
||||||
def required_version
|
def required_version
|
||||||
'7.x'
|
'7.x'
|
||||||
end
|
end
|
||||||
|
|
||||||
def compatible_version?
|
def compatible_version?
|
||||||
return false if running_version.nil?
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -88,8 +88,8 @@ class Account < ApplicationRecord
|
||||||
validates :username, presence: true
|
validates :username, presence: true
|
||||||
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
|
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
|
||||||
|
|
||||||
# Remote user validations
|
# Remote user validations, also applies to internal actors
|
||||||
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { !local? && will_save_change_to_username? }
|
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? }
|
||||||
|
|
||||||
# Local user validations
|
# 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' }
|
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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'csv'
|
||||||
|
|
||||||
# A non-activerecord helper class for csv upload
|
# A non-activerecord helper class for csv upload
|
||||||
class Admin::Import
|
class Admin::Import
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
|
@ -15,17 +17,46 @@ class Admin::Import
|
||||||
data.original_filename
|
data.original_filename
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def csv_rows
|
||||||
|
csv_data.rewind
|
||||||
|
|
||||||
|
csv_data.take(ROWS_PROCESSING_LIMIT + 1)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
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
|
def validate_data
|
||||||
return if data.blank?
|
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
|
||||||
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
|
|
||||||
rescue CSV::MalformedCSVError => e
|
rescue CSV::MalformedCSVError => e
|
||||||
errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
|
errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,7 +6,8 @@ class Admin::StatusBatchAction
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
attr_accessor :current_account, :type,
|
attr_accessor :current_account, :type,
|
||||||
:status_ids, :report_id
|
:status_ids, :report_id,
|
||||||
|
:text
|
||||||
|
|
||||||
attr_reader :send_email_notification
|
attr_reader :send_email_notification
|
||||||
|
|
||||||
|
@ -57,7 +58,8 @@ class Admin::StatusBatchAction
|
||||||
action: :delete_statuses,
|
action: :delete_statuses,
|
||||||
account: current_account,
|
account: current_account,
|
||||||
report: report,
|
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?
|
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,
|
action: :mark_statuses_as_sensitive,
|
||||||
account: current_account,
|
account: current_account,
|
||||||
report: report,
|
report: report,
|
||||||
status_ids: status_ids
|
status_ids: status_ids,
|
||||||
|
text: text
|
||||||
)
|
)
|
||||||
|
|
||||||
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
|
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
|
||||||
|
|
|
@ -13,9 +13,11 @@ module AccountFinderConcern
|
||||||
end
|
end
|
||||||
|
|
||||||
def representative
|
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
|
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
|
end
|
||||||
|
|
||||||
def find_local(username)
|
def find_local(username)
|
||||||
|
|
|
@ -28,6 +28,7 @@ class Form::AdminSettings
|
||||||
show_reblogs_in_public_timelines
|
show_reblogs_in_public_timelines
|
||||||
show_replies_in_public_timelines
|
show_replies_in_public_timelines
|
||||||
trends
|
trends
|
||||||
|
trends_as_landing_page
|
||||||
trendable_by_default
|
trendable_by_default
|
||||||
trending_status_cw
|
trending_status_cw
|
||||||
show_domain_blocks
|
show_domain_blocks
|
||||||
|
@ -57,6 +58,7 @@ class Form::AdminSettings
|
||||||
show_reblogs_in_public_timelines
|
show_reblogs_in_public_timelines
|
||||||
show_replies_in_public_timelines
|
show_replies_in_public_timelines
|
||||||
trends
|
trends
|
||||||
|
trends_as_landing_page
|
||||||
trendable_by_default
|
trendable_by_default
|
||||||
trending_status_cw
|
trending_status_cw
|
||||||
noindex
|
noindex
|
||||||
|
|
|
@ -46,6 +46,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
||||||
activity_api_enabled: Setting.activity_api_enabled,
|
activity_api_enabled: Setting.activity_api_enabled,
|
||||||
single_user_mode: Rails.configuration.x.single_user_mode,
|
single_user_mode: Rails.configuration.x.single_user_mode,
|
||||||
translation_enabled: TranslationService.configured?,
|
translation_enabled: TranslationService.configured?,
|
||||||
|
trends_as_landing_page: Setting.trends_as_landing_page,
|
||||||
}
|
}
|
||||||
|
|
||||||
if object.current_account
|
if object.current_account
|
||||||
|
|
|
@ -16,6 +16,16 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
||||||
attribute :silenced, key: :limited, if: :silenced?
|
attribute :silenced, key: :limited, if: :silenced?
|
||||||
attribute :noindex, if: :local?
|
attribute :noindex, if: :local?
|
||||||
|
|
||||||
|
class AccountDecorator < SimpleDelegator
|
||||||
|
def self.model_name
|
||||||
|
Account.model_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def moved?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class FieldSerializer < ActiveModel::Serializer
|
class FieldSerializer < ActiveModel::Serializer
|
||||||
include FormattingHelper
|
include FormattingHelper
|
||||||
|
|
||||||
|
@ -89,7 +99,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def moved_to_account
|
def moved_to_account
|
||||||
object.suspended? ? nil : object.moved_to_account
|
object.suspended? ? nil : AccountDecorator.new(object.moved_to_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def emojis
|
def emojis
|
||||||
|
@ -115,6 +125,6 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
||||||
delegate :suspended?, :silenced?, :local?, to: :object
|
delegate :suspended?, :silenced?, :local?, to: :object
|
||||||
|
|
||||||
def moved_and_not_nested?
|
def moved_and_not_nested?
|
||||||
object.moved? && object.moved_to_account.moved_to_account_id.nil?
|
object.moved?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -28,6 +28,7 @@ class ActivityPub::FetchRemoteActorService < BaseService
|
||||||
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?
|
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, "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 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']
|
@uri = @json['id']
|
||||||
@username = @json['preferredUsername']
|
@username = @json['preferredUsername']
|
||||||
|
|
|
@ -10,6 +10,7 @@ class UpdateStatusService < BaseService
|
||||||
# @param [Integer] account_id
|
# @param [Integer] account_id
|
||||||
# @param [Hash] options
|
# @param [Hash] options
|
||||||
# @option options [Array<Integer>] :media_ids
|
# @option options [Array<Integer>] :media_ids
|
||||||
|
# @option options [Array<Hash>] :media_attributes
|
||||||
# @option options [Hash] :poll
|
# @option options [Hash] :poll
|
||||||
# @option options [String] :text
|
# @option options [String] :text
|
||||||
# @option options [String] :spoiler_text
|
# @option options [String] :spoiler_text
|
||||||
|
@ -51,10 +52,18 @@ class UpdateStatusService < BaseService
|
||||||
next_media_attachments = validate_media!
|
next_media_attachments = validate_media!
|
||||||
added_media_attachments = next_media_attachments - previous_media_attachments
|
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)
|
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)
|
@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
|
@status.media_attachments.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
- unless @announcement.published?
|
- unless @announcement.published?
|
||||||
.fields-group
|
.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
|
.actions
|
||||||
= f.button :button, t('generic.save_changes'), type: :submit
|
= f.button :button, t('generic.save_changes'), type: :submit
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
= l report_note.created_at.to_date
|
= l report_note.created_at.to_date
|
||||||
|
|
||||||
.report-notes__item__content
|
.report-notes__item__content
|
||||||
= simple_format(h(report_note.content))
|
= linkify(report_note.content)
|
||||||
|
|
||||||
- if can?(:destroy, report_note)
|
- if can?(:destroy, report_note)
|
||||||
.report-notes__item__actions
|
.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
|
||||||
.report-actions__item
|
.report-actions__item
|
||||||
.report-actions__item__button
|
.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
|
.fields-group
|
||||||
= f.input :trends, as: :boolean, wrapper: :with_label
|
= f.input :trends, as: :boolean, wrapper: :with_label
|
||||||
|
|
||||||
|
.fields-group
|
||||||
|
= f.input :trends_as_landing_page, as: :boolean, wrapper: :with_label
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
= f.input :trendable_by_default, as: :boolean, wrapper: :with_label, recommended: :not_recommended
|
= f.input :trendable_by_default, as: :boolean, wrapper: :with_label, recommended: :not_recommended
|
||||||
|
|
||||||
|
|
|
@ -65,9 +65,11 @@ ignore_unused:
|
||||||
- 'errors.429'
|
- 'errors.429'
|
||||||
- 'admin.accounts.roles.*'
|
- 'admin.accounts.roles.*'
|
||||||
- 'admin.action_logs.actions.*'
|
- 'admin.action_logs.actions.*'
|
||||||
- 'themes.*'
|
- 'admin.reports.summary.action_preambles.*'
|
||||||
|
- 'admin.reports.summary.actions.*'
|
||||||
- 'admin_mailer.new_appeal.actions.*'
|
- 'admin_mailer.new_appeal.actions.*'
|
||||||
- 'statuses.attached.*'
|
- 'statuses.attached.*'
|
||||||
|
- 'themes.*'
|
||||||
- 'move_handler.carry_{mutes,blocks}_over_text'
|
- 'move_handler.carry_{mutes,blocks}_over_text'
|
||||||
- 'notification_mailer.*'
|
- '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_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}
|
private_comment_template: Imported from %{source} on %{date}
|
||||||
title: Import domain blocks
|
title: Import domain blocks
|
||||||
|
invalid_domain_block: 'One or more domain blocks were skipped because of the following error(s): %{error}'
|
||||||
new:
|
new:
|
||||||
title: Import domain blocks
|
title: Import domain blocks
|
||||||
no_file: No file selected
|
no_file: No file selected
|
||||||
|
@ -589,6 +590,7 @@ en:
|
||||||
comment:
|
comment:
|
||||||
none: None
|
none: None
|
||||||
comment_description_html: 'To provide more information, %{name} wrote:'
|
comment_description_html: 'To provide more information, %{name} wrote:'
|
||||||
|
confirm_action: Confirm moderation action against @%{acct}
|
||||||
created_at: Reported
|
created_at: Reported
|
||||||
delete_and_resolve: Delete posts
|
delete_and_resolve: Delete posts
|
||||||
forwarded: Forwarded
|
forwarded: Forwarded
|
||||||
|
@ -605,6 +607,7 @@ en:
|
||||||
placeholder: Describe what actions have been taken, or any other related updates...
|
placeholder: Describe what actions have been taken, or any other related updates...
|
||||||
title: Notes
|
title: Notes
|
||||||
notes_description_html: View and leave notes to other moderators and your future self
|
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:'
|
quick_actions_description_html: 'Take a quick action or scroll down to see reported content:'
|
||||||
remote_user_placeholder: the remote user from %{instance}
|
remote_user_placeholder: the remote user from %{instance}
|
||||||
reopen: Reopen report
|
reopen: Reopen report
|
||||||
|
@ -617,9 +620,28 @@ en:
|
||||||
status: Status
|
status: Status
|
||||||
statuses: Reported content
|
statuses: Reported content
|
||||||
statuses_description_html: Offending content will be cited in communication with the reported account
|
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
|
target_origin: Origin of reported account
|
||||||
title: Reports
|
title: Reports
|
||||||
unassign: Unassign
|
unassign: Unassign
|
||||||
|
unknown_action_msg: 'Unknown action: %{action}'
|
||||||
unresolved: Unresolved
|
unresolved: Unresolved
|
||||||
updated_at: Updated
|
updated_at: Updated
|
||||||
view_profile: View profile
|
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.
|
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.
|
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: 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:
|
form_challenge:
|
||||||
current_password: You are entering a secure area
|
current_password: You are entering a secure area
|
||||||
imports:
|
imports:
|
||||||
|
@ -256,6 +257,7 @@ en:
|
||||||
timeline_preview: Allow unauthenticated access to public timelines
|
timeline_preview: Allow unauthenticated access to public timelines
|
||||||
trendable_by_default: Allow trends without prior review
|
trendable_by_default: Allow trends without prior review
|
||||||
trends: Enable trends
|
trends: Enable trends
|
||||||
|
trends_as_landing_page: Use trends as the landing page
|
||||||
interactions:
|
interactions:
|
||||||
must_be_follower: Block notifications from non-followers
|
must_be_follower: Block notifications from non-followers
|
||||||
must_be_following: Block notifications from people you don't follow
|
must_be_following: Block notifications from people you don't follow
|
||||||
|
|
|
@ -27,6 +27,7 @@ Rails.application.routes.draw do
|
||||||
/blocks
|
/blocks
|
||||||
/domain_blocks
|
/domain_blocks
|
||||||
/mutes
|
/mutes
|
||||||
|
/followed_tags
|
||||||
/statuses/(*any)
|
/statuses/(*any)
|
||||||
).freeze
|
).freeze
|
||||||
|
|
||||||
|
@ -314,7 +315,11 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :reports, only: [:index, :show] do
|
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
|
member do
|
||||||
post :assign_to_self
|
post :assign_to_self
|
||||||
|
|
|
@ -39,6 +39,7 @@ defaults: &defaults
|
||||||
use_blurhash: true
|
use_blurhash: true
|
||||||
use_pending_items: false
|
use_pending_items: false
|
||||||
trends: true
|
trends: true
|
||||||
|
trends_as_landing_page: true
|
||||||
trendable_by_default: false
|
trendable_by_default: false
|
||||||
trending_status_cw: true
|
trending_status_cw: true
|
||||||
crop_images: 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 :dry_run, type: :boolean
|
||||||
option :limited_federation_mode, type: :boolean
|
option :limited_federation_mode, type: :boolean
|
||||||
option :by_uri, 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'
|
desc 'purge [DOMAIN...]', 'Remove accounts from a DOMAIN without a trace'
|
||||||
long_desc <<-LONG_DESC
|
long_desc <<-LONG_DESC
|
||||||
Remove all accounts from a given DOMAIN without leaving behind any
|
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
|
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
|
`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`.
|
`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
|
LONG_DESC
|
||||||
def purge(*domains)
|
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
|
# Sanity check on command arguments
|
||||||
if options[:limited_federation_mode]
|
if options[:limited_federation_mode] && !domains.empty?
|
||||||
Account.remote.where.not(domain: DomainAllow.pluck(:domain))
|
say('DOMAIN parameter not supported with --limited-federation-mode', :red)
|
||||||
elsif !domains.empty?
|
exit(1)
|
||||||
if options[:by_uri]
|
elsif domains.empty? && !options[:limited_federation_mode]
|
||||||
domains.map { |domain| Account.remote.where(Account.arel_table[:uri].matches("https://#{domain}/%", false, true)) }.reduce(:or)
|
|
||||||
else
|
|
||||||
Account.remote.where(domain: domains)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
say('No domain(s) given', :red)
|
say('No domain(s) given', :red)
|
||||||
exit(1)
|
exit(1)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
processed, = parallelize_with_progress(scope) do |account|
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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]
|
DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run]
|
||||||
end
|
end
|
||||||
|
|
||||||
DomainBlock.where(domain: domains).destroy_all unless options[:dry_run]
|
|
||||||
|
|
||||||
say("Removed #{processed} accounts#{dry_run}", :green)
|
say("Removed #{processed} accounts#{dry_run}", :green)
|
||||||
|
|
||||||
custom_emojis = CustomEmoji.where(domain: domains)
|
if options[:purge_domain_blocks]
|
||||||
custom_emojis_count = custom_emojis.count
|
domain_block_count = domain_block_scope.count
|
||||||
custom_emojis.destroy_all unless options[:dry_run]
|
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]
|
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
|
end
|
||||||
|
|
||||||
option :concurrency, type: :numeric, default: 50, aliases: [:c]
|
option :concurrency, type: :numeric, default: 50, aliases: [:c]
|
||||||
|
|
|
@ -9,9 +9,9 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
|
||||||
|
|
||||||
describe 'GET #export' do
|
describe 'GET #export' do
|
||||||
it 'renders instances' do
|
it 'renders instances' do
|
||||||
Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad')
|
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', obfuscate: true)
|
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')
|
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')
|
Fabricate(:domain_block, domain: 'no.op', severity: 'noop', public_comment: 'noop')
|
||||||
|
|
||||||
get :export, params: { format: :csv }
|
get :export, params: { format: :csv }
|
||||||
|
@ -21,10 +21,32 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #import' do
|
describe 'POST #import' do
|
||||||
it 'blocks imported domains' do
|
context 'with complete domain blocks CSV' do
|
||||||
|
before do
|
||||||
post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } }
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -4,39 +4,131 @@ describe Admin::Reports::ActionsController do
|
||||||
render_views
|
render_views
|
||||||
|
|
||||||
let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
|
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
|
before do
|
||||||
sign_in user, scope: :user
|
sign_in user, scope: :user
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #create' do
|
describe 'POST #preview' do
|
||||||
let(:report) { Fabricate(:report, status_ids: status_ids, account: user.account, target_account: account) }
|
let(:report) { Fabricate(:report) }
|
||||||
let(:status_ids) { [media_attached_status.id, media_attached_deleted_status.id] }
|
|
||||||
|
|
||||||
before do
|
before do
|
||||||
post :create, params: { report_id: report.id, action => '' }
|
post :preview, params: { report_id: report.id, action => '' }
|
||||||
end
|
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' }
|
let(:action) { 'mark_as_sensitive' }
|
||||||
|
|
||||||
it 'resolves the report' do
|
it 'returns http success' do
|
||||||
expect(report.reload.action_taken_at).to_not be_nil
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
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
|
it 'marks the non-deleted as sensitive' do
|
||||||
|
subject
|
||||||
expect(media_attached_status.reload.sensitive).to eq true
|
expect(media_attached_status.reload.sensitive).to eq true
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -16,6 +16,8 @@ describe ApplicationController, type: :controller do
|
||||||
controller do
|
controller do
|
||||||
include SignatureVerification
|
include SignatureVerification
|
||||||
|
|
||||||
|
before_action :require_actor_signature!, only: [:signature_required]
|
||||||
|
|
||||||
def success
|
def success
|
||||||
head 200
|
head 200
|
||||||
end
|
end
|
||||||
|
@ -23,10 +25,17 @@ describe ApplicationController, type: :controller do
|
||||||
def alternative_success
|
def alternative_success
|
||||||
head 200
|
head 200
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def signature_required
|
||||||
|
head 200
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
before do
|
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
|
end
|
||||||
|
|
||||||
context 'without signature header' do
|
context 'without signature header' do
|
||||||
|
@ -118,6 +127,37 @@ describe ApplicationController, type: :controller do
|
||||||
end
|
end
|
||||||
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
|
context 'with request older than a day' do
|
||||||
before do
|
before do
|
||||||
get :success
|
get :success
|
||||||
|
@ -140,6 +180,13 @@ describe ApplicationController, type: :controller do
|
||||||
expect(controller.signed_request_account).to be_nil
|
expect(controller.signed_request_account).to be_nil
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
context 'with inaccessible key' do
|
context 'with inaccessible key' do
|
||||||
|
@ -171,6 +218,7 @@ describe ApplicationController, type: :controller do
|
||||||
|
|
||||||
context 'with body' do
|
context 'with body' do
|
||||||
before do
|
before do
|
||||||
|
allow(controller).to receive(:actor_refresh_key!).and_return(author)
|
||||||
post :success, body: 'Hello world'
|
post :success, body: 'Hello world'
|
||||||
|
|
||||||
fake_request = Request.new(:post, request.url, 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
|
it 'returns an account' do
|
||||||
expect(controller.signed_request_account).to eq author
|
expect(controller.signed_request_account).to eq author
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns nil when path does not match' do
|
context 'when path does not match' do
|
||||||
|
before do
|
||||||
request.path = '/alternative-path'
|
request.path = '/alternative-path'
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
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
|
get :success
|
||||||
expect(controller.signed_request_account).to be_nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns nil when body has been tampered' do
|
describe '#signed_request_account' do
|
||||||
post :success, body: 'doo doo doo'
|
it 'returns nil' do
|
||||||
expect(controller.signed_request_account).to be_nil
|
expect(controller.signed_request_account).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when body has been tampered' do
|
||||||
|
before do
|
||||||
|
post :success, body: 'doo doo doo'
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate
|
#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate
|
||||||
bad.domain,silence,false,false,bad,false
|
bad.domain,silence,false,false,bad server,false
|
||||||
worse.domain,suspend,true,true,worse,true
|
worse.domain,suspend,true,true,worse server,true
|
||||||
reject.media,noop,true,false,reject media,false
|
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
|
||||||
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
|
context 'when poll changes' do
|
||||||
let(:account) { Fabricate(:account) }
|
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 }) }
|
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