Commit 9b2daf50 authored by Eugen Rochko's avatar Eugen Rochko
Browse files

Add editing for published statuses

parent 56555239
......@@ -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
......@@ -31,7 +31,7 @@ class Api::V1::MediaController < Api::BaseController
end
def set_media_attachment
@media_attachment = current_account.media_attachments.unattached.find(params[:id])
@media_attachment = current_account.media_attachments.find(params[:id])
end
def check_processing
......@@ -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
......
......@@ -3,8 +3,8 @@
class Api::V1::StatusesController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy]
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
before_action :require_user!, except: [:show, :context]
before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create]
......@@ -35,24 +35,44 @@ class Api::V1::StatusesController < Api::BaseController
end
def create
@status = PostStatusService.new.call(current_user.account,
text: status_params[:status],
thread: @thread,
media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text],
visibility: status_params[:visibility],
scheduled_at: status_params[:scheduled_at],
application: doorkeeper_token.application,
poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'],
with_rate_limit: true)
@status = PostStatusService.new.call(
current_user.account,
text: status_params[:status],
thread: @thread,
media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text],
visibility: status_params[:visibility],
language: status_params[:language],
scheduled_at: status_params[:scheduled_at],
application: doorkeeper_token.application,
poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'],
with_rate_limit: true
)
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end
def update
@status = Status.where(account: current_account).find(params[:id])
authorize @status, :update?
UpdateStatusService.new.call(
@status,
current_account.id,
text: status_params[:status],
media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text],
poll: status_params[:poll]
)
render json: @status, serializer: REST::StatusSerializer
end
def destroy
@status = Status.where(account_id: current_user.account).find(params[:id])
@status = Status.where(account: current_account).find(params[:id])
authorize @status, :destroy?
@status.discard
......@@ -84,6 +104,7 @@ class Api::V1::StatusesController < Api::BaseController
:sensitive,
:spoiler_text,
:visibility,
:language,
:scheduled_at,
media_ids: [],
poll: [
......
......@@ -40,8 +40,16 @@ class StatusesController < ApplicationController
end
def activity
presenter = begin
if params[:activity_id]
ActivityPub::ActivityPresenter.from_status_edit(@status.edits.find(params[:activity_id]))
else
ActivityPub::ActivityPresenter.from_status(@status)
end
end
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
render_with_cache json: presenter, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
end
def embed
......
......@@ -70,6 +70,8 @@ export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL';
export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
......@@ -83,6 +85,15 @@ export const ensureComposeIsVisible = (getState, routerHistory) => {
}
};
export function setComposeToStatus(status, text, spoiler_text) {
return{
type: COMPOSE_SET_STATUS,
status,
text,
spoiler_text,
};
};
export function changeCompose(text) {
return {
type: COMPOSE_CHANGE,
......@@ -137,8 +148,9 @@ export function directCompose(account, routerHistory) {
export function submitCompose(routerHistory) {
return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
const statusId = getState().getIn(['compose', 'id'], null);
if ((!status || !status.length) && media.size === 0) {
return;
......@@ -146,15 +158,18 @@ export function submitCompose(routerHistory) {
dispatch(submitComposeRequest());
api(getState).post('/api/v1/statuses', {
status,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
}, {
api(getState).request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put',
data: {
status,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
},
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
},
......@@ -176,11 +191,11 @@ export function submitCompose(routerHistory) {
}
};
if (response.data.visibility !== 'direct') {
if (statusId === null && response.data.visibility !== 'direct') {
insertIfOnline('home');
}
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') {
insertIfOnline('community');
insertIfOnline('public');
insertIfOnline(`account:${response.data.account.id}`);
......
......@@ -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,25 +88,45 @@ export function redraft(status, raw_text) {
};
};
export function deleteStatus(id, routerHistory, withRedraft = false) {
return (dispatch, getState) => {
let status = getState().getIn(['statuses', id]);
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')]));
}
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) {
return (dispatch, getState) => {
dispatch(deleteStatusRequest(id));
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account));
if (withRedraft) {
dispatch(redraft(status, response.data.text));
ensureComposeIsVisible(getState, routerHistory);
}
}).catch(error => {
dispatch(deleteStatusFail(id, error));
});
......
......@@ -11,7 +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}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
......@@ -133,8 +133,8 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onDelete(this.props.status, this.context.router.history);
}
handleRedraftClick = () => {
this.props.onDelete(this.props.status, this.context.router.history, true);
handleEditClick = () => {
this.props.onEdit(this.props.status, this.context.router.history);
}
handlePinClick = () => {
......@@ -254,8 +254,8 @@ 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 {
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
......
......@@ -24,6 +24,7 @@ import {
hideStatus,
revealStatus,
toggleStatusCollapse,
editStatus,
} from '../actions/statuses';
import {
unmuteAccount,
......@@ -130,18 +131,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
onDelete (status, history, withRedraft = false) {
onDelete (status, history) {
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), history, withRedraft));
dispatch(deleteStatus(status.get('id'), history));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history)),
}));
}
},
onEdit (status, history) {
dispatch(editStatus(status.get('id'), history));
},
onDirect (account, router) {
dispatch(directCompose(account, router));
},
......
......@@ -28,6 +28,7 @@ const messages = defineMessages({
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
});
export default @injectIntl
......@@ -49,6 +50,7 @@ class ComposeForm extends ImmutablePureComponent {
preselectDate: PropTypes.instanceOf(Date),
isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool,
isEditing: PropTypes.bool,
isUploading: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
......@@ -198,7 +200,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);
......
......@@ -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;
};
......
......@@ -10,7 +10,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 +59,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,
......@@ -94,8 +95,8 @@ class ActionBar extends React.PureComponent {
this.props.onDelete(this.props.status, this.context.router.history);
}
handleRedraftClick = () => {
this.props.onDelete(this.props.status, this.context.router.history, true);
handleEditClick = () => {
this.props.onEdit(this.props.status, this.context.router.history);
}
handleDirectClick = () => {
......@@ -208,8 +209,8 @@ 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 {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
......
......@@ -108,14 +108,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
onDelete (status, history, withRedraft = false) {
onDelete (status, history) {
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), history, withRedraft));
dispatch(deleteStatus(status.get('id'), history));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history)),
}));
}
},
......
......@@ -29,6 +29,7 @@ import {
muteStatus,
unmuteStatus,
deleteStatus,
editStatus,
hideStatus,
revealStatus,
} from '../../actions/statuses';
......@@ -61,8 +62,6 @@ import Icon from 'mastodon/components/icon';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
......@@ -259,20 +258,24 @@ class Status extends ImmutablePureComponent {
}
}
handleDeleteClick = (status, history, withRedraft = false) => {
handleDeleteClick = (status, history) => {
const { dispatch, intl } = this.props;
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), history, withRedraft));
dispatch(deleteStatus(status.get('id'), history));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history)),
}));
}
}
handleEditClick = (status, history) => {
this.props.dispatch(editStatus(status.get('id'), history));
}
handleDirectClick = (account, router) => {
this.props.dispatch(directCompose(account, router));
}
......@@ -567,6 +570,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}
......
......@@ -43,14 +43,13 @@ 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';
import { REDRAFT } from '../actions/statuses';
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import uuid from '../uuid';
import { me } from '../initial_state';
import { unescapeHTML } from '../utils/html';
const initialState = ImmutableMap({
mounted: 0,
......@@ -58,6 +57,7 @@ const initialState = ImmutableMap({
spoiler: false,
spoiler_text: '',
privacy: null,
id: null,
text: '',
focusDate: null,
caretPosition: null,
......@@ -107,6 +107,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', '');
......@@ -221,18 +222,6 @@ const hydrate = (state, hydratedState) => {
return state;
};
const domParser = new DOMParser();
const expandMentions = status => {
const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement;
status.get('mentions').forEach(mention => {
fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`;
});
return fragment.innerHTML;
};
const expiresInFromExpiresAt = expires_at => {
if (!expires_at) return 24 * 3600;
const delta = (new Date(expires_at).getTime() - Date.now()) / 1000;
......@@ -313,6 +302,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 +319,12 @@ export default function compose(state = initialState, action) {
map.set('spoiler_text', '');
}
});
case COMPOSE_REPLY_CANCEL:
case COMPOSE_RESET: