diff options
Diffstat (limited to 'app')
34 files changed, 616 insertions, 146 deletions
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index a2a919a3e..72094790f 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/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index e10083d45..052d70cc8 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/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index b1390ae48..eaac8e563 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,25 +35,46 @@ 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], - content_type: status_params[:content_type], - 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], + content_type: status_params[:content_type], + 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], + content_type: status_params[:content_type] + ) + + 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 @@ -85,6 +106,7 @@ class Api::V1::StatusesController < Api::BaseController :sensitive, :spoiler_text, :visibility, + :language, :scheduled_at, :content_type, media_ids: [], diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 261c72b2a..baa98e98f 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 7db357df1..6ffcf181d 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 ae67c6116..68d93cd67 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 bc3c43d85..358b89ab9 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 5dfc119c1..47dc87f16 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 { <Publisher countText={countText} disabled={!this.canSubmit()} + isEditing={isEditing} onSecondarySubmit={handleSecondarySubmit} onSubmit={handleSubmit} privacy={privacy} diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js index 1531dcaa9..9a8c0f510 100644 --- a/app/javascript/flavours/glitch/features/compose/components/publisher.js +++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js @@ -2,7 +2,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import { length } from 'stringz'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -23,6 +23,7 @@ const messages = defineMessages({ defaultMessage: '{publish}!', id: 'compose_form.publish_loud', }, + saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, }); export default @injectIntl @@ -36,6 +37,7 @@ class Publisher extends ImmutablePureComponent { onSubmit: PropTypes.func, privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']), sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']), + isEditing: PropTypes.bool, }; handleSubmit = () => { @@ -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 = ( + <span> + <Icon id={iconId} /> {intl.formatMessage(messages.publish)} + </span> + ); + } else { + publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); + } + return ( <div className={computedClass}> - {sideArm && sideArm !== 'none' ? ( + {sideArm && !isEditing && sideArm !== 'none' ? ( <Button className='side_arm' disabled={disabled} onClick={onSecondarySubmit} style={{ padding: null }} - text={ - <span> - <Icon - id={{ - public: 'globe', - unlisted: 'unlock', - private: 'lock', - direct: 'envelope', - }[sideArm]} - /> - </span> - } + text={<Icon id={privacyIcons[sideArm]} />} title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`} /> ) : null} <Button className='primary' - text={function () { - switch (true) { - case !!sideArm && sideArm !== 'none': - case privacy === 'direct': - case privacy === 'private': - return ( - <span> - <Icon - id={{ - direct: 'envelope', - private: 'lock', - public: 'globe', - unlisted: 'unlock', - }[privacy]} - /> - {' '} - <FormattedMessage {...messages.publish} /> - </span> - ); - case privacy === 'public': - return ( - <span> - <FormattedMessage - {...messages.publishLoud} - values={{ publish: <FormattedMessage {...messages.publish} /> }} - /> - </span> - ); - default: - return <span><FormattedMessage {...messages.publish} /></span>; - } - }()} + text={publishText} title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`} onClick={this.handleSubmit} disabled={disabled} diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js index 8eff8a36b..a037bbbcc 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -51,6 +51,7 @@ function mapStateToProps (state) { focusDate: state.getIn(['compose', 'focusDate']), caretPosition: state.getIn(['compose', 'caretPosition']), 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']), layout: state.getIn(['local_settings', 'layout']), diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js index 395a9aa5b..dd6899be4 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js @@ -1,14 +1,24 @@ import { connect } from 'react-redux'; import { cancelReplyCompose } from 'flavours/glitch/actions/compose'; -import { makeGetStatus } from 'flavours/glitch/selectors'; import ReplyIndicator from '../components/reply_indicator'; -function makeMapStateToProps (state) { - const inReplyTo = state.getIn(['compose', 'in_reply_to']); +const makeMapStateToProps = () => { + const mapStateToProps = state => { + let statusId = state.getIn(['compose', 'id'], null); + let editing = true; - return { - status: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null, + if (statusId === null) { + statusId = state.getIn(['compose', 'in_reply_to']); + editing = false; + } + + return { + status: state.getIn(['statuses', statusId]), + editing, + }; }; + + return mapStateToProps; }; const mapDispatchToProps = dispatch => ({ diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index eb4583026..a67a045da 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/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' }, @@ -52,6 +53,7 @@ class ActionBar extends React.PureComponent { onMuteConversation: PropTypes.func, onBlock: PropTypes.func, onDelete: PropTypes.func.isRequired, + onEdit: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onReport: PropTypes.func, @@ -84,6 +86,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); } @@ -166,6 +172,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/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 12ea407ad..653fabeae 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -26,7 +26,7 @@ import { directCompose, } from 'flavours/glitch/actions/compose'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; -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'; @@ -307,6 +307,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)); } @@ -585,6 +589,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/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index d2ea0a924..f97c799e7 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -46,6 +46,7 @@ import { INIT_MEDIA_EDIT_MODAL, COMPOSE_CHANGE_MEDIA_DESCRIPTION, COMPOSE_CHANGE_MEDIA_FOCUS, + COMPOSE_SET_STATUS, } from 'flavours/glitch/actions/compose'; import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines'; import { STORE_HYDRATE } from 'flavours/glitch/actions/store'; @@ -75,6 +76,7 @@ const initialState = ImmutableMap({ spoiler: false, spoiler_text: '', privacy: null, + id: null, content_type: defaultContentType || 'text/plain', text: '', focusDate: null, @@ -160,6 +162,7 @@ function apiStatusToTextHashtags (state, status) { function clearAll(state) { return state.withMutations(map => { + map.set('id', null); map.set('text', ''); if (defaultContentType) map.set('content_type', defaultContentType); map.set('spoiler', false); @@ -400,6 +403,7 @@ export default function compose(state = initialState, action) { .set('elefriend', (state.get('elefriend') + 1) % totalElefriends); 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'))); @@ -434,6 +438,7 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); + map.set('id', null); map.set('poll', null); map.update( 'advanced_options', @@ -573,6 +578,34 @@ export default function compose(state = initialState, action) { })); } }); + 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')), + multiple: action.status.getIn(['poll', 'multiple']), + expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])), + })); + } + }); case COMPOSE_POLL_ADD: return state.set('poll', initialPoll); case COMPOSE_POLL_REMOVE: diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 7c3bbcbd8..f3129f8d9 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'); if (!response.data.local_only) { insertIfOnline('public'); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 20d71362e..adc24eabf 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 4e19cc0e4..e3a7d763f 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 9abdec138..ef0aca13a 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 647d0fba2..311d00626 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -29,6 +29,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 @@ -50,6 +51,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, @@ -200,7 +202,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 = <span className='compose-form__publish-private'><Icon id='lock' /> {intl.formatMessage(messages.publish)}</span>; } 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 c44850294..1be7633cc 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 5eb1eb72a..a1302b2d4 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 a15a4d567..3f3ec0e71 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 f342a3641..4d7f24834 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 06a908e9d..ea882a71f 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: @@ -462,6 +457,34 @@ export default function compose(state = initialState, action) { })); } }); + 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')), + multiple: action.status.getIn(['poll', 'multiple']), + expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])), + })); + } + }); case COMPOSE_POLL_ADD: return state.set('poll', initialPoll); case COMPOSE_POLL_REMOVE: diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 14e6cabae..9eaacdc03 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/poll.rb b/app/models/poll.rb index 71b5e191f..ba08309a1 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/models/report.rb b/app/models/report.rb index ceb15133b..3dd8a6fdd 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/models/status.rb b/app/models/status.rb index 9bb2b3746..236f95c1f 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -215,6 +215,17 @@ 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, + content_type: content_type, + 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 d0359580d..8746fb2c6 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -39,7 +39,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 b1cea1cdf..7438a7c53 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 @@ -217,24 +215,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 47277c56c..43d7bcca6 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 = [] - - 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) + 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 = [] + + 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/report_service.rb b/app/services/report_service.rb index bc0a8b464..caf99ab6e 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/app/services/update_status_service.rb b/app/services/update_status_service.rb new file mode 100644 index 000000000..63bd27989 --- /dev/null +++ b/app/services/update_status_service.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +class UpdateStatusService < BaseService + include Redisable + include LanguagesHelper + + # @param [Status] status + # @param [Integer] account_id + # @param [Hash] options + # @option options [Array<Integer>] :media_ids + # @option options [Hash] :poll + # @option options [String] :text + # @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 + @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 + @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 + 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 = 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! + 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 000000000..a79ede2bf --- /dev/null +++ b/app/workers/activitypub/status_update_distribution_worker.rb @@ -0,0 +1,29 @@ +# 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) + @account = @status.account + + 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 |