Merge branch 'edit-posts' into 'develop'
Allow editing posts on Mastodon See merge request soapbox-pub/soapbox-fe!1271
This commit is contained in:
commit
8f09fcab2e
33 changed files with 523 additions and 89 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, 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);
|
||||
|
|
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,
|
||||
});
|
|
@ -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'>·</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}
|
||||
|
|
106
app/soapbox/features/ui/components/compare_history_modal.tsx
Normal file
106
app/soapbox/features/ui/components/compare_history_modal.tsx
Normal file
|
@ -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,
|
||||
|
|
79
app/soapbox/normalizers/status_edit.ts
Normal file
79
app/soapbox/normalizers/status_edit.ts
Normal file
|
@ -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());
|
||||
|
|
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';
|
||||
|
@ -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…
Reference in a new issue