diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js index becb39b81..f733ef3d4 100644 --- a/app/soapbox/actions/compose.js +++ b/app/soapbox/actions/compose.js @@ -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); diff --git a/app/soapbox/actions/history.js b/app/soapbox/actions/history.js new file mode 100644 index 000000000..e668d315e --- /dev/null +++ b/app/soapbox/actions/history.js @@ -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, +}); \ No newline at end of file diff --git a/app/soapbox/actions/statuses.js b/app/soapbox/actions/statuses.js index cbb1c6ed2..176f7325f 100644 --- a/app/soapbox/actions/statuses.js +++ b/app/soapbox/actions/statuses.js @@ -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 }); diff --git a/app/soapbox/actions/streaming.js b/app/soapbox/actions/streaming.js index bd1ed00da..5f2365f18 100644 --- a/app/soapbox/actions/streaming.js +++ b/app/soapbox/actions/streaming.js @@ -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; diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index f47d66bfe..1d670a344 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -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(null); const actionRef = React.useRef(null); @@ -210,6 +212,14 @@ const Account = ({ )} ) : null} + + {showEdit ? ( + <> + · + + + + ) : null} diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 3ae18b8fc..60c6ebcdb 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -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 { timestampUrl={statusUrl} action={reblogElement} hideActions={!reblogElement} + showEdit={!!status.edited_at} /> diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index d07fb94c0..0e17bd897 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -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 = () => { + this.props.onEdit(this.props.status); + } + handlePinClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onPin(this.props.status); @@ -437,12 +443,20 @@ class StatusActionBar extends ImmutablePureComponent { }); }, + onEdit(status) { + dispatch(editStatus(status.get('id'))); + }, + onDirect(account, router) { dispatch(directCompose(account, router)); }, diff --git a/app/soapbox/features/compose/components/compose_form.js b/app/soapbox/features/compose/components/compose_form.js index 89d58618c..cb1c8791f 100644 --- a/app/soapbox/features/compose/components/compose_form.js +++ b/app/soapbox/features/compose/components/compose_form.js @@ -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 = ( <> diff --git a/app/soapbox/features/compose/components/privacy_dropdown.js b/app/soapbox/features/compose/components/privacy_dropdown.js index 23111ab1b..caf6ae6c6 100644 --- a/app/soapbox/features/compose/components/privacy_dropdown.js +++ b/app/soapbox/features/compose/components/privacy_dropdown.js @@ -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 ( diff --git a/app/soapbox/features/compose/components/schedule_button.js b/app/soapbox/features/compose/components/schedule_button.js index af2fb5598..484174355 100644 --- a/app/soapbox/features/compose/components/schedule_button.js +++ b/app/soapbox/features/compose/components/schedule_button.js @@ -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 ( { @@ -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']), diff --git a/app/soapbox/features/compose/containers/privacy_dropdown_container.js b/app/soapbox/features/compose/containers/privacy_dropdown_container.js index 3cea17e9d..6f74a0293 100644 --- a/app/soapbox/features/compose/containers/privacy_dropdown_container.js +++ b/app/soapbox/features/compose/containers/privacy_dropdown_container.js @@ -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 => ({ diff --git a/app/soapbox/features/compose/containers/reply_indicator_container.js b/app/soapbox/features/compose/containers/reply_indicator_container.js index cea894b04..19a80c7bb 100644 --- a/app/soapbox/features/compose/containers/reply_indicator_container.js +++ b/app/soapbox/features/compose/containers/reply_indicator_container.js @@ -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; }; diff --git a/app/soapbox/features/compose/containers/schedule_button_container.js b/app/soapbox/features/compose/containers/schedule_button_container.js index 6076220a4..3fce8b22d 100644 --- a/app/soapbox/features/compose/containers/schedule_button_container.js +++ b/app/soapbox/features/compose/containers/schedule_button_container.js @@ -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 => ({ diff --git a/app/soapbox/features/status/components/action-bar.tsx b/app/soapbox/features/status/components/action-bar.tsx index 0a191b536..d80c5d5d2 100644 --- a/app/soapbox/features/status/components/action-bar.tsx +++ b/app/soapbox/features/status/components/action-bar.tsx @@ -26,6 +26,7 @@ type Dispatch = ThunkDispatch; 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 { this.props.onDelete(this.props.status, this.props.history, true); } + handleEditClick: React.EventHandler = () => { + this.props.onEdit(this.props.status); + } + handleDirectClick: React.EventHandler = () => { const { account } = this.props.status; if (!account || typeof account !== 'object') return; @@ -397,17 +403,18 @@ class ActionBar extends React.PureComponent { 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 { 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 }), diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 739fe4ad0..dbd890328 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -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 { + 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 +
{statusTypeIcon} - - - - - + + + + + + + + {status.edited_at && ( + <> + {' ยท '} +
+ + + +
+ + )} +
diff --git a/app/soapbox/features/status/containers/detailed_status_container.js b/app/soapbox/features/status/containers/detailed_status_container.js index 039cc400a..d0c871f5a 100644 --- a/app/soapbox/features/status/containers/detailed_status_container.js +++ b/app/soapbox/features/status/containers/detailed_status_container.js @@ -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)); diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index ba1f7332c..58d9c3b21 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -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 { }); } + 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 { } } + 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 { onToggleHidden={this.handleToggleHidden} showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} + onOpenCompareHistoryModal={this.handleOpenCompareHistoryModal} />
@@ -719,6 +735,7 @@ class Status extends ImmutablePureComponent { onReblog={this.handleReblogClick} onQuote={this.handleQuoteClick} onDelete={this.handleDeleteClick} + onEdit={this.handleEditClick} onDirect={this.handleDirectClick} onChat={this.handleChatClick} onMention={this.handleMentionClick} diff --git a/app/soapbox/features/ui/components/compare_history_modal.tsx b/app/soapbox/features/ui/components/compare_history_modal.tsx new file mode 100644 index 000000000..92bfe8b23 --- /dev/null +++ b/app/soapbox/features/ui/components/compare_history_modal.tsx @@ -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 = ({ onClose, statusId }) => { + const dispatch = useAppDispatch(); + + const loading = useAppSelector(state => state.history.getIn([statusId, 'loading'])); + // @ts-ignore + const versions = useAppSelector>(state => state.history.getIn([statusId, 'items'])); + + const onClickClose = () => { + onClose('COMPARE_HISTORY'); + }; + + useEffect(() => { + dispatch(fetchHistory(statusId)); + }, [statusId]); + + let body; + + if (loading) { + body = ; + } else { + body = ( +
+ {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 ( +
+ {version.spoiler_text?.length > 0 && ( + <> + +
+ + )} + +
+ + {poll && ( +
+ + {version.poll.options.map((option: any) => ( + + + + + + ))} + +
+ )} + + {version.media_attachments.size > 0 && ( + + )} + + + + +
+ ); + })} +
+ ); + } + + return ( + } + onClose={onClickClose} + > + {body} + + ); +}; + +export default CompareHistoryModal; diff --git a/app/soapbox/features/ui/components/compose_modal.js b/app/soapbox/features/ui/components/compose_modal.js index ff9ce136e..3e029070d 100644 --- a/app/soapbox/features/ui/components/compose_modal.js +++ b/app/soapbox/features/ui/components/compose_modal.js @@ -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 ; + } else if (privacy === 'direct') { return ; } else if (inReplyTo) { return ; diff --git a/app/soapbox/features/ui/components/modal_root.js b/app/soapbox/features/ui/components/modal_root.js index 39aea44ac..78f9ee2a4 100644 --- a/app/soapbox/features/ui/components/modal_root.js +++ b/app/soapbox/features/ui/components/modal_root.js @@ -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 { diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index f1ebe7a93..d08319735 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -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'); +} diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index b6d3ba8de..60802a057 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -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'; diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 8f1c27c1e..9a2bb1337 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -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(), favourited: false, favourites_count: 0, diff --git a/app/soapbox/normalizers/status_edit.ts b/app/soapbox/normalizers/status_edit.ts new file mode 100644 index 000000000..7bf38adc1 --- /dev/null +++ b/app/soapbox/normalizers/status_edit.ts @@ -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, + content: '', + created_at: new Date(), + emojis: ImmutableList(), + favourited: false, + media_attachments: ImmutableList(), + poll: null as EmbeddedEntity, + sensitive: false, + spoiler_text: '', + + // Internal fields + contentHtml: '', + spoilerHtml: '', +}); + +const normalizeAttachments = (statusEdit: ImmutableMap) => { + return statusEdit.update('media_attachments', ImmutableList(), attachments => { + return attachments.map(normalizeAttachment); + }); +}; + +// Normalize emojis +const normalizeEmojis = (entity: ImmutableMap) => { + return entity.update('emojis', ImmutableList(), emojis => { + return emojis.map(normalizeEmoji); + }); +}; + +// Normalize the poll in the status, if applicable +const normalizeStatusPoll = (statusEdit: ImmutableMap) => { + if (statusEdit.hasIn(['poll', 'options'])) { + return statusEdit.update('poll', ImmutableMap(), normalizePoll); + } else { + return statusEdit.set('poll', null); + } +}; + +const normalizeContent = (statusEdit: ImmutableMap) => { + 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) => { + return StatusEditRecord( + ImmutableMap(fromJS(statusEdit)).withMutations(statusEdit => { + normalizeAttachments(statusEdit); + normalizeEmojis(statusEdit); + normalizeStatusPoll(statusEdit); + normalizeContent(statusEdit); + }), + ); +}; diff --git a/app/soapbox/reducers/__tests__/compose-test.js b/app/soapbox/reducers/__tests__/compose-test.js index 57637209b..47565d5d6 100644 --- a/app/soapbox/reducers/__tests__/compose-test.js +++ b/app/soapbox/reducers/__tests__/compose-test.js @@ -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'))), }; diff --git a/app/soapbox/reducers/compose.js b/app/soapbox/reducers/compose.js index 5787e6ada..9d8f5bb62 100644 --- a/app/soapbox/reducers/compose.js +++ b/app/soapbox/reducers/compose.js @@ -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()); diff --git a/app/soapbox/reducers/history.ts b/app/soapbox/reducers/history.ts new file mode 100644 index 000000000..08711dea5 --- /dev/null +++ b/app/soapbox/reducers/history.ts @@ -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; + +const HistoryRecord = ImmutableRecord({ + loading: false, + items: ImmutableList(), +}); + +type State = ImmutableMap>; + +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; + } +} \ No newline at end of file diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index a8c225d31..bcbb07139 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -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` diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index cd3316a16..7e5b764b5 100644 --- a/app/soapbox/reducers/statuses.ts +++ b/app/soapbox/reducers/statuses.ts @@ -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, diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index 80caf1ea1..d01698e46 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -11,6 +11,7 @@ import { NotificationRecord, PollRecord, PollOptionRecord, + StatusEditRecord, StatusRecord, } from 'soapbox/normalizers'; @@ -27,6 +28,7 @@ type Mention = ReturnType; type Notification = ReturnType; type Poll = ReturnType; type PollOption = ReturnType; +type StatusEdit = ReturnType; interface Account extends ReturnType { // 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, diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 34691da4c..34cd9ea49 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -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