diff options
72 files changed, 1159 insertions, 152 deletions
diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index 3f2e28b6a..e89404b60 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -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 diff --git a/app/controllers/admin/export_domain_allows_controller.rb b/app/controllers/admin/export_domain_allows_controller.rb index 57fb12c62..adfc39da2 100644 --- a/app/controllers/admin/export_domain_allows_controller.rb +++ b/app/controllers/admin/export_domain_allows_controller.rb @@ -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) diff --git a/app/controllers/admin/export_domain_blocks_controller.rb b/app/controllers/admin/export_domain_blocks_controller.rb index fb0cd05d2..816422d4f 100644 --- a/app/controllers/admin/export_domain_blocks_controller.rb +++ b/app/controllers/admin/export_domain_blocks_controller.rb @@ -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)) + + 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 if domain_block.valid? + 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) diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb index 5cb5c744f..554f7906f 100644 --- a/app/controllers/admin/reports/actions_controller.rb +++ b/app/controllers/admin/reports/actions_controller.rb @@ -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 diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb index f3c0c4b6b..e77df3021 100644 --- a/app/controllers/api/v1/admin/trends/tags_controller.rb +++ b/app/controllers/api/v1/admin/trends/tags_controller.rb @@ -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? diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index e2e48f633..3a9cf056b 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -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, diff --git a/app/controllers/concerns/admin_export_controller_concern.rb b/app/controllers/concerns/admin_export_controller_concern.rb index b40c76557..4ac48a04b 100644 --- a/app/controllers/concerns/admin_export_controller_concern.rb +++ b/app/controllers/concerns/admin_export_controller_concern.rb @@ -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 diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 4502da698..a9950d21f 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -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? diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 7a4af4cda..267cff563 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -181,6 +181,18 @@ export function submitCompose(routerHistory) { dispatch(submitComposeRequest()); + // If we're editing a post with media attachments, those have not + // necessarily been changed on the server. Do it now in the same + // API call. + let media_attributes; + if (statusId !== null) { + media_attributes = media.map(item => ({ + id: item.get('id'), + description: item.get('description'), + focus: item.get('focus'), + })); + } + api(getState).request({ url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, method: statusId === null ? 'post' : 'put', @@ -189,6 +201,7 @@ export function submitCompose(routerHistory) { content_type: getState().getIn(['compose', 'content_type']), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: media.map(item => item.get('id')), + media_attributes, sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), spoiler_text: spoilerText, visibility: getState().getIn(['compose', 'privacy']), @@ -415,11 +428,31 @@ export function changeUploadCompose(id, params) { return (dispatch, getState) => { dispatch(changeUploadComposeRequest()); - api(getState).put(`/api/v1/media/${id}`, params).then(response => { - dispatch(changeUploadComposeSuccess(response.data)); - }).catch(error => { - dispatch(changeUploadComposeFail(id, error)); - }); + let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id); + + // Editing already-attached media is deferred to editing the post itself. + // For simplicity's sake, fake an API reply. + if (media && !media.get('unattached')) { + let { description, focus } = params; + const data = media.toJS(); + + if (description) { + data.description = description; + } + + if (focus) { + focus = focus.split(','); + data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } }; + } + + dispatch(changeUploadComposeSuccess(data, true)); + } else { + api(getState).put(`/api/v1/media/${id}`, params).then(response => { + dispatch(changeUploadComposeSuccess(response.data, false)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + } }; }; @@ -430,10 +463,11 @@ export function changeUploadComposeRequest() { }; }; -export function changeUploadComposeSuccess(media) { +export function changeUploadComposeSuccess(media, attached) { return { type: COMPOSE_UPLOAD_CHANGE_SUCCESS, media: media, + attached: attached, skipLoading: true, }; }; diff --git a/app/javascript/flavours/glitch/actions/tags.js b/app/javascript/flavours/glitch/actions/tags.js index 37e79d4cb..08a08cda3 100644 --- a/app/javascript/flavours/glitch/actions/tags.js +++ b/app/javascript/flavours/glitch/actions/tags.js @@ -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)); diff --git a/app/javascript/flavours/glitch/components/admin/Trends.js b/app/javascript/flavours/glitch/components/admin/Trends.js index 4c17b69a0..774bf36e6 100644 --- a/app/javascript/flavours/glitch/components/admin/Trends.js +++ b/app/javascript/flavours/glitch/components/admin/Trends.js @@ -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)} diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js index ec4a192bc..071d00bb4 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.js +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -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' }); diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.js b/app/javascript/flavours/glitch/features/compose/components/action_bar.js index 267c0ba69..838ef09ea 100644 --- a/app/javascript/flavours/glitch/features/compose/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.js @@ -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' }); diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js index 6528bbc84..cd4524540 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload.js +++ b/app/javascript/flavours/glitch/features/compose/components/upload.js @@ -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> diff --git a/app/javascript/flavours/glitch/features/followed_tags/index.js b/app/javascript/flavours/glitch/features/followed_tags/index.js new file mode 100644 index 000000000..4a23afc2d --- /dev/null +++ b/app/javascript/flavours/glitch/features/followed_tags/index.js @@ -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> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js index 0dd07fb76..fb432cf9c 100644 --- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js @@ -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> diff --git a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js index e8023803f..c0d62aca0 100644 --- a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js +++ b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js @@ -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> diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 72e13d9d6..d8889f9f9 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -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} /> diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js index 025b22e61..03e501628 100644 --- a/app/javascript/flavours/glitch/features/ui/util/async-components.js +++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js @@ -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'); } diff --git a/app/javascript/flavours/glitch/initial_state.js b/app/javascript/flavours/glitch/initial_state.js index bbf25c8a8..eefbdca80 100644 --- a/app/javascript/flavours/glitch/initial_state.js +++ b/app/javascript/flavours/glitch/initial_state.js @@ -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'); diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index 814b6a1a7..a69c0f7f2 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -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; diff --git a/app/javascript/flavours/glitch/reducers/followed_tags.js b/app/javascript/flavours/glitch/reducers/followed_tags.js new file mode 100644 index 000000000..4109b0b10 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/followed_tags.js @@ -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; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index 09c08a362..5b7bdbf69 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -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); diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 8ddf815c3..9aa2318ce 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -1588,6 +1588,15 @@ a.sparkline { margin-bottom: 0; } } + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } } &__actions { diff --git a/app/javascript/flavours/glitch/styles/components/compose_form.scss b/app/javascript/flavours/glitch/styles/components/compose_form.scss index aa2d52ed0..40adf28c9 100644 --- a/app/javascript/flavours/glitch/styles/components/compose_form.scss +++ b/app/javascript/flavours/glitch/styles/components/compose_form.scss @@ -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; } } } diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 12d25490b..cc27840d0 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -160,6 +160,18 @@ export function submitCompose(routerHistory) { dispatch(submitComposeRequest()); + // If we're editing a post with media attachments, those have not + // necessarily been changed on the server. Do it now in the same + // API call. + let media_attributes; + if (statusId !== null) { + media_attributes = media.map(item => ({ + id: item.get('id'), + description: item.get('description'), + focus: item.get('focus'), + })); + } + api(getState).request({ url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, method: statusId === null ? 'post' : 'put', @@ -167,6 +179,7 @@ export function submitCompose(routerHistory) { status, in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: media.map(item => item.get('id')), + media_attributes, sensitive: getState().getIn(['compose', 'sensitive']), spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', visibility: getState().getIn(['compose', 'privacy']), @@ -377,11 +390,31 @@ export function changeUploadCompose(id, params) { return (dispatch, getState) => { dispatch(changeUploadComposeRequest()); - api(getState).put(`/api/v1/media/${id}`, params).then(response => { - dispatch(changeUploadComposeSuccess(response.data)); - }).catch(error => { - dispatch(changeUploadComposeFail(id, error)); - }); + let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id); + + // Editing already-attached media is deferred to editing the post itself. + // For simplicity's sake, fake an API reply. + if (media && !media.get('unattached')) { + let { description, focus } = params; + const data = media.toJS(); + + if (description) { + data.description = description; + } + + if (focus) { + focus = focus.split(','); + data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } }; + } + + dispatch(changeUploadComposeSuccess(data, true)); + } else { + api(getState).put(`/api/v1/media/${id}`, params).then(response => { + dispatch(changeUploadComposeSuccess(response.data, false)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + } }; } @@ -392,10 +425,11 @@ export function changeUploadComposeRequest() { }; } -export function changeUploadComposeSuccess(media) { +export function changeUploadComposeSuccess(media, attached) { return { type: COMPOSE_UPLOAD_CHANGE_SUCCESS, media: media, + attached: attached, skipLoading: true, }; } diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js index 37e79d4cb..08a08cda3 100644 --- a/app/javascript/mastodon/actions/tags.js +++ b/app/javascript/mastodon/actions/tags.js @@ -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)); diff --git a/app/javascript/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js index 9530c2a5b..d01b8437e 100644 --- a/app/javascript/mastodon/components/admin/Trends.js +++ b/app/javascript/mastodon/components/admin/Trends.js @@ -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)} diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 2481e4783..46fb89f2f 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -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' }); diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index ceed928bf..90c85321e 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -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' }); diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index b08307ade..af06ce1bf 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -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> diff --git a/app/javascript/mastodon/features/followed_tags/index.js b/app/javascript/mastodon/features/followed_tags/index.js new file mode 100644 index 000000000..0a62ca76d --- /dev/null +++ b/app/javascript/mastodon/features/followed_tags/index.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 479f4abd2..b9dbd9390 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -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> diff --git a/app/javascript/mastodon/features/ui/components/sign_in_banner.js b/app/javascript/mastodon/features/ui/components/sign_in_banner.js index 8bd32edf9..86fcc11b5 100644 --- a/app/javascript/mastodon/features/ui/components/sign_in_banner.js +++ b/app/javascript/mastodon/features/ui/components/sign_in_banner.js @@ -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> diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index b05956606..78dc9ea40 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -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} /> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 6046578de..1cf07f645 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -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'); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 8eec7f7c7..f58b4eb0e 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -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'); diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 3ed438fb8..249ad0770 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -1392,6 +1392,10 @@ "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" }, { @@ -4310,4 +4314,4 @@ ], "path": "app/javascript/mastodon/features/video/index.json" } -] \ No newline at end of file +] diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 9db0f3eb7..a2f7b23ad 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 9ce7e97ed..1760c7c89 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -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; diff --git a/app/javascript/mastodon/reducers/followed_tags.js b/app/javascript/mastodon/reducers/followed_tags.js new file mode 100644 index 000000000..f50ee6aa3 --- /dev/null +++ b/app/javascript/mastodon/reducers/followed_tags.js @@ -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; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index bccdc1865..69771ad1b 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -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); diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 4250cf2b6..798d520cd 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -1588,6 +1588,15 @@ a.sparkline { margin-bottom: 0; } } + + a { + color: $highlight-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } } &__actions { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index d5937643f..22f404c3b 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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; } } } diff --git a/app/lib/admin/system_check/elasticsearch_check.rb b/app/lib/admin/system_check/elasticsearch_check.rb index 7f922978f..5b4c12399 100644 --- a/app/lib/admin/system_check/elasticsearch_check.rb +++ b/app/lib/admin/system_check/elasticsearch_check.rb @@ -30,19 +30,24 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck def running_version @running_version ||= begin - Chewy.client.info['version']['minimum_wire_compatibility_version'] || - Chewy.client.info['version']['number'] + Chewy.client.info['version']['number'] rescue Faraday::ConnectionFailed nil end end + def compatible_wire_version + Chewy.client.info['version']['minimum_wire_compatibility_version'] + end + def required_version '7.x' end def compatible_version? return false if running_version.nil? - Gem::Version.new(running_version) >= Gem::Version.new(required_version) + + Gem::Version.new(running_version) >= Gem::Version.new(required_version) || + Gem::Version.new(compatible_wire_version) >= Gem::Version.new(required_version) end end diff --git a/app/models/account.rb b/app/models/account.rb index 851ce1fd6..f4631e938 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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' } diff --git a/app/models/admin/import.rb b/app/models/admin/import.rb index 79c0722d5..fecde4878 100644 --- a/app/models/admin/import.rb +++ b/app/models/admin/import.rb @@ -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 validate_data - return if data.blank? + 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 - csv_data = CSV.read(data.path, encoding: 'UTF-8') + def csv_row_count + return @csv_row_count if defined?(@csv_row_count) - row_count = csv_data.size - row_count -= 1 if csv_data.first&.first == '#domain' + csv_data.rewind + @csv_row_count = csv_data.take(ROWS_PROCESSING_LIMIT + 2).count + end - errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if row_count > ROWS_PROCESSING_LIMIT + def validate_data + 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 diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb index 39cd7d0eb..b8bdec722 100644 --- a/app/models/admin/status_batch_action.rb +++ b/app/models/admin/status_batch_action.rb @@ -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? diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb index e8b804934..37c3b8895 100644 --- a/app/models/concerns/account_finder_concern.rb +++ b/app/models/concerns/account_finder_concern.rb @@ -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) diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index b595529f8..f76ee42e7 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -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 diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index d753fa51a..c45de4e06 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -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 diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 9a3ca75dc..6152d54f7 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -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 diff --git a/app/services/activitypub/fetch_remote_actor_service.rb b/app/services/activitypub/fetch_remote_actor_service.rb index a25fa54c4..4f60ea5e8 100644 --- a/app/services/activitypub/fetch_remote_actor_service.rb +++ b/app/services/activitypub/fetch_remote_actor_service.rb @@ -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'] diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index cc4ec670d..7a8245839 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -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 diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml index fa69c6fab..df1ac455f 100644 --- a/app/views/admin/announcements/edit.html.haml +++ b/app/views/admin/announcements/edit.html.haml @@ -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 diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml index 54c252ee8..64628989a 100644 --- a/app/views/admin/report_notes/_report_note.html.haml +++ b/app/views/admin/report_notes/_report_note.html.haml @@ -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 diff --git a/app/views/admin/reports/_actions.html.haml b/app/views/admin/reports/_actions.html.haml index 486eb486c..aad441625 100644 --- a/app/views/admin/reports/_actions.html.haml +++ b/app/views/admin/reports/_actions.html.haml @@ -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 diff --git a/app/views/admin/reports/actions/preview.html.haml b/app/views/admin/reports/actions/preview.html.haml new file mode 100644 index 000000000..58745319c --- /dev/null +++ b/app/views/admin/reports/actions/preview.html.haml @@ -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 diff --git a/app/views/admin/settings/discovery/show.html.haml b/app/views/admin/settings/discovery/show.html.haml index 01e3124cf..460bb5709 100644 --- a/app/views/admin/settings/discovery/show.html.haml +++ b/app/views/admin/settings/discovery/show.html.haml @@ -16,6 +16,9 @@ = 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 .fields-group diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 334511efe..a3b5e5c72 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -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.*' diff --git a/config/locales/en.yml b/config/locales/en.yml index 763110c77..3de2f2772 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index f66e12c4c..d01f0ae75 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 8639f0ef5..f78d692b6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/config/settings.yml b/config/settings.yml index 1f44fd308..c8d37a678 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -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 diff --git a/db/seeds/02_instance_actor.rb b/db/seeds/02_instance_actor.rb index 39186b273..f9aa372f1 100644 --- a/db/seeds/02_instance_actor.rb +++ b/db/seeds/02_instance_actor.rb @@ -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) diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb index 77364ffbb..81ee53c18 100644 --- a/lib/mastodon/domains_cli.rb +++ b/lib/mastodon/domains_cli.rb @@ -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)' : '' - - 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 + 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 + + # Sanity check on command arguments + if options[:limited_federation_mode] && !domains.empty? + say('DOMAIN parameter not supported with --limited-federation-mode', :red) + exit(1) + elsif domains.empty? && !options[:limited_federation_mode] + say('No domain(s) given', :red) + exit(1) + end + + # Build scopes from command arguments + if options[:limited_federation_mode] + account_scope = Account.remote.where.not(domain: DomainAllow.select(:domain)) + emoji_scope = CustomEmoji.remote.where.not(domain: DomainAllow.select(:domain)) + else + # Handle wildcard subdomains + subdomain_patterns = domains.filter_map { |domain| "%.#{Account.sanitize_sql_like(domain[2..])}" if domain.start_with?('*.') } + domains = domains.filter { |domain| !domain.start_with?('*.') } + # Handle --include-subdomains + subdomain_patterns += domains.map { |domain| "%.#{Account.sanitize_sql_like(domain)}" } if options[:include_subdomains] + uri_patterns = (domains.map { |domain| Account.sanitize_sql_like(domain) } + subdomain_patterns).map { |pattern| "https://#{pattern}/%" } + + if options[:purge_domain_blocks] + domain_block_scope = DomainBlock.where(domain: domains) + domain_block_scope = domain_block_scope.or(DomainBlock.where(DomainBlock.arel_table[:domain].matches_any(subdomain_patterns))) unless subdomain_patterns.empty? + end + + if options[:by_uri] + account_scope = Account.remote.where(Account.arel_table[:uri].matches_any(uri_patterns, false, true)) + emoji_scope = CustomEmoji.remote.where(CustomEmoji.arel_table[:uri].matches_any(uri_patterns, false, true)) else - say('No domain(s) given', :red) - exit(1) + account_scope = Account.remote.where(domain: domains) + account_scope = account_scope.or(Account.remote.where(Account.arel_table[:domain].matches_any(subdomain_patterns))) unless subdomain_patterns.empty? + emoji_scope = CustomEmoji.where(domain: domains) + emoji_scope = emoji_scope.or(CustomEmoji.remote.where(CustomEmoji.arel_table[:uri].matches_any(subdomain_patterns))) unless subdomain_patterns.empty? end end - processed, = parallelize_with_progress(scope) do |account| + # Actually perform the deletions + processed, = parallelize_with_progress(account_scope) do |account| DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run] end - DomainBlock.where(domain: domains).destroy_all unless options[:dry_run] - say("Removed #{processed} accounts#{dry_run}", :green) - custom_emojis = CustomEmoji.where(domain: domains) - custom_emojis_count = custom_emojis.count - custom_emojis.destroy_all unless options[:dry_run] + if options[:purge_domain_blocks] + domain_block_count = domain_block_scope.count + domain_block_scope.in_batches.destroy_all unless options[:dry_run] + say("Removed #{domain_block_count} domain blocks#{dry_run}", :green) + end + + custom_emojis_count = emoji_scope.count + emoji_scope.in_batches.destroy_all unless options[:dry_run] Instance.refresh unless options[:dry_run] - say("Removed #{custom_emojis_count} custom emojis", :green) + say("Removed #{custom_emojis_count} custom emojis#{dry_run}", :green) end option :concurrency, type: :numeric, default: 50, aliases: [:c] diff --git a/spec/controllers/admin/export_domain_blocks_controller_spec.rb b/spec/controllers/admin/export_domain_blocks_controller_spec.rb index 8697e0c21..2766102c8 100644 --- a/spec/controllers/admin/export_domain_blocks_controller_spec.rb +++ b/spec/controllers/admin/export_domain_blocks_controller_spec.rb @@ -9,9 +9,9 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do describe 'GET #export' do it 'renders instances' do - Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad') - Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse', obfuscate: true) - Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media') + Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad server') + Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse server', obfuscate: true) + Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media and test unicode characters ♥') Fabricate(:domain_block, domain: 'no.op', severity: 'noop', public_comment: 'noop') get :export, params: { format: :csv } @@ -21,10 +21,32 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do end describe 'POST #import' do - it 'blocks imported domains' do - post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } } + context 'with complete domain blocks CSV' do + before do + post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } } + end - expect(assigns(:domain_blocks).map(&:domain)).to match_array ['bad.domain', 'worse.domain', 'reject.media'] + it 'renders page with expected domain blocks' do + expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :silence], ['worse.domain', :suspend], ['reject.media', :noop]] + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + end + + context 'with a list of only domains' do + before do + post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks_list.txt') } } + end + + it 'renders page with expected domain blocks' do + expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :suspend], ['worse.domain', :suspend], ['reject.media', :suspend]] + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end end end diff --git a/spec/controllers/admin/reports/actions_controller_spec.rb b/spec/controllers/admin/reports/actions_controller_spec.rb index 6609798dc..9890ac9ce 100644 --- a/spec/controllers/admin/reports/actions_controller_spec.rb +++ b/spec/controllers/admin/reports/actions_controller_spec.rb @@ -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 the action is "suspend"' do + let(:action) { 'suspend' } + + it 'returns http success' do + expect(response).to have_http_status(200) + end end - context 'when action is mark_as_sensitive' do + 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 'marks the non-deleted as sensitive' do - expect(media_attached_status.reload.sensitive).to eq true + 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 diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb index 6e73643b4..13655f313 100644 --- a/spec/controllers/concerns/signature_verification_spec.rb +++ b/spec/controllers/concerns/signature_verification_spec.rb @@ -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,21 +237,66 @@ describe ApplicationController, type: :controller do it 'returns an account' do expect(controller.signed_request_account).to eq author end + end - it 'returns nil when path does not match' do + context 'when path does not match' do + before do request.path = '/alternative-path' - expect(controller.signed_request_account).to be_nil end - it 'returns nil when method does not match' do + describe '#signed_request_account' do + it 'returns nil' do + expect(controller.signed_request_account).to be_nil + end + end + + describe '#signature_verification_failure_reason' do + it 'contains an error description' do + controller.signed_request_account + expect(controller.signature_verification_failure_reason[:error]).to include('using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)') + expect(controller.signature_verification_failure_reason[:signed_string]).to include("(request-target): post /alternative-path\n") + end + end + end + + context 'when method does not match' do + before do get :success - expect(controller.signed_request_account).to be_nil end - it 'returns nil when body has been tampered' do + describe '#signed_request_account' do + it 'returns nil' do + expect(controller.signed_request_account).to be_nil + end + end + end + + context 'when body has been tampered' do + before do post :success, body: 'doo doo doo' - expect(controller.signed_request_account).to be_nil end + + describe '#signed_request_account' do + it 'returns nil when body has been tampered' do + expect(controller.signed_request_account).to be_nil + end + end + end + end + end + + context 'when a signature is required' do + before do + get :signature_required + end + + context 'without signature header' do + it 'returns HTTP 401' do + expect(response).to have_http_status(401) + end + + it 'returns an error' do + expect(Oj.load(response.body)['error']).to eq 'Request not signed' end end end diff --git a/spec/fixtures/files/domain_blocks.csv b/spec/fixtures/files/domain_blocks.csv index 28ffb9175..9dbfb4eaf 100644 --- a/spec/fixtures/files/domain_blocks.csv +++ b/spec/fixtures/files/domain_blocks.csv @@ -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 diff --git a/spec/fixtures/files/domain_blocks_list.txt b/spec/fixtures/files/domain_blocks_list.txt new file mode 100644 index 000000000..7b6b24253 --- /dev/null +++ b/spec/fixtures/files/domain_blocks_list.txt @@ -0,0 +1,3 @@ +bad.domain +worse.domain +reject.media diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index 71a73be5b..16e981d2b 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -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 }) } |