diff --git a/src/actions/compose.ts b/src/actions/compose.ts index 3c5bc660e..d20de9ebf 100644 --- a/src/actions/compose.ts +++ b/src/actions/compose.ts @@ -8,7 +8,7 @@ import { isNativeEmoji } from 'soapbox/features/emoji'; import emojiSearch from 'soapbox/features/emoji/search'; import { userTouching } from 'soapbox/is-mobile'; import { normalizeTag } from 'soapbox/normalizers'; -import { selectAccount, selectOwnAccount } from 'soapbox/selectors'; +import { selectAccount, selectOwnAccount, makeGetAccount } from 'soapbox/selectors'; import { tagHistory } from 'soapbox/settings'; import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; @@ -90,6 +90,8 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS' as const; const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const; +const getAccount = makeGetAccount(); + const messages = defineMessages({ scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' }, success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent!' }, @@ -111,9 +113,11 @@ interface ComposeSetStatusAction { contentType?: string | false; v: ReturnType; withRedraft?: boolean; + draftId?: string; + editorState?: string | null; } -const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) => +const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean, draftId?: string, editorState?: string | null) => (dispatch: AppDispatch, getState: () => RootState) => { const { instance } = getState(); const { explicitAddressing } = getFeatures(instance); @@ -128,6 +132,8 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin contentType, v: parseVersion(instance.version), withRedraft, + draftId, + editorState, }; dispatch(action); @@ -280,8 +286,13 @@ const directComposeById = (accountId: string) => const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, composeId: string, data: APIEntity, status: string, edit?: boolean, propagate?: boolean) => { if (!dispatch || !getState) return; + const state = getState(); + + const accountUrl = getAccount(state, state.me as string)!.url; + const draftId = getState().compose.get(composeId)!.draft_id; + dispatch(insertIntoTagHistory(composeId, data.tags || [], status)); - dispatch(submitComposeSuccess(composeId, { ...data })); + dispatch(submitComposeSuccess(composeId, { ...data }, accountUrl, draftId)); const toastMessage = edit ? messages.editSuccess : messages.success; const toastOpts = { actionLabel: messages.view, @@ -398,10 +409,12 @@ const submitComposeRequest = (composeId: string) => ({ id: composeId, }); -const submitComposeSuccess = (composeId: string, status: APIEntity) => ({ +const submitComposeSuccess = (composeId: string, status: APIEntity, accountUrl: string, draftId?: string | null) => ({ type: COMPOSE_SUBMIT_SUCCESS, id: composeId, status: status, + accountUrl, + draftId, }); const submitComposeFail = (composeId: string, error: unknown) => ({ @@ -861,10 +874,11 @@ const eventDiscussionCompose = (composeId: string, status: Status) => }); }; -const setEditorState = (composeId: string, editorState: EditorState | string | null) => ({ +const setEditorState = (composeId: string, editorState: EditorState | string | null, text?: string) => ({ type: COMPOSE_EDITOR_STATE_SET, id: composeId, editorState: editorState, + text, }); type ComposeAction = diff --git a/src/actions/draft-statuses.ts b/src/actions/draft-statuses.ts new file mode 100644 index 000000000..90d10c652 --- /dev/null +++ b/src/actions/draft-statuses.ts @@ -0,0 +1,66 @@ +import { v4 as uuid } from 'uuid'; + +import { makeGetAccount } from 'soapbox/selectors'; +import KVStore from 'soapbox/storage/kv-store'; + +import type { AppDispatch, RootState } from 'soapbox/store'; + +const DRAFT_STATUSES_FETCH_SUCCESS = 'DRAFT_STATUSES_FETCH_SUCCESS'; + +const PERSIST_DRAFT_STATUS = 'PERSIST_DRAFT_STATUS'; +const CANCEL_DRAFT_STATUS = 'DELETE_DRAFT_STATUS'; + +const getAccount = makeGetAccount(); + +const fetchDraftStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const accountUrl = getAccount(state, state.me as string)!.url; + + return KVStore.getItem(`drafts:${accountUrl}`).then((statuses) => { + dispatch({ + type: DRAFT_STATUSES_FETCH_SUCCESS, + statuses, + }); + }).catch(() => {}); + }; + +const saveDraftStatus = (composeId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const accountUrl = getAccount(state, state.me as string)!.url; + + const compose = state.compose.get(composeId)!; + + const draft = { + ...compose.toJS(), + draft_id: compose.draft_id || uuid(), + }; + + dispatch({ + type: PERSIST_DRAFT_STATUS, + status: draft, + accountUrl, + }); + }; + +const cancelDraftStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const accountUrl = getAccount(state, state.me as string)!.url; + + dispatch({ + type: CANCEL_DRAFT_STATUS, + id, + accountUrl, + }); + }; + +export { + DRAFT_STATUSES_FETCH_SUCCESS, + PERSIST_DRAFT_STATUS, + CANCEL_DRAFT_STATUS, + fetchDraftStatuses, + saveDraftStatus, + cancelDraftStatus, +}; diff --git a/src/components/modal-root.tsx b/src/components/modal-root.tsx index 4f0f89973..d2cbaf76d 100644 --- a/src/components/modal-root.tsx +++ b/src/components/modal-root.tsx @@ -5,6 +5,7 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { cancelReplyCompose } from 'soapbox/actions/compose'; +import { saveDraftStatus } from 'soapbox/actions/draft-statuses'; import { cancelEventCompose } from 'soapbox/actions/events'; import { openModal, closeModal } from 'soapbox/actions/modals'; import { useAppDispatch, usePrevious } from 'soapbox/hooks'; @@ -16,6 +17,7 @@ import type { ReducerRecord as ReducerComposeEvent } from 'soapbox/reducers/comp const messages = defineMessages({ confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' }, cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' }, + saveDraft: { id: 'confirmations.cancel_editing.save_draft', defaultMessage: 'Save draft' }, }); export const checkComposeContent = (compose?: ReturnType) => { @@ -90,6 +92,12 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) onCancel: () => { dispatch(closeModal('CONFIRM')); }, + secondary: intl.formatMessage(messages.saveDraft), + onSecondary: isEditing ? undefined : () => { + dispatch(saveDraftStatus('compose-modal')); + dispatch(closeModal('COMPOSE')); + dispatch(cancelReplyCompose()); + }, })); } else if (hasEventComposeContent && type === 'COMPOSE_EVENT') { const isEditing = getState().compose_event.id !== null; diff --git a/src/components/sidebar-menu.tsx b/src/components/sidebar-menu.tsx index 7615dea74..2977b9154 100644 --- a/src/components/sidebar-menu.tsx +++ b/src/components/sidebar-menu.tsx @@ -34,6 +34,7 @@ const messages = defineMessages({ events: { id: 'column.events', defaultMessage: 'Events' }, invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' }, developers: { id: 'navigation.developers', defaultMessage: 'Developers' }, + drafts: { id: 'navigation.drafts', defaultMessage: 'Drafts' }, addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' }, addExternalAccount: { id: 'profile_dropdown.add_external_account', defaultMessage: 'Add account from external instance' }, followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, @@ -86,6 +87,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const settings = useAppSelector((state) => getSettings(state)); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); + const draftCount = useAppSelector((state) => state.draft_statuses.size); const groupsPath = useGroupsPath(); const closeButtonRef = React.useRef(null); @@ -250,6 +252,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { /> )} + {draftCount > 0 && ( + + )} + {features.publicTimeline && <> diff --git a/src/components/sidebar-navigation.tsx b/src/components/sidebar-navigation.tsx index c0d91cc40..d0d21a8f8 100644 --- a/src/components/sidebar-navigation.tsx +++ b/src/components/sidebar-navigation.tsx @@ -17,6 +17,7 @@ const messages = defineMessages({ events: { id: 'column.events', defaultMessage: 'Events' }, profileDirectory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' }, developers: { id: 'navigation.developers', defaultMessage: 'Developers' }, + drafts: { id: 'navigation.drafts', defaultMessage: 'Drafts' }, }); /** Desktop sidebar with links to different views in the app. */ @@ -32,6 +33,7 @@ const SidebarNavigation = () => { const notificationCount = useAppSelector((state) => state.notifications.unread); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); + const draftCount = useAppSelector((state) => state.draft_statuses.size); const restrictUnauth = instance.pleroma.metadata.restrict_unauthenticated; @@ -87,6 +89,15 @@ const SidebarNavigation = () => { text: intl.formatMessage(messages.developers), }); } + + if (draftCount > 0) { + menu.push({ + to: '/draft_statuses', + icon: require('@tabler/icons/notes.svg'), + text: intl.formatMessage(messages.drafts), + count: draftCount, + }); + } } return menu; diff --git a/src/features/compose/components/compose-form.tsx b/src/features/compose/components/compose-form.tsx index f6fa55fff..9dbf50367 100644 --- a/src/features/compose/components/compose-form.tsx +++ b/src/features/compose/components/compose-form.tsx @@ -1,8 +1,7 @@ -import { $createRemarkExport } from '@mkljczk/lexical-remark'; import clsx from 'clsx'; import { CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor } from 'lexical'; import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; import { length } from 'stringz'; @@ -88,6 +87,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab is_uploading: isUploading, schedule: scheduledAt, group_id: groupId, + text, } = compose; const prevSpoiler = usePrevious(spoiler); @@ -105,12 +105,12 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab const { isDraggedOver } = useDraggedFiles(formRef); - const text = editorRef.current?.getEditorState().read($createRemarkExport({ - handlers: { - hashtag: (node) => ({ type: 'text', value: node.getTextContent() }), - mention: (node) => ({ type: 'text', value: node.getTextContent() }), - }, - })) ?? ''; + // const text = editorRef.current?.getEditorState().read($createRemarkExport({ + // handlers: { + // hashtag: (node) => ({ type: 'text', value: node.getTextContent() }), + // mention: (node) => ({ type: 'text', value: node.getTextContent() }), + // }, + // })) ?? ''; const fulltext = [spoilerText, countableText(text)].join(''); diff --git a/src/features/compose/editor/nodes/emoji-node.tsx b/src/features/compose/editor/nodes/emoji-node.tsx index d3f7a58bc..767731930 100644 --- a/src/features/compose/editor/nodes/emoji-node.tsx +++ b/src/features/compose/editor/nodes/emoji-node.tsx @@ -81,7 +81,7 @@ class EmojiNode extends DecoratorNode { decorate(): JSX.Element { const emoji = this.__emoji; if (isNativeEmoji(emoji)) { - return ; + return ; } else { return ; } diff --git a/src/features/compose/editor/plugins/state-plugin.tsx b/src/features/compose/editor/plugins/state-plugin.tsx index 06f102b64..f8d198ac8 100644 --- a/src/features/compose/editor/plugins/state-plugin.tsx +++ b/src/features/compose/editor/plugins/state-plugin.tsx @@ -15,9 +15,10 @@ const StatePlugin: React.FC = ({ composeId }) => { useEffect(() => { editor.registerUpdateListener(({ editorState }) => { - const isEmpty = editorState.read(() => $getRoot().getTextContent()) === ''; + const text = editorState.read(() => $getRoot().getTextContent()); + const isEmpty = text === ''; const data = isEmpty ? null : JSON.stringify(editorState.toJSON()); - dispatch(setEditorState(composeId, data)); + dispatch(setEditorState(composeId, data, text)); }); }, [editor]); diff --git a/src/features/draft-statuses/builder.tsx b/src/features/draft-statuses/builder.tsx new file mode 100644 index 000000000..8eee5ce4e --- /dev/null +++ b/src/features/draft-statuses/builder.tsx @@ -0,0 +1,43 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { Entities } from 'soapbox/entity-store/entities'; +import { normalizeStatus } from 'soapbox/normalizers/status'; +import { calculateStatus } from 'soapbox/reducers/statuses'; + +import type { DraftStatus } from 'soapbox/reducers/draft-statuses'; +import type { RootState } from 'soapbox/store'; + +const buildPoll = (draftStatus: DraftStatus) => { + if (draftStatus.hasIn(['poll', 'options'])) { + return draftStatus.poll! + .set('id', `${draftStatus.draft_id}-poll`) + .update('options', (options: ImmutableMap) => { + return options.map((title: string) => ImmutableMap({ title })); + }); + } else { + return null; + } +}; + +export const buildStatus = (state: RootState, draftStatus: DraftStatus) => { + const me = state.me as string; + const account = state.entities[Entities.ACCOUNTS]?.store[me]; + + const status = ImmutableMap({ + account, + content: draftStatus.text.replace(new RegExp('\n', 'g'), '
'), /* eslint-disable-line no-control-regex */ + created_at: draftStatus.schedule, + group: draftStatus.group_id, + in_reply_to_id: draftStatus.in_reply_to, + media_attachments: draftStatus.media_attachments, + poll: buildPoll(draftStatus), + quote: draftStatus.quote, + sensitive: draftStatus.sensitive, + spoiler_text: draftStatus.spoiler_text, + uri: `/draft_statuses/${draftStatus.draft_id}`, + url: `/draft_statuses/${draftStatus.draft_id}`, + visibility: draftStatus.privacy, + }); + + return calculateStatus(normalizeStatus(status)); +}; diff --git a/src/features/draft-statuses/components/draft-status-action-bar.tsx b/src/features/draft-statuses/components/draft-status-action-bar.tsx new file mode 100644 index 000000000..9b4487662 --- /dev/null +++ b/src/features/draft-statuses/components/draft-status-action-bar.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { setComposeToStatus } from 'soapbox/actions/compose'; +import { cancelDraftStatus } from 'soapbox/actions/draft-statuses'; +import { openModal } from 'soapbox/actions/modals'; +import { getSettings } from 'soapbox/actions/settings'; +import { Button, HStack } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; + +import type { DraftStatus } from 'soapbox/reducers/draft-statuses'; +import type { Status as StatusEntity } from 'soapbox/types/entities'; + +const messages = defineMessages({ + edit: { id: 'draft_status.edit', defaultMessage: 'Edit' }, + cancel: { id: 'draft_status.cancel', defaultMessage: 'Cancel' }, + deleteConfirm: { id: 'confirmations.draft_status_delete.confirm', defaultMessage: 'Discard' }, + deleteHeading: { id: 'confirmations.draft_status_delete.heading', defaultMessage: 'Cancel draft post' }, + deleteMessage: { id: 'confirmations.draft_status_delete.message', defaultMessage: 'Are you sure you want to discard this draft post?' }, +}); + +interface IDraftStatusActionBar { + source: DraftStatus; + status: StatusEntity; +} + +const DraftStatusActionBar: React.FC = ({ source, status }) => { + const intl = useIntl(); + + const dispatch = useAppDispatch(); + + const handleCancelClick = () => { + dispatch((_, getState) => { + + const deleteModal = getSettings(getState()).get('deleteModal'); + if (!deleteModal) { + dispatch(cancelDraftStatus(source.draft_id)); + } else { + dispatch(openModal('CONFIRM', { + icon: require('@tabler/icons/calendar-stats.svg'), + heading: intl.formatMessage(messages.deleteHeading), + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => dispatch(cancelDraftStatus(source.draft_id)), + })); + } + }); + }; + + const handleEditClick = () => { + dispatch(setComposeToStatus(status, source.text, source.spoiler_text, source.content_type, false, source.draft_id, source.editorState)); + dispatch(openModal('COMPOSE')); + }; + + return ( + + + + + ); +}; + +export default DraftStatusActionBar; diff --git a/src/features/draft-statuses/components/draft-status.tsx b/src/features/draft-statuses/components/draft-status.tsx new file mode 100644 index 000000000..92531d24a --- /dev/null +++ b/src/features/draft-statuses/components/draft-status.tsx @@ -0,0 +1,88 @@ +import clsx from 'clsx'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Account from 'soapbox/components/account'; +import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; +import StatusContent from 'soapbox/components/status-content'; +import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; +import { HStack, Stack } from 'soapbox/components/ui'; +import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container'; +import PollPreview from 'soapbox/features/ui/components/poll-preview'; +import { useAppSelector } from 'soapbox/hooks'; + +import { buildStatus } from '../builder'; + +import DraftStatusActionBar from './draft-status-action-bar'; + +import type { DraftStatus as DraftStatusType } from 'soapbox/reducers/draft-statuses'; +import type { Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities'; + +interface IDraftStatus { + draftStatus: DraftStatusType; +} + +const DraftStatus: React.FC = ({ draftStatus, ...other }) => { + const status = useAppSelector((state) => { + if (!draftStatus) return null; + return buildStatus(state, draftStatus); + }) as StatusEntity | null; + + if (!status) return null; + + const account = status.account; + + let quote; + + if (status.quote) { + if (status.pleroma.get('quote_visible', true) === false) { + quote = ( +
+

+
+ ); + } else { + quote = ; + } + } + + return ( +
+
+
+ + } + /> + +
+ + + + + + + {status.media_attachments.size > 0 && ( + + )} + + {quote} + + {status.poll && } + +
+
+ ); +}; + +export default DraftStatus; diff --git a/src/features/draft-statuses/index.tsx b/src/features/draft-statuses/index.tsx new file mode 100644 index 000000000..60fd1bd7a --- /dev/null +++ b/src/features/draft-statuses/index.tsx @@ -0,0 +1,40 @@ +import React, { useEffect } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { fetchDraftStatuses } from 'soapbox/actions/draft-statuses'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Column } from 'soapbox/components/ui'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; + +import DraftStatus from './components/draft-status'; + +const messages = defineMessages({ + heading: { id: 'column.draft_statuses', defaultMessage: 'Drafts' }, +}); + +const DraftStatuses = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const drafts = useAppSelector((state) => state.draft_statuses); + + useEffect(() => { + dispatch(fetchDraftStatuses()); + }, []); + + const emptyMessage = ; + + return ( + + + {drafts.toOrderedSet().reverse().map((draft) => )} + + + ); +}; + +export default DraftStatuses; diff --git a/src/features/scheduled-statuses/components/scheduled-status-action-bar.tsx b/src/features/scheduled-statuses/components/scheduled-status-action-bar.tsx index c4ea472ba..495e14818 100644 --- a/src/features/scheduled-statuses/components/scheduled-status-action-bar.tsx +++ b/src/features/scheduled-statuses/components/scheduled-status-action-bar.tsx @@ -1,11 +1,10 @@ import React from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import { cancelScheduledStatus } from 'soapbox/actions/scheduled-statuses'; import { getSettings } from 'soapbox/actions/settings'; -import IconButton from 'soapbox/components/icon-button'; -import { HStack } from 'soapbox/components/ui'; +import { Button, HStack } from 'soapbox/components/ui'; import { useAppDispatch } from 'soapbox/hooks'; import type { Status as StatusEntity } from 'soapbox/types/entities'; @@ -46,12 +45,9 @@ const ScheduledStatusActionBar: React.FC = ({ status return ( - + ); }; diff --git a/src/features/scheduled-statuses/components/scheduled-status.tsx b/src/features/scheduled-statuses/components/scheduled-status.tsx index 7243bffd4..09e59ad16 100644 --- a/src/features/scheduled-statuses/components/scheduled-status.tsx +++ b/src/features/scheduled-statuses/components/scheduled-status.tsx @@ -5,7 +5,7 @@ import Account from 'soapbox/components/account'; import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; import StatusContent from 'soapbox/components/status-content'; import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; -import { HStack } from 'soapbox/components/ui'; +import { HStack, Stack } from 'soapbox/components/ui'; import PollPreview from 'soapbox/features/ui/components/poll-preview'; import { useAppSelector } from 'soapbox/hooks'; @@ -13,7 +13,7 @@ import { buildStatus } from '../builder'; import ScheduledStatusActionBar from './scheduled-status-action-bar'; -import type { Status as StatusEntity } from 'soapbox/types/entities'; +import type { Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities'; interface IScheduledStatus { statusId: string; @@ -31,7 +31,7 @@ const ScheduledStatus: React.FC = ({ statusId, ...other }) => const account = status.account; return ( -
+
@@ -40,28 +40,28 @@ const ScheduledStatus: React.FC = ({ statusId, ...other }) => account={account} timestamp={status.created_at} futureTimestamp - hideActions + action={} />
- - - {status.media_attachments.size > 0 && ( - + - )} - {status.poll && } + {status.media_attachments.size > 0 && ( + + )} - + {status.poll && } +
); diff --git a/src/features/scheduled-statuses/index.tsx b/src/features/scheduled-statuses/index.tsx index 781a06759..0a9b3dcae 100644 --- a/src/features/scheduled-statuses/index.tsx +++ b/src/features/scheduled-statuses/index.tsx @@ -39,6 +39,7 @@ const ScheduledStatuses = () => { isLoading={typeof isLoading === 'boolean' ? isLoading : true} onLoadMore={() => handleLoadMore(dispatch)} emptyMessage={emptyMessage} + listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800' > {statusIds.map((id: string) => )} diff --git a/src/features/ui/components/modals/compose-modal.tsx b/src/features/ui/components/modals/compose-modal.tsx index 83e471475..d84645e33 100644 --- a/src/features/ui/components/modals/compose-modal.tsx +++ b/src/features/ui/components/modals/compose-modal.tsx @@ -3,6 +3,7 @@ import React, { useRef } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { cancelReplyCompose, setGroupTimelineVisible, uploadCompose } from 'soapbox/actions/compose'; +import { saveDraftStatus } from 'soapbox/actions/draft-statuses'; import { openModal, closeModal } from 'soapbox/actions/modals'; import { useGroup } from 'soapbox/api/hooks'; import { checkComposeContent } from 'soapbox/components/modal-root'; @@ -14,6 +15,7 @@ import ComposeForm from '../../../compose/components/compose-form'; const messages = defineMessages({ confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' }, cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' }, + saveDraft: { id: 'confirmations.cancel_editing.save_draft', defaultMessage: 'Save draft' }, }); interface IComposeModal { @@ -48,6 +50,12 @@ const ComposeModal: React.FC = ({ onClose, composeId = 'compose-m dispatch(closeModal('COMPOSE')); dispatch(cancelReplyCompose()); }, + secondary: intl.formatMessage(messages.saveDraft), + onSecondary: statusId ? undefined : () => { + dispatch(saveDraftStatus(composeId)); + dispatch(closeModal('COMPOSE')); + dispatch(cancelReplyCompose()); + }, })); } else { onClose('COMPOSE'); diff --git a/src/features/ui/components/pending-status.tsx b/src/features/ui/components/pending-status.tsx index 49dd2bcd8..4594fb669 100644 --- a/src/features/ui/components/pending-status.tsx +++ b/src/features/ui/components/pending-status.tsx @@ -14,7 +14,7 @@ import { buildStatus } from '../util/pending-status-builder'; import PollPreview from './poll-preview'; -import type { Status as StatusEntity } from 'soapbox/types/entities'; +import type { Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities'; const shouldHaveCard = (pendingStatus: StatusEntity) => { return Boolean(pendingStatus.content.match(/https?:\/\/\S*/)); @@ -60,7 +60,10 @@ const PendingStatus: React.FC = ({ idempotencyKey, className, mu
@@ -86,7 +89,7 @@ const PendingStatus: React.FC = ({ idempotencyKey, className, mu - {status.poll && } + {status.poll && } {status.quote && } diff --git a/src/features/ui/components/poll-preview.tsx b/src/features/ui/components/poll-preview.tsx index 03de9a582..f22018df0 100644 --- a/src/features/ui/components/poll-preview.tsx +++ b/src/features/ui/components/poll-preview.tsx @@ -3,17 +3,14 @@ import React from 'react'; import PollOption from 'soapbox/components/polls/poll-option'; import { Stack } from 'soapbox/components/ui'; -import { useAppSelector } from 'soapbox/hooks'; import { Poll as PollEntity } from 'soapbox/types/entities'; interface IPollPreview { - pollId: string; + poll: PollEntity; } -const PollPreview: React.FC = ({ pollId }) => { - const poll = useAppSelector((state) => state.polls.get(pollId) as PollEntity); - - if (!poll) { +const PollPreview: React.FC = ({ poll }) => { + if (typeof poll !== 'object') { return null; } diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index d1d429af9..dfe125e92 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -7,6 +7,7 @@ import { fetchFollowRequests } from 'soapbox/actions/accounts'; import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin'; import { fetchAnnouncements } from 'soapbox/actions/announcements'; import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis'; +import { fetchDraftStatuses } from 'soapbox/actions/draft-statuses'; import { fetchFilters } from 'soapbox/actions/filters'; import { fetchMarker } from 'soapbox/actions/markers'; import { expandNotifications } from 'soapbox/actions/notifications'; @@ -141,6 +142,7 @@ import { EditIdentity, Domains, NostrRelays, + DraftStatuses, } from './util/async-components'; import GlobalHotkeys from './util/global-hotkeys'; import { WrappedRoute } from './util/react-router-helpers'; @@ -307,6 +309,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.scheduledStatuses && } + {features.nip05 && } @@ -402,6 +405,8 @@ const UI: React.FC = ({ children }) => { const loadAccountData = () => { if (!account) return; + dispatch(fetchDraftStatuses()); + dispatch(expandHomeTimeline({}, intl, () => { dispatch(fetchSuggestionsForTimeline()); })); diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 39f97a165..82d9e8da0 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -169,4 +169,5 @@ export const SelectBookmarkFolderModal = lazy(() => import('soapbox/features/ui/ export const EditIdentity = lazy(() => import('soapbox/features/edit-identity')); export const Domains = lazy(() => import('soapbox/features/admin/domains')); export const EditDomainModal = lazy(() => import('soapbox/features/ui/components/modals/edit-domain-modal')); -export const NostrRelays = lazy(() => import('soapbox/features/nostr-relays')); \ No newline at end of file +export const NostrRelays = lazy(() => import('soapbox/features/nostr-relays')); +export const DraftStatuses = lazy(() => import('soapbox/features/draft-statuses')); diff --git a/src/locales/en.json b/src/locales/en.json index 9d7fd8fe6..f05d071f3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -339,6 +339,7 @@ "column.directory": "Browse profiles", "column.dislikes": "Dislikes", "column.domain_blocks": "Hidden domains", + "column.draft_statuses": "Drafts", "column.edit_profile": "Edit profile", "column.event_map": "Event location", "column.event_participants": "Event participants", @@ -511,6 +512,7 @@ "confirmations.cancel_editing.confirm": "Cancel editing", "confirmations.cancel_editing.heading": "Cancel post editing", "confirmations.cancel_editing.message": "Are you sure you want to cancel editing this post? All changes will be lost.", + "confirmations.cancel_editing.save_draft": "Save draft", "confirmations.cancel_event_editing.heading": "Cancel event editing", "confirmations.cancel_event_editing.message": "Are you sure you want to cancel editing this event? All changes will be lost.", "confirmations.delete.confirm": "Delete", @@ -532,6 +534,9 @@ "confirmations.domain_block.confirm": "Hide entire domain", "confirmations.domain_block.heading": "Block {domain}", "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications.", + "confirmations.draft_status_delete.confirm": "Discard", + "confirmations.draft_status_delete.heading": "Cancel draft post", + "confirmations.draft_status_delete.message": "Are you sure you want to discard this draft post?", "confirmations.kick_from_group.confirm": "Kick", "confirmations.kick_from_group.message": "Are you sure you want to kick @{name} from this group?", "confirmations.leave_event.confirm": "Leave event", @@ -602,6 +607,8 @@ "directory.local": "From {domain} only", "directory.new_arrivals": "New arrivals", "directory.recently_active": "Recently active", + "draft_status.cancel": "Delete", + "draft_status.edit": "Edit", "edit_bookmark_folder_modal.confirm": "Save", "edit_bookmark_folder_modal.header_title": "Edit folder", "edit_email.header": "Change Email", @@ -688,6 +695,7 @@ "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.", "empty_column.dislikes": "No one has disliked this post yet. When someone does, they will show up here.", "empty_column.domain_blocks": "There are no hidden domains yet.", + "empty_column.draft_statuses": "You don't have any draft statuses yet. When you add one, it will show up here.", "empty_column.event_participant_requests": "There are no pending event participation requests.", "empty_column.event_participants": "No one joined this event yet. When someone does, they will show up here.", "empty_column.favourited_statuses": "You don't have any liked posts yet. When you like one, it will show up here.", @@ -1074,6 +1082,7 @@ "navigation.dashboard": "Dashboard", "navigation.developers": "Developers", "navigation.direct_messages": "Messages", + "navigation.drafts": "Drafts", "navigation.home": "Home", "navigation.notifications": "Notifications", "navigation.search": "Search", diff --git a/src/reducers/compose.ts b/src/reducers/compose.ts index 5a89c91a2..d0dac1e65 100644 --- a/src/reducers/compose.ts +++ b/src/reducers/compose.ts @@ -82,6 +82,7 @@ const PollRecord = ImmutableRecord({ export const ReducerCompose = ImmutableRecord({ caretPosition: null as number | null, content_type: 'text/plain', + draft_id: null as string | null, editorState: null as string | null, focusDate: null as Date | null, group_id: null as string | null, @@ -111,7 +112,7 @@ export const ReducerCompose = ImmutableRecord({ }); type State = ImmutableMap; -type Compose = ReturnType; +export type Compose = ReturnType; type Poll = ReturnType; const statusToTextMentions = (status: Status, account: Account) => { @@ -438,7 +439,7 @@ export default function compose(state = initialState, action: ComposeAction | Ev }))); case COMPOSE_SET_STATUS: return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => { - if (!action.withRedraft) { + if (!action.withRedraft && !action.draftId) { map.set('id', action.status.id); } map.set('text', action.rawText || unescapeHTML(expandMentions(action.status))); @@ -473,6 +474,14 @@ export default function compose(state = initialState, action: ComposeAction | Ev expires_in: 24 * 3600, })); } + + if (action.draftId) { + map.set('draft_id', action.draftId); + } + + if (action.editorState) { + map.set('editorState', action.editorState); + } })); case COMPOSE_POLL_ADD: return updateCompose(state, action.id, compose => compose.set('poll', PollRecord())); @@ -514,7 +523,9 @@ export default function compose(state = initialState, action: ComposeAction | Ev case SETTING_CHANGE: return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value)); case COMPOSE_EDITOR_STATE_SET: - return updateCompose(state, action.id, compose => compose.set('editorState', action.editorState as string)); + return updateCompose(state, action.id, compose => compose + .set('editorState', action.editorState as string) + .set('text', action.text as string)); case EVENT_COMPOSE_CANCEL: return updateCompose(state, 'event-compose-modal', compose => compose.set('text', '')); case EVENT_FORM_SET: diff --git a/src/reducers/draft-statuses.ts b/src/reducers/draft-statuses.ts new file mode 100644 index 000000000..689d486f6 --- /dev/null +++ b/src/reducers/draft-statuses.ts @@ -0,0 +1,64 @@ +import { List as ImmutableList, Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord, fromJS } from 'immutable'; + +import { COMPOSE_SUBMIT_SUCCESS } from 'soapbox/actions/compose'; +import { DRAFT_STATUSES_FETCH_SUCCESS, PERSIST_DRAFT_STATUS, CANCEL_DRAFT_STATUS } from 'soapbox/actions/draft-statuses'; +import KVStore from 'soapbox/storage/kv-store'; + +import type { AnyAction } from 'redux'; +import type { StatusVisibility } from 'soapbox/normalizers/status'; +import type { APIEntity, Attachment as AttachmentEntity } from 'soapbox/types/entities'; + +const DraftStatusRecord = ImmutableRecord({ + content_type: 'text/plain', + draft_id: '', + editorState: null as string | null, + group_id: null as string | null, + in_reply_to: null as string | null, + media_attachments: ImmutableList(), + poll: null as ImmutableMap | null, + privacy: 'public' as StatusVisibility, + quote: null as string | null, + schedule: null as Date | null, + sensitive: false, + spoiler: false, + spoiler_text: '', + text: '', + to: ImmutableOrderedSet(), +}); + +export type DraftStatus = ReturnType; +type State = ImmutableMap; + +const initialState: State = ImmutableMap(); + +const importStatus = (state: State, status: APIEntity) => { + return state.set(status.draft_id, DraftStatusRecord(ImmutableMap(fromJS(status)))); +}; + +const importStatuses = (state: State, statuses: APIEntity[]) => + state.withMutations(mutable => Object.values(statuses || {}).forEach(status => importStatus(mutable, status))); + +const deleteStatus = (state: State, id: string) => { + if (id) return state.delete(id); + return state; +}; + +const persistState = (state: State, accountUrl: string) => { + KVStore.setItem(`drafts:${accountUrl}`, state.toJS()); + return state; +}; + +export default function scheduled_statuses(state: State = initialState, action: AnyAction) { + switch (action.type) { + case DRAFT_STATUSES_FETCH_SUCCESS: + return importStatuses(state, action.statuses); + case PERSIST_DRAFT_STATUS: + return persistState(importStatus(state, action.status), action.accountUrl); + case CANCEL_DRAFT_STATUS: + return persistState(deleteStatus(state, action.id), action.accountUrl); + case COMPOSE_SUBMIT_SUCCESS: + return persistState(deleteStatus(state, action.draftId), action.accountUrl); + default: + return state; + } +} diff --git a/src/reducers/index.ts b/src/reducers/index.ts index a30369561..8b0126131 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -23,6 +23,7 @@ import contexts from './contexts'; import conversations from './conversations'; import custom_emojis from './custom-emojis'; import domain_lists from './domain-lists'; +import draft_statuses from './draft-statuses'; import dropdown_menu from './dropdown-menu'; import filters from './filters'; import followed_tags from './followed-tags'; @@ -84,6 +85,7 @@ const reducers = { conversations, custom_emojis, domain_lists, + draft_statuses, dropdown_menu, entities, filters, diff --git a/src/reducers/pending-statuses.ts b/src/reducers/pending-statuses.ts index 51c4c1aba..6a69508ac 100644 --- a/src/reducers/pending-statuses.ts +++ b/src/reducers/pending-statuses.ts @@ -1,6 +1,7 @@ import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; import { + STATUS_CREATE_FAIL, STATUS_CREATE_REQUEST, STATUS_CREATE_SUCCESS, } from 'soapbox/actions/statuses'; @@ -36,6 +37,7 @@ export default function pending_statuses(state = initialState, action: AnyAction switch (action.type) { case STATUS_CREATE_REQUEST: return action.editing ? state : importStatus(state, ImmutableMap(fromJS(action.params)), action.idempotencyKey); + case STATUS_CREATE_FAIL: case STATUS_CREATE_SUCCESS: return deleteStatus(state, action.idempotencyKey); default: