Allow editing posts on Mastodon

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
api-accept
marcin mikołajczak 2 years ago
parent 7363d9c7f8
commit 387ebfc56c

@ -5,7 +5,7 @@ import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures, parseVersion } from 'soapbox/utils/features';
import { formatBytes } from 'soapbox/utils/media'; import { formatBytes } from 'soapbox/utils/media';
import api from '../api'; import api from '../api';
@ -78,6 +78,8 @@ export const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE';
export const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS'; export const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS';
export const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS'; export const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS';
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
const messages = defineMessages({ const messages = defineMessages({
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' }, exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' },
@ -96,6 +98,23 @@ export const ensureComposeIsVisible = (getState, routerHistory) => {
} }
}; };
export function setComposeToStatus(status, text, spoiler_text, content_type) {
return (dispatch, getState) => {
const { instance } = getState();
const { explicitAddressing } = getFeatures(instance);
dispatch({
type: COMPOSE_SET_STATUS,
status,
text,
explicitAddressing,
spoiler_text,
content_type,
v: parseVersion(instance.version),
});
};
}
export function changeCompose(text) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@ -221,9 +240,10 @@ export function submitCompose(routerHistory, force = false) {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
const state = getState(); const state = getState();
const status = state.getIn(['compose', 'text'], ''); const status = state.getIn(['compose', 'text'], '');
const media = state.getIn(['compose', 'media_attachments']); const media = state.getIn(['compose', 'media_attachments']);
let to = state.getIn(['compose', 'to'], ImmutableOrderedSet()); const statusId = state.getIn(['compose', 'id'], null);
let to = state.getIn(['compose', 'to'], ImmutableOrderedSet());
if (!validateSchedule(state)) { if (!validateSchedule(state)) {
dispatch(snackbar.error(messages.scheduleError)); dispatch(snackbar.error(messages.scheduleError));
@ -270,7 +290,7 @@ export function submitCompose(routerHistory, force = false) {
to, to,
}; };
dispatch(createStatus(params, idempotencyKey)).then(function(data) { dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
if (data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) { if (data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) {
routerHistory.push('/messages'); routerHistory.push('/messages');
} }

@ -0,0 +1,38 @@
import api from 'soapbox/api';
import { importFetchedAccounts } from './importer';
export const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST';
export const HISTORY_FETCH_SUCCESS = 'HISTORY_FETCH_SUCCESS';
export const HISTORY_FETCH_FAIL = 'HISTORY_FETCH_FAIL';
export const fetchHistory = statusId => (dispatch, getState) => {
const loading = getState().getIn(['history', statusId, 'loading']);
if (loading) {
return;
}
dispatch(fetchHistoryRequest(statusId));
api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => {
dispatch(importFetchedAccounts(data.map(x => x.account)));
dispatch(fetchHistorySuccess(statusId, data));
}).catch(error => dispatch(fetchHistoryFail(error)));
};
export const fetchHistoryRequest = statusId => ({
type: HISTORY_FETCH_REQUEST,
statusId,
});
export const fetchHistorySuccess = (statusId, history) => ({
type: HISTORY_FETCH_SUCCESS,
statusId,
history,
});
export const fetchHistoryFail = error => ({
type: HISTORY_FETCH_FAIL,
error,
});

@ -4,6 +4,7 @@ import { shouldHaveCard } from 'soapbox/utils/status';
import api, { getNextLink } from '../api'; import api, { getNextLink } from '../api';
import { setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses } from './importer'; import { importFetchedStatus, importFetchedStatuses } from './importer';
import { openModal } from './modals'; import { openModal } from './modals';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
@ -12,6 +13,10 @@ export const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST';
export const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS'; export const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS';
export const STATUS_CREATE_FAIL = 'STATUS_CREATE_FAIL'; export const STATUS_CREATE_FAIL = 'STATUS_CREATE_FAIL';
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 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';
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
@ -41,11 +46,14 @@ const statusExists = (getState, statusId) => {
return getState().getIn(['statuses', statusId], null) !== null; return getState().getIn(['statuses', statusId], null) !== null;
}; };
export function createStatus(params, idempotencyKey) { export function createStatus(params, idempotencyKey, statusId) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey }); dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey });
return api(getState).post('/api/v1/statuses', params, { return api(getState).request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put',
data: params,
headers: { 'Idempotency-Key': idempotencyKey }, headers: { 'Idempotency-Key': idempotencyKey },
}).then(({ data: status }) => { }).then(({ data: status }) => {
// The backend might still be processing the rich media attachment // The backend might still be processing the rich media attachment
@ -81,6 +89,25 @@ export function createStatus(params, idempotencyKey) {
}; };
} }
export const editStatus = (id) => (dispatch, getState) => {
let status = getState().getIn(['statuses', id]);
if (status.get('poll')) {
status = status.set('poll', getState().getIn(['polls', status.get('poll')]));
}
dispatch({ type: STATUS_FETCH_SOURCE_REQUEST });
api(getState).get(`/api/v1/statuses/${id}/source`).then(response => {
dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS });
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text));
dispatch(openModal('COMPOSE'));
}).catch(error => {
dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error });
});
};
export function fetchStatus(id) { export function fetchStatus(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
const skipLoading = statusExists(getState, id); const skipLoading = statusExists(getState, id);
@ -130,7 +157,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
dispatch(deleteFromTimelines(id)); dispatch(deleteFromTimelines(id));
if (withRedraft) { if (withRedraft) {
dispatch(redraft(status, response.data.text, response.data.pleroma?.content_type)); dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.pleroma?.content_type));
dispatch(openModal('COMPOSE')); dispatch(openModal('COMPOSE'));
} }
}).catch(error => { }).catch(error => {
@ -139,6 +166,9 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
}; };
} }
export const updateStatus = status => dispatch =>
dispatch(importFetchedStatus(status));
export function fetchContext(id) { export function fetchContext(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: CONTEXT_FETCH_REQUEST, id }); dispatch({ type: CONTEXT_FETCH_REQUEST, id });

@ -6,6 +6,7 @@ import { connectStream } from '../stream';
import { updateConversations } from './conversations'; import { updateConversations } from './conversations';
import { fetchFilters } from './filters'; import { fetchFilters } from './filters';
import { updateNotificationsQueue, expandNotifications } from './notifications'; import { updateNotificationsQueue, expandNotifications } from './notifications';
import { updateStatus } from './statuses';
import { import {
deleteFromTimelines, deleteFromTimelines,
expandHomeTimeline, expandHomeTimeline,
@ -54,6 +55,9 @@ export function connectTimelineStream(timelineId, path, pollingRefresh = null, a
case 'update': case 'update':
dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept)); dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept));
break; break;
case 'status.update':
dispatch(updateStatus(JSON.parse(data.payload)));
break;
case 'delete': case 'delete':
dispatch(deleteFromTimelines(data.payload)); dispatch(deleteFromTimelines(data.payload));
break; break;

@ -72,6 +72,7 @@ interface IStatus extends RouteComponentProps {
onReblog: (status: StatusEntity, e?: KeyboardEvent) => void, onReblog: (status: StatusEntity, e?: KeyboardEvent) => void,
onQuote: (status: StatusEntity) => void, onQuote: (status: StatusEntity) => void,
onDelete: (status: StatusEntity) => void, onDelete: (status: StatusEntity) => void,
onEdit: (status: StatusEntity) => void,
onDirect: (status: StatusEntity) => void, onDirect: (status: StatusEntity) => void,
onChat: (status: StatusEntity) => void, onChat: (status: StatusEntity) => void,
onMention: (account: StatusEntity['account'], history: History) => void, onMention: (account: StatusEntity['account'], history: History) => void,

@ -25,6 +25,7 @@ import type { Features } from 'soapbox/utils/features';
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}' },
chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
@ -77,6 +78,7 @@ interface IStatusActionBar extends RouteComponentProps {
onReblog: (status: Status, e: React.MouseEvent) => void, onReblog: (status: Status, e: React.MouseEvent) => void,
onQuote: (status: Status, history: History) => void, onQuote: (status: Status, history: History) => void,
onDelete: (status: Status, history: History, redraft?: boolean) => void, onDelete: (status: Status, history: History, redraft?: boolean) => void,
onEdit: (status: Status) => void,
onDirect: (account: any, history: History) => void, onDirect: (account: any, history: History) => void,
onChat: (account: any, history: History) => void, onChat: (account: any, history: History) => void,
onMention: (account: any, history: History) => void, onMention: (account: any, history: History) => void,
@ -246,6 +248,10 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
this.props.onDelete(this.props.status, this.props.history, true); this.props.onDelete(this.props.status, this.props.history, true);
} }
handleEditClick: React.EventHandler<React.MouseEvent> = () => {
this.props.onEdit(this.props.status);
}
handlePinClick: React.EventHandler<React.MouseEvent> = (e) => { handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
e.stopPropagation(); e.stopPropagation();
this.props.onPin(this.props.status); this.props.onPin(this.props.status);
@ -432,12 +438,20 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
icon: require('@tabler/icons/icons/trash.svg'), icon: require('@tabler/icons/icons/trash.svg'),
destructive: true, destructive: true,
}); });
menu.push({ if (features.editStatuses) {
text: intl.formatMessage(messages.redraft), menu.push({
action: this.handleRedraftClick, text: intl.formatMessage(messages.edit),
icon: require('@tabler/icons/icons/edit.svg'), action: this.handleEditClick,
destructive: true, icon: require('@tabler/icons/icons/edit.svg'),
}); });
} else {
menu.push({
text: intl.formatMessage(messages.redraft),
action: this.handleRedraftClick,
icon: require('@tabler/icons/icons/edit.svg'),
destructive: true,
});
}
} else { } else {
menu.push({ menu.push({
text: intl.formatMessage(messages.mention, { name: username }), text: intl.formatMessage(messages.mention, { name: username }),

@ -38,6 +38,7 @@ import {
deleteStatus, deleteStatus,
hideStatus, hideStatus,
revealStatus, revealStatus,
editStatus,
} from '../actions/statuses'; } from '../actions/statuses';
import Status from '../components/status'; import Status from '../components/status';
import { makeGetStatus } from '../selectors'; import { makeGetStatus } from '../selectors';
@ -172,6 +173,10 @@ const mapDispatchToProps = (dispatch, { intl }) => {
}); });
}, },
onEdit(status) {
dispatch(editStatus(status.get('id')));
},
onDirect(account, router) { onDirect(account, router) {
dispatch(directCompose(account, router)); dispatch(directCompose(account, router));
}, },

@ -43,6 +43,7 @@ const messages = defineMessages({
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
message: { id: 'compose_form.message', defaultMessage: 'Message' }, message: { id: 'compose_form.message', defaultMessage: 'Message' },
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' }, schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
}); });
export default @withRouter export default @withRouter
@ -63,6 +64,7 @@ class ComposeForm extends ImmutablePureComponent {
caretPosition: PropTypes.number, caretPosition: PropTypes.number,
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,
@ -261,7 +263,9 @@ class ComposeForm extends ImmutablePureComponent {
let publishText = ''; let publishText = '';
if (this.props.privacy === 'direct') { if (this.props.isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (this.props.privacy === 'direct') {
publishText = ( publishText = (
<> <>
<Icon src={require('@tabler/icons/icons/mail.svg')} /> <Icon src={require('@tabler/icons/icons/mail.svg')} />

@ -27,6 +27,7 @@ const mapStateToProps = state => {
focusDate: state.getIn(['compose', 'focusDate']), focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']), caretPosition: state.getIn(['compose', 'caretPosition']),
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']),

@ -7,9 +7,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 }),
hideActions: editing,
};
};
return mapStateToProps; return mapStateToProps;
}; };

@ -26,6 +26,7 @@ type Dispatch = ThunkDispatch<RootState, void, AnyAction>;
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}' },
chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
@ -95,6 +96,7 @@ interface OwnProps {
onFavourite: (status: StatusEntity) => void, onFavourite: (status: StatusEntity) => void,
onEmojiReact: (status: StatusEntity, emoji: string) => void, onEmojiReact: (status: StatusEntity, emoji: string) => void,
onDelete: (status: StatusEntity, history: History, redraft?: boolean) => void, onDelete: (status: StatusEntity, history: History, redraft?: boolean) => void,
onEdit: (status: StatusEntity) => void,
onBookmark: (status: StatusEntity) => void, onBookmark: (status: StatusEntity) => void,
onDirect: (account: AccountEntity, history: History) => void, onDirect: (account: AccountEntity, history: History) => void,
onChat: (account: AccountEntity, history: History) => void, onChat: (account: AccountEntity, history: History) => void,
@ -242,6 +244,10 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
this.props.onDelete(this.props.status, this.props.history, true); this.props.onDelete(this.props.status, this.props.history, true);
} }
handleEditClick: React.EventHandler<React.MouseEvent> = () => {
this.props.onEdit(this.props.status);
}
handleDirectClick: React.EventHandler<React.MouseEvent> = () => { handleDirectClick: React.EventHandler<React.MouseEvent> = () => {
const { account } = this.props.status; const { account } = this.props.status;
if (!account || typeof account !== 'object') return; if (!account || typeof account !== 'object') return;
@ -394,17 +400,18 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
action: this.handlePinClick, action: this.handlePinClick,
icon: require(mutingConversation ? '@tabler/icons/icons/pinned-off.svg' : '@tabler/icons/icons/pin.svg'), icon: require(mutingConversation ? '@tabler/icons/icons/pinned-off.svg' : '@tabler/icons/icons/pin.svg'),
}); });
} else {
if (status.visibility === 'private') { menu.push(null);
menu.push({ } else if (status.visibility === 'private') {
text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), menu.push({
action: this.handleReblogClick, text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private),
icon: require('@tabler/icons/icons/repeat.svg'), action: this.handleReblogClick,
}); icon: require('@tabler/icons/icons/repeat.svg'),
} });
menu.push(null);
} }
menu.push(null);
menu.push({ menu.push({
text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation),
action: this.handleConversationMuteClick, action: this.handleConversationMuteClick,
@ -417,12 +424,20 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
icon: require('@tabler/icons/icons/trash.svg'), icon: require('@tabler/icons/icons/trash.svg'),
destructive: true, destructive: true,
}); });
menu.push({ if (features.editStatuses) {
text: intl.formatMessage(messages.redraft), menu.push({
action: this.handleRedraftClick, text: intl.formatMessage(messages.edit),
icon: require('@tabler/icons/icons/edit.svg'), action: this.handleEditClick,
destructive: true, icon: require('@tabler/icons/icons/edit.svg'),
}); });
} else {
menu.push({
text: intl.formatMessage(messages.redraft),
action: this.handleRedraftClick,
icon: require('@tabler/icons/icons/edit.svg'),
destructive: true,
});
}
} else { } else {
menu.push({ menu.push({
text: intl.formatMessage(messages.mention, { name: username }), text: intl.formatMessage(messages.mention, { name: username }),

@ -32,6 +32,7 @@ interface IDetailedStatus extends IntlProps {
domain: string, domain: string,
compact: boolean, compact: boolean,
showMedia: boolean, showMedia: boolean,
onOpenCompareHistoryModal: (status: StatusEntity) => void,
onToggleMediaVisibility: () => void, onToggleMediaVisibility: () => void,
} }
@ -57,6 +58,10 @@ class DetailedStatus extends ImmutablePureComponent<IDetailedStatus, IDetailedSt
this.props.onToggleHidden(this.props.status); this.props.onToggleHidden(this.props.status);
} }
handleOpenCompareHistoryModal = () => {
this.props.onOpenCompareHistoryModal(this.props.status);
}
_measureHeight(heightJustChanged = false) { _measureHeight(heightJustChanged = false) {
if (this.props.measureHeight && this.node) { if (this.props.measureHeight && this.node) {
scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 })); scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
@ -238,14 +243,33 @@ class DetailedStatus extends ImmutablePureComponent<IDetailedStatus, IDetailedSt
<HStack justifyContent='between' alignItems='center' className='py-2'> <HStack justifyContent='between' alignItems='center' className='py-2'>
<StatusInteractionBar status={status} /> <StatusInteractionBar status={status} />
<div className='detailed-status__timestamp'> <div className='detailed-status__timestamp'>
{statusTypeIcon} {statusTypeIcon}
<a href={status.url} target='_blank' rel='noopener' className='hover:underline'> <span>
<Text tag='span' theme='muted' size='sm'> <a href={status.url} target='_blank' rel='noopener' className='hover:underline'>
<FormattedDate value={new Date(status.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> <Text tag='span' theme='muted' size='sm'>
</Text> <FormattedDate value={new Date(status.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a> </Text>
</a>
{status.edited_at && (
<>
{' · '}
<div
className='inline hover:underline'
onClick={this.handleOpenCompareHistoryModal}
role='button'
tabIndex={0}
>
<Text tag='span' theme='muted' size='sm'>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: this.props.intl.formatDate(new Date(status.edited_at), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
</Text>
</div>
</>
)}
</span>
</div> </div>
</HStack> </HStack>
</div> </div>

@ -2,17 +2,14 @@ import React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { blockAccount } from 'soapbox/actions/accounts';
import { showAlertForError } from 'soapbox/actions/alerts';
import { launchChat } from 'soapbox/actions/chats'; import { launchChat } from 'soapbox/actions/chats';
import { deactivateUserModal, deleteUserModal, deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { getSettings } from 'soapbox/actions/settings';
import { blockAccount } from '../../../actions/accounts';
import { showAlertForError } from '../../../actions/alerts';
import { import {
replyCompose, replyCompose,
mentionCompose, mentionCompose,
directCompose, directCompose,
} from '../../../actions/compose'; } from 'soapbox/actions/compose';
import { import {
reblog, reblog,
favourite, favourite,
@ -22,18 +19,22 @@ import {
unbookmark, unbookmark,
pin, pin,
unpin, unpin,
} from '../../../actions/interactions'; } from 'soapbox/actions/interactions';
import { openModal } from '../../../actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { initMuteModal } from '../../../actions/mutes'; import { deactivateUserModal, deleteUserModal, deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { initReport } from '../../../actions/reports'; import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports';
import { getSettings } from 'soapbox/actions/settings';
import { import {
muteStatus, muteStatus,
unmuteStatus, unmuteStatus,
deleteStatus, deleteStatus,
hideStatus, hideStatus,
revealStatus, revealStatus,
} from '../../../actions/statuses'; editStatus,
import { makeGetStatus } from '../../../selectors'; } from 'soapbox/actions/statuses';
import { makeGetStatus } from 'soapbox/selectors';
import DetailedStatus from '../components/detailed-status'; import DetailedStatus from '../components/detailed-status';
const messages = defineMessages({ const messages = defineMessages({
@ -144,6 +145,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}); });
}, },
onEdit(status) {
dispatch(editStatus(status.get('id')));
},
onDirect(account, router) { onDirect(account, router) {
dispatch(directCompose(account, router)); dispatch(directCompose(account, router));
}, },
@ -220,6 +225,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(deleteStatusModal(intl, status.get('id'))); dispatch(deleteStatusModal(intl, status.get('id')));
}, },
onOpenCompareHistoryModal(status) {
dispatch(openModal('COMPARE_HISTORY', {
statusId: status.get('id'),
}));
},
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus)); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));

@ -50,6 +50,7 @@ import {
deleteStatus, deleteStatus,
hideStatus, hideStatus,
revealStatus, revealStatus,
editStatus,
} from '../../actions/statuses'; } from '../../actions/statuses';
import { fetchStatusWithContext, fetchNext } from '../../actions/statuses'; import { fetchStatusWithContext, fetchNext } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator'; import MissingIndicator from '../../components/missing_indicator';
@ -320,6 +321,12 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
}); });
} }
handleEditClick = (status: StatusEntity) => {
const { dispatch } = this.props;
dispatch(editStatus(status.get('id')));
}
handleDirectClick = (account: AccountEntity, router: History) => { handleDirectClick = (account: AccountEntity, router: History) => {
this.props.dispatch(directCompose(account, router)); this.props.dispatch(directCompose(account, router));
} }
@ -653,6 +660,14 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
} }
} }
handleOpenCompareHistoryModal = (status: StatusEntity) => {
const { dispatch } = this.props;
dispatch(openModal('COMPARE_HISTORY', {
statusId: status.id,
}));
}
render() { render() {
const { status, ancestorsIds, descendantsIds, intl } = this.props; const { status, ancestorsIds, descendantsIds, intl } = this.props;
@ -707,6 +722,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
onToggleHidden={this.handleToggleHidden} onToggleHidden={this.handleToggleHidden}
showMedia={this.state.showMedia} showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility} onToggleMediaVisibility={this.handleToggleMediaVisibility}
onOpenCompareHistoryModal={this.handleOpenCompareHistoryModal}
/> />
<hr className='mb-2 dark:border-slate-600' /> <hr className='mb-2 dark:border-slate-600' />
@ -719,6 +735,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
onReblog={this.handleReblogClick} onReblog={this.handleReblogClick}
onQuote={this.handleQuoteClick} onQuote={this.handleQuoteClick}
onDelete={this.handleDeleteClick} onDelete={this.handleDeleteClick}
onEdit={this.handleEditClick}
onDirect={this.handleDirectClick} onDirect={this.handleDirectClick}
onChat={this.handleChatClick} onChat={this.handleChatClick}
onMention={this.handleMentionClick} onMention={this.handleMentionClick}

@ -0,0 +1,67 @@
import React, { useEffect } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import { fetchHistory } from 'soapbox/actions/history';
import { Modal, Spinner, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
interface ICompareHistoryModal {
onClose: (string: string) => void,
statusId: string,
}
const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId }) => {
const dispatch = useAppDispatch();
const loading = useAppSelector(state => state.history.getIn([statusId, 'loading']));
const versions = useAppSelector<any>(state => state.history.getIn([statusId, 'items']));
const onClickClose = () => {
onClose('COMPARE_HISTORY');
};
useEffect(() => {
dispatch(fetchHistory(statusId));
}, [statusId]);
let body;
if (loading) {
body = <Spinner />;
} else {
body = (
<div className='divide-y divide-solid divide-gray-200 dark:divide-slate-700'>
{versions?.map((version: any) => {
const content = { __html: version.contentHtml };
const spoilerContent = { __html: version.spoilerHtml };
return (
<div className='flex flex-col py-2 first:pt-0 last:pb-0'>
{version.spoiler_text?.length > 0 && (
<>
<span dangerouslySetInnerHTML={spoilerContent} />
<hr />
</>
)}
<div className='status__content' dangerouslySetInnerHTML={content} />
<Text align='right' tag='span' theme='muted' size='sm'>
<FormattedDate value={new Date(version.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</Text>
</div>
);
})}
</div>
);
}
return (
<Modal
title={<FormattedMessage id='compare_history_modal.header' defaultMessage='Edit history' />}
onClose={onClickClose}
>
{body}
</Modal>
);
};
export default CompareHistoryModal;

@ -18,6 +18,7 @@ const messages = defineMessages({
const mapStateToProps = state => { const mapStateToProps = state => {
const me = state.get('me'); const me = state.get('me');
return { return {
statusId: state.getIn(['compose', 'id']),
account: state.getIn(['accounts', me]), account: state.getIn(['accounts', me]),
composeText: state.getIn(['compose', 'text']), composeText: state.getIn(['compose', 'text']),
privacy: state.getIn(['compose', 'privacy']), privacy: state.getIn(['compose', 'privacy']),
@ -59,9 +60,11 @@ class ComposeModal extends ImmutablePureComponent {
}; };
renderTitle = () => { renderTitle = () => {
const { privacy, inReplyTo, quote } = this.props; const { statusId, privacy, inReplyTo, quote } = this.props;
if (privacy === 'direct') { if (statusId) {
return <FormattedMessage id='navigation_bar.compose_edit' defaultMessage='Edit post' />;
} else if (privacy === 'direct') {
return <FormattedMessage id='navigation_bar.compose_direct' defaultMessage='Direct message' />; return <FormattedMessage id='navigation_bar.compose_direct' defaultMessage='Direct message' />;
} else if (inReplyTo) { } else if (inReplyTo) {
return <FormattedMessage id='navigation_bar.compose_reply' defaultMessage='Reply to post' />; return <FormattedMessage id='navigation_bar.compose_reply' defaultMessage='Reply to post' />;

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Base from '../../../components/modal_root'; import Base from 'soapbox/components/modal_root';
import { import {
MediaModal, MediaModal,
VideoModal, VideoModal,
@ -29,7 +29,9 @@ import {
LandingPageModal, LandingPageModal,
BirthdaysModal, BirthdaysModal,
AccountNoteModal, AccountNoteModal,
} from '../../../features/ui/util/async-components'; CompareHistoryModal,
} from 'soapbox/features/ui/util/async-components';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
import BundleModalError from './bundle_modal_error'; import BundleModalError from './bundle_modal_error';
@ -63,6 +65,7 @@ const MODAL_COMPONENTS = {
'LANDING_PAGE': LandingPageModal, 'LANDING_PAGE': LandingPageModal,
'BIRTHDAYS': BirthdaysModal, 'BIRTHDAYS': BirthdaysModal,
'ACCOUNT_NOTE': AccountNoteModal, 'ACCOUNT_NOTE': AccountNoteModal,
'COMPARE_HISTORY': CompareHistoryModal,
}; };
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {

@ -497,3 +497,7 @@ export function TestTimeline() {
export function DatePicker() { export function DatePicker() {
return import(/* webpackChunkName: "date_picker" */'../../birthdays/date_picker'); return import(/* webpackChunkName: "date_picker" */'../../birthdays/date_picker');
} }
export function CompareHistoryModal() {
return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal');
}

@ -10,5 +10,6 @@ export { MentionRecord, normalizeMention } from './mention';
export { NotificationRecord, normalizeNotification } from './notification'; export { NotificationRecord, normalizeNotification } from './notification';
export { PollRecord, PollOptionRecord, normalizePoll } from './poll'; export { PollRecord, PollOptionRecord, normalizePoll } from './poll';
export { StatusRecord, normalizeStatus } from './status'; export { StatusRecord, normalizeStatus } from './status';
export { StatusEditRecord, normalizeStatusEdit } from './status_edit';
export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config'; export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config';

@ -29,6 +29,7 @@ export const StatusRecord = ImmutableRecord({
card: null as Card | null, card: null as Card | null,
content: '', content: '',
created_at: new Date(), created_at: new Date(),
edited_at: null as Date | null,
emojis: ImmutableList<Emoji>(), emojis: ImmutableList<Emoji>(),
favourited: false, favourited: false,
favourites_count: 0, favourites_count: 0,

@ -0,0 +1,78 @@
/**
* Status edit normalizer
*/
import escapeTextContentForBrowser from 'escape-html';
import {
Map as ImmutableMap,
List as ImmutableList,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizePoll } from 'soapbox/normalizers/poll';
import { stripCompatibilityFeatures } from 'soapbox/utils/html';
import { makeEmojiMap } from 'soapbox/utils/normalizers';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
import type { Account, Attachment, Emoji, EmbeddedEntity } from 'soapbox/types/entities';
export const StatusEditRecord = ImmutableRecord({
account: null as EmbeddedEntity<Account | ReducerAccount>,
content: '',
created_at: new Date(),
emojis: ImmutableList<Emoji>(),
favourited: false,
media_attachments: ImmutableList<Attachment>(),
sensitive: false,
spoiler_text: '',
// Internal fields
contentHtml: '',
spoilerHtml: '',
});
const normalizeAttachments = (statusEdit: ImmutableMap<string, any>) => {
return statusEdit.update('media_attachments', ImmutableList(), attachments => {
return attachments.map(normalizeAttachment);
});
};
// Normalize emojis
const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
return entity.update('emojis', ImmutableList(), emojis => {
return emojis.map(normalizeEmoji);
});
};
// Normalize the poll in the status, if applicable
const normalizeStatusPoll = (statusEdit: ImmutableMap<string, any>) => {
if (statusEdit.hasIn(['poll', 'options'])) {
return statusEdit.update('poll', ImmutableMap(), normalizePoll);
} else {
return statusEdit.set('poll', null);
}
};
const normalizeContent = (statusEdit: ImmutableMap<string, any>) => {
const emojiMap = makeEmojiMap(statusEdit.get('emojis'));
const contentHtml = stripCompatibilityFeatures(emojify(statusEdit.get('content'), emojiMap));
const spoilerHtml = emojify(escapeTextContentForBrowser(statusEdit.get('spoiler_text')), emojiMap);
return statusEdit
.set('contentHtml', contentHtml)
.set('spoilerHtml', spoilerHtml);
};
export const normalizeStatusEdit = (statusEdit: Record<string, any>) => {
return StatusEditRecord(
ImmutableMap(fromJS(statusEdit)).withMutations(statusEdit => {
normalizeAttachments(statusEdit);
normalizeEmojis(statusEdit);
normalizeStatusPoll(statusEdit);
normalizeContent(statusEdit);
}),
);
};

@ -50,10 +50,10 @@ import {
COMPOSE_POLL_SETTINGS_CHANGE, COMPOSE_POLL_SETTINGS_CHANGE,
COMPOSE_ADD_TO_MENTIONS, COMPOSE_ADD_TO_MENTIONS,
COMPOSE_REMOVE_FROM_MENTIONS, COMPOSE_REMOVE_FROM_MENTIONS,
COMPOSE_SET_STATUS,
} from '../actions/compose'; } from '../actions/compose';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from '../actions/me'; import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from '../actions/me';
import { SETTING_CHANGE, FE_NAME } from '../actions/settings'; import { SETTING_CHANGE, FE_NAME } from '../actions/settings';
import { REDRAFT } from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
@ -427,8 +427,9 @@ export default function compose(state = initialState, action) {
return item; return item;
})); }));
case REDRAFT: case COMPOSE_SET_STATUS:
return state.withMutations(map => { return state.withMutations(map => {
map.set('id', action.status.get('id'));
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status))); map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
map.set('to', action.explicitAddressing ? getExplicitMentions(action.status.get('account', 'id'), action.status) : ImmutableOrderedSet()); map.set('to', action.explicitAddressing ? getExplicitMentions(action.status.get('account', 'id'), action.status) : ImmutableOrderedSet());
map.set('in_reply_to', action.status.get('in_reply_to_id')); map.set('in_reply_to', action.status.get('in_reply_to_id'));

@ -0,0 +1,35 @@
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
import { AnyAction } from 'redux';
import { HISTORY_FETCH_REQUEST, HISTORY_FETCH_SUCCESS, HISTORY_FETCH_FAIL } from 'soapbox/actions/history';
import { normalizeStatusEdit } from 'soapbox/normalizers';
type StatusEditRecord = ReturnType<typeof normalizeStatusEdit>;
const HistoryRecord = ImmutableRecord({
loading: false,
items: ImmutableList<StatusEditRecord>(),
});
type State = ImmutableMap<string, ReturnType<typeof HistoryRecord>>;
const initialState: State = ImmutableMap();
export default function history(state: State = initialState, action: AnyAction) {
switch(action.type) {
case HISTORY_FETCH_REQUEST:
return state.update(action.statusId, HistoryRecord(), history => history!.withMutations(map => {
map.set('loading', true);
map.set('items', ImmutableList());
}));
case HISTORY_FETCH_SUCCESS:
return state.update(action.statusId, HistoryRecord(), history => history!.withMutations(map => {
map.set('loading', false);
map.set('items', ImmutableList(action.history.map((x: any, i: number) => ({ ...x, account: x.account.id, original: i === 0 })).reverse().map(normalizeStatusEdit)));
}));
case HISTORY_FETCH_FAIL:
return state.update(action.statusId, HistoryRecord(), history => history!.set('loading', false));
default:
return state;
}
}

@ -28,6 +28,7 @@ import group_editor from './group_editor';
import group_lists from './group_lists'; import group_lists from './group_lists';
import group_relationships from './group_relationships'; import group_relationships from './group_relationships';
import groups from './groups'; import groups from './groups';
import history from './history';
import identity_proofs from './identity_proofs'; import identity_proofs from './identity_proofs';
import instance from './instance'; import instance from './instance';
import listAdder from './list_adder'; import listAdder from './list_adder';
@ -116,6 +117,7 @@ const reducers = {
accounts_meta, accounts_meta,
trending_statuses, trending_statuses,
verification, verification,
history,
}; };
// Build a default state from all reducers: it has the key and `undefined` // Build a default state from all reducers: it has the key and `undefined`

@ -91,7 +91,7 @@ export const calculateStatus = (
oldStatus?: StatusRecord, oldStatus?: StatusRecord,
expandSpoilers: boolean = false, expandSpoilers: boolean = false,
): StatusRecord => { ): StatusRecord => {
if (oldStatus) { if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) {
return status.merge({ return status.merge({
search_index: oldStatus.search_index, search_index: oldStatus.search_index,
contentHtml: oldStatus.contentHtml, contentHtml: oldStatus.contentHtml,

@ -172,6 +172,8 @@ const getInstanceFeatures = (instance: Instance) => {
v.software === PLEROMA && gte(v.version, '0.9.9'), v.software === PLEROMA && gte(v.version, '0.9.9'),
]), ]),
editStatuses: v.software === MASTODON && gte(v.version, '3.5.0'),
/** /**
* Soapbox email list. * Soapbox email list.
* @see POST /api/v1/accounts * @see POST /api/v1/accounts

Loading…
Cancel
Save