Merge branch 'edit-posts' into 'develop'

Allow editing posts on Mastodon

See merge request soapbox-pub/soapbox-fe!1271
api-accept
marcin mikołajczak 2 years ago
commit 8f09fcab2e

@ -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, rawText, spoilerText, contentType) {
return (dispatch, getState) => {
const { instance } = getState();
const { explicitAddressing } = getFeatures(instance);
dispatch({
type: COMPOSE_SET_STATUS,
status,
rawText,
explicitAddressing,
spoilerText,
contentType,
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,8 +290,8 @@ export function submitCompose(routerHistory, force = false) {
to,
};
dispatch(createStatus(params, idempotencyKey)).then(function(data) {
if (data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) {
dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
if (!statusId && data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) {
routerHistory.push('/messages');
}
handleComposeSubmit(dispatch, getState, data, status);

@ -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,
});

@ -1,9 +1,10 @@
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion } from 'soapbox/utils/features';
import { getFeatures } from 'soapbox/utils/features';
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';
@ -35,17 +40,18 @@ export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
export const STATUS_REVEAL = 'STATUS_REVEAL';
export const STATUS_HIDE = 'STATUS_HIDE';
export const REDRAFT = 'REDRAFT';
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 +87,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);
@ -97,22 +122,6 @@ export function fetchStatus(id) {
};
}
export function redraft(status, raw_text, content_type) {
return (dispatch, getState) => {
const { instance } = getState();
const { explicitAddressing } = getFeatures(instance);
dispatch({
type: REDRAFT,
status,
raw_text,
explicitAddressing,
content_type,
v: parseVersion(instance.version),
});
};
}
export function deleteStatus(id, routerHistory, withRedraft = false) {
return (dispatch, getState) => {
if (!isLoggedIn(getState)) return;
@ -130,7 +139,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 +148,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;

@ -9,7 +9,7 @@ import { getAcct } from 'soapbox/utils/accounts';
import { displayFqn } from 'soapbox/utils/state';
import RelativeTimestamp from './relative_timestamp';
import { Avatar, HStack, IconButton, Text } from './ui';
import { Avatar, HStack, Icon, IconButton, Text } from './ui';
import type { Account as AccountEntity } from 'soapbox/types/entities';
@ -58,6 +58,7 @@ interface IAccount {
timestampUrl?: string,
withDate?: boolean,
withRelationship?: boolean,
showEdit?: boolean,
}
const Account = ({
@ -76,6 +77,7 @@ const Account = ({
timestampUrl,
withDate = false,
withRelationship = true,
showEdit = false,
}: IAccount) => {
const overflowRef = React.useRef<HTMLDivElement>(null);
const actionRef = React.useRef<HTMLDivElement>(null);
@ -210,6 +212,14 @@ const Account = ({
)}
</>
) : null}
{showEdit ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
<Icon className='h-5 w-5 stroke-[1.35]' src={require('@tabler/icons/icons/pencil.svg')} />
</>
) : null}
</HStack>
</div>
</HStack>

@ -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,
@ -630,6 +631,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
timestampUrl={statusUrl}
action={reblogElement}
hideActions={!reblogElement}
showEdit={!!status.edited_at}
/>
</HStack>
</div>

@ -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,
@ -248,6 +250,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);
@ -437,12 +443,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')} />

@ -34,6 +34,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
placement: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
unavailable: PropTypes.bool,
};
state = {
@ -244,9 +245,13 @@ class PrivacyDropdown extends React.PureComponent {
}
render() {
const { value, intl } = this.props;
const { value, intl, unavailable } = this.props;
const { open, placement } = this.state;
if (unavailable) {
return null;
}
const valueOption = this.options.find(item => item.value === value);
return (

@ -16,6 +16,7 @@ class ScheduleButton extends React.PureComponent {
static propTypes = {
disabled: PropTypes.bool,
active: PropTypes.bool,
unavailable: PropTypes.bool,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -25,7 +26,11 @@ class ScheduleButton extends React.PureComponent {
}
render() {
const { intl, active, disabled } = this.props;
const { intl, active, unavailable, disabled } = this.props;
if (unavailable) {
return null;
}
return (
<ComposeFormButton

@ -1,8 +1,6 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { getFeatures } from 'soapbox/utils/features';
import {
changeCompose,
submitCompose,
@ -12,7 +10,9 @@ import {
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
} from '../../../actions/compose';
} from 'soapbox/actions/compose';
import { getFeatures } from 'soapbox/utils/features';
import ComposeForm from '../components/compose_form';
const mapStateToProps = state => {
@ -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']),

@ -8,6 +8,7 @@ import PrivacyDropdown from '../components/privacy_dropdown';
const mapStateToProps = state => ({
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'ACTIONS'),
value: state.getIn(['compose', 'privacy']),
unavailable: !!state.getIn(['compose', 'id']),
});
const mapDispatchToProps = dispatch => ({

@ -7,9 +7,15 @@ 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 => {
const statusId = state.getIn(['compose', 'in_reply_to']);
const editing = !!state.getIn(['compose', 'id']);
return {
status: getStatus(state, { id: statusId }),
hideActions: editing,
};
};
return mapStateToProps;
};

@ -5,6 +5,7 @@ import ScheduleButton from '../components/schedule_button';
const mapStateToProps = state => ({
active: state.getIn(['compose', 'schedule']) ? true : false,
unavailable: !!state.getIn(['compose', 'id']),
});
const mapDispatchToProps = dispatch => ({

@ -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;
@ -397,17 +403,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,
@ -420,12 +427,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}

@ -0,0 +1,106 @@
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable';
import React, { useEffect } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import { fetchHistory } from 'soapbox/actions/history';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import { HStack, Modal, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import type { StatusEdit as StatusEditEntity } from 'soapbox/types/entities';
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']));
// @ts-ignore
const versions = useAppSelector<ImmutableList<StatusEditEntity>>(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) => {
const content = { __html: version.contentHtml };
const spoilerContent = { __html: version.spoilerHtml };
const poll = typeof version.poll !== 'string' && version.poll;
console.log(version.toJS());
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} />
{poll && (
<div className='poll'>
<Stack>
{version.poll.options.map((option: any) => (
<HStack alignItems='center' className='p-1 text-gray-900 dark:text-gray-300'>
<span
className={classNames('inline-block w-4 h-4 flex-none mr-2.5 border border-solid border-primary-600 rounded-full', {
'rounded': poll.multiple,
})}
tabIndex={0}
role={poll.multiple ? 'checkbox' : 'radio'}
/>
<span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
</HStack>
))}
</Stack>
</div>
)}
{version.media_attachments.size > 0 && (
<AttachmentThumbs
compact
media={version.media_attachments}
/>
)}
<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 {

@ -489,3 +489,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,

@ -0,0 +1,79 @@
/**
* 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, Poll } 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>(),
poll: null as EmbeddedEntity<Poll>,
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);
}),
);
};

@ -3,7 +3,6 @@ import { Map as ImmutableMap, fromJS } from 'immutable';
import * as actions from 'soapbox/actions/compose';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from 'soapbox/actions/me';
import { SETTING_CHANGE } from 'soapbox/actions/settings';
import { REDRAFT } from 'soapbox/actions/statuses';
import { TIMELINE_DELETE } from 'soapbox/actions/timelines';
import { normalizeStatus } from 'soapbox/normalizers/status';
@ -39,10 +38,10 @@ describe('compose reducer', () => {
expect(state.get('idempotencyKey').length === 36);
});
describe('REDRAFT', () => {
describe('COMPOSE_SET_STATUS', () => {
it('strips Pleroma integer attachments', () => {
const action = {
type: REDRAFT,
type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
v: { software: 'Pleroma' },
};
@ -53,7 +52,7 @@ describe('compose reducer', () => {
it('leaves non-Pleroma integer attachments alone', () => {
const action = {
type: REDRAFT,
type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
};

@ -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,16 +427,17 @@ export default function compose(state = initialState, action) {
return item;
}));
case REDRAFT:
case COMPOSE_SET_STATUS:
return state.withMutations(map => {
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
map.set('id', action.status.get('id'));
map.set('text', action.rawText || 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'));
map.set('privacy', action.status.get('visibility'));
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('content_type', action.content_type || 'text/plain');
map.set('content_type', action.contentType || 'text/plain');
if (action.v?.software === PLEROMA && hasIntegerMediaIds(action.status)) {
map.set('media_attachments', ImmutableList());

@ -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';
@ -120,6 +121,7 @@ const reducers = {
verification,
onboarding,
rules,
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,

@ -11,6 +11,7 @@ import {
NotificationRecord,
PollRecord,
PollOptionRecord,
StatusEditRecord,
StatusRecord,
} from 'soapbox/normalizers';
@ -27,6 +28,7 @@ type Mention = ReturnType<typeof MentionRecord>;
type Notification = ReturnType<typeof NotificationRecord>;
type Poll = ReturnType<typeof PollRecord>;
type PollOption = ReturnType<typeof PollOptionRecord>;
type StatusEdit = ReturnType<typeof StatusEditRecord>;
interface Account extends ReturnType<typeof AccountRecord> {
// HACK: we can't do a circular reference in the Record definition itself,
@ -58,6 +60,7 @@ export {
Poll,
PollOption,
Status,
StatusEdit,
// Utility types
APIEntity,

@ -197,6 +197,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…
Cancel
Save