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
This commit is contained in:
Eugen Rochko 2022-02-10 00:15:30 +01:00 committed by GitHub
parent 20a3564ab2
commit 63002cde03
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 839 additions and 77 deletions

View file

@ -20,7 +20,7 @@ class Api::V1::MediaController < Api::BaseController
end end
def update 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 render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment
end end
@ -42,6 +42,10 @@ class Api::V1::MediaController < Api::BaseController
params.permit(:file, :thumbnail, :description, :focus) params.permit(:file, :thumbnail, :description, :focus)
end end
def updateable_media_attachment_params
params.permit(:thumbnail, :description, :focus)
end
def file_type_error def file_type_error
{ error: 'File type of uploaded media could not be verified' } { error: 'File type of uploaded media could not be verified' }
end end

View file

@ -3,8 +3,8 @@
class Api::V1::StatusesController < Api::BaseController class Api::V1::StatusesController < Api::BaseController
include Authorization include Authorization
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy] before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
before_action :require_user!, except: [:show, :context] before_action :require_user!, except: [:show, :context]
before_action :set_status, only: [:show, :context] before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create] before_action :set_thread, only: [:create]
@ -35,24 +35,44 @@ class Api::V1::StatusesController < Api::BaseController
end end
def create def create
@status = PostStatusService.new.call(current_user.account, @status = PostStatusService.new.call(
text: status_params[:status], current_user.account,
thread: @thread, text: status_params[:status],
media_ids: status_params[:media_ids], thread: @thread,
sensitive: status_params[:sensitive], media_ids: status_params[:media_ids],
spoiler_text: status_params[:spoiler_text], sensitive: status_params[:sensitive],
visibility: status_params[:visibility], spoiler_text: status_params[:spoiler_text],
scheduled_at: status_params[:scheduled_at], visibility: status_params[:visibility],
application: doorkeeper_token.application, language: status_params[:language],
poll: status_params[:poll], scheduled_at: status_params[:scheduled_at],
idempotency: request.headers['Idempotency-Key'], application: doorkeeper_token.application,
with_rate_limit: true) 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 render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end 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 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? authorize @status, :destroy?
@status.discard @status.discard
@ -84,6 +104,7 @@ class Api::V1::StatusesController < Api::BaseController
:sensitive, :sensitive,
:spoiler_text, :spoiler_text,
:visibility, :visibility,
:language,
:scheduled_at, :scheduled_at,
media_ids: [], media_ids: [],
poll: [ poll: [

View file

@ -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_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
const messages = defineMessages({ const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, 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) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@ -137,8 +148,9 @@ export function directCompose(account, routerHistory) {
export function submitCompose(routerHistory) { export function submitCompose(routerHistory) {
return function (dispatch, getState) { return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], ''); const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
const statusId = getState().getIn(['compose', 'id'], null);
if ((!status || !status.length) && media.size === 0) { if ((!status || !status.length) && media.size === 0) {
return; return;
@ -146,15 +158,18 @@ export function submitCompose(routerHistory) {
dispatch(submitComposeRequest()); dispatch(submitComposeRequest());
api(getState).post('/api/v1/statuses', { api(getState).request({
status, url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), method: statusId === null ? 'post' : 'put',
media_ids: media.map(item => item.get('id')), data: {
sensitive: getState().getIn(['compose', 'sensitive']), status,
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
visibility: getState().getIn(['compose', 'privacy']), media_ids: media.map(item => item.get('id')),
poll: getState().getIn(['compose', 'poll'], null), 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: { headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), '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'); 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('community');
insertIfOnline('public'); insertIfOnline('public');
insertIfOnline(`account:${response.data.account.id}`); insertIfOnline(`account:${response.data.account.id}`);

View file

@ -2,7 +2,7 @@ import api from '../api';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; 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_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@ -30,6 +30,10 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
export const REDRAFT = 'REDRAFT'; 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) { export function fetchStatusRequest(id, skipLoading) {
return { return {
type: STATUS_FETCH_REQUEST, 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) { export function deleteStatus(id, routerHistory, withRedraft = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
let status = getState().getIn(['statuses', id]); let status = getState().getIn(['statuses', id]);

View file

@ -12,6 +12,7 @@ import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
edit: { id: 'status.edit', defaultMessage: 'Edit' },
direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{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); this.props.onDelete(this.props.status, this.context.router.history, true);
} }
handleEditClick = () => {
this.props.onEdit(this.props.status, this.context.router.history);
}
handlePinClick = () => { handlePinClick = () => {
this.props.onPin(this.props.status); this.props.onPin(this.props.status);
} }
@ -255,6 +260,7 @@ class StatusActionBar extends ImmutablePureComponent {
} }
if (writtenByMe) { 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.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
} else { } else {

View file

@ -24,6 +24,7 @@ import {
hideStatus, hideStatus,
revealStatus, revealStatus,
toggleStatusCollapse, toggleStatusCollapse,
editStatus,
} from '../actions/statuses'; } from '../actions/statuses';
import { import {
unmuteAccount, unmuteAccount,
@ -142,6 +143,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onEdit (status, history) {
dispatch(editStatus(status.get('id'), history));
},
onDirect (account, router) { onDirect (account, router) {
dispatch(directCompose(account, router)); dispatch(directCompose(account, router));
}, },

View file

@ -28,6 +28,7 @@ const messages = defineMessages({
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
}); });
export default @injectIntl export default @injectIntl
@ -49,6 +50,7 @@ class ComposeForm extends ImmutablePureComponent {
preselectDate: PropTypes.instanceOf(Date), preselectDate: PropTypes.instanceOf(Date),
isSubmitting: PropTypes.bool, isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool, isChangingUpload: PropTypes.bool,
isEditing: PropTypes.bool,
isUploading: PropTypes.bool, isUploading: PropTypes.bool,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired,
@ -199,7 +201,9 @@ class ComposeForm extends ImmutablePureComponent {
const disabled = this.props.isSubmitting; const disabled = this.props.isSubmitting;
let publishText = ''; 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>; publishText = <span className='compose-form__publish-private'><Icon id='lock' /> {intl.formatMessage(messages.publish)}</span>;
} else { } else {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);

View file

@ -21,6 +21,7 @@ const mapStateToProps = state => ({
caretPosition: state.getIn(['compose', 'caretPosition']), caretPosition: state.getIn(['compose', 'caretPosition']),
preselectDate: state.getIn(['compose', 'preselectDate']), preselectDate: state.getIn(['compose', 'preselectDate']),
isSubmitting: state.getIn(['compose', 'is_submitting']), isSubmitting: state.getIn(['compose', 'is_submitting']),
isEditing: state.getIn(['compose', 'id']) !== null,
isChangingUpload: state.getIn(['compose', 'is_changing_upload']), isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']), isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),

View file

@ -6,9 +6,20 @@ import ReplyIndicator from '../components/reply_indicator';
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const mapStateToProps = state => ({ const mapStateToProps = state => {
status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }), 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; return mapStateToProps;
}; };

View file

@ -11,6 +11,7 @@ import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
edit: { id: 'status.edit', defaultMessage: 'Edit' },
direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
@ -59,6 +60,7 @@ class ActionBar extends React.PureComponent {
onFavourite: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,
onMute: PropTypes.func, onMute: PropTypes.func,
@ -98,6 +100,10 @@ class ActionBar extends React.PureComponent {
this.props.onDelete(this.props.status, this.context.router.history, true); this.props.onDelete(this.props.status, this.context.router.history, true);
} }
handleEditClick = () => {
this.props.onEdit(this.props.status, this.context.router.history);
}
handleDirectClick = () => { handleDirectClick = () => {
this.props.onDirect(this.props.status.get('account'), this.context.router.history); 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({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null); 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.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
} else { } else {

View file

@ -29,6 +29,7 @@ import {
muteStatus, muteStatus,
unmuteStatus, unmuteStatus,
deleteStatus, deleteStatus,
editStatus,
hideStatus, hideStatus,
revealStatus, revealStatus,
} from '../../actions/statuses'; } 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) => { handleDirectClick = (account, router) => {
this.props.dispatch(directCompose(account, router)); this.props.dispatch(directCompose(account, router));
} }
@ -567,6 +572,7 @@ class Status extends ImmutablePureComponent {
onReblog={this.handleReblogClick} onReblog={this.handleReblogClick}
onBookmark={this.handleBookmarkClick} onBookmark={this.handleBookmarkClick}
onDelete={this.handleDeleteClick} onDelete={this.handleDeleteClick}
onEdit={this.handleEditClick}
onDirect={this.handleDirectClick} onDirect={this.handleDirectClick}
onMention={this.handleMentionClick} onMention={this.handleMentionClick}
onMute={this.handleMuteClick} onMute={this.handleMuteClick}

View file

@ -43,6 +43,7 @@ import {
INIT_MEDIA_EDIT_MODAL, INIT_MEDIA_EDIT_MODAL,
COMPOSE_CHANGE_MEDIA_DESCRIPTION, COMPOSE_CHANGE_MEDIA_DESCRIPTION,
COMPOSE_CHANGE_MEDIA_FOCUS, COMPOSE_CHANGE_MEDIA_FOCUS,
COMPOSE_SET_STATUS,
} from '../actions/compose'; } from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
@ -58,6 +59,7 @@ const initialState = ImmutableMap({
spoiler: false, spoiler: false,
spoiler_text: '', spoiler_text: '',
privacy: null, privacy: null,
id: null,
text: '', text: '',
focusDate: null, focusDate: null,
caretPosition: null, caretPosition: null,
@ -107,6 +109,7 @@ function statusToTextMentions(state, status) {
function clearAll(state) { function clearAll(state) {
return state.withMutations(map => { return state.withMutations(map => {
map.set('id', null);
map.set('text', ''); map.set('text', '');
map.set('spoiler', false); map.set('spoiler', false);
map.set('spoiler_text', ''); map.set('spoiler_text', '');
@ -313,6 +316,7 @@ export default function compose(state = initialState, action) {
return state.set('is_composing', action.value); return state.set('is_composing', action.value);
case COMPOSE_REPLY: case COMPOSE_REPLY:
return state.withMutations(map => { return state.withMutations(map => {
map.set('id', null);
map.set('in_reply_to', action.status.get('id')); map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status)); map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); 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', ''); 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: case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true); return state.set('is_submitting', true);
case COMPOSE_UPLOAD_CHANGE_REQUEST: case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_changing_upload', true); return state.set('is_changing_upload', true);
case COMPOSE_REPLY_CANCEL:
case COMPOSE_RESET:
case COMPOSE_SUBMIT_SUCCESS: case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state); return clearAll(state);
case COMPOSE_SUBMIT_FAIL: case COMPOSE_SUBMIT_FAIL:
@ -454,6 +449,34 @@ export default function compose(state = initialState, action) {
map.set('spoiler_text', ''); 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')) { if (action.status.get('poll')) {
map.set('poll', ImmutableMap({ map.set('poll', ImmutableMap({
options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),

View file

@ -208,6 +208,10 @@ class MediaAttachment < ApplicationRecord
file.blank? && remote_url.present? file.blank? && remote_url.present?
end end
def significantly_changed?
description_previously_changed? || thumbnail_updated_at_previously_changed? || file_meta_previously_changed?
end
def larger_media_format? def larger_media_format?
video? || gifv? || audio? video? || gifv? || audio?
end end

View file

@ -209,6 +209,16 @@ class Status < ApplicationRecord
public_visibility? || unlisted_visibility? public_visibility? || unlisted_visibility?
end 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? def edited?
edited_at.present? edited_at.present?
end end

View file

@ -38,7 +38,7 @@ class StatusPolicy < ApplicationPolicy
alias unreblog? destroy? alias unreblog? destroy?
def update? def update?
staff? staff? || owned?
end end
private private

View file

@ -217,24 +217,18 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
return if @status.edits.any? return if @status.edits.any?
@status.edits.create( @status.snapshot!(
text: @status.text,
spoiler_text: @status.spoiler_text,
media_attachments_changed: false, media_attachments_changed: false,
account_id: @account.id, at_time: @status.created_at
created_at: @status.created_at
) )
end end
def create_edit! def create_edit!
return unless significant_changes? return unless significant_changes?
@status_edit = @status.edits.create( @status.snapshot!(
text: @status.text,
spoiler_text: @status.spoiler_text,
media_attachments_changed: @media_attachments_changed || @poll_changed, media_attachments_changed: @media_attachments_changed || @poll_changed,
account_id: @account.id, account_id: @account.id
created_at: @status.edited_at
) )
end end

View file

@ -1,20 +1,40 @@
# frozen_string_literal: true # frozen_string_literal: true
class ProcessHashtagsService < BaseService class ProcessHashtagsService < BaseService
def call(status, tags = []) def call(status, raw_tags = [])
tags = Extractor.extract_hashtags(status.text) if status.local? @status = status
records = [] @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| assign_tags!
status.tags << tag update_featured_tags!
records << tag end
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)
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 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| unless removed_tags.empty?
featured_tag.increment(status.created_at) @account.featured_tags.where(tag_id: removed_tags.map(&:id)).each do |featured_tag|
featured_tag.decrement(@status.id)
end
end end
end end
end end

View file

@ -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<Integer>] :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

View file

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

View file

@ -332,7 +332,7 @@ Rails.application.routes.draw do
# JSON / REST API # JSON / REST API
namespace :v1 do namespace :v1 do
resources :statuses, only: [:create, :show, :destroy] do resources :statuses, only: [:create, :show, :update, :destroy] do
scope module: :statuses do scope module: :statuses do
resources :reblogged_by, controller: :reblogged_by_accounts, only: :index resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
resources :favourited_by, controller: :favourited_by_accounts, only: :index resources :favourited_by, controller: :favourited_by_accounts, only: :index

View file

@ -110,21 +110,24 @@ RSpec.describe Api::V1::MediaController, type: :controller do
end end
end end
context 'when not attached to a status' do context 'when the author \'s' do
let(:media) { Fabricate(:media_attachment, status: nil, account: user.account) } 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 it 'updates the description' do
put :update, params: { id: media.id, description: 'Lorem ipsum!!!' }
expect(media.reload.description).to eq 'Lorem ipsum!!!' expect(media.reload.description).to eq 'Lorem ipsum!!!'
end end
end
context 'when attached to a status' do context 'when already attached to a status' do
let(:media) { Fabricate(:media_attachment, status: Fabricate(:status), account: user.account) } let(:status) { Fabricate(:status, account: user.account) }
it 'returns http not found' do it 'returns http not found' do
put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } expect(response).to have_http_status(:not_found)
expect(response).to have_http_status(:not_found) end
end end
end end
end end

View file

@ -102,6 +102,23 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
expect(Status.find_by(id: status.id)).to be nil expect(Status.find_by(id: status.id)).to be nil
end end
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 end
context 'without an oauth token' do context 'without an oauth token' do

View file

@ -125,7 +125,7 @@ RSpec.describe StatusPolicy, type: :model do
end end
end end
permissions :index?, :update? do permissions :index? do
it 'grants access if staff' do it 'grants access if staff' do
expect(subject).to permit(admin.account) expect(subject).to permit(admin.account)
end end
@ -134,4 +134,18 @@ RSpec.describe StatusPolicy, type: :model do
expect(subject).to_not permit(alice) expect(subject).to_not permit(alice)
end end
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 end

View file

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

View file

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