Allow editing posts on Mastodon
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
7363d9c7f8
commit
387ebfc56c
26 changed files with 449 additions and 57 deletions
|
@ -5,7 +5,7 @@ import { defineMessages } from 'react-intl';
|
|||
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
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 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_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS';
|
||||
|
||||
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
|
||||
|
||||
const messages = defineMessages({
|
||||
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})' },
|
||||
|
@ -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) {
|
||||
return {
|
||||
type: COMPOSE_CHANGE,
|
||||
|
@ -221,9 +240,10 @@ export function submitCompose(routerHistory, force = false) {
|
|||
if (!isLoggedIn(getState)) return;
|
||||
const state = getState();
|
||||
|
||||
const status = state.getIn(['compose', 'text'], '');
|
||||
const media = state.getIn(['compose', 'media_attachments']);
|
||||
let to = state.getIn(['compose', 'to'], ImmutableOrderedSet());
|
||||
const status = state.getIn(['compose', 'text'], '');
|
||||
const media = state.getIn(['compose', 'media_attachments']);
|
||||
const statusId = state.getIn(['compose', 'id'], null);
|
||||
let to = state.getIn(['compose', 'to'], ImmutableOrderedSet());
|
||||
|
||||
if (!validateSchedule(state)) {
|
||||
dispatch(snackbar.error(messages.scheduleError));
|
||||
|
@ -270,7 +290,7 @@ export function submitCompose(routerHistory, force = false) {
|
|||
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) {
|
||||
routerHistory.push('/messages');
|
||||
}
|
||||
|
|
38
app/soapbox/actions/history.js
Normal file
38
app/soapbox/actions/history.js
Normal file
|
@ -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 { setComposeToStatus } from './compose';
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modals';
|
||||
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_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_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
|
||||
|
@ -41,11 +46,14 @@ const statusExists = (getState, statusId) => {
|
|||
return getState().getIn(['statuses', statusId], null) !== null;
|
||||
};
|
||||
|
||||
export function createStatus(params, idempotencyKey) {
|
||||
export function createStatus(params, idempotencyKey, statusId) {
|
||||
return (dispatch, getState) => {
|
||||
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 },
|
||||
}).then(({ data: status }) => {
|
||||
// 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) {
|
||||
return (dispatch, getState) => {
|
||||
const skipLoading = statusExists(getState, id);
|
||||
|
@ -130,7 +157,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
|
|||
dispatch(deleteFromTimelines(id));
|
||||
|
||||
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'));
|
||||
}
|
||||
}).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) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: CONTEXT_FETCH_REQUEST, id });
|
||||
|
|
|
@ -6,6 +6,7 @@ import { connectStream } from '../stream';
|
|||
import { updateConversations } from './conversations';
|
||||
import { fetchFilters } from './filters';
|
||||
import { updateNotificationsQueue, expandNotifications } from './notifications';
|
||||
import { updateStatus } from './statuses';
|
||||
import {
|
||||
deleteFromTimelines,
|
||||
expandHomeTimeline,
|
||||
|
@ -54,6 +55,9 @@ export function connectTimelineStream(timelineId, path, pollingRefresh = null, a
|
|||
case 'update':
|
||||
dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept));
|
||||
break;
|
||||
case 'status.update':
|
||||
dispatch(updateStatus(JSON.parse(data.payload)));
|
||||
break;
|
||||
case 'delete':
|
||||
dispatch(deleteFromTimelines(data.payload));
|
||||
break;
|
||||
|
|
|
@ -72,6 +72,7 @@ interface IStatus extends RouteComponentProps {
|
|||
onReblog: (status: StatusEntity, e?: KeyboardEvent) => void,
|
||||
onQuote: (status: StatusEntity) => void,
|
||||
onDelete: (status: StatusEntity) => void,
|
||||
onEdit: (status: StatusEntity) => void,
|
||||
onDirect: (status: StatusEntity) => void,
|
||||
onChat: (status: StatusEntity) => void,
|
||||
onMention: (account: StatusEntity['account'], history: History) => void,
|
||||
|
|
|
@ -25,6 +25,7 @@ import type { Features } from 'soapbox/utils/features';
|
|||
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}' },
|
||||
chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
|
@ -77,6 +78,7 @@ interface IStatusActionBar extends RouteComponentProps {
|
|||
onReblog: (status: Status, e: React.MouseEvent) => void,
|
||||
onQuote: (status: Status, history: History) => void,
|
||||
onDelete: (status: Status, history: History, redraft?: boolean) => void,
|
||||
onEdit: (status: Status) => void,
|
||||
onDirect: (account: any, history: History) => void,
|
||||
onChat: (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);
|
||||
}
|
||||
|
||||
handleEditClick: React.EventHandler<React.MouseEvent> = () => {
|
||||
this.props.onEdit(this.props.status);
|
||||
}
|
||||
|
||||
handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.onPin(this.props.status);
|
||||
|
@ -432,12 +438,20 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
icon: require('@tabler/icons/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.redraft),
|
||||
action: this.handleRedraftClick,
|
||||
icon: require('@tabler/icons/icons/edit.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
if (features.editStatuses) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.edit),
|
||||
action: this.handleEditClick,
|
||||
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 {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mention, { name: username }),
|
||||
|
|
|
@ -38,6 +38,7 @@ import {
|
|||
deleteStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
editStatus,
|
||||
} from '../actions/statuses';
|
||||
import Status from '../components/status';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
|
@ -172,6 +173,10 @@ const mapDispatchToProps = (dispatch, { intl }) => {
|
|||
});
|
||||
},
|
||||
|
||||
onEdit(status) {
|
||||
dispatch(editStatus(status.get('id')));
|
||||
},
|
||||
|
||||
onDirect(account, router) {
|
||||
dispatch(directCompose(account, router));
|
||||
},
|
||||
|
|
|
@ -43,6 +43,7 @@ const messages = defineMessages({
|
|||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
||||
message: { id: 'compose_form.message', defaultMessage: 'Message' },
|
||||
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
|
||||
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
|
||||
});
|
||||
|
||||
export default @withRouter
|
||||
|
@ -63,6 +64,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
caretPosition: PropTypes.number,
|
||||
isSubmitting: PropTypes.bool,
|
||||
isChangingUpload: PropTypes.bool,
|
||||
isEditing: PropTypes.bool,
|
||||
isUploading: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
|
@ -261,7 +263,9 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
|
||||
let publishText = '';
|
||||
|
||||
if (this.props.privacy === 'direct') {
|
||||
if (this.props.isEditing) {
|
||||
publishText = intl.formatMessage(messages.saveChanges);
|
||||
} else if (this.props.privacy === 'direct') {
|
||||
publishText = (
|
||||
<>
|
||||
<Icon src={require('@tabler/icons/icons/mail.svg')} />
|
||||
|
|
|
@ -27,6 +27,7 @@ const mapStateToProps = state => {
|
|||
focusDate: state.getIn(['compose', 'focusDate']),
|
||||
caretPosition: state.getIn(['compose', 'caretPosition']),
|
||||
isSubmitting: state.getIn(['compose', 'is_submitting']),
|
||||
isEditing: state.getIn(['compose', 'id']) !== null,
|
||||
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
|
||||
isUploading: state.getIn(['compose', 'is_uploading']),
|
||||
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
|
||||
|
|
|
@ -7,9 +7,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 }),
|
||||
hideActions: editing,
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
|
|
@ -26,6 +26,7 @@ type Dispatch = ThunkDispatch<RootState, void, AnyAction>;
|
|||
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}' },
|
||||
chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
|
@ -95,6 +96,7 @@ interface OwnProps {
|
|||
onFavourite: (status: StatusEntity) => void,
|
||||
onEmojiReact: (status: StatusEntity, emoji: string) => void,
|
||||
onDelete: (status: StatusEntity, history: History, redraft?: boolean) => void,
|
||||
onEdit: (status: StatusEntity) => void,
|
||||
onBookmark: (status: StatusEntity) => void,
|
||||
onDirect: (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);
|
||||
}
|
||||
|
||||
handleEditClick: React.EventHandler<React.MouseEvent> = () => {
|
||||
this.props.onEdit(this.props.status);
|
||||
}
|
||||
|
||||
handleDirectClick: React.EventHandler<React.MouseEvent> = () => {
|
||||
const { account } = this.props.status;
|
||||
if (!account || typeof account !== 'object') return;
|
||||
|
@ -394,17 +400,18 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
|
|||
action: this.handlePinClick,
|
||||
icon: require(mutingConversation ? '@tabler/icons/icons/pinned-off.svg' : '@tabler/icons/icons/pin.svg'),
|
||||
});
|
||||
} else {
|
||||
if (status.visibility === 'private') {
|
||||
menu.push({
|
||||
text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private),
|
||||
action: this.handleReblogClick,
|
||||
icon: require('@tabler/icons/icons/repeat.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
} else if (status.visibility === 'private') {
|
||||
menu.push({
|
||||
text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private),
|
||||
action: this.handleReblogClick,
|
||||
icon: require('@tabler/icons/icons/repeat.svg'),
|
||||
});
|
||||
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation),
|
||||
action: this.handleConversationMuteClick,
|
||||
|
@ -417,12 +424,20 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
|
|||
icon: require('@tabler/icons/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.redraft),
|
||||
action: this.handleRedraftClick,
|
||||
icon: require('@tabler/icons/icons/edit.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
if (features.editStatuses) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.edit),
|
||||
action: this.handleEditClick,
|
||||
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 {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mention, { name: username }),
|
||||
|
|
|
@ -32,6 +32,7 @@ interface IDetailedStatus extends IntlProps {
|
|||
domain: string,
|
||||
compact: boolean,
|
||||
showMedia: boolean,
|
||||
onOpenCompareHistoryModal: (status: StatusEntity) => void,
|
||||
onToggleMediaVisibility: () => void,
|
||||
}
|
||||
|
||||
|
@ -57,6 +58,10 @@ class DetailedStatus extends ImmutablePureComponent<IDetailedStatus, IDetailedSt
|
|||
this.props.onToggleHidden(this.props.status);
|
||||
}
|
||||
|
||||
handleOpenCompareHistoryModal = () => {
|
||||
this.props.onOpenCompareHistoryModal(this.props.status);
|
||||
}
|
||||
|
||||
_measureHeight(heightJustChanged = false) {
|
||||
if (this.props.measureHeight && this.node) {
|
||||
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'>
|
||||
<StatusInteractionBar status={status} />
|
||||
|
||||
|
||||
<div className='detailed-status__timestamp'>
|
||||
{statusTypeIcon}
|
||||
|
||||
<a href={status.url} target='_blank' rel='noopener' className='hover:underline'>
|
||||
<Text tag='span' theme='muted' size='sm'>
|
||||
<FormattedDate value={new Date(status.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||
</Text>
|
||||
</a>
|
||||
<span>
|
||||
<a href={status.url} target='_blank' rel='noopener' className='hover:underline'>
|
||||
<Text tag='span' theme='muted' size='sm'>
|
||||
<FormattedDate value={new Date(status.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||
</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>
|
||||
</HStack>
|
||||
</div>
|
||||
|
|
|
@ -2,17 +2,14 @@ import React from 'react';
|
|||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { blockAccount } from 'soapbox/actions/accounts';
|
||||
import { showAlertForError } from 'soapbox/actions/alerts';
|
||||
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 {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
directCompose,
|
||||
} from '../../../actions/compose';
|
||||
} from 'soapbox/actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
favourite,
|
||||
|
@ -22,18 +19,22 @@ import {
|
|||
unbookmark,
|
||||
pin,
|
||||
unpin,
|
||||
} from '../../../actions/interactions';
|
||||
import { openModal } from '../../../actions/modals';
|
||||
import { initMuteModal } from '../../../actions/mutes';
|
||||
import { initReport } from '../../../actions/reports';
|
||||
} from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deactivateUserModal, deleteUserModal, deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||
import { initReport } from 'soapbox/actions/reports';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import {
|
||||
muteStatus,
|
||||
unmuteStatus,
|
||||
deleteStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
} from '../../../actions/statuses';
|
||||
import { makeGetStatus } from '../../../selectors';
|
||||
editStatus,
|
||||
} from 'soapbox/actions/statuses';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
import DetailedStatus from '../components/detailed-status';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -144,6 +145,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
});
|
||||
},
|
||||
|
||||
onEdit(status) {
|
||||
dispatch(editStatus(status.get('id')));
|
||||
},
|
||||
|
||||
onDirect(account, router) {
|
||||
dispatch(directCompose(account, router));
|
||||
},
|
||||
|
@ -220,6 +225,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
dispatch(deleteStatusModal(intl, status.get('id')));
|
||||
},
|
||||
|
||||
onOpenCompareHistoryModal(status) {
|
||||
dispatch(openModal('COMPARE_HISTORY', {
|
||||
statusId: status.get('id'),
|
||||
}));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
|
||||
|
|
|
@ -50,6 +50,7 @@ import {
|
|||
deleteStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
editStatus,
|
||||
} from '../../actions/statuses';
|
||||
import { fetchStatusWithContext, fetchNext } from '../../actions/statuses';
|
||||
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) => {
|
||||
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() {
|
||||
const { status, ancestorsIds, descendantsIds, intl } = this.props;
|
||||
|
||||
|
@ -707,6 +722,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
onToggleHidden={this.handleToggleHidden}
|
||||
showMedia={this.state.showMedia}
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
onOpenCompareHistoryModal={this.handleOpenCompareHistoryModal}
|
||||
/>
|
||||
|
||||
<hr className='mb-2 dark:border-slate-600' />
|
||||
|
@ -719,6 +735,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
onReblog={this.handleReblogClick}
|
||||
onQuote={this.handleQuoteClick}
|
||||
onDelete={this.handleDeleteClick}
|
||||
onEdit={this.handleEditClick}
|
||||
onDirect={this.handleDirectClick}
|
||||
onChat={this.handleChatClick}
|
||||
onMention={this.handleMentionClick}
|
||||
|
|
67
app/soapbox/features/ui/components/compare_history_modal.tsx
Normal file
67
app/soapbox/features/ui/components/compare_history_modal.tsx
Normal file
|
@ -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 me = state.get('me');
|
||||
return {
|
||||
statusId: state.getIn(['compose', 'id']),
|
||||
account: state.getIn(['accounts', me]),
|
||||
composeText: state.getIn(['compose', 'text']),
|
||||
privacy: state.getIn(['compose', 'privacy']),
|
||||
|
@ -59,9 +60,11 @@ class ComposeModal extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
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' />;
|
||||
} else if (inReplyTo) {
|
||||
return <FormattedMessage id='navigation_bar.compose_reply' defaultMessage='Reply to post' />;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import Base from '../../../components/modal_root';
|
||||
import Base from 'soapbox/components/modal_root';
|
||||
import {
|
||||
MediaModal,
|
||||
VideoModal,
|
||||
|
@ -29,7 +29,9 @@ import {
|
|||
LandingPageModal,
|
||||
BirthdaysModal,
|
||||
AccountNoteModal,
|
||||
} from '../../../features/ui/util/async-components';
|
||||
CompareHistoryModal,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
|
||||
import BundleModalError from './bundle_modal_error';
|
||||
|
@ -63,6 +65,7 @@ const MODAL_COMPONENTS = {
|
|||
'LANDING_PAGE': LandingPageModal,
|
||||
'BIRTHDAYS': BirthdaysModal,
|
||||
'ACCOUNT_NOTE': AccountNoteModal,
|
||||
'COMPARE_HISTORY': CompareHistoryModal,
|
||||
};
|
||||
|
||||
export default class ModalRoot extends React.PureComponent {
|
||||
|
|
|
@ -497,3 +497,7 @@ export function TestTimeline() {
|
|||
export function DatePicker() {
|
||||
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 { PollRecord, PollOptionRecord, normalizePoll } from './poll';
|
||||
export { StatusRecord, normalizeStatus } from './status';
|
||||
export { StatusEditRecord, normalizeStatusEdit } from './status_edit';
|
||||
|
||||
export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config';
|
||||
|
|
|
@ -29,6 +29,7 @@ export const StatusRecord = ImmutableRecord({
|
|||
card: null as Card | null,
|
||||
content: '',
|
||||
created_at: new Date(),
|
||||
edited_at: null as Date | null,
|
||||
emojis: ImmutableList<Emoji>(),
|
||||
favourited: false,
|
||||
favourites_count: 0,
|
||||
|
|
78
app/soapbox/normalizers/status_edit.ts
Normal file
78
app/soapbox/normalizers/status_edit.ts
Normal file
|
@ -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_ADD_TO_MENTIONS,
|
||||
COMPOSE_REMOVE_FROM_MENTIONS,
|
||||
COMPOSE_SET_STATUS,
|
||||
} from '../actions/compose';
|
||||
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from '../actions/me';
|
||||
import { SETTING_CHANGE, FE_NAME } from '../actions/settings';
|
||||
import { REDRAFT } from '../actions/statuses';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
|
||||
|
@ -427,8 +427,9 @@ export default function compose(state = initialState, action) {
|
|||
|
||||
return item;
|
||||
}));
|
||||
case REDRAFT:
|
||||
case COMPOSE_SET_STATUS:
|
||||
return state.withMutations(map => {
|
||||
map.set('id', action.status.get('id'));
|
||||
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('in_reply_to', action.status.get('in_reply_to_id'));
|
||||
|
|
35
app/soapbox/reducers/history.ts
Normal file
35
app/soapbox/reducers/history.ts
Normal file
|
@ -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_relationships from './group_relationships';
|
||||
import groups from './groups';
|
||||
import history from './history';
|
||||
import identity_proofs from './identity_proofs';
|
||||
import instance from './instance';
|
||||
import listAdder from './list_adder';
|
||||
|
@ -116,6 +117,7 @@ const reducers = {
|
|||
accounts_meta,
|
||||
trending_statuses,
|
||||
verification,
|
||||
history,
|
||||
};
|
||||
|
||||
// Build a default state from all reducers: it has the key and `undefined`
|
||||
|
|
|
@ -91,7 +91,7 @@ export const calculateStatus = (
|
|||
oldStatus?: StatusRecord,
|
||||
expandSpoilers: boolean = false,
|
||||
): StatusRecord => {
|
||||
if (oldStatus) {
|
||||
if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) {
|
||||
return status.merge({
|
||||
search_index: oldStatus.search_index,
|
||||
contentHtml: oldStatus.contentHtml,
|
||||
|
|
|
@ -172,6 +172,8 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
v.software === PLEROMA && gte(v.version, '0.9.9'),
|
||||
]),
|
||||
|
||||
editStatuses: v.software === MASTODON && gte(v.version, '3.5.0'),
|
||||
|
||||
/**
|
||||
* Soapbox email list.
|
||||
* @see POST /api/v1/accounts
|
||||
|
|
Loading…
Reference in a new issue