Merge pull request #2087 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes
This commit is contained in:
Claire 2023-01-18 18:41:24 +01:00 committed by GitHub
commit 01405bc6f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 1162 additions and 155 deletions

View File

@ -21,7 +21,7 @@ module Admin
account_action.save!
if account_action.with_report?
redirect_to admin_reports_path
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id])
else
redirect_to admin_account_path(@account.id)
end

View File

@ -23,9 +23,7 @@ module Admin
@import = Admin::Import.new(import_params)
return render :new unless @import.validate
parse_import_data!(export_headers)
@data.take(Admin::Import::ROWS_PROCESSING_LIMIT).each do |row|
@import.csv_rows.each do |row|
domain = row['#domain'].strip
next if DomainAllow.allowed?(domain)

View File

@ -23,24 +23,30 @@ module Admin
@import = Admin::Import.new(import_params)
return render :new unless @import.validate
parse_import_data!(export_headers)
@global_private_comment = I18n.t('admin.export_domain_blocks.import.private_comment_template', source: @import.data_file_name, date: I18n.l(Time.now.utc))
@form = Form::DomainBlockBatch.new
@domain_blocks = @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).filter_map do |row|
@domain_blocks = @import.csv_rows.filter_map do |row|
domain = row['#domain'].strip
next if DomainBlock.rule_for(domain).present?
domain_block = DomainBlock.new(domain: domain,
severity: row['#severity'].strip,
reject_media: row['#reject_media'].strip,
reject_reports: row['#reject_reports'].strip,
severity: row.fetch('#severity', :suspend),
reject_media: row.fetch('#reject_media', false),
reject_reports: row.fetch('#reject_reports', false),
private_comment: @global_private_comment,
public_comment: row['#public_comment']&.strip,
obfuscate: row['#obfuscate'].strip)
public_comment: row['#public_comment'],
obfuscate: row.fetch('#obfuscate', false))
domain_block if domain_block.valid?
if domain_block.invalid?
flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: domain_block.errors.full_messages.join(', '))
next
end
domain_block
rescue ArgumentError => e
flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: e.message)
next
end
@warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain)

View File

@ -3,6 +3,11 @@
class Admin::Reports::ActionsController < Admin::BaseController
before_action :set_report
def preview
authorize @report, :show?
@moderation_action = action_from_button
end
def create
authorize @report, :show?
@ -13,7 +18,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
status_ids: @report.status_ids,
current_account: current_account,
report_id: @report.id,
send_email_notification: !@report.spam?
send_email_notification: !@report.spam?,
text: params[:text]
)
status_batch_action.save!
@ -23,13 +29,16 @@ class Admin::Reports::ActionsController < Admin::BaseController
report_id: @report.id,
target_account: @report.target_account,
current_account: current_account,
send_email_notification: !@report.spam?
send_email_notification: !@report.spam?,
text: params[:text]
)
account_action.save!
else
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
end
redirect_to admin_reports_path
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: @report.id)
end
private
@ -47,6 +56,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
'silence'
elsif params[:suspend]
'suspend'
elsif params[:moderation_action]
params[:moderation_action]
end
end
end

View File

@ -3,6 +3,14 @@
class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
before_action -> { authorize_if_got_token! :'admin:read' }
def index
if current_user&.can?(:manage_taxonomies)
render json: @tags, each_serializer: REST::Admin::TagSerializer
else
super
end
end
private
def enabled?

View File

@ -80,6 +80,7 @@ class Api::V1::StatusesController < Api::BaseController
current_account.id,
text: status_params[:status],
media_ids: status_params[:media_ids],
media_attributes: status_params[:media_attributes],
sensitive: status_params[:sensitive],
language: status_params[:language],
spoiler_text: status_params[:spoiler_text],
@ -131,6 +132,12 @@ class Api::V1::StatusesController < Api::BaseController
:scheduled_at,
:content_type,
media_ids: [],
media_attributes: [
:id,
:thumbnail,
:description,
:focus,
],
poll: [
:multiple,
:hide_totals,

View File

@ -26,14 +26,4 @@ module AdminExportControllerConcern
def import_params
params.require(:admin_import).permit(:data)
end
def import_data_path
params[:admin_import][:data].path
end
def parse_import_data!(default_headers)
data = CSV.read(import_data_path, headers: true, encoding: 'UTF-8')
data = CSV.read(import_data_path, headers: default_headers, encoding: 'UTF-8') unless data.headers&.first&.strip&.include?(default_headers[0])
@data = data.reject(&:blank?)
end
end

View File

@ -46,11 +46,11 @@ module SignatureVerification
end
def require_account_signature!
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
end
def require_actor_signature!
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor
end
def signed_request?
@ -97,11 +97,11 @@ module SignatureVerification
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
rescue SignatureVerificationError => e
fail_with! e.message
rescue HTTP::Error, OpenSSL::SSL::SSLError => e
@ -118,8 +118,8 @@ module SignatureVerification
private
def fail_with!(message)
@signature_verification_failure_reason = message
def fail_with!(message, **options)
@signature_verification_failure_reason = { error: message }.merge(options)
@signed_request_actor = nil
end
@ -209,8 +209,8 @@ module SignatureVerification
end
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
rescue ArgumentError
return false
rescue ArgumentError => e
raise SignatureVerificationError, "Invalid Date header: #{e.message}"
end
expires_time ||= created_time + 5.minutes unless created_time.nil?

View File

@ -181,6 +181,18 @@ export function submitCompose(routerHistory) {
dispatch(submitComposeRequest());
// If we're editing a post with media attachments, those have not
// necessarily been changed on the server. Do it now in the same
// API call.
let media_attributes;
if (statusId !== null) {
media_attributes = media.map(item => ({
id: item.get('id'),
description: item.get('description'),
focus: item.get('focus'),
}));
}
api(getState).request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put',
@ -189,6 +201,7 @@ export function submitCompose(routerHistory) {
content_type: getState().getIn(['compose', 'content_type']),
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')),
media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
spoiler_text: spoilerText,
visibility: getState().getIn(['compose', 'privacy']),
@ -415,11 +428,31 @@ export function changeUploadCompose(id, params) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
// Editing already-attached media is deferred to editing the post itself.
// For simplicity's sake, fake an API reply.
if (media && !media.get('unattached')) {
let { description, focus } = params;
const data = media.toJS();
if (description) {
data.description = description;
}
if (focus) {
focus = focus.split(',');
data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
}
dispatch(changeUploadComposeSuccess(data, true));
} else {
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
dispatch(changeUploadComposeSuccess(response.data));
dispatch(changeUploadComposeSuccess(response.data, false));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));
});
}
};
};
@ -430,10 +463,11 @@ export function changeUploadComposeRequest() {
};
};
export function changeUploadComposeSuccess(media) {
export function changeUploadComposeSuccess(media, attached) {
return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
media: media,
attached: attached,
skipLoading: true,
};
};

View File

@ -1,9 +1,17 @@
import api from '../api';
import api, { getLinks } from '../api';
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
error,
});
export const fetchFollowedHashtags = () => (dispatch, getState) => {
dispatch(fetchFollowedHashtagsRequest());
api(getState).get('/api/v1/followed_tags').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchFollowedHashtagsFail(err));
});
};
export function fetchFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
};
};
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
followed_tags,
next,
};
};
export function fetchFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
error,
};
};
export function expandFollowedHashtags() {
return (dispatch, getState) => {
const url = getState().getIn(['followed_tags', 'next']);
if (url === null) {
return;
}
dispatch(expandFollowedHashtagsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFollowedHashtagsFail(error));
});
};
};
export function expandFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
};
};
export function expandFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
followed_tags,
next,
};
};
export function expandFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
error,
};
};
export const followHashtag = name => (dispatch, getState) => {
dispatch(followHashtagRequest(name));

View File

@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
<Hashtag
key={hashtag.name}
name={hashtag.name}
href={`/admin/tags/${hashtag.id}`}
href={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
history={hashtag.history.reverse().map(day => day.uses)}

View File

@ -45,6 +45,7 @@ const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@ -188,7 +189,7 @@ class Header extends ImmutablePureComponent {
}
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
}
if (me !== account.get('id')) {
@ -245,6 +246,7 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });

View File

@ -12,6 +12,7 @@ const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@ -46,6 +47,7 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });

View File

@ -43,13 +43,13 @@ export default class Upload extends ImmutablePureComponent {
{({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className='compose-form__upload__actions'>
<button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
{!!media.get('unattached') && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
</div>
{(media.get('description') || '').length === 0 && !!media.get('unattached') && (
{(media.get('description') || '').length === 0 && (
<div className='compose-form__upload__warning'>
<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
</div>
)}
</div>

View File

@ -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>
);
}
}

View File

@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
<React.Fragment>
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
<Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
<Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>

View File

@ -30,7 +30,7 @@ const SignInBanner = () => {
return (
<div className='sign-in-banner'>
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
{signupButton}
</div>

View File

@ -42,6 +42,7 @@ import {
FollowRequests,
FavouritedStatuses,
BookmarkedStatuses,
FollowedTags,
ListTimeline,
Blocks,
DomainBlocks,
@ -56,7 +57,7 @@ import {
PrivacyPolicy,
} from './util/async-components';
import { HotKeys } from 'react-hotkeys';
import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state';
import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
@ -177,7 +178,7 @@ class SwitchingColumnsArea extends React.PureComponent {
}
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
} else if (showTrends) {
} else if (showTrends && trendsAsLanding) {
redirect = <Redirect from='/' to='/explore' exact />;
} else {
redirect = <Redirect from='/' to='/about' exact />;
@ -230,6 +231,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />

View File

@ -98,6 +98,10 @@ export function FavouritedStatuses () {
return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses');
}
export function FollowedTags () {
return import(/* webpackChunkName: "flavours/glitch/async/followed_tags" */'flavours/glitch/features/followed_tags');
}
export function BookmarkedStatuses () {
return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses');
}

View File

@ -75,6 +75,7 @@
* @property {boolean} timeline_preview
* @property {string} title
* @property {boolean} trends
* @property {boolean} trends_as_landing_page
* @property {boolean} unfollow_modal
* @property {boolean} use_blurhash
* @property {boolean=} use_pending_items
@ -134,6 +135,7 @@ export const singleUserMode = getMeta('single_user_mode');
export const source_url = getMeta('source_url');
export const timelinePreview = getMeta('timeline_preview');
export const title = getMeta('title');
export const trendsAsLanding = getMeta('trends_as_landing_page');
export const unfollowModal = getMeta('unfollow_modal');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');

View File

@ -551,7 +551,7 @@ export default function compose(state = initialState, action) {
.setIn(['media_modal', 'dirty'], false)
.update('media_attachments', list => list.map(item => {
if (item.get('id') === action.media.id) {
return fromJS(action.media).set('unattached', true);
return fromJS(action.media).set('unattached', !action.attached);
}
return item;

View File

@ -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;
}
};

View File

@ -42,6 +42,7 @@ import picture_in_picture from './picture_in_picture';
import accounts_map from './accounts_map';
import history from './history';
import tags from './tags';
import followed_tags from './followed_tags';
const reducers = {
announcements,
@ -87,6 +88,7 @@ const reducers = {
picture_in_picture,
history,
tags,
followed_tags,
};
export default combineReducers(reducers);

View File

@ -1588,6 +1588,15 @@ a.sparkline {
margin-bottom: 0;
}
}
a {
color: $highlight-text-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&__actions {

View File

@ -118,7 +118,7 @@
&.active {
border-color: $highlight-text-color;
background: $highlight-text-color;
background: $highlight-text-color url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") center center no-repeat;
}
}
}

View File

@ -160,6 +160,18 @@ export function submitCompose(routerHistory) {
dispatch(submitComposeRequest());
// If we're editing a post with media attachments, those have not
// necessarily been changed on the server. Do it now in the same
// API call.
let media_attributes;
if (statusId !== null) {
media_attributes = media.map(item => ({
id: item.get('id'),
description: item.get('description'),
focus: item.get('focus'),
}));
}
api(getState).request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put',
@ -167,6 +179,7 @@ export function submitCompose(routerHistory) {
status,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')),
media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']),
@ -377,11 +390,31 @@ export function changeUploadCompose(id, params) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
// Editing already-attached media is deferred to editing the post itself.
// For simplicity's sake, fake an API reply.
if (media && !media.get('unattached')) {
let { description, focus } = params;
const data = media.toJS();
if (description) {
data.description = description;
}
if (focus) {
focus = focus.split(',');
data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
}
dispatch(changeUploadComposeSuccess(data, true));
} else {
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
dispatch(changeUploadComposeSuccess(response.data));
dispatch(changeUploadComposeSuccess(response.data, false));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));
});
}
};
}
@ -392,10 +425,11 @@ export function changeUploadComposeRequest() {
};
}
export function changeUploadComposeSuccess(media) {
export function changeUploadComposeSuccess(media, attached) {
return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
media: media,
attached: attached,
skipLoading: true,
};
}

View File

@ -1,9 +1,17 @@
import api from '../api';
import api, { getLinks } from '../api';
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
error,
});
export const fetchFollowedHashtags = () => (dispatch, getState) => {
dispatch(fetchFollowedHashtagsRequest());
api(getState).get('/api/v1/followed_tags').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchFollowedHashtagsFail(err));
});
};
export function fetchFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
};
};
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
followed_tags,
next,
};
};
export function fetchFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
error,
};
};
export function expandFollowedHashtags() {
return (dispatch, getState) => {
const url = getState().getIn(['followed_tags', 'next']);
if (url === null) {
return;
}
dispatch(expandFollowedHashtagsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFollowedHashtagsFail(error));
});
};
};
export function expandFollowedHashtagsRequest() {
return {
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
};
};
export function expandFollowedHashtagsSuccess(followed_tags, next) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
followed_tags,
next,
};
};
export function expandFollowedHashtagsFail(error) {
return {
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
error,
};
};
export const followHashtag = name => (dispatch, getState) => {
dispatch(followHashtagRequest(name));

View File

@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
<Hashtag
key={hashtag.name}
name={hashtag.name}
to={`/admin/tags/${hashtag.id}`}
to={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
history={hashtag.history.reverse().map(day => day.uses)}

View File

@ -46,6 +46,7 @@ const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@ -193,7 +194,7 @@ class Header extends ImmutablePureComponent {
}
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
}
if (me !== account.get('id')) {
@ -242,6 +243,7 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });

View File

@ -11,6 +11,7 @@ const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@ -45,6 +46,7 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });

View File

@ -43,10 +43,10 @@ export default class Upload extends ImmutablePureComponent {
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className='compose-form__upload__actions'>
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
{!!media.get('unattached') && (<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
</div>
{(media.get('description') || '').length === 0 && !!media.get('unattached') && (
{(media.get('description') || '').length === 0 && (
<div className='compose-form__upload__warning'>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
</div>

View File

@ -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>
);
}
}

View File

@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
<React.Fragment>
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
<Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
<Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>

View File

@ -30,7 +30,7 @@ const SignInBanner = () => {
return (
<div className='sign-in-banner'>
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
{signupButton}
</div>

View File

@ -42,6 +42,7 @@ import {
FollowRequests,
FavouritedStatuses,
BookmarkedStatuses,
FollowedTags,
ListTimeline,
Blocks,
DomainBlocks,
@ -54,7 +55,7 @@ import {
About,
PrivacyPolicy,
} from './util/async-components';
import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state';
import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
import Header from './components/header';
@ -163,7 +164,7 @@ class SwitchingColumnsArea extends React.PureComponent {
}
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
} else if (showTrends) {
} else if (showTrends && trendsAsLanding) {
redirect = <Redirect from='/' to='/explore' exact />;
} else {
redirect = <Redirect from='/' to='/about' exact />;
@ -216,6 +217,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />

View File

@ -90,6 +90,10 @@ export function FavouritedStatuses () {
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
}
export function FollowedTags () {
return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags');
}
export function BookmarkedStatuses () {
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
}

View File

@ -75,6 +75,7 @@
* @property {boolean} timeline_preview
* @property {string} title
* @property {boolean} trends
* @property {boolean} trends_as_landing_page
* @property {boolean} unfollow_modal
* @property {boolean} use_blurhash
* @property {boolean=} use_pending_items
@ -126,6 +127,7 @@ export const singleUserMode = getMeta('single_user_mode');
export const source_url = getMeta('source_url');
export const timelinePreview = getMeta('timeline_preview');
export const title = getMeta('title');
export const trendsAsLanding = getMeta('trends_as_landing_page');
export const unfollowModal = getMeta('unfollow_modal');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');

View File

@ -1391,6 +1391,10 @@
"defaultMessage": "Lists",
"id": "navigation_bar.lists"
},
{
"defaultMessage": "Followed hashtags",
"id": "navigation_bar.followed_tags"
},
{
"defaultMessage": "Blocked users",
"id": "navigation_bar.blocks"
@ -4220,7 +4224,7 @@
"id": "sign_in_banner.create_account"
},
{
"defaultMessage": "Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.",
"defaultMessage": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
"id": "sign_in_banner.text"
},
{

View File

@ -383,6 +383,7 @@
"navigation_bar.favourites": "Favourites",
"navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests",
"navigation_bar.followed_tags": "Followed hashtags",
"navigation_bar.follows_and_followers": "Follows and followers",
"navigation_bar.lists": "Lists",
"navigation_bar.misc": "Misc",
@ -545,7 +546,7 @@
"server_banner.server_stats": "Server stats:",
"sign_in_banner.create_account": "Create account",
"sign_in_banner.sign_in": "Sign in",
"sign_in_banner.text": "Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.",
"sign_in_banner.text": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_domain": "Open moderation interface for {domain}",
"status.admin_status": "Open this post in the moderation interface",

View File

@ -444,7 +444,7 @@ export default function compose(state = initialState, action) {
.setIn(['media_modal', 'dirty'], false)
.update('media_attachments', list => list.map(item => {
if (item.get('id') === action.media.id) {
return fromJS(action.media).set('unattached', true);
return fromJS(action.media).set('unattached', !action.attached);
}
return item;

View File

@ -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;
}
};

View File

@ -40,6 +40,7 @@ import picture_in_picture from './picture_in_picture';
import accounts_map from './accounts_map';
import history from './history';
import tags from './tags';
import followed_tags from './followed_tags';
const reducers = {
announcements,
@ -83,6 +84,7 @@ const reducers = {
picture_in_picture,
history,
tags,
followed_tags,
};
export default combineReducers(reducers);

View File

@ -1588,6 +1588,15 @@ a.sparkline {
margin-bottom: 0;
}
}
a {
color: $highlight-text-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&__actions {

View File

@ -423,7 +423,7 @@ body > [data-popper-placement] {
&.active {
border-color: $highlight-text-color;
background: $highlight-text-color;
background: $highlight-text-color url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") center center no-repeat;
}
}
}

View File

@ -30,19 +30,24 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
def running_version
@running_version ||= begin
Chewy.client.info['version']['minimum_wire_compatibility_version'] ||
Chewy.client.info['version']['number']
rescue Faraday::ConnectionFailed
nil
end
end
def compatible_wire_version
Chewy.client.info['version']['minimum_wire_compatibility_version']
end
def required_version
'7.x'
end
def compatible_version?
return false if running_version.nil?
Gem::Version.new(running_version) >= Gem::Version.new(required_version)
Gem::Version.new(running_version) >= Gem::Version.new(required_version) ||
Gem::Version.new(compatible_wire_version) >= Gem::Version.new(required_version)
end
end

View File

@ -88,8 +88,8 @@ class Account < ApplicationRecord
validates :username, presence: true
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
# Remote user validations
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { !local? && will_save_change_to_username? }
# Remote user validations, also applies to internal actors
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? }
# Local user validations
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true
require 'csv'
# A non-activerecord helper class for csv upload
class Admin::Import
include ActiveModel::Model
@ -15,17 +17,46 @@ class Admin::Import
data.original_filename
end
def csv_rows
csv_data.rewind
csv_data.take(ROWS_PROCESSING_LIMIT + 1)
end
private
def csv_data
return @csv_data if defined?(@csv_data)
csv_converter = lambda do |field, field_info|
case field_info.header
when '#domain', '#public_comment'
field&.strip
when '#severity'
field&.strip&.to_sym
when '#reject_media', '#reject_reports', '#obfuscate'
ActiveModel::Type::Boolean.new.cast(field)
else
field
end
end
@csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: true, converters: csv_converter)
@csv_data.take(1) # Ensure the headers are read
@csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: ['#domain'], converters: csv_converter) unless @csv_data.headers&.first == '#domain'
@csv_data
end
def csv_row_count
return @csv_row_count if defined?(@csv_row_count)
csv_data.rewind
@csv_row_count = csv_data.take(ROWS_PROCESSING_LIMIT + 2).count
end
def validate_data
return if data.blank?
csv_data = CSV.read(data.path, encoding: 'UTF-8')
row_count = csv_data.size
row_count -= 1 if csv_data.first&.first == '#domain'
errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if row_count > ROWS_PROCESSING_LIMIT
return if data.nil?
errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if csv_row_count > ROWS_PROCESSING_LIMIT
rescue CSV::MalformedCSVError => e
errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
end

View File

@ -6,7 +6,8 @@ class Admin::StatusBatchAction
include Authorization
attr_accessor :current_account, :type,
:status_ids, :report_id
:status_ids, :report_id,
:text
attr_reader :send_email_notification
@ -57,7 +58,8 @@ class Admin::StatusBatchAction
action: :delete_statuses,
account: current_account,
report: report,
status_ids: status_ids
status_ids: status_ids,
text: text
)
statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
@ -95,7 +97,8 @@ class Admin::StatusBatchAction
action: :mark_statuses_as_sensitive,
account: current_account,
report: report,
status_ids: status_ids
status_ids: status_ids,
text: text
)
UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?

View File

@ -13,9 +13,11 @@ module AccountFinderConcern
end
def representative
Account.find(-99).tap(&:ensure_keys!)
actor = Account.find(-99).tap(&:ensure_keys!)
actor.update!(username: 'mastodon.internal') if actor.username.include?(':')
actor
rescue ActiveRecord::RecordNotFound
Account.create!(id: -99, actor_type: 'Application', locked: true, username: Rails.configuration.x.local_domain)
Account.create!(id: -99, actor_type: 'Application', locked: true, username: 'mastodon.internal')
end
def find_local(username)

View File

@ -28,6 +28,7 @@ class Form::AdminSettings
show_reblogs_in_public_timelines
show_replies_in_public_timelines
trends
trends_as_landing_page
trendable_by_default
trending_status_cw
show_domain_blocks
@ -57,6 +58,7 @@ class Form::AdminSettings
show_reblogs_in_public_timelines
show_replies_in_public_timelines
trends
trends_as_landing_page
trendable_by_default
trending_status_cw
noindex

View File

@ -46,6 +46,7 @@ class InitialStateSerializer < ActiveModel::Serializer
activity_api_enabled: Setting.activity_api_enabled,
single_user_mode: Rails.configuration.x.single_user_mode,
translation_enabled: TranslationService.configured?,
trends_as_landing_page: Setting.trends_as_landing_page,
}
if object.current_account

View File

@ -16,6 +16,16 @@ class REST::AccountSerializer < ActiveModel::Serializer
attribute :silenced, key: :limited, if: :silenced?
attribute :noindex, if: :local?
class AccountDecorator < SimpleDelegator
def self.model_name
Account.model_name
end
def moved?
false
end
end
class FieldSerializer < ActiveModel::Serializer
include FormattingHelper
@ -89,7 +99,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
end
def moved_to_account
object.suspended? ? nil : object.moved_to_account
object.suspended? ? nil : AccountDecorator.new(object.moved_to_account)
end
def emojis
@ -115,6 +125,6 @@ class REST::AccountSerializer < ActiveModel::Serializer
delegate :suspended?, :silenced?, :local?, to: :object
def moved_and_not_nested?
object.moved? && object.moved_to_account.moved_to_account_id.nil?
object.moved?
end
end

View File

@ -28,6 +28,7 @@ class ActivityPub::FetchRemoteActorService < BaseService
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?
raise Error, "Unexpected object type for actor #{uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_type?
raise Error, "Actor #{uri} has moved to #{@json['movedTo']}" if break_on_redirect && @json['movedTo'].present?
raise Error, "Actor #{uri} has no 'preferredUsername', which is a requirement for Mastodon compatibility" unless @json['preferredUsername'].present?
@uri = @json['id']
@username = @json['preferredUsername']

View File

@ -10,6 +10,7 @@ class UpdateStatusService < BaseService
# @param [Integer] account_id
# @param [Hash] options
# @option options [Array<Integer>] :media_ids
# @option options [Array<Hash>] :media_attributes
# @option options [Hash] :poll
# @option options [String] :text
# @option options [String] :spoiler_text
@ -51,10 +52,18 @@ class UpdateStatusService < BaseService
next_media_attachments = validate_media!
added_media_attachments = next_media_attachments - previous_media_attachments
(@options[:media_attributes] || []).each do |attributes|
media = next_media_attachments.find { |attachment| attachment.id == attributes[:id].to_i }
next if media.nil?
media.update!(attributes.slice(:thumbnail, :description, :focus))
@media_attachments_changed ||= media.significantly_changed?
end
MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
@status.ordered_media_attachment_ids = (@options[:media_ids] || []).map(&:to_i) & next_media_attachments.map(&:id)
@media_attachments_changed = previous_media_attachments.map(&:id) != @status.ordered_media_attachment_ids
@media_attachments_changed ||= previous_media_attachments.map(&:id) != @status.ordered_media_attachment_ids
@status.media_attachments.reload
end

View File

@ -16,7 +16,7 @@
- unless @announcement.published?
.fields-group
= f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
= f.input :scheduled_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') }
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View File

@ -8,7 +8,7 @@
= l report_note.created_at.to_date
.report-notes__item__content
= simple_format(h(report_note.content))
= linkify(report_note.content)
- if can?(:destroy, report_note)
.report-notes__item__actions

View File

@ -1,4 +1,4 @@
= form_tag admin_report_actions_path(@report), method: :post do
= form_tag preview_admin_report_actions_path(@report), method: :post do
.report-actions
.report-actions__item
.report-actions__item__button

View File

@ -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

View File

@ -15,6 +15,9 @@
.fields-group
= f.input :trends, as: :boolean, wrapper: :with_label
.fields-group
= f.input :trends_as_landing_page, as: :boolean, wrapper: :with_label
.fields-group
= f.input :trendable_by_default, as: :boolean, wrapper: :with_label, recommended: :not_recommended

View File

@ -65,9 +65,11 @@ ignore_unused:
- 'errors.429'
- 'admin.accounts.roles.*'
- 'admin.action_logs.actions.*'
- 'themes.*'
- 'admin.reports.summary.action_preambles.*'
- 'admin.reports.summary.actions.*'
- 'admin_mailer.new_appeal.actions.*'
- 'statuses.attached.*'
- 'themes.*'
- 'move_handler.carry_{mutes,blocks}_over_text'
- 'notification_mailer.*'

View File

@ -441,6 +441,7 @@ en:
private_comment_description_html: 'To help you track where imported blocks come from, imported blocks will be created with the following private comment: <q>%{comment}</q>'
private_comment_template: Imported from %{source} on %{date}
title: Import domain blocks
invalid_domain_block: 'One or more domain blocks were skipped because of the following error(s): %{error}'
new:
title: Import domain blocks
no_file: No file selected
@ -589,6 +590,7 @@ en:
comment:
none: None
comment_description_html: 'To provide more information, %{name} wrote:'
confirm_action: Confirm moderation action against @%{acct}
created_at: Reported
delete_and_resolve: Delete posts
forwarded: Forwarded
@ -605,6 +607,7 @@ en:
placeholder: Describe what actions have been taken, or any other related updates...
title: Notes
notes_description_html: View and leave notes to other moderators and your future self
processed_msg: 'Report #%{id} successfully processed'
quick_actions_description_html: 'Take a quick action or scroll down to see reported content:'
remote_user_placeholder: the remote user from %{instance}
reopen: Reopen report
@ -617,9 +620,28 @@ en:
status: Status
statuses: Reported content
statuses_description_html: Offending content will be cited in communication with the reported account
summary:
action_preambles:
delete_html: 'You are about to <strong>remove</strong> some of <strong>@%{acct}</strong>''s posts. This will:'
mark_as_sensitive_html: 'You are about to <strong>mark</strong> some of <strong>@%{acct}</strong>''s posts as <strong>sensitive</strong>. This will:'
silence_html: 'You are about to <strong>limit</strong> <strong>@%{acct}</strong>''s account. This will:'
suspend_html: 'You are about to <strong>suspend</strong> <strong>@%{acct}</strong>''s account. This will:'
actions:
delete_html: Remove the offending posts
mark_as_sensitive_html: Mark the offending posts' media as sensitive
silence_html: Severely limit <strong>@%{acct}</strong>'s reach by making their profile and contents only visible to people already following them or manually looking it profile up
suspend_html: Suspend <strong>@%{acct}</strong>, making their profile and contents inaccessible and impossible to interact with
close_report: 'Mark report #%{id} as resolved'
close_reports_html: Mark <strong>all</strong> reports against <strong>@%{acct}</strong> as resolved
delete_data_html: Delete <strong>@%{acct}</strong>'s profile and contents 30 days from now unless they get unsuspended in the meantime
preview_preamble_html: "<strong>@%{acct}</strong> will receive a warning with the following contents:"
record_strike_html: Record a strike against <strong>@%{acct}</strong> to help you escalate on future violations from this account
send_email_html: Send <strong>@%{acct}</strong> a warning e-mail
warning_placeholder: Optional additional reasoning for the moderation action.
target_origin: Origin of reported account
title: Reports
unassign: Unassign
unknown_action_msg: 'Unknown action: %{action}'
unresolved: Unresolved
updated_at: Updated
view_profile: View profile

View File

@ -96,6 +96,7 @@ en:
timeline_preview: Logged out visitors will be able to browse the most recent public posts available on the server.
trendable_by_default: Skip manual review of trending content. Individual items can still be removed from trends after the fact.
trends: Trends show which posts, hashtags and news stories are gaining traction on your server.
trends_as_landing_page: Show trending content to logged-out users and visitors instead of a description of this server. Requires trends to be enabled.
form_challenge:
current_password: You are entering a secure area
imports:
@ -256,6 +257,7 @@ en:
timeline_preview: Allow unauthenticated access to public timelines
trendable_by_default: Allow trends without prior review
trends: Enable trends
trends_as_landing_page: Use trends as the landing page
interactions:
must_be_follower: Block notifications from non-followers
must_be_following: Block notifications from people you don't follow

View File

@ -27,6 +27,7 @@ Rails.application.routes.draw do
/blocks
/domain_blocks
/mutes
/followed_tags
/statuses/(*any)
).freeze
@ -314,7 +315,11 @@ Rails.application.routes.draw do
end
resources :reports, only: [:index, :show] do
resources :actions, only: [:create], controller: 'reports/actions'
resources :actions, only: [:create], controller: 'reports/actions' do
collection do
post :preview
end
end
member do
post :assign_to_self

View File

@ -39,6 +39,7 @@ defaults: &defaults
use_blurhash: true
use_pending_items: false
trends: true
trends_as_landing_page: true
trendable_by_default: false
trending_status_cw: true
crop_images: true

View File

@ -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)

View File

@ -18,6 +18,8 @@ module Mastodon
option :dry_run, type: :boolean
option :limited_federation_mode, type: :boolean
option :by_uri, type: :boolean
option :include_subdomains, type: :boolean
option :purge_domain_blocks, type: :boolean
desc 'purge [DOMAIN...]', 'Remove accounts from a DOMAIN without a trace'
long_desc <<-LONG_DESC
Remove all accounts from a given DOMAIN without leaving behind any
@ -33,40 +35,75 @@ module Mastodon
that has the handle `foo@bar.com` but whose profile is at the URL
`https://mastodon-bar.com/users/foo`, would be purged by either
`tootctl domains purge bar.com` or `tootctl domains purge --by-uri mastodon-bar.com`.
When the --include-subdomains option is given, not only DOMAIN is deleted, but all
subdomains as well. Note that this may be considerably slower.
When the --purge-domain-blocks option is given, also purge matching domain blocks.
LONG_DESC
def purge(*domains)
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
domains = domains.map { |domain| TagManager.instance.normalize_domain(domain) }
account_scope = Account.none
domain_block_scope = DomainBlock.none
emoji_scope = CustomEmoji.none
scope = begin
if options[:limited_federation_mode]
Account.remote.where.not(domain: DomainAllow.pluck(:domain))
elsif !domains.empty?
if options[:by_uri]
domains.map { |domain| Account.remote.where(Account.arel_table[:uri].matches("https://#{domain}/%", false, true)) }.reduce(:or)
else
Account.remote.where(domain: domains)
end
else
# Sanity check on command arguments
if options[:limited_federation_mode] && !domains.empty?
say('DOMAIN parameter not supported with --limited-federation-mode', :red)
exit(1)
elsif domains.empty? && !options[:limited_federation_mode]
say('No domain(s) given', :red)
exit(1)
end
# Build scopes from command arguments
if options[:limited_federation_mode]
account_scope = Account.remote.where.not(domain: DomainAllow.select(:domain))
emoji_scope = CustomEmoji.remote.where.not(domain: DomainAllow.select(:domain))
else
# Handle wildcard subdomains
subdomain_patterns = domains.filter_map { |domain| "%.#{Account.sanitize_sql_like(domain[2..])}" if domain.start_with?('*.') }
domains = domains.filter { |domain| !domain.start_with?('*.') }
# Handle --include-subdomains
subdomain_patterns += domains.map { |domain| "%.#{Account.sanitize_sql_like(domain)}" } if options[:include_subdomains]
uri_patterns = (domains.map { |domain| Account.sanitize_sql_like(domain) } + subdomain_patterns).map { |pattern| "https://#{pattern}/%" }
if options[:purge_domain_blocks]
domain_block_scope = DomainBlock.where(domain: domains)
domain_block_scope = domain_block_scope.or(DomainBlock.where(DomainBlock.arel_table[:domain].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
end
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]
end
DomainBlock.where(domain: domains).destroy_all unless options[:dry_run]
say("Removed #{processed} accounts#{dry_run}", :green)
custom_emojis = CustomEmoji.where(domain: domains)
custom_emojis_count = custom_emojis.count
custom_emojis.destroy_all unless options[:dry_run]
if options[:purge_domain_blocks]
domain_block_count = domain_block_scope.count
domain_block_scope.in_batches.destroy_all unless options[:dry_run]
say("Removed #{domain_block_count} domain blocks#{dry_run}", :green)
end
custom_emojis_count = emoji_scope.count
emoji_scope.in_batches.destroy_all unless options[:dry_run]
Instance.refresh unless options[:dry_run]
say("Removed #{custom_emojis_count} custom emojis", :green)
say("Removed #{custom_emojis_count} custom emojis#{dry_run}", :green)
end
option :concurrency, type: :numeric, default: 50, aliases: [:c]

View File

@ -9,9 +9,9 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
describe 'GET #export' do
it 'renders instances' do
Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad')
Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse', obfuscate: true)
Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media')
Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad server')
Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse server', obfuscate: true)
Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media and test unicode characters ♥')
Fabricate(:domain_block, domain: 'no.op', severity: 'noop', public_comment: 'noop')
get :export, params: { format: :csv }
@ -21,10 +21,32 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
end
describe 'POST #import' do
it 'blocks imported domains' do
context 'with complete domain blocks CSV' do
before do
post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } }
end
expect(assigns(:domain_blocks).map(&:domain)).to match_array ['bad.domain', 'worse.domain', 'reject.media']
it 'renders page with expected domain blocks' do
expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :silence], ['worse.domain', :suspend], ['reject.media', :noop]]
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
end
context 'with a list of only domains' do
before do
post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks_list.txt') } }
end
it 'renders page with expected domain blocks' do
expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :suspend], ['worse.domain', :suspend], ['reject.media', :suspend]]
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
end
end

View File

@ -4,39 +4,131 @@ describe Admin::Reports::ActionsController do
render_views
let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
let(:account) { Fabricate(:account) }
let!(:status) { Fabricate(:status, account: account) }
let(:media_attached_status) { Fabricate(:status, account: account) }
let!(:media_attachment) { Fabricate(:media_attachment, account: account, status: media_attached_status) }
let(:media_attached_deleted_status) { Fabricate(:status, account: account, deleted_at: 1.day.ago) }
let!(:media_attachment2) { Fabricate(:media_attachment, account: account, status: media_attached_deleted_status) }
let(:last_media_attached_status) { Fabricate(:status, account: account) }
let!(:last_media_attachment) { Fabricate(:media_attachment, account: account, status: last_media_attached_status) }
let!(:last_status) { Fabricate(:status, account: account) }
before do
sign_in user, scope: :user
end
describe 'POST #create' do
let(:report) { Fabricate(:report, status_ids: status_ids, account: user.account, target_account: account) }
let(:status_ids) { [media_attached_status.id, media_attached_deleted_status.id] }
describe 'POST #preview' do
let(:report) { Fabricate(:report) }
before do
post :create, params: { report_id: report.id, action => '' }
post :preview, params: { report_id: report.id, action => '' }
end
context 'when action is mark_as_sensitive' do
context 'when the action is "suspend"' do
let(:action) { 'suspend' }
it 'returns http success' do
expect(response).to have_http_status(200)
end
end
context 'when the action is "silence"' do
let(:action) { 'silence' }
it 'returns http success' do
expect(response).to have_http_status(200)
end
end
context 'when the action is "delete"' do
let(:action) { 'delete' }
it 'returns http success' do
expect(response).to have_http_status(200)
end
end
context 'when the action is "mark_as_sensitive"' do
let(:action) { 'mark_as_sensitive' }
it 'resolves the report' do
expect(report.reload.action_taken_at).to_not be_nil
it 'returns http success' do
expect(response).to have_http_status(200)
end
end
end
describe 'POST #create' do
let(:target_account) { Fabricate(:account) }
let(:statuses) { [Fabricate(:status, account: target_account), Fabricate(:status, account: target_account)] }
let!(:media) { Fabricate(:media_attachment, account: target_account, status: statuses[0]) }
let(:report) { Fabricate(:report, target_account: target_account, status_ids: statuses.map(&:id)) }
let(:text) { 'hello' }
shared_examples 'common behavior' do
it 'closes the report' do
expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
end
it 'creates a strike with the expected text' do
expect { subject }.to change { report.target_account.strikes.count }.by(1)
expect(report.target_account.strikes.last.text).to eq text
end
it 'redirects' do
subject
expect(response).to redirect_to(admin_reports_path)
end
end
shared_examples 'all action types' do
context 'when the action is "suspend"' do
let(:action) { 'suspend' }
it_behaves_like 'common behavior'
it 'suspends the target account' do
expect { subject }.to change { report.target_account.reload.suspended? }.from(false).to(true)
end
end
context 'when the action is "silence"' do
let(:action) { 'silence' }
it_behaves_like 'common behavior'
it 'suspends the target account' do
expect { subject }.to change { report.target_account.reload.silenced? }.from(false).to(true)
end
end
context 'when the action is "delete"' do
let(:action) { 'delete' }
it_behaves_like 'common behavior'
end
context 'when the action is "mark_as_sensitive"' do
let(:action) { 'mark_as_sensitive' }
let(:statuses) { [media_attached_status, media_attached_deleted_status] }
let!(:status) { Fabricate(:status, account: target_account) }
let(:media_attached_status) { Fabricate(:status, account: target_account) }
let!(:media_attachment) { Fabricate(:media_attachment, account: target_account, status: media_attached_status) }
let(:media_attached_deleted_status) { Fabricate(:status, account: target_account, deleted_at: 1.day.ago) }
let!(:media_attachment2) { Fabricate(:media_attachment, account: target_account, status: media_attached_deleted_status) }
let(:last_media_attached_status) { Fabricate(:status, account: target_account) }
let!(:last_media_attachment) { Fabricate(:media_attachment, account: target_account, status: last_media_attached_status) }
let!(:last_status) { Fabricate(:status, account: target_account) }
it_behaves_like 'common behavior'
it 'marks the non-deleted as sensitive' do
subject
expect(media_attached_status.reload.sensitive).to eq true
end
end
end
context 'action as submit button' do
subject { post :create, params: { report_id: report.id, text: text, action => '' } }
it_behaves_like 'all action types'
end
context 'action as submit button' do
subject { post :create, params: { report_id: report.id, text: text, moderation_action: action } }
it_behaves_like 'all action types'
end
end
end

View File

@ -16,6 +16,8 @@ describe ApplicationController, type: :controller do
controller do
include SignatureVerification
before_action :require_actor_signature!, only: [:signature_required]
def success
head 200
end
@ -23,10 +25,17 @@ describe ApplicationController, type: :controller do
def alternative_success
head 200
end
def signature_required
head 200
end
end
before do
routes.draw { match via: [:get, :post], 'success' => 'anonymous#success' }
routes.draw do
match via: [:get, :post], 'success' => 'anonymous#success'
match via: [:get, :post], 'signature_required' => 'anonymous#signature_required'
end
end
context 'without signature header' do
@ -118,6 +127,37 @@ describe ApplicationController, type: :controller do
end
end
context 'with request with unparseable Date header' do
before do
get :success
fake_request = Request.new(:get, request.url)
fake_request.add_headers({ 'Date' => 'wrong date' })
fake_request.on_behalf_of(author)
request.headers.merge!(fake_request.headers)
end
describe '#signed_request?' do
it 'returns true' do
expect(controller.signed_request?).to be true
end
end
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
describe '#signature_verification_failure_reason' do
it 'contains an error description' do
controller.signed_request_account
expect(controller.signature_verification_failure_reason[:error]).to eq 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
end
end
end
context 'with request older than a day' do
before do
get :success
@ -140,6 +180,13 @@ describe ApplicationController, type: :controller do
expect(controller.signed_request_account).to be_nil
end
end
describe '#signature_verification_failure_reason' do
it 'contains an error description' do
controller.signed_request_account
expect(controller.signature_verification_failure_reason[:error]).to eq 'Signed request date outside acceptable time window'
end
end
end
context 'with inaccessible key' do
@ -171,6 +218,7 @@ describe ApplicationController, type: :controller do
context 'with body' do
before do
allow(controller).to receive(:actor_refresh_key!).and_return(author)
post :success, body: 'Hello world'
fake_request = Request.new(:post, request.url, body: 'Hello world')
@ -189,22 +237,67 @@ describe ApplicationController, type: :controller do
it 'returns an account' do
expect(controller.signed_request_account).to eq author
end
end
it 'returns nil when path does not match' do
context 'when path does not match' do
before do
request.path = '/alternative-path'
expect(controller.signed_request_account).to be_nil
end
it 'returns nil when method does not match' do
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
describe '#signature_verification_failure_reason' do
it 'contains an error description' do
controller.signed_request_account
expect(controller.signature_verification_failure_reason[:error]).to include('using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)')
expect(controller.signature_verification_failure_reason[:signed_string]).to include("(request-target): post /alternative-path\n")
end
end
end
context 'when method does not match' do
before do
get :success
expect(controller.signed_request_account).to be_nil
end
it 'returns nil when body has been tampered' do
post :success, body: 'doo doo doo'
describe '#signed_request_account' do
it 'returns nil' do
expect(controller.signed_request_account).to be_nil
end
end
end
context 'when body has been tampered' do
before do
post :success, body: 'doo doo doo'
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

View File

@ -1,4 +1,4 @@
#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate
bad.domain,silence,false,false,bad,false
worse.domain,suspend,true,true,worse,true
reject.media,noop,true,false,reject media,false
bad.domain,silence,false,false,bad server,false
worse.domain,suspend,true,true,worse server,true
reject.media,noop,true,false,reject media and test unicode characters ♥,false

1 #domain #severity #reject_media #reject_reports #public_comment #obfuscate
2 bad.domain silence false false bad bad server false
3 worse.domain suspend true true worse worse server true
4 reject.media noop true false reject media reject media and test unicode characters ♥ false

View File

@ -0,0 +1,3 @@
bad.domain
worse.domain
reject.media

View File

@ -87,6 +87,28 @@ RSpec.describe UpdateStatusService, type: :service do
end
end
context 'when already-attached media changes' do
let!(:status) { Fabricate(:status, text: 'Foo') }
let!(:media_attachment) { Fabricate(:media_attachment, account: status.account, description: 'Old description') }
before do
status.media_attachments << media_attachment
subject.call(status, status.account_id, text: 'Foo', media_ids: [media_attachment.id], media_attributes: [{ id: media_attachment.id, description: 'New description' }])
end
it 'does not detach media attachment' do
expect(media_attachment.reload.status_id).to eq status.id
end
it 'updates the media attachment description' do
expect(media_attachment.reload.description).to eq 'New description'
end
it 'saves edit history' do
expect(status.edits.map { |edit| edit.ordered_media_attachments.map(&:description) }).to eq [['Old description'], ['New description']]
end
end
context 'when poll changes' do
let(:account) { Fabricate(:account) }
let!(:status) { Fabricate(:status, text: 'Foo', account: account, poll_attributes: {options: %w(Foo Bar), account: account, multiple: false, hide_totals: false, expires_at: 7.days.from_now }) }