From f136ee3f43cdd24d1ebc377a21a5aa86cc696c90 Mon Sep 17 00:00:00 2001 From: Takuya Yoshida Date: Wed, 9 Feb 2022 20:30:00 +0900 Subject: [PATCH 01/19] Add support >= 1.22 (#17490) --- chart/templates/ingress.yaml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml index 8930d2c10e..7295297fb1 100644 --- a/chart/templates/ingress.yaml +++ b/chart/templates/ingress.yaml @@ -2,7 +2,9 @@ {{- $fullName := include "mastodon.fullname" . -}} {{- $webPort := .Values.mastodon.web.port -}} {{- $streamingPort := .Values.mastodon.streaming.port -}} -{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +{{- if or (.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not (.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} apiVersion: networking.k8s.io/v1beta1 {{- else -}} apiVersion: extensions/v1beta1 @@ -35,12 +37,32 @@ spec: {{- range .paths }} - path: {{ .path }} backend: + {{- if or ($.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not ($.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }} + service: + name: {{ $fullName }}-web + port: + number: {{ $webPort }} + {{- else }} serviceName: {{ $fullName }}-web servicePort: {{ $webPort }} + {{- end }} + {{- if or ($.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not ($.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }} + pathType: ImplementationSpecific + {{- end }} - path: {{ .path }}api/v1/streaming backend: + {{- if or ($.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not ($.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }} + service: + name: {{ $fullName }}-streaming + port: + number: {{ $streamingPort }} + {{- else }} serviceName: {{ $fullName }}-streaming servicePort: {{ $streamingPort }} + {{- end }} + {{- if or ($.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress") (not ($.Capabilities.APIVersions.Has "networking.k8s.io/v1beta1/Ingress")) }} + pathType: ImplementationSpecific + {{- end }} {{- end }} {{- end }} {{- end }} From 2c9def57b3810edde1fcbe1c9e2b043b66928c26 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 10 Feb 2022 00:10:16 +0100 Subject: [PATCH 02/19] Add `category` and `rule_ids` params to `POST /api/v1/reports` (#17492) --- app/controllers/api/v1/reports_controller.rb | 2 +- app/models/report.rb | 9 +++++++++ app/services/report_service.rb | 12 ++++++++++-- config/locales/en.yml | 3 +++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index e10083d450..052d70cc8d 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -33,6 +33,6 @@ class Api::V1::ReportsController < Api::BaseController end def report_params - params.permit(:account_id, :comment, :forward, status_ids: []) + params.permit(:account_id, :comment, :category, :forward, status_ids: [], rule_ids: []) end end diff --git a/app/models/report.rb b/app/models/report.rb index ceb15133b1..3dd8a6fdd4 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -39,6 +39,9 @@ class Report < ApplicationRecord scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) } validates :comment, length: { maximum: 1_000 } + validates :rule_ids, absence: true, unless: :violation? + + validate :validate_rule_ids enum category: { other: 0, @@ -122,4 +125,10 @@ class Report < ApplicationRecord def set_uri self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil? && account.local? end + + def validate_rule_ids + return unless violation? + + errors.add(:rule_ids, I18n.t('reports.errors.invalid_rules')) unless rules.size == rule_ids.size + end end diff --git a/app/services/report_service.rb b/app/services/report_service.rb index bc0a8b4642..caf99ab6e0 100644 --- a/app/services/report_service.rb +++ b/app/services/report_service.rb @@ -8,13 +8,15 @@ class ReportService < BaseService @target_account = target_account @status_ids = options.delete(:status_ids) || [] @comment = options.delete(:comment) || '' + @category = options.delete(:category) || 'other' + @rule_ids = options.delete(:rule_ids) @options = options raise ActiveRecord::RecordNotFound if @target_account.suspended? create_report! notify_staff! - forward_to_origin! if !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward]) + forward_to_origin! if forward? @report end @@ -27,7 +29,9 @@ class ReportService < BaseService status_ids: @status_ids, comment: @comment, uri: @options[:uri], - forwarded: ActiveModel::Type::Boolean.new.cast(@options[:forward]) + forwarded: forward?, + category: @category, + rule_ids: @rule_ids ) end @@ -48,6 +52,10 @@ class ReportService < BaseService ) end + def forward? + !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward]) + end + def payload Oj.dump(serialize_payload(@report, ActivityPub::FlagSerializer, account: some_local_account)) end diff --git a/config/locales/en.yml b/config/locales/en.yml index 600090e784..ccaff84b4b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1225,6 +1225,9 @@ en: reply: proceed: Proceed to reply prompt: 'You want to reply to this post:' + reports: + errors: + invalid_rules: does not reference valid rules scheduled_statuses: over_daily_limit: You have exceeded the limit of %{limit} scheduled posts for today over_total_limit: You have exceeded the limit of %{limit} scheduled posts From f52e8d466371e69a24dab59c453416d9630c4959 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 10 Feb 2022 00:10:27 +0100 Subject: [PATCH 03/19] Chore: Update browserslist (#17493) --- yarn.lock | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/yarn.lock b/yarn.lock index 158d3a1b8e..eb922dad13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2906,20 +2906,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001219: - version "1.0.30001228" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz#bfdc5942cd3326fa51ee0b42fbef4da9d492a7fa" - integrity sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A== - -caniuse-lite@^1.0.30001271: - version "1.0.30001274" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001274.tgz#26ca36204d15b17601ba6fc35dbdad950a647cc7" - integrity sha512-+Nkvv0fHyhISkiMIjnyjmf5YJcQ1IQHZN6U9TLUMroWR38FNwpsC51Gb68yueafX1V6ifOisInSgP9WJFS13ew== - -caniuse-lite@^1.0.30001286: - version "1.0.30001300" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz#11ab6c57d3eb6f964cba950401fd00a146786468" - integrity sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001271, caniuse-lite@^1.0.30001286: + version "1.0.30001310" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001310.tgz" + integrity sha512-cb9xTV8k9HTIUA3GnPUJCk0meUnrHL5gy5QePfDjxHyNBcnzPzrHFv5GqfP7ue5b1ZyzZL0RJboD6hQlPXjhjg== chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: version "1.1.3" From cb76142d9e1a7a9a1a7d1b6ab4ec43e56f8b352d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 10 Feb 2022 00:15:30 +0100 Subject: [PATCH 04/19] Add editing for published statuses (#17320) * Add editing for published statuses * Fix change of multiple-choice boolean in poll not resetting votes * Remove the ability to update existing media attachments for now --- app/controllers/api/v1/media_controller.rb | 6 +- app/controllers/api/v1/statuses_controller.rb | 51 ++-- app/javascript/mastodon/actions/compose.js | 41 ++- app/javascript/mastodon/actions/statuses.js | 37 ++- .../mastodon/components/status_action_bar.js | 6 + .../mastodon/containers/status_container.js | 5 + .../compose/components/compose_form.js | 6 +- .../containers/compose_form_container.js | 1 + .../containers/reply_indicator_container.js | 17 +- .../features/status/components/action_bar.js | 7 + .../mastodon/features/status/index.js | 6 + app/javascript/mastodon/reducers/compose.js | 45 +++- app/models/media_attachment.rb | 4 + app/models/status.rb | 10 + app/policies/status_policy.rb | 2 +- .../process_status_update_service.rb | 14 +- app/services/process_hashtags_service.rb | 40 ++- app/services/update_status_service.rb | 156 +++++++++++ .../status_update_distribution_worker.rb | 28 ++ config/routes.rb | 2 +- .../api/v1/media_controller_spec.rb | 21 +- .../api/v1/statuses_controller_spec.rb | 17 ++ spec/policies/status_policy_spec.rb | 16 +- .../process_status_update_service_spec.rb | 248 ++++++++++++++++++ spec/services/update_status_service_spec.rb | 130 +++++++++ 25 files changed, 839 insertions(+), 77 deletions(-) create mode 100644 app/services/update_status_service.rb create mode 100644 app/workers/activitypub/status_update_distribution_worker.rb create mode 100644 spec/services/activitypub/process_status_update_service_spec.rb create mode 100644 spec/services/update_status_service_spec.rb diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index a2a919a3e6..72094790fd 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -20,7 +20,7 @@ class Api::V1::MediaController < Api::BaseController end def update - @media_attachment.update!(media_attachment_params) + @media_attachment.update!(updateable_media_attachment_params) render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment end @@ -42,6 +42,10 @@ class Api::V1::MediaController < Api::BaseController params.permit(:file, :thumbnail, :description, :focus) end + def updateable_media_attachment_params + params.permit(:thumbnail, :description, :focus) + end + def file_type_error { error: 'File type of uploaded media could not be verified' } end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 98b1776ea9..2d82a7a99b 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -3,8 +3,8 @@ class Api::V1::StatusesController < Api::BaseController include Authorization - before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy] - before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy] + before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] + before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy] before_action :require_user!, except: [:show, :context] before_action :set_status, only: [:show, :context] before_action :set_thread, only: [:create] @@ -35,24 +35,44 @@ class Api::V1::StatusesController < Api::BaseController end def create - @status = PostStatusService.new.call(current_user.account, - text: status_params[:status], - thread: @thread, - media_ids: status_params[:media_ids], - sensitive: status_params[:sensitive], - spoiler_text: status_params[:spoiler_text], - visibility: status_params[:visibility], - scheduled_at: status_params[:scheduled_at], - application: doorkeeper_token.application, - poll: status_params[:poll], - idempotency: request.headers['Idempotency-Key'], - with_rate_limit: true) + @status = PostStatusService.new.call( + current_user.account, + text: status_params[:status], + thread: @thread, + media_ids: status_params[:media_ids], + sensitive: status_params[:sensitive], + spoiler_text: status_params[:spoiler_text], + visibility: status_params[:visibility], + language: status_params[:language], + scheduled_at: status_params[:scheduled_at], + application: doorkeeper_token.application, + poll: status_params[:poll], + idempotency: request.headers['Idempotency-Key'], + with_rate_limit: true + ) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer end + def update + @status = Status.where(account: current_account).find(params[:id]) + authorize @status, :update? + + UpdateStatusService.new.call( + @status, + current_account.id, + text: status_params[:status], + media_ids: status_params[:media_ids], + sensitive: status_params[:sensitive], + spoiler_text: status_params[:spoiler_text], + poll: status_params[:poll] + ) + + render json: @status, serializer: REST::StatusSerializer + end + def destroy - @status = Status.where(account_id: current_user.account).find(params[:id]) + @status = Status.where(account: current_account).find(params[:id]) authorize @status, :destroy? @status.discard @@ -84,6 +104,7 @@ class Api::V1::StatusesController < Api::BaseController :sensitive, :spoiler_text, :visibility, + :language, :scheduled_at, media_ids: [], poll: [ diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 94a4df4181..b0a217550f 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -70,6 +70,8 @@ export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL'; export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION'; export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; +export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; + const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, @@ -83,6 +85,15 @@ export const ensureComposeIsVisible = (getState, routerHistory) => { } }; +export function setComposeToStatus(status, text, spoiler_text) { + return{ + type: COMPOSE_SET_STATUS, + status, + text, + spoiler_text, + }; +}; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -137,8 +148,9 @@ export function directCompose(account, routerHistory) { export function submitCompose(routerHistory) { return function (dispatch, getState) { - const status = getState().getIn(['compose', 'text'], ''); - const media = getState().getIn(['compose', 'media_attachments']); + const status = getState().getIn(['compose', 'text'], ''); + const media = getState().getIn(['compose', 'media_attachments']); + const statusId = getState().getIn(['compose', 'id'], null); if ((!status || !status.length) && media.size === 0) { return; @@ -146,15 +158,18 @@ export function submitCompose(routerHistory) { dispatch(submitComposeRequest()); - api(getState).post('/api/v1/statuses', { - status, - in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), - media_ids: media.map(item => item.get('id')), - sensitive: getState().getIn(['compose', 'sensitive']), - spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', - visibility: getState().getIn(['compose', 'privacy']), - poll: getState().getIn(['compose', 'poll'], null), - }, { + api(getState).request({ + url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, + method: statusId === null ? 'post' : 'put', + data: { + status, + in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), + media_ids: media.map(item => item.get('id')), + sensitive: getState().getIn(['compose', 'sensitive']), + spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', + visibility: getState().getIn(['compose', 'privacy']), + poll: getState().getIn(['compose', 'poll'], null), + }, headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, @@ -176,11 +191,11 @@ export function submitCompose(routerHistory) { } }; - if (response.data.visibility !== 'direct') { + if (statusId === null && response.data.visibility !== 'direct') { insertIfOnline('home'); } - if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { + if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') { insertIfOnline('community'); insertIfOnline('public'); insertIfOnline(`account:${response.data.account.id}`); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 20d71362e9..adc24eabfb 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -2,7 +2,7 @@ import api from '../api'; import { deleteFromTimelines } from './timelines'; import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; -import { ensureComposeIsVisible } from './compose'; +import { ensureComposeIsVisible, setComposeToStatus } from './compose'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -30,6 +30,10 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE'; export const REDRAFT = 'REDRAFT'; +export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; +export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS'; +export const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL'; + export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, @@ -84,6 +88,37 @@ export function redraft(status, raw_text) { }; }; +export const editStatus = (id, routerHistory) => (dispatch, getState) => { + let status = getState().getIn(['statuses', id]); + + if (status.get('poll')) { + status = status.set('poll', getState().getIn(['polls', status.get('poll')])); + } + + dispatch(fetchStatusSourceRequest()); + + api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { + dispatch(fetchStatusSourceSuccess()); + ensureComposeIsVisible(getState, routerHistory); + dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text)); + }).catch(error => { + dispatch(fetchStatusSourceFail(error)); + }); +}; + +export const fetchStatusSourceRequest = () => ({ + type: STATUS_FETCH_SOURCE_REQUEST, +}); + +export const fetchStatusSourceSuccess = () => ({ + type: STATUS_FETCH_SOURCE_SUCCESS, +}); + +export const fetchStatusSourceFail = error => ({ + type: STATUS_FETCH_SOURCE_FAIL, + error, +}); + export function deleteStatus(id, routerHistory, withRedraft = false) { return (dispatch, getState) => { let status = getState().getIn(['statuses', id]); diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 4e19cc0e46..7bd63a49da 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -12,6 +12,7 @@ import classNames from 'classnames'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, @@ -137,6 +138,10 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onDelete(this.props.status, this.context.router.history, true); } + handleEditClick = () => { + this.props.onEdit(this.props.status, this.context.router.history); + } + handlePinClick = () => { this.props.onPin(this.props.status); } @@ -255,6 +260,7 @@ class StatusActionBar extends ImmutablePureComponent { } if (writtenByMe) { + menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 9abdec138b..ef0aca13a6 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -24,6 +24,7 @@ import { hideStatus, revealStatus, toggleStatusCollapse, + editStatus, } from '../actions/statuses'; import { unmuteAccount, @@ -142,6 +143,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onEdit (status, history) { + dispatch(editStatus(status.get('id'), history)); + }, + onDirect (account, router) { dispatch(directCompose(account, router)); }, diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index d224222ace..f2cfaba4e6 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -28,6 +28,7 @@ const messages = defineMessages({ spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, + saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, }); export default @injectIntl @@ -49,6 +50,7 @@ class ComposeForm extends ImmutablePureComponent { preselectDate: PropTypes.instanceOf(Date), isSubmitting: PropTypes.bool, isChangingUpload: PropTypes.bool, + isEditing: PropTypes.bool, isUploading: PropTypes.bool, onChange: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired, @@ -199,7 +201,9 @@ class ComposeForm extends ImmutablePureComponent { const disabled = this.props.isSubmitting; let publishText = ''; - if (this.props.privacy === 'private' || this.props.privacy === 'direct') { + if (this.props.isEditing) { + publishText = intl.formatMessage(messages.saveChanges); + } else if (this.props.privacy === 'private' || this.props.privacy === 'direct') { publishText = {intl.formatMessage(messages.publish)}; } else { publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index c44850294d..1be7633ccd 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -21,6 +21,7 @@ const mapStateToProps = state => ({ caretPosition: state.getIn(['compose', 'caretPosition']), preselectDate: state.getIn(['compose', 'preselectDate']), isSubmitting: state.getIn(['compose', 'is_submitting']), + isEditing: state.getIn(['compose', 'id']) !== null, isChangingUpload: state.getIn(['compose', 'is_changing_upload']), isUploading: state.getIn(['compose', 'is_uploading']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js index 5eb1eb72a4..a1302b2d4a 100644 --- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js +++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js @@ -6,9 +6,20 @@ import ReplyIndicator from '../components/reply_indicator'; const makeMapStateToProps = () => { const getStatus = makeGetStatus(); - const mapStateToProps = state => ({ - status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }), - }); + const mapStateToProps = state => { + let statusId = state.getIn(['compose', 'id'], null); + let editing = true; + + if (statusId === null) { + statusId = state.getIn(['compose', 'in_reply_to']); + editing = false; + } + + return { + status: getStatus(state, { id: statusId }), + editing, + }; + }; return mapStateToProps; }; diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index a15a4d567a..ed02a493c4 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -11,6 +11,7 @@ import classNames from 'classnames'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, @@ -59,6 +60,7 @@ class ActionBar extends React.PureComponent { onFavourite: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, + onEdit: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onMute: PropTypes.func, @@ -98,6 +100,10 @@ class ActionBar extends React.PureComponent { this.props.onDelete(this.props.status, this.context.router.history, true); } + handleEditClick = () => { + this.props.onEdit(this.props.status, this.context.router.history); + } + handleDirectClick = () => { this.props.onDirect(this.props.status.get('account'), this.context.router.history); } @@ -209,6 +215,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); + menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index f342a3641c..4d7f24834e 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -29,6 +29,7 @@ import { muteStatus, unmuteStatus, deleteStatus, + editStatus, hideStatus, revealStatus, } from '../../actions/statuses'; @@ -273,6 +274,10 @@ class Status extends ImmutablePureComponent { } } + handleEditClick = (status, history) => { + this.props.dispatch(editStatus(status.get('id'), history)); + } + handleDirectClick = (account, router) => { this.props.dispatch(directCompose(account, router)); } @@ -567,6 +572,7 @@ class Status extends ImmutablePureComponent { onReblog={this.handleReblogClick} onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} + onEdit={this.handleEditClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} onMute={this.handleMuteClick} diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 06a908e9d4..ea882a71fc 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -43,6 +43,7 @@ import { INIT_MEDIA_EDIT_MODAL, COMPOSE_CHANGE_MEDIA_DESCRIPTION, COMPOSE_CHANGE_MEDIA_FOCUS, + COMPOSE_SET_STATUS, } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; import { STORE_HYDRATE } from '../actions/store'; @@ -58,6 +59,7 @@ const initialState = ImmutableMap({ spoiler: false, spoiler_text: '', privacy: null, + id: null, text: '', focusDate: null, caretPosition: null, @@ -107,6 +109,7 @@ function statusToTextMentions(state, status) { function clearAll(state) { return state.withMutations(map => { + map.set('id', null); map.set('text', ''); map.set('spoiler', false); map.set('spoiler_text', ''); @@ -313,6 +316,7 @@ export default function compose(state = initialState, action) { return state.set('is_composing', action.value); case COMPOSE_REPLY: return state.withMutations(map => { + map.set('id', null); map.set('in_reply_to', action.status.get('id')); map.set('text', statusToTextMentions(state, action.status)); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); @@ -329,21 +333,12 @@ export default function compose(state = initialState, action) { map.set('spoiler_text', ''); } }); - case COMPOSE_REPLY_CANCEL: - case COMPOSE_RESET: - return state.withMutations(map => { - map.set('in_reply_to', null); - map.set('text', ''); - map.set('spoiler', false); - map.set('spoiler_text', ''); - map.set('privacy', state.get('default_privacy')); - map.set('poll', null); - map.set('idempotencyKey', uuid()); - }); case COMPOSE_SUBMIT_REQUEST: return state.set('is_submitting', true); case COMPOSE_UPLOAD_CHANGE_REQUEST: return state.set('is_changing_upload', true); + case COMPOSE_REPLY_CANCEL: + case COMPOSE_RESET: case COMPOSE_SUBMIT_SUCCESS: return clearAll(state); case COMPOSE_SUBMIT_FAIL: @@ -454,6 +449,34 @@ export default function compose(state = initialState, action) { map.set('spoiler_text', ''); } + if (action.status.get('poll')) { + map.set('poll', ImmutableMap({ + options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), + multiple: action.status.getIn(['poll', 'multiple']), + expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])), + })); + } + }); + case COMPOSE_SET_STATUS: + return state.withMutations(map => { + map.set('id', action.status.get('id')); + map.set('text', action.text); + map.set('in_reply_to', action.status.get('in_reply_to_id')); + map.set('privacy', action.status.get('visibility')); + map.set('media_attachments', action.status.get('media_attachments')); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('idempotencyKey', uuid()); + map.set('sensitive', action.status.get('sensitive')); + + if (action.spoiler_text.length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.spoiler_text); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + if (action.status.get('poll')) { map.set('poll', ImmutableMap({ options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 1741d20e96..0a9d05f1d7 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -208,6 +208,10 @@ class MediaAttachment < ApplicationRecord file.blank? && remote_url.present? end + def significantly_changed? + description_previously_changed? || thumbnail_updated_at_previously_changed? || file_meta_previously_changed? + end + def larger_media_format? video? || gifv? || audio? end diff --git a/app/models/status.rb b/app/models/status.rb index 47671c0f52..4fabf24ef2 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -209,6 +209,16 @@ class Status < ApplicationRecord public_visibility? || unlisted_visibility? end + def snapshot!(media_attachments_changed: false, account_id: nil, at_time: nil) + edits.create!( + text: text, + spoiler_text: spoiler_text, + media_attachments_changed: media_attachments_changed, + account_id: account_id || self.account_id, + created_at: at_time || edited_at + ) + end + def edited? edited_at.present? end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index bcf9c3395c..6e9b840db6 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -38,7 +38,7 @@ class StatusPolicy < ApplicationPolicy alias unreblog? destroy? def update? - staff? + staff? || owned? end private diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index b1cea1cdfc..5e38852fcb 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -217,24 +217,18 @@ class ActivityPub::ProcessStatusUpdateService < BaseService return if @status.edits.any? - @status.edits.create( - text: @status.text, - spoiler_text: @status.spoiler_text, + @status.snapshot!( media_attachments_changed: false, - account_id: @account.id, - created_at: @status.created_at + at_time: @status.created_at ) end def create_edit! return unless significant_changes? - @status_edit = @status.edits.create( - text: @status.text, - spoiler_text: @status.spoiler_text, + @status.snapshot!( media_attachments_changed: @media_attachments_changed || @poll_changed, - account_id: @account.id, - created_at: @status.edited_at + account_id: @account.id ) end diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index 47277c56c0..43d7bcca6f 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -1,20 +1,40 @@ # frozen_string_literal: true class ProcessHashtagsService < BaseService - def call(status, tags = []) - tags = Extractor.extract_hashtags(status.text) if status.local? - records = [] + def call(status, raw_tags = []) + @status = status + @account = status.account + @raw_tags = status.local? ? Extractor.extract_hashtags(status.text) : raw_tags + @previous_tags = status.tags.to_a + @current_tags = [] - Tag.find_or_create_by_names(tags) do |tag| - status.tags << tag - records << tag - tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago) + assign_tags! + update_featured_tags! + end + + private + + def assign_tags! + @status.tags = @current_tags = Tag.find_or_create_by_names(@raw_tags) + end + + def update_featured_tags! + return unless @status.distributable? + + added_tags = @current_tags - @previous_tags + + unless added_tags.empty? + @account.featured_tags.where(tag_id: added_tags.map(&:id)).each do |featured_tag| + featured_tag.increment(@status.created_at) + end end - return unless status.distributable? + removed_tags = @previous_tags - @current_tags - status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag| - featured_tag.increment(status.created_at) + unless removed_tags.empty? + @account.featured_tags.where(tag_id: removed_tags.map(&:id)).each do |featured_tag| + featured_tag.decrement(@status.id) + end end end end diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb new file mode 100644 index 0000000000..69dc18e87d --- /dev/null +++ b/app/services/update_status_service.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +class UpdateStatusService < BaseService + include Redisable + + # @param [Status] status + # @param [Integer] account_id + # @param [Hash] options + # @option options [Array] :media_ids + # @option options [Hash] :poll + # @option options [String] :text + # @option options [String] :spoiler_text + # @option options [Boolean] :sensitive + # @option options [String] :language + def call(status, account_id, options = {}) + @status = status + @options = options + @account_id = account_id + @media_attachments_changed = false + @poll_changed = false + + Status.transaction do + create_previous_edit! + update_media_attachments! + update_poll! + update_immediate_attributes! + create_edit! + end + + queue_poll_notifications! + reset_preview_card! + update_metadata! + broadcast_updates! + + @status + end + + private + + def update_media_attachments! + previous_media_attachments = @status.media_attachments.to_a + next_media_attachments = validate_media! + removed_media_attachments = previous_media_attachments - next_media_attachments + added_media_attachments = next_media_attachments - previous_media_attachments + + MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil) + MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id) + + @status.media_attachments.reload + @media_attachments_changed = true if removed_media_attachments.any? || added_media_attachments.any? + end + + def validate_media! + return [] if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable) + + raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present? + + media_attachments = @status.account.media_attachments.where(status_id: [nil, @status.id]).where(scheduled_status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i)).to_a + + raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if media_attachments.size > 1 && media_attachments.find(&:audio_or_video?) + raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if media_attachments.any?(&:not_processed?) + + media_attachments + end + + def update_poll! + previous_poll = @status.preloadable_poll + @previous_expires_at = previous_poll&.expires_at + + if @options[:poll].present? + poll = previous_poll || @status.account.polls.new(status: @status, votes_count: 0) + + # If for some reasons the options were changed, it invalidates all previous + # votes, so we need to remove them + if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple + @poll_changed = true + poll.votes.delete_all unless poll.new_record? + end + + poll.options = @options[:poll][:options] + poll.hide_totals = @options[:poll][:hide_totals] || false + poll.multiple = @options[:poll][:multiple] || false + poll.expires_in = @options[:poll][:expires_in] + poll.save! + + @status.poll_id = poll.id + elsif previous_poll.present? + previous_poll.destroy + @poll_changed = true + @status.poll_id = nil + end + end + + def update_immediate_attributes! + @status.text = @options[:text].presence || @options.delete(:spoiler_text) || '' + @status.spoiler_text = @options[:spoiler_text] || '' + @status.sensitive = @options[:sensitive] || @options[:spoiler_text].present? + @status.language = language_from_option || @status.language + @status.edited_at = Time.now.utc + + @status.save! + end + + def language_from_option + ISO_639.find(@options[:language])&.alpha2 + end + + def reset_preview_card! + return unless @status.text_previously_changed? + + @status.preview_cards.clear + LinkCrawlWorker.perform_async(@status.id) + end + + def update_metadata! + ProcessHashtagsService.new.call(@status) + ProcessMentionsService.new.call(@status) + end + + def broadcast_updates! + DistributionWorker.perform_async(@status.id, { 'update' => true }) + ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id) + end + + def queue_poll_notifications! + poll = @status.preloadable_poll + + # If the poll had no expiration date set but now has, or now has a sooner + # expiration date, and people have voted, schedule a notification + + return unless poll.present? && poll.expires_at.present? && poll.votes.exists? + + PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at + PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id) + end + + def create_previous_edit! + # We only need to create a previous edit when no previous edits exist, e.g. + # when the status has never been edited. For other cases, we always create + # an edit, so the step can be skipped + + return if @status.edits.any? + + @status.snapshot!( + media_attachments_changed: false, + at_time: @status.created_at + ) + end + + def create_edit! + @status.snapshot!( + media_attachments_changed: @media_attachments_changed || @poll_changed, + account_id: @account_id + ) + end +end diff --git a/app/workers/activitypub/status_update_distribution_worker.rb b/app/workers/activitypub/status_update_distribution_worker.rb new file mode 100644 index 0000000000..261aaa0cc8 --- /dev/null +++ b/app/workers/activitypub/status_update_distribution_worker.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ActivityPub::StatusUpdateDistributionWorker < ActivityPub::DistributionWorker + # Distribute an profile update to servers that might have a copy + # of the account in question + def perform(status_id, options = {}) + @options = options.with_indifferent_access + @status = Status.find(status_id) + + distribute! + rescue ActiveRecord::RecordNotFound + true + end + + protected + + def activity + ActivityPub::ActivityPresenter.new( + id: [ActivityPub::TagManager.instance.uri_for(@status), '#updates/', @status.edited_at.to_i].join, + type: 'Update', + actor: ActivityPub::TagManager.instance.uri_for(@status.account), + published: @status.edited_at, + to: ActivityPub::TagManager.instance.to(@status), + cc: ActivityPub::TagManager.instance.cc(@status), + virtual_object: @status + ) + end +end diff --git a/config/routes.rb b/config/routes.rb index 121587819a..670bebce20 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -332,7 +332,7 @@ Rails.application.routes.draw do # JSON / REST API namespace :v1 do - resources :statuses, only: [:create, :show, :destroy] do + resources :statuses, only: [:create, :show, :update, :destroy] do scope module: :statuses do resources :reblogged_by, controller: :reblogged_by_accounts, only: :index resources :favourited_by, controller: :favourited_by_accounts, only: :index diff --git a/spec/controllers/api/v1/media_controller_spec.rb b/spec/controllers/api/v1/media_controller_spec.rb index d8d7326302..a1f6ddb244 100644 --- a/spec/controllers/api/v1/media_controller_spec.rb +++ b/spec/controllers/api/v1/media_controller_spec.rb @@ -110,21 +110,24 @@ RSpec.describe Api::V1::MediaController, type: :controller do end end - context 'when not attached to a status' do - let(:media) { Fabricate(:media_attachment, status: nil, account: user.account) } + context 'when the author \'s' do + let(:status) { nil } + let(:media) { Fabricate(:media_attachment, status: status, account: user.account) } + + before do + put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } + end it 'updates the description' do - put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } expect(media.reload.description).to eq 'Lorem ipsum!!!' end - end - context 'when attached to a status' do - let(:media) { Fabricate(:media_attachment, status: Fabricate(:status), account: user.account) } + context 'when already attached to a status' do + let(:status) { Fabricate(:status, account: user.account) } - it 'returns http not found' do - put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } - expect(response).to have_http_status(:not_found) + it 'returns http not found' do + expect(response).to have_http_status(:not_found) + end end end end diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb index 2679ab017e..190dfad117 100644 --- a/spec/controllers/api/v1/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/statuses_controller_spec.rb @@ -102,6 +102,23 @@ RSpec.describe Api::V1::StatusesController, type: :controller do expect(Status.find_by(id: status.id)).to be nil end end + + describe 'PUT #update' do + let(:scopes) { 'write:statuses' } + let(:status) { Fabricate(:status, account: user.account) } + + before do + put :update, params: { id: status.id, status: 'I am updated' } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'updates the status' do + expect(status.reload.text).to eq 'I am updated' + end + end end context 'without an oauth token' do diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 1cddf4abd5..28b808ee27 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -125,7 +125,7 @@ RSpec.describe StatusPolicy, type: :model do end end - permissions :index?, :update? do + permissions :index? do it 'grants access if staff' do expect(subject).to permit(admin.account) end @@ -134,4 +134,18 @@ RSpec.describe StatusPolicy, type: :model do expect(subject).to_not permit(alice) end end + + permissions :update? do + it 'grants access if staff' do + expect(subject).to permit(admin.account, status) + end + + it 'grants access if owner' do + expect(subject).to permit(status.account, status) + end + + it 'denies access unless staff' do + expect(subject).to_not permit(bob, status) + end + end end diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb new file mode 100644 index 0000000000..6ee1dcb43a --- /dev/null +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -0,0 +1,248 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do + let!(:status) { Fabricate(:status, text: 'Hello world', account: Fabricate(:account, domain: 'example.com')) } + + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + + let(:mentions) { [] } + let(:tags) { [] } + let(:media_attachments) { [] } + + before do + mentions.each { |a| Fabricate(:mention, status: status, account: a) } + tags.each { |t| status.tags << t } + media_attachments.each { |m| status.media_attachments << m } + end + + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Note', + summary: 'Show more', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + tag: [ + { type: 'Hashtag', name: 'hoge' }, + { type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) }, + ], + } + end + + let(:json) { Oj.load(Oj.dump(payload)) } + + subject { described_class.new } + + describe '#call' do + it 'updates text' do + subject.call(status, json) + expect(status.reload.text).to eq 'Hello universe' + end + + it 'updates content warning' do + subject.call(status, json) + expect(status.reload.spoiler_text).to eq 'Show more' + end + + context 'originally without tags' do + before do + subject.call(status, json) + end + + it 'updates tags' do + expect(status.tags.reload.map(&:name)).to eq %w(hoge) + end + end + + context 'originally with tags' do + let(:tags) { [Fabricate(:tag, name: 'test'), Fabricate(:tag, name: 'foo')] } + + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Note', + summary: 'Show more', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + tag: [ + { type: 'Hashtag', name: 'foo' }, + ], + } + end + + before do + subject.call(status, json) + end + + it 'updates tags' do + expect(status.tags.reload.map(&:name)).to eq %w(foo) + end + end + + context 'originally without mentions' do + before do + subject.call(status, json) + end + + it 'updates mentions' do + expect(status.active_mentions.reload.map(&:account_id)).to eq [alice.id] + end + end + + context 'originally with mentions' do + let(:mentions) { [alice, bob] } + + before do + subject.call(status, json) + end + + it 'updates mentions' do + expect(status.active_mentions.reload.map(&:account_id)).to eq [alice.id] + end + end + + context 'originally without media attachments' do + before do + allow(RedownloadMediaWorker).to receive(:perform_async) + subject.call(status, json) + end + + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Note', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + attachment: [ + { type: 'Image', mediaType: 'image/png', url: 'https://example.com/foo.png' }, + ] + } + end + + it 'updates media attachments' do + media_attachment = status.media_attachments.reload.first + + expect(media_attachment).to_not be_nil + expect(media_attachment.remote_url).to eq 'https://example.com/foo.png' + end + + it 'queues download of media attachments' do + expect(RedownloadMediaWorker).to have_received(:perform_async) + end + + it 'records media change in edit' do + expect(status.edits.reload.last.media_attachments_changed).to be true + end + end + + context 'originally with media attachments' do + let(:media_attachments) { [Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png'), Fabricate(:media_attachment, remote_url: 'https://example.com/unused.png')] } + + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Note', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + attachment: [ + { type: 'Image', mediaType: 'image/png', url: 'https://example.com/foo.png', name: 'A picture' }, + ] + } + end + + before do + allow(RedownloadMediaWorker).to receive(:perform_async) + subject.call(status, json) + end + + it 'updates the existing media attachment in-place' do + media_attachment = status.media_attachments.reload.first + + expect(media_attachment).to_not be_nil + expect(media_attachment.remote_url).to eq 'https://example.com/foo.png' + expect(media_attachment.description).to eq 'A picture' + end + + it 'does not queue redownload for the existing media attachment' do + expect(RedownloadMediaWorker).to_not have_received(:perform_async) + end + + it 'updates media attachments' do + expect(status.media_attachments.reload.map(&:remote_url)).to eq %w(https://example.com/foo.png) + end + + it 'records media change in edit' do + expect(status.edits.reload.last.media_attachments_changed).to be true + end + end + + context 'originally with a poll' do + before do + poll = Fabricate(:poll, status: status) + status.update(preloadable_poll: poll) + subject.call(status, json) + end + + it 'removes poll' do + expect(status.reload.poll).to eq nil + end + + it 'records media change in edit' do + expect(status.edits.reload.last.media_attachments_changed).to be true + end + end + + context 'originally without a poll' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Question', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + closed: true, + oneOf: [ + { type: 'Note', name: 'Foo' }, + { type: 'Note', name: 'Bar' }, + { type: 'Note', name: 'Baz' }, + ], + } + end + + before do + subject.call(status, json) + end + + it 'creates a poll' do + poll = status.reload.poll + + expect(poll).to_not be_nil + expect(poll.options).to eq %w(Foo Bar Baz) + end + + it 'records media change in edit' do + expect(status.edits.reload.last.media_attachments_changed).to be true + end + end + + it 'creates edit history' do + subject.call(status, json) + expect(status.edits.reload.map(&:text)).to eq ['Hello world', 'Hello universe'] + end + + it 'sets edited timestamp' do + subject.call(status, json) + expect(status.reload.edited_at.to_s).to eq '2021-09-08 22:39:25 UTC' + end + + it 'records that no media has been changed in edit' do + subject.call(status, json) + expect(status.edits.reload.last.media_attachments_changed).to be false + end + end +end diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb new file mode 100644 index 0000000000..1d4b0a6be2 --- /dev/null +++ b/spec/services/update_status_service_spec.rb @@ -0,0 +1,130 @@ +require 'rails_helper' + +RSpec.describe UpdateStatusService, type: :service do + subject { described_class.new } + + context 'when text changes' do + let!(:status) { Fabricate(:status, text: 'Foo') } + let(:preview_card) { Fabricate(:preview_card) } + + before do + status.preview_cards << preview_card + subject.call(status, status.account_id, text: 'Bar') + end + + it 'updates text' do + expect(status.reload.text).to eq 'Bar' + end + + it 'resets preview card' do + expect(status.reload.preview_card).to be_nil + end + + it 'saves edit history' do + expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Bar', false]] + end + end + + context 'when content warning changes' do + let!(:status) { Fabricate(:status, text: 'Foo', spoiler_text: '') } + let(:preview_card) { Fabricate(:preview_card) } + + before do + status.preview_cards << preview_card + subject.call(status, status.account_id, text: 'Foo', spoiler_text: 'Bar') + end + + it 'updates content warning' do + expect(status.reload.spoiler_text).to eq 'Bar' + end + + it 'saves edit history' do + expect(status.edits.pluck(:text, :spoiler_text, :media_attachments_changed)).to eq [['Foo', '', false], ['Foo', 'Bar', false]] + end + end + + context 'when media attachments change' do + let!(:status) { Fabricate(:status, text: 'Foo') } + let!(:detached_media_attachment) { Fabricate(:media_attachment, account: status.account) } + let!(:attached_media_attachment) { Fabricate(:media_attachment, account: status.account) } + + before do + status.media_attachments << detached_media_attachment + subject.call(status, status.account_id, text: 'Foo', media_ids: [attached_media_attachment.id]) + end + + it 'updates media attachments' do + expect(status.media_attachments.to_a).to eq [attached_media_attachment] + end + + it 'detaches detached media attachments' do + expect(detached_media_attachment.reload.status_id).to be_nil + end + + it 'attaches attached media attachments' do + expect(attached_media_attachment.reload.status_id).to eq status.id + end + + it 'saves edit history' do + expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]] + end + end + + context 'when poll changes' do + let!(:status) { Fabricate(:status, text: 'Foo') } + let!(:poll) { Fabricate(:poll, options: %w(Foo Bar)) } + + before do + status.update(poll: poll) + subject.call(status, status.account_id, text: 'Foo', poll: { options: %w(Bar Baz), expires_in: 5.days.to_i }) + end + + it 'updates poll' do + expect(status.poll).to_not eq poll + expect(status.poll.options).to eq %w(Bar Baz) + end + + it 'saves edit history' do + expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]] + end + end + + context 'when mentions in text change' do + let!(:account) { Fabricate(:account) } + let!(:alice) { Fabricate(:account, username: 'alice') } + let!(:bob) { Fabricate(:account, username: 'bob') } + let!(:status) { PostStatusService.new.call(account, text: 'Hello @alice') } + + before do + subject.call(status, status.account_id, text: 'Hello @bob') + end + + it 'changes mentions' do + expect(status.active_mentions.pluck(:account_id)).to eq [bob.id] + end + + it 'keeps old mentions as silent mentions' do + expect(status.mentions.pluck(:account_id)).to eq [alice.id, bob.id] + end + end + + context 'when hashtags in text change' do + let!(:account) { Fabricate(:account) } + let!(:status) { PostStatusService.new.call(account, text: 'Hello #foo') } + + before do + subject.call(status, status.account_id, text: 'Hello #bar') + end + + it 'changes tags' do + expect(status.tags.pluck(:name)).to eq %w(bar) + end + end + + it 'notifies ActivityPub about the update' do + status = Fabricate(:status, text: 'Foo') + allow(ActivityPub::DistributionWorker).to receive(:perform_async) + subject.call(status, status.account_id, text: 'Bar') + expect(ActivityPub::DistributionWorker).to have_received(:perform_async) + end +end From 6dfda3670ff0a079b5beaf68fc79e1afd44c3968 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 10 Feb 2022 03:09:44 +0100 Subject: [PATCH 05/19] Fix outdated iso-639 reference in update status service (#17496) --- app/services/update_status_service.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index 69dc18e87d..68e73d3b46 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -2,6 +2,7 @@ class UpdateStatusService < BaseService include Redisable + include LanguagesHelper # @param [Status] status # @param [Integer] account_id @@ -95,16 +96,12 @@ class UpdateStatusService < BaseService @status.text = @options[:text].presence || @options.delete(:spoiler_text) || '' @status.spoiler_text = @options[:spoiler_text] || '' @status.sensitive = @options[:sensitive] || @options[:spoiler_text].present? - @status.language = language_from_option || @status.language + @status.language = valid_locale_or_nil(@options[:language] || @status.language || @status.account.user&.preferred_posting_language || I18n.default_locale) @status.edited_at = Time.now.utc @status.save! end - def language_from_option - ISO_639.find(@options[:language])&.alpha2 - end - def reset_preview_card! return unless @status.text_previously_changed? From 2e7bc0fbf50c30681330ed77aa27f1f87e645cfc Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 10 Feb 2022 14:26:54 +0100 Subject: [PATCH 06/19] Fix poll votes not being properly reset on poll change (#17498) * Fix poll votes not being properly reset on poll change * Fix and add tests * Fix poll update handling when the number of options changes --- app/models/poll.rb | 6 ++++++ .../process_status_update_service.rb | 6 ++---- app/services/update_status_service.rb | 6 ++---- spec/services/update_status_service_spec.rb | 20 ++++++++++++++----- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/app/models/poll.rb b/app/models/poll.rb index 71b5e191f8..ba08309a15 100644 --- a/app/models/poll.rb +++ b/app/models/poll.rb @@ -83,6 +83,12 @@ class Poll < ApplicationRecord end end + def reset_votes! + self.cached_tallies = options.map { 0 } + self.votes_count = 0 + votes.delete_all unless new_record? + end + private def prepare_cached_tallies diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 5e38852fcb..7438a7c536 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -95,10 +95,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService # If for some reasons the options were changed, it invalidates all previous # votes, so we need to remove them - if poll_parser.significantly_changes?(poll) - @poll_changed = true - poll.votes.delete_all unless poll.new_record? - end + @poll_changed = true if poll_parser.significantly_changes?(poll) poll.last_fetched_at = Time.now.utc poll.options = poll_parser.options @@ -106,6 +103,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService poll.expires_at = poll_parser.expires_at poll.voters_count = poll_parser.voters_count poll.cached_tallies = poll_parser.cached_tallies + poll.reset_votes! if @poll_changed poll.save! @status.poll_id = poll.id diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index 68e73d3b46..238ef07556 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -73,15 +73,13 @@ class UpdateStatusService < BaseService # If for some reasons the options were changed, it invalidates all previous # votes, so we need to remove them - if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple - @poll_changed = true - poll.votes.delete_all unless poll.new_record? - end + @poll_changed = true if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple poll.options = @options[:poll][:options] poll.hide_totals = @options[:poll][:hide_totals] || false poll.multiple = @options[:poll][:multiple] || false poll.expires_in = @options[:poll][:expires_in] + poll.reset_votes! if @poll_changed poll.save! @status.poll_id = poll.id diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index 1d4b0a6be2..fe1b60d24d 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -71,17 +71,27 @@ RSpec.describe UpdateStatusService, type: :service do end context 'when poll changes' do - let!(:status) { Fabricate(:status, text: 'Foo') } - let!(:poll) { Fabricate(:poll, options: %w(Foo Bar)) } + let(:account) { Fabricate(:account) } + let!(:status) { Fabricate(:status, text: 'Foo', account: account, poll_attributes: {options: %w(Foo Bar), account: account, multiple: false, hide_totals: false, expires_at: 7.days.from_now }) } + let!(:poll) { status.poll } + let!(:voter) { Fabricate(:account) } before do status.update(poll: poll) - subject.call(status, status.account_id, text: 'Foo', poll: { options: %w(Bar Baz), expires_in: 5.days.to_i }) + VoteService.new.call(voter, poll, [0]) + subject.call(status, status.account_id, text: 'Foo', poll: { options: %w(Bar Baz Foo), expires_in: 5.days.to_i }) end it 'updates poll' do - expect(status.poll).to_not eq poll - expect(status.poll.options).to eq %w(Bar Baz) + poll = status.poll.reload + expect(poll.options).to eq %w(Bar Baz Foo) + end + + it 'resets votes' do + poll = status.poll.reload + expect(poll.votes_count).to eq 0 + expect(poll.votes.count).to eq 0 + expect(poll.cached_tallies).to eq [0, 0, 0] end it 'saves edit history' do From c6de53c1a92278f5c538c5ce7097de3665bfc9bd Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 10 Feb 2022 14:57:10 +0100 Subject: [PATCH 07/19] Fix NoMethodError in StatusUpdateDistributionWorker (#17499) * Add tests * Fix NoMethodError in StatusUpdateDistributionWorker * Fix tests --- .../status_update_distribution_worker.rb | 1 + .../status_update_distribution_worker_spec.rb | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 spec/workers/activitypub/status_update_distribution_worker_spec.rb diff --git a/app/workers/activitypub/status_update_distribution_worker.rb b/app/workers/activitypub/status_update_distribution_worker.rb index 261aaa0cc8..a79ede2bf6 100644 --- a/app/workers/activitypub/status_update_distribution_worker.rb +++ b/app/workers/activitypub/status_update_distribution_worker.rb @@ -6,6 +6,7 @@ class ActivityPub::StatusUpdateDistributionWorker < ActivityPub::DistributionWor def perform(status_id, options = {}) @options = options.with_indifferent_access @status = Status.find(status_id) + @account = @status.account distribute! rescue ActiveRecord::RecordNotFound diff --git a/spec/workers/activitypub/status_update_distribution_worker_spec.rb b/spec/workers/activitypub/status_update_distribution_worker_spec.rb new file mode 100644 index 0000000000..6633b601fc --- /dev/null +++ b/spec/workers/activitypub/status_update_distribution_worker_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +describe ActivityPub::StatusUpdateDistributionWorker do + subject { described_class.new } + + let(:status) { Fabricate(:status, text: 'foo') } + let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') } + + describe '#perform' do + before do + follower.follow!(status.account) + + status.snapshot! + status.text = 'bar' + status.edited_at = Time.now.utc + status.snapshot! + status.save! + end + + context 'with public status' do + before do + status.update(visibility: :public) + end + + it 'delivers to followers' do + expect(ActivityPub::DeliveryWorker).to receive(:push_bulk) do |items, &block| + expect(items.map(&block)).to match([[kind_of(String), status.account.id, 'http://example.com', anything]]) + end + + subject.perform(status.id) + end + end + + context 'with private status' do + before do + status.update(visibility: :private) + end + + it 'delivers to followers' do + expect(ActivityPub::DeliveryWorker).to receive(:push_bulk) do |items, &block| + expect(items.map(&block)).to match([[kind_of(String), status.account.id, 'http://example.com', anything]]) + end + + subject.perform(status.id) + end + end + end +end From abbe50e519ec5cc38d4125e5d761c122777d92a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:07:06 +0100 Subject: [PATCH 08/19] Bump sidekiq from 6.4.0 to 6.4.1 (#17480) Bumps [sidekiq](https://github.com/mperham/sidekiq) from 6.4.0 to 6.4.1. - [Release notes](https://github.com/mperham/sidekiq/releases) - [Changelog](https://github.com/mperham/sidekiq/blob/main/Changes.md) - [Commits](https://github.com/mperham/sidekiq/compare/v6.4.0...v6.4.1) --- updated-dependencies: - dependency-name: sidekiq dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f7dd292dd8..9f831d03e8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -560,7 +560,7 @@ GEM railties (>= 4.0.0) securecompare (1.0.0) semantic_range (3.0.0) - sidekiq (6.4.0) + sidekiq (6.4.1) connection_pool (>= 2.2.2) rack (~> 2.0) redis (>= 4.2.0) From 9a270fd54a2bee6cce9fe80dc18776fbbc9b87c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:09:06 +0100 Subject: [PATCH 09/19] Bump puma from 5.5.2 to 5.6.1 (#17411) Bumps [puma](https://github.com/puma/puma) from 5.5.2 to 5.6.1. - [Release notes](https://github.com/puma/puma/releases) - [Changelog](https://github.com/puma/puma/blob/master/History.md) - [Commits](https://github.com/puma/puma/compare/v5.5.2...v5.6.1) --- updated-dependencies: - dependency-name: puma dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index e869e5f7a2..a5da8f12bd 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ ruby '>= 2.5.0', '< 3.1.0' gem 'pkg-config', '~> 1.4' gem 'rexml', '~> 3.2' -gem 'puma', '~> 5.5' +gem 'puma', '~> 5.6' gem 'rails', '~> 6.1.4' gem 'sprockets', '~> 3.7.2' gem 'thor', '~> 1.2' diff --git a/Gemfile.lock b/Gemfile.lock index 9f831d03e8..7b7a581fd9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -434,7 +434,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.5.2) + puma (5.6.1) nio4r (~> 2.0) pundit (2.1.1) activesupport (>= 3.0.0) @@ -752,7 +752,7 @@ DEPENDENCIES private_address_check (~> 0.5) pry-byebug (~> 3.9) pry-rails (~> 0.3) - puma (~> 5.5) + puma (~> 5.6) pundit (~> 2.1) rack (~> 2.2.3) rack-attack (~> 6.5) From 71804742a1d9f1a4bf6c514ea141109e35b89528 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:10:47 +0100 Subject: [PATCH 10/19] Bump pg from 1.3.0 to 1.3.1 (#17450) Bumps [pg](https://github.com/ged/ruby-pg) from 1.3.0 to 1.3.1. - [Release notes](https://github.com/ged/ruby-pg/releases) - [Changelog](https://github.com/ged/ruby-pg/blob/master/History.rdoc) - [Commits](https://github.com/ged/ruby-pg/compare/v1.3.0...v1.3.1) --- updated-dependencies: - dependency-name: pg dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7b7a581fd9..bf12321728 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -412,7 +412,7 @@ GEM parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.3.0) + pg (1.3.1) pghero (2.8.2) activerecord (>= 5) pkg-config (1.4.7) From 7315afb3f33ed253e75be120ac01484f5560a8d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:11:16 +0100 Subject: [PATCH 11/19] Bump ox from 2.14.6 to 2.14.7 (#17453) Bumps [ox](https://github.com/ohler55/ox) from 2.14.6 to 2.14.7. - [Release notes](https://github.com/ohler55/ox/releases) - [Changelog](https://github.com/ohler55/ox/blob/develop/CHANGELOG.md) - [Commits](https://github.com/ohler55/ox/compare/v2.14.6...v2.14.7) --- updated-dependencies: - dependency-name: ox dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index bf12321728..5d4516123b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -405,7 +405,7 @@ GEM openssl (2.2.0) openssl-signature_algorithm (0.4.0) orm_adapter (0.5.0) - ox (2.14.6) + ox (2.14.7) parallel (1.21.0) parser (3.1.0.0) ast (~> 2.4.1) From 0296013d2e4e663416ba0a8511b5f431e4c14ccf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:11:41 +0100 Subject: [PATCH 12/19] Bump bootsnap from 1.10.2 to 1.10.3 (#17466) Bumps [bootsnap](https://github.com/Shopify/bootsnap) from 1.10.2 to 1.10.3. - [Release notes](https://github.com/Shopify/bootsnap/releases) - [Changelog](https://github.com/Shopify/bootsnap/blob/main/CHANGELOG.md) - [Commits](https://github.com/Shopify/bootsnap/compare/v1.10.2...v1.10.3) --- updated-dependencies: - dependency-name: bootsnap dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index a5da8f12bd..d567a362fb 100644 --- a/Gemfile +++ b/Gemfile @@ -26,7 +26,7 @@ gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.10.2', require: false +gem 'bootsnap', '~> 1.10.3', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.2' diff --git a/Gemfile.lock b/Gemfile.lock index 5d4516123b..2882c81d07 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -104,7 +104,7 @@ GEM debug_inspector (>= 0.0.1) blurhash (0.1.5) ffi (~> 1.14) - bootsnap (1.10.2) + bootsnap (1.10.3) msgpack (~> 1.2) brakeman (5.2.1) browser (4.2.0) @@ -683,7 +683,7 @@ DEPENDENCIES better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.10.2) + bootsnap (~> 1.10.3) brakeman (~> 5.2) browser bullet (~> 7.0) From 06882c8097dde4f6f5336be042c0712d89494f96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:12:09 +0100 Subject: [PATCH 13/19] Bump aws-sdk-s3 from 1.111.3 to 1.112.0 (#17452) Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.111.3 to 1.112.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-s3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile | 2 +- Gemfile.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Gemfile b/Gemfile index d567a362fb..38a71d6a88 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ gem 'makara', '~> 0.5' gem 'pghero', '~> 2.8' gem 'dotenv-rails', '~> 2.7' -gem 'aws-sdk-s3', '~> 1.111', require: false +gem 'aws-sdk-s3', '~> 1.112', require: false gem 'fog-core', '<= 2.1.0' gem 'fog-openstack', '~> 0.3', require: false gem 'kt-paperclip', '~> 7.0' diff --git a/Gemfile.lock b/Gemfile.lock index 2882c81d07..2a9f229797 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,17 +79,17 @@ GEM encryptor (~> 3.0.0) awrence (1.1.1) aws-eventstream (1.2.0) - aws-partitions (1.549.0) - aws-sdk-core (3.125.5) + aws-partitions (1.553.0) + aws-sdk-core (3.126.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.53.0) - aws-sdk-core (~> 3, >= 3.125.0) + aws-sdk-kms (1.54.0) + aws-sdk-core (~> 3, >= 3.126.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.111.3) - aws-sdk-core (~> 3, >= 3.125.0) + aws-sdk-s3 (1.112.0) + aws-sdk-core (~> 3, >= 3.126.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) aws-sigv4 (1.4.0) @@ -679,7 +679,7 @@ DEPENDENCIES active_record_query_trace (~> 1.8) addressable (~> 2.8) annotate (~> 3.1) - aws-sdk-s3 (~> 1.111) + aws-sdk-s3 (~> 1.112) better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) From 5b519e118ff9fac1c59e09ea47988371467fa062 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:12:33 +0100 Subject: [PATCH 14/19] Bump rubocop from 1.25.0 to 1.25.1 (#17446) Bumps [rubocop](https://github.com/rubocop/rubocop) from 1.25.0 to 1.25.1. - [Release notes](https://github.com/rubocop/rubocop/releases) - [Changelog](https://github.com/rubocop/rubocop/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop/compare/v1.25.0...v1.25.1) --- updated-dependencies: - dependency-name: rubocop dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2a9f229797..e61ede94ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -528,7 +528,7 @@ GEM rspec-support (3.10.3) rspec_junit_formatter (0.5.1) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.25.0) + rubocop (1.25.1) parallel (~> 1.10) parser (>= 3.1.0.0) rainbow (>= 2.2.2, < 4.0) From 8bfbe14288cf4ea5b9f75544e56dbd222cf9ae97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:25:42 +0100 Subject: [PATCH 15/19] Bump fabrication from 2.24.0 to 2.27.0 (#17447) Bumps [fabrication](https://gitlab.com/fabrication-gem/fabrication) from 2.24.0 to 2.27.0. - [Release notes](https://gitlab.com/fabrication-gem/fabrication/tags) - [Changelog](https://gitlab.com/fabrication-gem/fabrication/blob/master/Changelog.markdown) - [Commits](https://gitlab.com/fabrication-gem/fabrication/compare/2.24.0...2.27.0) --- updated-dependencies: - dependency-name: fabrication dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 38a71d6a88..9b52c0e6c6 100644 --- a/Gemfile +++ b/Gemfile @@ -98,7 +98,7 @@ gem 'json-ld-preloaded', '~> 3.2' gem 'rdf-normalize', '~> 0.5' group :development, :test do - gem 'fabrication', '~> 2.24' + gem 'fabrication', '~> 2.27' gem 'fuubar', '~> 2.5' gem 'i18n-tasks', '~> 0.9', require: false gem 'pry-byebug', '~> 3.9' diff --git a/Gemfile.lock b/Gemfile.lock index e61ede94ff..8e3e08781f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -209,7 +209,7 @@ GEM et-orbi (1.2.6) tzinfo excon (0.76.0) - fabrication (2.24.0) + fabrication (2.27.0) faker (2.19.0) i18n (>= 1.6, < 2) faraday (1.8.0) @@ -706,7 +706,7 @@ DEPENDENCIES doorkeeper (~> 5.5) dotenv-rails (~> 2.7) ed25519 (~> 1.3) - fabrication (~> 2.24) + fabrication (~> 2.27) faker (~> 2.19) fast_blank (~> 1.0) fastimage From 64a94a889b3be3010914bb02b962668678f4b652 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 10 Feb 2022 15:28:53 +0100 Subject: [PATCH 16/19] Chore: Disable menu items for editing statuses in web UI (#17497) Feature must be unlocked in a separate release for max. compatibility --- app/javascript/mastodon/components/status_action_bar.js | 2 +- .../mastodon/features/status/components/action_bar.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 7bd63a49da..e3a7d763f2 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -260,7 +260,7 @@ class StatusActionBar extends ImmutablePureComponent { } if (writtenByMe) { - menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); + // menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index ed02a493c4..3f3ec0e718 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -215,7 +215,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); - menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); + // menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { From d377c690a45399fa2baa072e01e51e8d82a8b384 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 10 Feb 2022 09:13:27 +0100 Subject: [PATCH 17/19] Add ability to change content-type when editing a toot Content-type defaults to edited toot's content-type to avoid surprising behaviors when using clients that do not support this feature. --- app/controllers/api/v1/statuses_controller.rb | 3 ++- app/models/status.rb | 1 + app/services/update_status_service.rb | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index c928a24def..eaac8e5636 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -66,7 +66,8 @@ class Api::V1::StatusesController < Api::BaseController media_ids: status_params[:media_ids], sensitive: status_params[:sensitive], spoiler_text: status_params[:spoiler_text], - poll: status_params[:poll] + poll: status_params[:poll], + content_type: status_params[:content_type] ) render json: @status, serializer: REST::StatusSerializer diff --git a/app/models/status.rb b/app/models/status.rb index e5a0beab6e..236f95c1f7 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -221,6 +221,7 @@ class Status < ApplicationRecord spoiler_text: spoiler_text, media_attachments_changed: media_attachments_changed, account_id: account_id || self.account_id, + content_type: content_type, created_at: at_time || edited_at ) end diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index 238ef07556..63bd279891 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -13,6 +13,7 @@ class UpdateStatusService < BaseService # @option options [String] :spoiler_text # @option options [Boolean] :sensitive # @option options [String] :language + # @option options [String] :content_type def call(status, account_id, options = {}) @status = status @options = options @@ -95,6 +96,7 @@ class UpdateStatusService < BaseService @status.spoiler_text = @options[:spoiler_text] || '' @status.sensitive = @options[:sensitive] || @options[:spoiler_text].present? @status.language = valid_locale_or_nil(@options[:language] || @status.language || @status.account.user&.preferred_posting_language || I18n.default_locale) + @status.content_type = @options[:content_type] || @status.content_type @status.edited_at = Time.now.utc @status.save! From 838f80080844cbc1e0e3fc16cdc1a3c0fc61c7e8 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 10 Feb 2022 00:15:30 +0100 Subject: [PATCH 18/19] [Glitch] Add editing for published statuses Port cb76142d9e1a7a9a1a7d1b6ab4ec43e56f8b352d to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/compose.js | 51 +++++++++----- .../flavours/glitch/actions/statuses.js | 37 +++++++++- .../glitch/components/status_action_bar.js | 6 ++ .../glitch/containers/status_container.js | 6 +- .../compose/components/compose_form.js | 3 + .../features/compose/components/publisher.js | 70 ++++++------------- .../containers/compose_form_container.js | 1 + .../containers/reply_indicator_container.js | 20 ++++-- .../features/status/components/action_bar.js | 7 ++ .../flavours/glitch/features/status/index.js | 7 +- .../flavours/glitch/reducers/compose.js | 33 +++++++++ 11 files changed, 170 insertions(+), 71 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 261c72b2a7..baa98e98fe 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -75,6 +75,8 @@ export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL'; export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION'; export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; +export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; + const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, @@ -88,6 +90,15 @@ export const ensureComposeIsVisible = (getState, routerHistory) => { } }; +export function setComposeToStatus(status, text, spoiler_text) { + return{ + type: COMPOSE_SET_STATUS, + status, + text, + spoiler_text, + }; +}; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -150,8 +161,9 @@ export function directCompose(account, routerHistory) { export function submitCompose(routerHistory) { return function (dispatch, getState) { - let status = getState().getIn(['compose', 'text'], ''); - let media = getState().getIn(['compose', 'media_attachments']); + let status = getState().getIn(['compose', 'text'], ''); + const media = getState().getIn(['compose', 'media_attachments']); + const statusId = getState().getIn(['compose', 'id'], null); const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']); let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : ''; @@ -159,20 +171,25 @@ export function submitCompose(routerHistory) { return; } - dispatch(submitComposeRequest()); if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { status = status + ' 👁️'; } - api(getState).post('/api/v1/statuses', { - status, - 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')), - sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), - spoiler_text: spoilerText, - visibility: getState().getIn(['compose', 'privacy']), - poll: getState().getIn(['compose', 'poll'], null), - }, { + + dispatch(submitComposeRequest()); + + api(getState).request({ + url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, + method: statusId === null ? 'post' : 'put', + data: { + status, + 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')), + sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), + spoiler_text: spoilerText, + visibility: getState().getIn(['compose', 'privacy']), + poll: getState().getIn(['compose', 'poll'], null), + }, headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, @@ -202,14 +219,16 @@ export function submitCompose(routerHistory) { } }; - insertIfOnline('home'); + if (statusId === null) { + insertIfOnline('home'); + } - if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { + if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') { insertIfOnline('community'); if (!response.data.local_only) { insertIfOnline('public'); } - } else if (response.data.visibility === 'direct') { + } else if (statusId === null && response.data.visibility === 'direct') { insertIfOnline('direct'); } }).catch(function (error) { diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 7db357df1b..6ffcf181de 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -2,7 +2,7 @@ import api from 'flavours/glitch/util/api'; import { deleteFromTimelines } from './timelines'; import { importFetchedStatus, importFetchedStatuses } from './importer'; -import { ensureComposeIsVisible } from './compose'; +import { ensureComposeIsVisible, setComposeToStatus } from './compose'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -26,6 +26,10 @@ export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; export const REDRAFT = 'REDRAFT'; +export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; +export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS'; +export const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL'; + export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, @@ -81,6 +85,37 @@ export function redraft(status, raw_text, content_type) { }; }; +export const editStatus = (id, routerHistory) => (dispatch, getState) => { + let status = getState().getIn(['statuses', id]); + + if (status.get('poll')) { + status = status.set('poll', getState().getIn(['polls', status.get('poll')])); + } + + dispatch(fetchStatusSourceRequest()); + + api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { + dispatch(fetchStatusSourceSuccess()); + ensureComposeIsVisible(getState, routerHistory); + dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text)); + }).catch(error => { + dispatch(fetchStatusSourceFail(error)); + }); +}; + +export const fetchStatusSourceRequest = () => ({ + type: STATUS_FETCH_SOURCE_REQUEST, +}); + +export const fetchStatusSourceSuccess = () => ({ + type: STATUS_FETCH_SOURCE_SUCCESS, +}); + +export const fetchStatusSourceFail = error => ({ + type: STATUS_FETCH_SOURCE_FAIL, + error, +}); + export function deleteStatus(id, routerHistory, withRedraft = false) { return (dispatch, getState) => { let status = getState().getIn(['statuses', id]); diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index ae67c6116d..d553c24137 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -13,6 +13,7 @@ import classNames from 'classnames'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, @@ -126,6 +127,10 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onDelete(this.props.status, this.context.router.history, true); } + handleEditClick = () => { + this.props.onEdit(this.props.status, this.context.router.history); + } + handlePinClick = () => { this.props.onPin(this.props.status); } @@ -225,6 +230,7 @@ class StatusActionBar extends ImmutablePureComponent { } if (writtenByMe) { + menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index bc3c43d856..358b89ab9f 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -17,7 +17,7 @@ import { pin, unpin, } from 'flavours/glitch/actions/interactions'; -import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses'; +import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; @@ -169,6 +169,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onEdit (status, history) { + dispatch(editStatus(status.get('id'), history)); + }, + onDirect (account, router) { dispatch(directCompose(account, router)); }, diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js index 5dfc119c1b..47dc87f163 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -47,6 +47,7 @@ class ComposeForm extends ImmutablePureComponent { preselectDate: PropTypes.instanceOf(Date), isSubmitting: PropTypes.bool, isChangingUpload: PropTypes.bool, + isEditing: PropTypes.bool, isUploading: PropTypes.bool, onChange: PropTypes.func, onSubmit: PropTypes.func, @@ -293,6 +294,7 @@ class ComposeForm extends ImmutablePureComponent { spoilerText, suggestions, spoilersAlwaysOn, + isEditing, } = this.props; const countText = this.getFulltextForCharacterCounting(); @@ -364,6 +366,7 @@ class ComposeForm extends ImmutablePureComponent { { @@ -43,7 +45,7 @@ class Publisher extends ImmutablePureComponent { }; render () { - const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props; + const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props; const diff = maxChars - length(countText || ''); const computedClass = classNames('composer--publisher', { @@ -51,63 +53,37 @@ class Publisher extends ImmutablePureComponent { over: diff < 0, }); + const privacyIcons = { direct: 'envelope', private: 'lock', public: 'globe', unlisted: 'unlock' }; + + let publishText; + if (isEditing) { + publishText = intl.formatMessage(messages.saveChanges); + } else if (privacy === 'private' || privacy === 'direct') { + const iconId = privacyIcons[privacy]; + publishText = ( + + {intl.formatMessage(messages.publish)} + + ); + } else { + publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); + } + return (
- {sideArm && sideArm !== 'none' ? ( + {sideArm && !isEditing && sideArm !== 'none' ? (