From c577a182f1ea65da87455434c0e5e1f25791e2cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 25 Feb 2026 22:41:45 +0100 Subject: [PATCH] nicolium: migrate compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- packages/pl-fe/src/actions/admin.ts | 20 +- packages/pl-fe/src/actions/compose.ts | 1374 ----------------- packages/pl-fe/src/actions/events.ts | 40 +- packages/pl-fe/src/actions/instance.ts | 2 + packages/pl-fe/src/actions/me.ts | 3 + packages/pl-fe/src/actions/statuses.ts | 65 +- packages/pl-fe/src/actions/timelines.ts | 2 + .../src/components/autosuggest-input.tsx | 2 +- packages/pl-fe/src/components/modal-root.tsx | 122 +- .../src/components/status-action-bar.tsx | 28 +- packages/pl-fe/src/components/status.tsx | 7 +- .../pl-fe/src/components/thumb-navigation.tsx | 11 +- .../features/account/components/header.tsx | 7 +- .../compose-event/tabs/edit-event.tsx | 27 +- .../compose/components/compose-form.tsx | 81 +- .../components/content-type-button.tsx | 12 +- .../compose/components/drive-button.tsx | 9 +- .../components/hashtag-casing-suggestion.tsx | 11 +- .../compose/components/language-dropdown.tsx | 36 +- .../compose/components/location-button.tsx | 17 +- .../compose/components/location-form.tsx | 12 +- .../compose/components/poll-button.tsx | 14 +- .../compose/components/polls/poll-form.tsx | 73 +- .../compose/components/privacy-dropdown.tsx | 17 +- .../components/reply-group-indicator.tsx | 6 +- .../compose/components/schedule-button.tsx | 14 +- .../compose/components/schedule-form.tsx | 15 +- .../components/sensitive-media-button.tsx | 11 +- .../compose/components/spoiler-input.tsx | 55 +- .../compose/components/upload-form.tsx | 13 +- .../features/compose/components/upload.tsx | 27 +- .../containers/preview-compose-container.tsx | 10 +- .../containers/quoted-status-container.tsx | 15 +- .../containers/reply-indicator-container.tsx | 8 +- .../containers/upload-button-container.tsx | 8 +- .../src/features/compose/editor/index.tsx | 11 +- .../editor/plugins/autosuggest-plugin.tsx | 75 +- .../floating-block-type-toolbar-plugin.tsx | 2 +- .../compose/editor/plugins/state-plugin.tsx | 163 +- .../components/draft-status-action-bar.tsx | 16 +- .../event/components/event-header.tsx | 9 +- .../notifications/components/notification.tsx | 11 +- .../src/features/reply-mentions/account.tsx | 21 +- .../src/features/status/components/thread.tsx | 11 +- .../features/ui/components/compose-button.tsx | 7 +- .../src/features/ui/components/modal-root.tsx | 6 +- .../src/features/ui/util/global-hotkeys.tsx | 7 +- .../src/hooks/use-compose-suggestions.ts | 54 + packages/pl-fe/src/hooks/use-compose.ts | 11 +- packages/pl-fe/src/layouts/home-layout.tsx | 11 +- .../compose-interaction-policy-modal.tsx | 32 +- packages/pl-fe/src/modals/compose-modal.tsx | 13 +- .../pl-fe/src/modals/reply-mentions-modal.tsx | 2 +- packages/pl-fe/src/pages/fun/circle.tsx | 10 +- .../src/pages/statuses/compose-event.tsx | 7 +- .../src/pages/statuses/event-discussion.tsx | 5 +- .../src/pages/timelines/group-timeline.tsx | 18 +- packages/pl-fe/src/pages/utils/share.tsx | 7 +- .../queries/statuses/use-draft-statuses.ts | 27 +- .../make-paginated-response-query-options.ts | 5 +- packages/pl-fe/src/reducers/compose.ts | 888 ----------- packages/pl-fe/src/reducers/index.ts | 2 - packages/pl-fe/src/stores/compose.ts | 1038 +++++++++++++ 63 files changed, 1739 insertions(+), 2904 deletions(-) delete mode 100644 packages/pl-fe/src/actions/compose.ts create mode 100644 packages/pl-fe/src/hooks/use-compose-suggestions.ts delete mode 100644 packages/pl-fe/src/reducers/compose.ts create mode 100644 packages/pl-fe/src/stores/compose.ts diff --git a/packages/pl-fe/src/actions/admin.ts b/packages/pl-fe/src/actions/admin.ts index 6c99d761d..b7b23893c 100644 --- a/packages/pl-fe/src/actions/admin.ts +++ b/packages/pl-fe/src/actions/admin.ts @@ -1,11 +1,11 @@ import { importEntities } from '@/actions/importer'; import { queryClient } from '@/queries/client'; +import { useComposeStore } from '@/stores/compose'; import { useModalsStore } from '@/stores/modals'; import { filterBadges, getTagDiff } from '@/utils/badges'; import { getClient } from '../api'; -import { setComposeToStatus } from './compose'; import { STATUS_FETCH_SOURCE_FAIL, type StatusesAction } from './statuses'; import { deleteFromTimelines } from './timelines'; @@ -144,20 +144,10 @@ const redactStatus = (statusId: string) => (dispatch: AppDispatch, getState: () return getClient(state) .statuses.getStatusSource(statusId) - .then((response) => { - dispatch( - setComposeToStatus( - status, - poll, - response.text, - response.spoiler_text, - response.content_type, - false, - undefined, - undefined, - true, - ), - ); + .then((source) => { + useComposeStore + .getState() + .actions.setComposeToStatus(status, poll, source, false, null, null, true); useModalsStore.getState().actions.openModal('COMPOSE'); }) .catch((error) => { diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts deleted file mode 100644 index 838c2e004..000000000 --- a/packages/pl-fe/src/actions/compose.ts +++ /dev/null @@ -1,1374 +0,0 @@ -import throttle from 'lodash/throttle'; -import { defineMessages, IntlShape } from 'react-intl'; - -import { getClient } from '@/api'; -import { isNativeEmoji } from '@/features/emoji'; -import emojiSearch from '@/features/emoji/search'; -import { Language } from '@/features/preferences'; -import { selectAccount, selectOwnAccount } from '@/queries/accounts/selectors'; -import { queryClient } from '@/queries/client'; -import { cancelDraftStatus } from '@/queries/statuses/use-draft-statuses'; -import { useModalsStore } from '@/stores/modals'; -import { useSettingsStore } from '@/stores/settings'; -import toast from '@/toast'; -import { isLoggedIn } from '@/utils/auth'; - -import { importEntities } from './importer'; -import { uploadFile, updateMedia } from './media'; -import { saveSettings } from './settings'; -import { createStatus } from './statuses'; - -import type { AutoSuggestion } from '@/components/autosuggest-input'; -import type { Emoji } from '@/features/emoji'; -import type { Status } from '@/normalizers/status'; -import type { Policy, Rule, Scope } from '@/pages/settings/interaction-policies'; -import type { ClearLinkSuggestion } from '@/reducers/compose'; -import type { AppDispatch, RootState } from '@/store'; -import type { LinkOptions } from '@tanstack/react-router'; -import type { EditorState } from 'lexical'; -import type { - Account, - CreateStatusParams, - CustomEmoji, - Group, - MediaAttachment, - Status as BaseStatus, - Tag, - Poll, - ScheduledStatus, - InteractionPolicy, - UpdateMediaParams, - Location, - EditStatusParams, -} from 'pl-api'; - -let cancelFetchComposeSuggestions = new AbortController(); - -const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const; -const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const; -const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' as const; -const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL' as const; -const COMPOSE_PREVIEW_SUCCESS = 'COMPOSE_PREVIEW_SUCCESS' as const; -const COMPOSE_PREVIEW_CANCEL = 'COMPOSE_PREVIEW_CANCEL' as const; -const COMPOSE_REPLY = 'COMPOSE_REPLY' as const; -const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY' as const; -const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL' as const; -const COMPOSE_QUOTE = 'COMPOSE_QUOTE' as const; -const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL' as const; -const COMPOSE_DIRECT = 'COMPOSE_DIRECT' as const; -const COMPOSE_MENTION = 'COMPOSE_MENTION' as const; -const COMPOSE_RESET = 'COMPOSE_RESET' as const; -const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST' as const; -const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS' as const; -const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL' as const; -const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS' as const; -const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO' as const; -const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST' as const; - -const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR' as const; -const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY' as const; -const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT' as const; -const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE' as const; - -const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE' as const; -const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE' as const; -const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE' as const; -const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE' as const; -const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE' as const; -const COMPOSE_MODIFIED_LANGUAGE_CHANGE = 'COMPOSE_MODIFIED_LANGUAGE_CHANGE' as const; -const COMPOSE_LANGUAGE_ADD = 'COMPOSE_LANGUAGE_ADD' as const; -const COMPOSE_LANGUAGE_DELETE = 'COMPOSE_LANGUAGE_DELETE' as const; -const COMPOSE_FEDERATED_CHANGE = 'COMPOSE_FEDERATED_CHANGE' as const; - -const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST' as const; -const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS' as const; -const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL' as const; - -const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD' as const; -const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE' as const; -const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD' as const; -const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE' as const; -const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE' as const; -const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE' as const; - -const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD' as const; -const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET' as const; -const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE' as const; - -const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS' as const; -const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS' as const; - -const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS' as const; - -const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const; - -const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER' as const; - -const COMPOSE_ADD_SUGGESTED_QUOTE = 'COMPOSE_ADD_SUGGESTED_QUOTE' as const; -const COMPOSE_ADD_SUGGESTED_LANGUAGE = 'COMPOSE_ADD_SUGGESTED_LANGUAGE' as const; - -const COMPOSE_INTERACTION_POLICY_OPTION_CHANGE = - 'COMPOSE_INTERACTION_POLICY_OPTION_CHANGE' as const; -const COMPOSE_QUOTE_POLICY_OPTION_CHANGE = 'COMPOSE_QUOTE_POLICY_OPTION_CHANGE' as const; - -const COMPOSE_CLEAR_LINK_SUGGESTION_CREATE = 'COMPOSE_CLEAR_LINK_SUGGESTION_CREATE' as const; -const COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE = 'COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE' as const; - -const COMPOSE_HASHTAG_CASING_SUGGESTION_SET = 'COMPOSE_HASHTAG_CASING_SUGGESTION_SET' as const; -const COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE = - 'COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE' as const; - -const COMPOSE_REDACTING_OVERWRITE_CHANGE = 'COMPOSE_REDACTING_OVERWRITE_CHANGE' as const; - -const COMPOSE_SET_LOCATION = 'COMPOSE_SET_LOCATION' as const; -const COMPOSE_SET_SHOW_LOCATION_PICKER = 'COMPOSE_SET_SHOW_LOCATION_PICKER' as const; - -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!' }, - editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, - redactSuccess: { id: 'compose.redact_success', defaultMessage: 'The post was redacted' }, - scheduledSuccess: { id: 'compose.scheduled_success', defaultMessage: 'Your post was scheduled' }, - uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, - uploadErrorPoll: { - id: 'upload_error.poll', - defaultMessage: 'File upload not allowed with polls.', - }, - view: { id: 'toast.view', defaultMessage: 'View' }, - replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, - replyMessage: { - id: 'confirmations.reply.message', - defaultMessage: - 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?', - }, -}); - -interface ComposeSetStatusAction { - type: typeof COMPOSE_SET_STATUS; - composeId: string; - status: Pick< - Status, - | 'id' - | 'account' - | 'content' - | 'group_id' - | 'in_reply_to_id' - | 'language' - | 'media_attachments' - | 'mentions' - | 'quote_id' - | 'sensitive' - | 'spoiler_text' - | 'visibility' - >; - poll?: Poll | null; - rawText: string; - explicitAddressing: boolean; - spoilerText?: string; - contentType?: string | false; - withRedraft?: boolean; - draftId?: string; - editorState?: string | null; - redacting?: boolean; -} - -const setComposeToStatus = - ( - status: ComposeSetStatusAction['status'], - poll: Poll | null | undefined, - rawText: string, - spoilerText?: string, - contentType?: string | false, - withRedraft?: boolean, - draftId?: string, - editorState?: string | null, - redacting?: boolean, - ) => - (dispatch: AppDispatch, getState: () => RootState) => { - const { features } = getClient(getState); - const explicitAddressing = - features.createStatusExplicitAddressing && - !useSettingsStore.getState().settings.forceImplicitAddressing; - - dispatch({ - type: COMPOSE_SET_STATUS, - composeId: 'compose-modal', - status, - poll, - rawText, - explicitAddressing, - spoilerText, - contentType, - withRedraft, - draftId, - editorState, - redacting, - }); - }; - -const changeCompose = (composeId: string, text: string) => ({ - type: COMPOSE_CHANGE, - composeId, - text: text, -}); - -interface ComposeReplyAction { - type: typeof COMPOSE_REPLY; - composeId: string; - status: Pick< - Status, - | 'id' - | 'account' - | 'group_id' - | 'list_id' - | 'local_only' - | 'mentions' - | 'spoiler_text' - | 'visibility' - >; - account: Pick; - explicitAddressing: boolean; - preserveSpoilers: boolean; - rebloggedBy?: Pick; - approvalRequired?: boolean; - conversationScope: boolean; -} - -const replyCompose = - ( - status: ComposeReplyAction['status'], - rebloggedBy?: ComposeReplyAction['rebloggedBy'], - approvalRequired?: ComposeReplyAction['approvalRequired'], - ) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const { features } = getClient(getState); - const { forceImplicitAddressing, preserveSpoilers } = useSettingsStore.getState().settings; - const explicitAddressing = features.createStatusExplicitAddressing && !forceImplicitAddressing; - const account = selectOwnAccount(state); - - if (!account) return; - - dispatch({ - type: COMPOSE_REPLY, - composeId: 'compose-modal', - status, - account, - explicitAddressing, - preserveSpoilers, - rebloggedBy, - approvalRequired, - conversationScope: features.createStatusConversationScope, - }); - useModalsStore.getState().actions.openModal('COMPOSE'); - }; - -const cancelReplyCompose = () => ({ - type: COMPOSE_REPLY_CANCEL, - composeId: 'compose-modal', -}); - -interface ComposeQuoteAction { - type: typeof COMPOSE_QUOTE; - composeId: string; - status: Pick; - account: Pick | undefined; - explicitAddressing: boolean; - conversationScope: boolean; - approvalRequired?: boolean; -} - -const quoteCompose = - (status: ComposeQuoteAction['status'], approvalRequired?: boolean) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const { forceImplicitAddressing } = useSettingsStore.getState().settings; - const { createStatusConversationScope, createStatusExplicitAddressing } = - state.auth.client.features; - const explicitAddressing = createStatusExplicitAddressing && !forceImplicitAddressing; - - dispatch({ - type: COMPOSE_QUOTE, - composeId: 'compose-modal', - status, - account: selectOwnAccount(state), - explicitAddressing, - conversationScope: createStatusConversationScope, - approvalRequired, - }); - useModalsStore.getState().actions.openModal('COMPOSE'); - }; - -const cancelQuoteCompose = (composeId: string) => ({ - type: COMPOSE_QUOTE_CANCEL, - composeId, -}); - -const groupComposeModal = (group: Pick) => (dispatch: AppDispatch) => { - const composeId = `group:${group.id}`; - - dispatch(groupCompose(composeId, group.id)); - useModalsStore.getState().actions.openModal('COMPOSE', { composeId }); -}; - -const resetCompose = (composeId = 'compose-modal') => ({ - type: COMPOSE_RESET, - composeId, -}); - -interface ComposeMentionAction { - type: typeof COMPOSE_MENTION; - composeId: string; - account: Pick; -} - -const mentionCompose = - (account: ComposeMentionAction['account']) => - (dispatch: AppDispatch, getState: () => RootState) => { - if (!getState().me) return; - - dispatch({ - type: COMPOSE_MENTION, - composeId: 'compose-modal', - account: account, - }); - useModalsStore.getState().actions.openModal('COMPOSE'); - }; - -interface ComposeDirectAction { - type: typeof COMPOSE_DIRECT; - composeId: string; - account: Pick; -} - -const directCompose = (account: ComposeDirectAction['account']) => (dispatch: AppDispatch) => { - dispatch({ - type: COMPOSE_DIRECT, - composeId: 'compose-modal', - account, - }); - useModalsStore.getState().actions.openModal('COMPOSE'); -}; - -const handleComposeSubmit = ( - dispatch: AppDispatch, - getState: () => RootState, - composeId: string, - data: BaseStatus | ScheduledStatus, - status: string, - edit?: boolean, - redact?: boolean, -) => { - if (!dispatch || !getState) return; - - const state = getState(); - - const accountUrl = selectOwnAccount(state)!.url; - const draftId = getState().compose[composeId].draftId; - - dispatch(submitComposeSuccess(composeId, data)); - - if (draftId) { - cancelDraftStatus(queryClient, accountUrl, draftId); - } - - if (data.scheduled_at === null) { - const linkOptions: LinkOptions = - data.visibility === 'direct' && getClient(getState()).features.conversations - ? { to: '/conversations' } - : { - to: '/@{$username}/posts/$statusId', - params: { username: data.account.acct, statusId: data.id }, - }; - toast.success( - redact ? messages.redactSuccess : edit ? messages.editSuccess : messages.success, - { - actionLabel: messages.view, - actionLinkOptions: linkOptions, - }, - ); - } else { - toast.success(messages.scheduledSuccess, { - actionLabel: messages.view, - actionLinkOptions: { to: '/scheduled_statuses' }, - }); - } -}; - -const needsDescriptions = (state: RootState, composeId: string) => { - const media = state.compose[composeId].mediaAttachments; - const missingDescriptionModal = useSettingsStore.getState().settings.missingDescriptionModal; - - const hasMissing = media.filter((item) => !item.description).length > 0; - - return missingDescriptionModal && hasMissing; -}; - -const validateSchedule = (state: RootState, composeId: string) => { - const scheduledAt = state.compose[composeId]?.scheduledAt; - if (!scheduledAt) return true; - - const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); - - return ( - scheduledAt.getTime() > fiveMinutesFromNow.getTime() || - (state.auth.client.features.scheduledStatusesBackwards && - scheduledAt.getTime() < new Date().getTime()) - ); -}; - -interface SubmitComposeOpts { - force?: boolean; - onSuccess?: () => void; -} - -const submitCompose = - (composeId: string, opts: SubmitComposeOpts = {}, preview = false) => - async (dispatch: AppDispatch, getState: () => RootState) => { - const { force = false, onSuccess } = opts; - - if (!isLoggedIn(getState)) return; - const state = getState(); - - const compose = state.compose[composeId]; - - const status = compose.text; - const media = compose.mediaAttachments; - const editedId = compose.editedId; - let to = compose.to; - const { forceImplicitAddressing } = useSettingsStore.getState().settings; - const explicitAddressing = - state.auth.client.features.createStatusExplicitAddressing && !forceImplicitAddressing; - - if (!preview) { - if (!validateSchedule(state, composeId)) { - toast.error(messages.scheduleError); - return; - } - - if ((!status || !status.length) && media.length === 0) { - return; - } - - if (!force && needsDescriptions(state, composeId)) { - useModalsStore.getState().actions.openModal('MISSING_DESCRIPTION', { - onContinue: () => { - useModalsStore.getState().actions.closeModal('MISSING_DESCRIPTION'); - dispatch(submitCompose(composeId, { force: true, onSuccess })); - }, - }); - return; - } - } - - // https://stackoverflow.com/a/30007882 for domain regex - const mentions: string[] | null = status.match( - /(?:^|\s)@([a-z\d_-]+(?:@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]+)?)/gi, - ); - - if (mentions) { - to = [ - ...new Set([ - ...to, - ...mentions.map((mention) => - mention - .replace(/ /g, '') - .trim() - .slice(1), - ), - ]), - ]; - } - - if (!preview) { - dispatch(submitComposeRequest(composeId)); - - useModalsStore.getState().actions.closeModal('COMPOSE'); - - if (compose.language && !editedId && !preview) { - useSettingsStore.getState().actions.rememberLanguageUse(compose.language); - dispatch(saveSettings()); - } - } - - const idempotencyKey = compose.idempotencyKey; - const contentType = compose.contentType === 'wysiwyg' ? 'text/markdown' : compose.contentType; - - const params: CreateStatusParams = { - status, - in_reply_to_id: compose.inReplyToId ?? undefined, - quote_id: compose.quoteId ?? undefined, - media_ids: media.map((item) => item.id), - sensitive: compose.sensitive, - spoiler_text: compose.spoilerText, - visibility: compose.visibility, - content_type: contentType, - scheduled_at: preview ? undefined : compose.scheduledAt?.toISOString(), - language: compose.language ?? compose.suggestedLanguage ?? undefined, - to: explicitAddressing && to.length ? to : undefined, - local_only: compose.localOnly, - interaction_policy: - (['public', 'unlisted', 'private'].includes(compose.visibility) && - compose.interactionPolicy) || - undefined, - quote_approval_policy: compose.quoteApprovalPolicy ?? undefined, - location_id: compose.location?.origin_id ?? undefined, - }; - - if (compose.editedId) { - // @ts-ignore - params.media_attributes = media.map((item) => { - const focalPoint = (item.type === 'image' || item.type === 'gifv') && item.meta?.focus; - - const focus = focalPoint - ? `${focalPoint.x.toFixed(2)},${focalPoint.y.toFixed(2)}` - : undefined; - - return { - id: item.id, - description: item.description, - focus, - }; - }) as EditStatusParams['media_attributes']; - } - - if (compose.poll) { - params.poll = { - options: compose.poll.options, - expires_in: compose.poll.expires_in, - multiple: compose.poll.multiple, - hide_totals: compose.poll.hide_totals, - options_map: compose.poll.options_map, - }; - } - - if (compose.language && Object.keys(compose.textMap).length) { - params.status_map = compose.textMap; - params.status_map[compose.language] = status; - - if (params.spoiler_text) { - params.spoiler_text_map = compose.spoilerTextMap; - params.spoiler_text_map[compose.language] = compose.spoilerText; - } - - const poll = params.poll; - if (poll?.options_map) { - poll.options.forEach( - (option, index: number) => (poll.options_map![index][compose.language!] = option), - ); - } - } - - if (compose.visibility === 'group' && compose.groupId) { - params.group_id = compose.groupId; - } - - if (preview) { - const data = await getClient(state).statuses.previewStatus(params); - dispatch(previewComposeSuccess(composeId, data)); - onSuccess?.(); - } else { - if (compose.redacting) { - // @ts-ignore - params.overwrite = compose.redactingOverwrite; - } - - try { - const data = await dispatch( - createStatus(params, idempotencyKey, editedId, compose.redacting), - ); - handleComposeSubmit( - dispatch, - getState, - composeId, - data, - status, - !!editedId, - compose.redacting, - ); - onSuccess?.(); - } catch (error) { - dispatch(submitComposeFail(composeId, error)); - } - } - }; - -const submitComposeRequest = (composeId: string) => ({ - type: COMPOSE_SUBMIT_REQUEST, - composeId, -}); - -const submitComposeSuccess = (composeId: string, status: BaseStatus | ScheduledStatus) => ({ - type: COMPOSE_SUBMIT_SUCCESS, - composeId, - status, -}); - -const submitComposeFail = (composeId: string, error: unknown) => ({ - type: COMPOSE_SUBMIT_FAIL, - composeId, - error, -}); - -const previewComposeSuccess = (composeId: string, status: Partial) => ({ - type: COMPOSE_PREVIEW_SUCCESS, - composeId, - status, -}); - -const cancelPreviewCompose = (composeId: string) => ({ - type: COMPOSE_PREVIEW_CANCEL, - composeId, -}); - -const uploadCompose = - (composeId: string, files: FileList, intl: IntlShape) => - (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - const attachmentLimit = getState().instance.configuration.statuses.max_media_attachments; - - const media = getState().compose[composeId]?.mediaAttachments; - const progress = new Array(files.length).fill(0); - let total = Array.from(files).reduce((a, v) => a + v.size, 0); - - const mediaCount = media ? media.length : 0; - - if (files.length + mediaCount > attachmentLimit) { - toast.error(messages.uploadErrorLimit); - return; - } - - dispatch(uploadComposeRequest(composeId)); - - Array.from(files).forEach((f, i) => { - if (mediaCount + i > attachmentLimit - 1) return; - - dispatch( - uploadFile( - f, - intl, - (data) => dispatch(uploadComposeSuccess(composeId, data)), - (error) => dispatch(uploadComposeFail(composeId, error)), - ({ loaded }) => { - progress[i] = loaded; - dispatch( - uploadComposeProgress( - composeId, - progress.reduce((a, v) => a + v, 0), - total, - ), - ); - }, - (value) => (total += value), - ), - ); - }); - }; - -const uploadComposeRequest = (composeId: string) => ({ - type: COMPOSE_UPLOAD_REQUEST, - composeId, -}); - -const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({ - type: COMPOSE_UPLOAD_PROGRESS, - composeId, - loaded, - total, -}); - -const uploadComposeSuccess = (composeId: string, media: MediaAttachment) => ({ - type: COMPOSE_UPLOAD_SUCCESS, - composeId, - media, -}); - -const uploadComposeFail = (composeId: string, error: unknown) => ({ - type: COMPOSE_UPLOAD_FAIL, - composeId, - error, -}); - -const changeUploadCompose = - (composeId: string, mediaId: string, params: UpdateMediaParams) => - (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return Promise.resolve(); - - const compose = getState().compose[composeId]; - - dispatch(changeUploadComposeRequest(composeId)); - - return dispatch(updateMedia(mediaId, params)) - .then((response) => { - dispatch(changeUploadComposeSuccess(composeId, response)); - return response; - }) - .catch((error) => { - if (error.response?.status === 404 && compose.editedId) { - // Editing an existing status. Mastodon doesn't let you update media attachments for already posted statuses. - // Pretend we got a success response. - const previousMedia = compose.mediaAttachments.find((m) => m.id === mediaId); - - if (previousMedia) { - dispatch(changeUploadComposeSuccess(composeId, { ...previousMedia, ...params })); - return; - } - } - dispatch(changeUploadComposeFail(composeId, mediaId, error)); - }); - }; - -const changeUploadComposeRequest = (composeId: string) => ({ - type: COMPOSE_UPLOAD_CHANGE_REQUEST, - composeId, -}); - -const changeUploadComposeSuccess = (composeId: string, media: MediaAttachment) => ({ - type: COMPOSE_UPLOAD_CHANGE_SUCCESS, - composeId, - media, -}); - -const changeUploadComposeFail = (composeId: string, mediaId: string, error: unknown) => ({ - type: COMPOSE_UPLOAD_CHANGE_FAIL, - composeId, - mediaId, - error, -}); - -const undoUploadCompose = (composeId: string, mediaId: string) => ({ - type: COMPOSE_UPLOAD_UNDO, - composeId, - mediaId, -}); - -const groupCompose = (composeId: string, groupId: string) => ({ - type: COMPOSE_GROUP_POST, - composeId, - groupId, -}); - -const clearComposeSuggestions = (composeId: string) => { - if (cancelFetchComposeSuggestions) { - cancelFetchComposeSuggestions.abort(); - cancelFetchComposeSuggestions = new AbortController(); - } - return { - type: COMPOSE_SUGGESTIONS_CLEAR, - composeId, - }; -}; - -const fetchComposeSuggestionsAccounts = throttle( - (dispatch, getState, composeId, token) => { - if (cancelFetchComposeSuggestions) { - cancelFetchComposeSuggestions.abort(); - cancelFetchComposeSuggestions = new AbortController(); - } - - const signal = cancelFetchComposeSuggestions.signal; - - return getClient(getState) - .accounts.searchAccounts(token.slice(1), { resolve: false, limit: 10 }, { signal }) - .then((response) => { - dispatch(importEntities({ accounts: response })); - dispatch(readyComposeSuggestionsAccounts(composeId, token, response)); - }) - .catch((error) => { - if (!signal.aborted) { - toast.showAlertForError(error); - } - }); - }, - 200, - { leading: true, trailing: true }, -); - -const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, composeId: string, token: string) => { - const customEmojis = queryClient.getQueryData>(['instance', 'customEmojis']); - const results = emojiSearch(token.replace(':', ''), { maxResults: 10 }, customEmojis); - - dispatch(readyComposeSuggestionsEmojis(composeId, token, results)); -}; - -const fetchComposeSuggestionsTags = ( - dispatch: AppDispatch, - getState: () => RootState, - composeId: string, - token: string, -) => { - const signal = cancelFetchComposeSuggestions.signal; - - if (cancelFetchComposeSuggestions) { - cancelFetchComposeSuggestions.abort(); - cancelFetchComposeSuggestions = new AbortController(); - } - - const state = getState(); - - const { trends } = state.auth.client.features; - - if (trends) { - const currentTrends = queryClient.getQueryData>(['trends', 'tags']) ?? []; - - return dispatch(updateSuggestionTags(composeId, token, currentTrends)); - } - - return getClient(state) - .search.search(token.slice(1), { limit: 10, type: 'hashtags' }, { signal }) - .then((response) => { - dispatch(updateSuggestionTags(composeId, token, response.hashtags)); - }) - .catch((error) => { - if (!signal.aborted) { - toast.showAlertForError(error); - } - }); -}; - -const fetchComposeSuggestions = - (composeId: string, token: string) => (dispatch: AppDispatch, getState: () => RootState) => { - switch (token[0]) { - case ':': - fetchComposeSuggestionsEmojis(dispatch, composeId, token); - break; - case '#': - fetchComposeSuggestionsTags(dispatch, getState, composeId, token); - break; - default: - fetchComposeSuggestionsAccounts(dispatch, getState, composeId, token); - break; - } - }; - -interface ComposeSuggestionsReadyAction { - type: typeof COMPOSE_SUGGESTIONS_READY; - composeId: string; - token: string; - emojis?: Emoji[]; - accounts?: Account[]; -} - -const readyComposeSuggestionsEmojis = (composeId: string, token: string, emojis: Emoji[]) => ({ - type: COMPOSE_SUGGESTIONS_READY, - composeId, - token, - emojis, -}); - -const readyComposeSuggestionsAccounts = ( - composeId: string, - token: string, - accounts: Account[], -) => ({ - type: COMPOSE_SUGGESTIONS_READY, - composeId, - token, - accounts, -}); - -interface ComposeSuggestionSelectAction { - type: typeof COMPOSE_SUGGESTION_SELECT; - composeId: string; - position: number; - token: string | null; - completion: string; - path: ['spoiler_text'] | ['poll', 'options', number]; -} - -const selectComposeSuggestion = - ( - composeId: string, - position: number, - token: string | null, - suggestion: AutoSuggestion, - path: ComposeSuggestionSelectAction['path'], - ) => - (dispatch: AppDispatch, getState: () => RootState) => { - let completion = '', - startPosition = position; - - if (typeof suggestion === 'object' && 'id' in suggestion) { - completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons; - startPosition = position - 1; - - useSettingsStore.getState().actions.rememberEmojiUse(suggestion); - dispatch(saveSettings()); - } else if (typeof suggestion === 'string' && suggestion[0] === '#') { - completion = suggestion; - startPosition = position - 1; - } else if (typeof suggestion === 'string') { - completion = selectAccount(suggestion)!.acct; - startPosition = position; - } - - dispatch({ - type: COMPOSE_SUGGESTION_SELECT, - composeId, - position: startPosition, - token, - completion, - path, - }); - }; - -const updateSuggestionTags = (composeId: string, token: string, tags: Array) => ({ - type: COMPOSE_SUGGESTION_TAGS_UPDATE, - composeId, - token, - tags, -}); - -const changeComposeSpoilerness = (composeId: string) => ({ - type: COMPOSE_SPOILERNESS_CHANGE, - composeId, -}); - -const changeComposeContentType = (composeId: string, value: string) => ({ - type: COMPOSE_TYPE_CHANGE, - composeId, - value, -}); - -const changeComposeSpoilerText = (composeId: string, text: string) => ({ - type: COMPOSE_SPOILER_TEXT_CHANGE, - composeId, - text, -}); - -const changeComposeVisibility = (composeId: string, value: string) => ({ - type: COMPOSE_VISIBILITY_CHANGE, - composeId, - value, -}); - -const changeComposeLanguage = (composeId: string, value: Language | null) => ({ - type: COMPOSE_LANGUAGE_CHANGE, - composeId, - value, -}); - -const changeComposeModifiedLanguage = (composeId: string, value: Language | null) => ({ - type: COMPOSE_MODIFIED_LANGUAGE_CHANGE, - composeId, - value, -}); - -const addComposeLanguage = (composeId: string, value: Language) => ({ - type: COMPOSE_LANGUAGE_ADD, - composeId, - value, -}); - -const deleteComposeLanguage = (composeId: string, value: Language) => ({ - type: COMPOSE_LANGUAGE_DELETE, - composeId, - value, -}); - -const addPoll = (composeId: string) => ({ - type: COMPOSE_POLL_ADD, - composeId, -}); - -const removePoll = (composeId: string) => ({ - type: COMPOSE_POLL_REMOVE, - composeId, -}); - -const addSchedule = (composeId: string) => ({ - type: COMPOSE_SCHEDULE_ADD, - composeId, -}); - -const setSchedule = (composeId: string, date: Date) => ({ - type: COMPOSE_SCHEDULE_SET, - composeId, - date: date, -}); - -const removeSchedule = (composeId: string) => ({ - type: COMPOSE_SCHEDULE_REMOVE, - composeId, -}); - -const addPollOption = (composeId: string, title: string) => ({ - type: COMPOSE_POLL_OPTION_ADD, - composeId, - title, -}); - -const changePollOption = (composeId: string, index: number, title: string) => ({ - type: COMPOSE_POLL_OPTION_CHANGE, - composeId, - index, - title, -}); - -const removePollOption = (composeId: string, index: number) => ({ - type: COMPOSE_POLL_OPTION_REMOVE, - composeId, - index, -}); - -const changePollSettings = (composeId: string, expiresIn?: number, isMultiple?: boolean) => ({ - type: COMPOSE_POLL_SETTINGS_CHANGE, - composeId, - expiresIn, - isMultiple, -}); - -const openComposeWithText = - (composeId: string, text = '') => - (dispatch: AppDispatch) => { - dispatch(resetCompose(composeId)); - useModalsStore.getState().actions.openModal('COMPOSE'); - dispatch(changeCompose(composeId, text)); - }; - -interface ComposeAddToMentionsAction { - type: typeof COMPOSE_ADD_TO_MENTIONS; - composeId: string; - account: string; -} - -const addToMentions = (composeId: string, accountId: string) => (dispatch: AppDispatch) => { - const account = selectAccount(accountId); - if (!account) return; - - return dispatch({ - type: COMPOSE_ADD_TO_MENTIONS, - composeId, - account: account.acct, - }); -}; - -interface ComposeRemoveFromMentionsAction { - type: typeof COMPOSE_REMOVE_FROM_MENTIONS; - composeId: string; - account: string; -} - -const removeFromMentions = (composeId: string, accountId: string) => (dispatch: AppDispatch) => { - const account = selectAccount(accountId); - if (!account) return; - - return dispatch({ - type: COMPOSE_REMOVE_FROM_MENTIONS, - composeId, - account: account.acct, - }); -}; - -interface ComposeEventReplyAction { - type: typeof COMPOSE_EVENT_REPLY; - composeId: string; - status: Pick; - account: Pick; - explicitAddressing: boolean; -} - -const eventDiscussionCompose = - (composeId: string, status: ComposeEventReplyAction['status']) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const { forceImplicitAddressing } = useSettingsStore.getState().settings; - const explicitAddressing = - state.auth.client.features.createStatusExplicitAddressing && !forceImplicitAddressing; - - return dispatch({ - type: COMPOSE_EVENT_REPLY, - composeId, - status, - account: selectOwnAccount(state), - explicitAddressing, - }); - }; - -const setEditorState = ( - composeId: string, - editorState: EditorState | string | null, - text?: string, -) => ({ - type: COMPOSE_EDITOR_STATE_SET, - composeId, - editorState, - text, -}); - -const changeMediaOrder = (composeId: string, a: string, b: string) => ({ - type: COMPOSE_CHANGE_MEDIA_ORDER, - composeId, - a, - b, -}); - -const addSuggestedQuote = (composeId: string, quoteId: string) => ({ - type: COMPOSE_ADD_SUGGESTED_QUOTE, - composeId, - quoteId, -}); - -const addSuggestedLanguage = (composeId: string, language: string) => ({ - type: COMPOSE_ADD_SUGGESTED_LANGUAGE, - composeId, - language, -}); - -const changeComposeFederated = (composeId: string) => ({ - type: COMPOSE_FEDERATED_CHANGE, - composeId, -}); - -const changeComposeInteractionPolicyOption = ( - composeId: string, - policy: Policy, - rule: Rule, - value: Scope[], - initial: InteractionPolicy, -) => ({ - type: COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, - composeId, - policy, - rule, - value, - initial, -}); - -const changeComposeQuotePolicyOption = ( - composeId: string, - value: CreateStatusParams['quote_approval_policy'], -) => ({ - type: COMPOSE_QUOTE_POLICY_OPTION_CHANGE, - composeId, - value, -}); - -const suggestClearLink = (composeId: string, suggestion: ClearLinkSuggestion | null) => ({ - type: COMPOSE_CLEAR_LINK_SUGGESTION_CREATE, - composeId, - suggestion, -}); - -const ignoreClearLinkSuggestion = (composeId: string, key: string) => ({ - type: COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE, - composeId, - key, -}); - -const suggestHashtagCasing = (composeId: string, suggestion: string | null) => ({ - type: COMPOSE_HASHTAG_CASING_SUGGESTION_SET, - composeId, - suggestion, -}); - -const ignoreHashtagCasingSuggestion = (composeId: string) => ({ - type: COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE, - composeId, -}); - -const changeComposeRedactingOverwrite = (composeId: string, value: boolean) => ({ - type: COMPOSE_REDACTING_OVERWRITE_CHANGE, - composeId, - value, -}); - -const setComposeLocation = (composeId: string, location: Location | null) => ({ - type: COMPOSE_SET_LOCATION, - composeId, - location, -}); - -const setComposeShowLocationPicker = (composeId: string, showLocation: boolean) => ({ - type: COMPOSE_SET_SHOW_LOCATION_PICKER, - composeId, - showLocation, -}); - -type ComposeAction = - | ComposeSetStatusAction - | ReturnType - | ComposeReplyAction - | ReturnType - | ComposeQuoteAction - | ReturnType - | ReturnType - | ComposeMentionAction - | ComposeDirectAction - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ComposeSuggestionsReadyAction - | ComposeSuggestionSelectAction - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ComposeAddToMentionsAction - | ComposeRemoveFromMentionsAction - | ComposeEventReplyAction - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType; - -export { - COMPOSE_CHANGE, - COMPOSE_SUBMIT_REQUEST, - COMPOSE_SUBMIT_SUCCESS, - COMPOSE_SUBMIT_FAIL, - COMPOSE_PREVIEW_SUCCESS, - COMPOSE_PREVIEW_CANCEL, - COMPOSE_REPLY, - COMPOSE_REPLY_CANCEL, - COMPOSE_EVENT_REPLY, - COMPOSE_QUOTE, - COMPOSE_QUOTE_CANCEL, - COMPOSE_DIRECT, - COMPOSE_MENTION, - COMPOSE_RESET, - COMPOSE_UPLOAD_REQUEST, - COMPOSE_UPLOAD_SUCCESS, - COMPOSE_UPLOAD_FAIL, - COMPOSE_UPLOAD_PROGRESS, - COMPOSE_UPLOAD_UNDO, - COMPOSE_GROUP_POST, - COMPOSE_SUGGESTIONS_CLEAR, - COMPOSE_SUGGESTIONS_READY, - COMPOSE_SUGGESTION_SELECT, - COMPOSE_SUGGESTION_TAGS_UPDATE, - COMPOSE_SPOILERNESS_CHANGE, - COMPOSE_TYPE_CHANGE, - COMPOSE_SPOILER_TEXT_CHANGE, - COMPOSE_VISIBILITY_CHANGE, - COMPOSE_LANGUAGE_CHANGE, - COMPOSE_MODIFIED_LANGUAGE_CHANGE, - COMPOSE_LANGUAGE_ADD, - COMPOSE_LANGUAGE_DELETE, - COMPOSE_UPLOAD_CHANGE_REQUEST, - COMPOSE_UPLOAD_CHANGE_SUCCESS, - COMPOSE_UPLOAD_CHANGE_FAIL, - COMPOSE_POLL_ADD, - COMPOSE_POLL_REMOVE, - COMPOSE_POLL_OPTION_ADD, - COMPOSE_POLL_OPTION_CHANGE, - COMPOSE_POLL_OPTION_REMOVE, - COMPOSE_POLL_SETTINGS_CHANGE, - COMPOSE_SCHEDULE_ADD, - COMPOSE_SCHEDULE_SET, - COMPOSE_SCHEDULE_REMOVE, - COMPOSE_ADD_TO_MENTIONS, - COMPOSE_REMOVE_FROM_MENTIONS, - COMPOSE_SET_STATUS, - COMPOSE_EDITOR_STATE_SET, - COMPOSE_CHANGE_MEDIA_ORDER, - COMPOSE_ADD_SUGGESTED_QUOTE, - COMPOSE_ADD_SUGGESTED_LANGUAGE, - COMPOSE_FEDERATED_CHANGE, - COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, - COMPOSE_QUOTE_POLICY_OPTION_CHANGE, - COMPOSE_CLEAR_LINK_SUGGESTION_CREATE, - COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE, - COMPOSE_HASHTAG_CASING_SUGGESTION_SET, - COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE, - COMPOSE_REDACTING_OVERWRITE_CHANGE, - COMPOSE_SET_LOCATION, - COMPOSE_SET_SHOW_LOCATION_PICKER, - setComposeToStatus, - replyCompose, - cancelReplyCompose, - quoteCompose, - cancelQuoteCompose, - resetCompose, - mentionCompose, - directCompose, - submitCompose, - uploadFile, - uploadCompose, - changeUploadCompose, - uploadComposeSuccess, - undoUploadCompose, - groupCompose, - groupComposeModal, - clearComposeSuggestions, - fetchComposeSuggestions, - selectComposeSuggestion, - changeComposeSpoilerness, - changeComposeContentType, - changeComposeSpoilerText, - changeComposeVisibility, - changeComposeLanguage, - changeComposeModifiedLanguage, - addComposeLanguage, - deleteComposeLanguage, - addPoll, - removePoll, - addSchedule, - setSchedule, - removeSchedule, - addPollOption, - changePollOption, - removePollOption, - changePollSettings, - openComposeWithText, - addToMentions, - removeFromMentions, - eventDiscussionCompose, - setEditorState, - changeMediaOrder, - addSuggestedQuote, - addSuggestedLanguage, - changeComposeFederated, - changeComposeInteractionPolicyOption, - changeComposeQuotePolicyOption, - suggestClearLink, - ignoreClearLinkSuggestion, - cancelPreviewCompose, - suggestHashtagCasing, - ignoreHashtagCasingSuggestion, - changeComposeRedactingOverwrite, - setComposeLocation, - setComposeShowLocationPicker, - type ComposeReplyAction, - type ComposeSuggestionSelectAction, - type ComposeAction, -}; diff --git a/packages/pl-fe/src/actions/events.ts b/packages/pl-fe/src/actions/events.ts index 103deffb5..eadb820a1 100644 --- a/packages/pl-fe/src/actions/events.ts +++ b/packages/pl-fe/src/actions/events.ts @@ -1,6 +1,7 @@ import { defineMessages } from 'react-intl'; import { getClient } from '@/api'; +import { useComposeStore } from '@/stores/compose'; import toast from '@/toast'; import { importEntities } from './importer'; @@ -19,10 +20,6 @@ const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL' as const; const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST' as const; const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL' as const; -const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL' as const; - -const EVENT_FORM_SET = 'EVENT_FORM_SET' as const; - const messages = defineMessages({ exceededImageSizeLimit: { id: 'upload_error.image_size_limit', @@ -123,15 +120,12 @@ interface LeaveEventFail { const fetchEventIcs = (statusId: string) => (dispatch: AppDispatch, getState: () => RootState) => getClient(getState).events.getEventIcs(statusId); -const cancelEventCompose = () => ({ - type: EVENT_COMPOSE_CANCEL, -}); - -interface EventFormSetAction { - type: typeof EVENT_FORM_SET; - composeId: string; - text: string; -} +// todo: move to compose store? +const cancelEventCompose = () => { + useComposeStore.getState().actions.updateCompose('event-compose-modal', (draft) => { + draft.text = ''; + }); +}; const initEventEdit = (statusId: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: STATUS_FETCH_SOURCE_REQUEST, statusId }); @@ -140,11 +134,11 @@ const initEventEdit = (statusId: string) => (dispatch: AppDispatch, getState: () .statuses.getStatusSource(statusId) .then((response) => { dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS, statusId }); - dispatch({ - type: EVENT_FORM_SET, - composeId: `compose-event-modal-${statusId}`, - text: response.text, - }); + useComposeStore + .getState() + .actions.updateCompose(`compose-event-modal-${statusId}`, (draft) => { + draft.text = response.text; + }); return response; }) .catch((error) => { @@ -152,21 +146,13 @@ const initEventEdit = (statusId: string) => (dispatch: AppDispatch, getState: () }); }; -type EventsAction = - | JoinEventRequest - | JoinEventFail - | LeaveEventRequest - | LeaveEventFail - | ReturnType - | EventFormSetAction; +type EventsAction = JoinEventRequest | JoinEventFail | LeaveEventRequest | LeaveEventFail; export { EVENT_JOIN_REQUEST, EVENT_JOIN_FAIL, EVENT_LEAVE_REQUEST, EVENT_LEAVE_FAIL, - EVENT_COMPOSE_CANCEL, - EVENT_FORM_SET, submitEvent, fetchEventIcs, cancelEventCompose, diff --git a/packages/pl-fe/src/actions/instance.ts b/packages/pl-fe/src/actions/instance.ts index 6e934c53c..e90e4ba17 100644 --- a/packages/pl-fe/src/actions/instance.ts +++ b/packages/pl-fe/src/actions/instance.ts @@ -1,3 +1,4 @@ +import { useComposeStore } from '@/stores/compose'; import { getAuthUserUrl, getMeUrl } from '@/utils/auth'; import { getClient, staticFetch } from '../api'; @@ -35,6 +36,7 @@ const fetchInstance = () => async (dispatch: AppDispatch, getState: () => RootSt const instance = await getClient(getState).instance.getInstance(); dispatch({ type: INSTANCE_FETCH_SUCCESS, instance }); + useComposeStore.getState().actions.importDefaultContentType(instance); } catch (error) { dispatch({ type: INSTANCE_FETCH_FAIL, error }); } diff --git a/packages/pl-fe/src/actions/me.ts b/packages/pl-fe/src/actions/me.ts index ee58776f0..27db72148 100644 --- a/packages/pl-fe/src/actions/me.ts +++ b/packages/pl-fe/src/actions/me.ts @@ -1,6 +1,7 @@ import { selectAccount } from '@/queries/accounts/selectors'; import { setSentryAccount } from '@/sentry'; import KVStore from '@/storage/kv-store'; +import { useComposeStore } from '@/stores/compose'; import { useSettingsStore } from '@/stores/settings'; import { getAuthUserId, getAuthUserUrl } from '@/utils/auth'; @@ -89,6 +90,7 @@ const fetchMeSuccess = (account: CredentialAccount) => { setSentryAccount(account); useSettingsStore.getState().actions.loadUserSettings(account.settings_store?.[FE_NAME]); + useComposeStore.getState().actions.importDefaultSettings(account); return { type: ME_FETCH_SUCCESS, @@ -109,6 +111,7 @@ interface MePatchSuccessAction { const patchMeSuccess = (me: CredentialAccount) => (dispatch: AppDispatch) => { dispatch(importEntities({ accounts: [me] })); + useComposeStore.getState().actions.importDefaultSettings(me); dispatch({ type: ME_PATCH_SUCCESS, me, diff --git a/packages/pl-fe/src/actions/statuses.ts b/packages/pl-fe/src/actions/statuses.ts index 6e42df438..7714877ea 100644 --- a/packages/pl-fe/src/actions/statuses.ts +++ b/packages/pl-fe/src/actions/statuses.ts @@ -1,5 +1,6 @@ import { queryClient } from '@/queries/client'; import { scheduledStatusesQueryOptions } from '@/queries/statuses/scheduled-statuses'; +import { useComposeStore } from '@/stores/compose'; import { useContextStore } from '@/stores/contexts'; import { useModalsStore } from '@/stores/modals'; import { usePendingStatusesStore } from '@/stores/pending-statuses'; @@ -9,19 +10,12 @@ import { shouldHaveCard } from '@/utils/status'; import { getClient } from '../api'; -import { setComposeToStatus } from './compose'; import { importEntities } from './importer'; import { deleteFromTimelines } from './timelines'; import type { Status } from '@/normalizers/status'; import type { AppDispatch, RootState } from '@/store'; -import type { - CreateStatusParams, - Status as BaseStatus, - ScheduledStatus, - StatusSource, - Poll, -} from 'pl-api'; +import type { CreateStatusParams, Status as BaseStatus, ScheduledStatus, Poll } from 'pl-api'; import type { IntlShape } from 'react-intl'; const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST' as const; @@ -157,16 +151,7 @@ const editStatus = (statusId: string) => (dispatch: AppDispatch, getState: () => .statuses.getStatusSource(statusId) .then((response) => { dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS }); - dispatch( - setComposeToStatus( - status, - poll, - response.text, - response.spoiler_text, - response.content_type, - false, - ), - ); + useComposeStore.getState().actions.setComposeToStatus(status, poll, response); useModalsStore.getState().actions.openModal('COMPOSE'); }) .catch((error) => { @@ -192,7 +177,7 @@ const fetchStatus = }; const deleteStatus = - (statusId: string, groupId?: string, withRedraft = false) => + (statusId: string, withRedraft = false) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return null; @@ -205,27 +190,15 @@ const deleteStatus = dispatch({ type: STATUS_DELETE_REQUEST, params: status }); - return ( - groupId - ? getClient(state).experimental.groups.deleteGroupStatus(statusId, groupId) - : getClient(state).statuses.deleteStatus(statusId) - ) - .then((response) => { + return getClient(state) + .statuses.deleteStatus(statusId) + .then((source) => { usePendingStatusesStore.getState().actions.deleteStatus(statusId); dispatch({ type: STATUS_DELETE_SUCCESS, statusId }); dispatch(deleteFromTimelines(statusId)); if (withRedraft) { - dispatch( - setComposeToStatus( - status, - poll, - response.text ?? '', - response.spoiler_text, - (response as StatusSource).content_type, - withRedraft, - ), - ); + useComposeStore.getState().actions.setComposeToStatus(status, poll, source, withRedraft); useModalsStore.getState().actions.openModal('COMPOSE'); } }) @@ -234,6 +207,27 @@ const deleteStatus = }); }; +const deleteStatusFromGroup = + (statusId: string, groupId: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const state = getState(); + const status = state.statuses[statusId]; + + dispatch({ type: STATUS_DELETE_REQUEST, params: status }); + + return getClient(state) + .experimental.groups.deleteGroupStatus(statusId, groupId) + .then((response) => { + usePendingStatusesStore.getState().actions.deleteStatus(statusId); + dispatch({ type: STATUS_DELETE_SUCCESS, statusId }); + dispatch(deleteFromTimelines(statusId)); + }) + .catch((error) => { + dispatch({ type: STATUS_DELETE_FAIL, params: status, error }); + }); + }; + const updateStatus = (status: BaseStatus) => (dispatch: AppDispatch) => { dispatch(importEntities({ statuses: [status] })); }; @@ -404,6 +398,7 @@ export { editStatus, fetchStatus, deleteStatus, + deleteStatusFromGroup, updateStatus, fetchContext, fetchStatusWithContext, diff --git a/packages/pl-fe/src/actions/timelines.ts b/packages/pl-fe/src/actions/timelines.ts index 04de2ced3..fd9eff940 100644 --- a/packages/pl-fe/src/actions/timelines.ts +++ b/packages/pl-fe/src/actions/timelines.ts @@ -1,4 +1,5 @@ import { getLocale } from '@/actions/settings'; +import { useComposeStore } from '@/stores/compose'; import { useContextStore } from '@/stores/contexts'; import { usePendingStatusesStore } from '@/stores/pending-statuses'; import { useSettingsStore } from '@/stores/settings'; @@ -133,6 +134,7 @@ const deleteFromTimelines = const reblogOf = getState().statuses[statusId]?.reblog_id ?? null; useContextStore.getState().actions.deleteStatuses([statusId]); + useComposeStore.getState().actions.handleTimelineDelete(statusId); dispatch({ type: TIMELINE_DELETE, diff --git a/packages/pl-fe/src/components/autosuggest-input.tsx b/packages/pl-fe/src/components/autosuggest-input.tsx index 929532bce..9fff5994f 100644 --- a/packages/pl-fe/src/components/autosuggest-input.tsx +++ b/packages/pl-fe/src/components/autosuggest-input.tsx @@ -300,4 +300,4 @@ const AutosuggestInput: React.FC = ({ ]; }; -export { type AutoSuggestion, type IAutosuggestInput, AutosuggestInput as default }; +export { type AutoSuggestion, AutosuggestInput as default }; diff --git a/packages/pl-fe/src/components/modal-root.tsx b/packages/pl-fe/src/components/modal-root.tsx index b65d2262b..f022ab1f0 100644 --- a/packages/pl-fe/src/components/modal-root.tsx +++ b/packages/pl-fe/src/components/modal-root.tsx @@ -4,14 +4,13 @@ import range from 'lodash/range'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; -import { cancelReplyCompose } from '@/actions/compose'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { usePrevious } from '@/hooks/use-previous'; import { usePersistDraftStatus } from '@/queries/statuses/use-draft-statuses'; +import { useComposeStore } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import type { ModalType } from '@/features/ui/components/modal-root'; -import type { Compose } from '@/reducers/compose'; +import type { Compose } from '@/stores/compose'; const messages = defineMessages({ confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' }, @@ -40,8 +39,6 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type, mo const intl = useIntl(); const router = useRouter(); const navigate = useNavigate(); - const dispatch = useAppDispatch(); - const persistDraftStatus = usePersistDraftStatus(); const { openModal } = useModalsActions(); @@ -64,65 +61,64 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type, mo }; const handleOnClose = () => { - dispatch((_, getState) => { - const compose = getState().compose['compose-modal']; - const hasComposeContent = checkComposeContent(compose); + const { actions } = useComposeStore.getState(); + const compose = actions.getCompose('compose-modal'); + const hasComposeContent = checkComposeContent(compose); - if (hasComposeContent && type === 'COMPOSE') { - const isEditing = compose.editedId !== null; - openModal('CONFIRM', { - heading: isEditing ? ( - - ) : compose.draftId ? ( - - ) : ( - - ), - message: isEditing ? ( - - ) : compose.draftId ? ( - - ) : ( - - ), - confirm: intl.formatMessage(messages.confirm), - onConfirm: () => { - onClose('COMPOSE'); - dispatch(cancelReplyCompose()); - }, - onCancel: () => { - onClose('CONFIRM'); - }, - secondary: intl.formatMessage(messages.saveDraft), - onSecondary: isEditing - ? undefined - : () => { - persistDraftStatus('compose-modal'); - onClose('COMPOSE'); - dispatch(cancelReplyCompose()); - }, - }); - } else if (hasComposeContent && type === 'CONFIRM') { - onClose('CONFIRM'); - } else { - onClose(); - } - }); + if (hasComposeContent && type === 'COMPOSE') { + const isEditing = compose.editedId !== null; + openModal('CONFIRM', { + heading: isEditing ? ( + + ) : compose.draftId ? ( + + ) : ( + + ), + message: isEditing ? ( + + ) : compose.draftId ? ( + + ) : ( + + ), + confirm: intl.formatMessage(messages.confirm), + onConfirm: () => { + onClose('COMPOSE'); + actions.resetCompose('compose-modal'); + }, + onCancel: () => { + onClose('CONFIRM'); + }, + secondary: intl.formatMessage(messages.saveDraft), + onSecondary: isEditing + ? undefined + : () => { + persistDraftStatus('compose-modal'); + onClose('COMPOSE'); + actions.resetCompose('compose-modal'); + }, + }); + } else if (hasComposeContent && type === 'CONFIRM') { + onClose('CONFIRM'); + } else { + onClose(); + } }; const handleKeyDown = useCallback((e: KeyboardEvent) => { diff --git a/packages/pl-fe/src/components/status-action-bar.tsx b/packages/pl-fe/src/components/status-action-bar.tsx index 753a71589..c26514d1b 100644 --- a/packages/pl-fe/src/components/status-action-bar.tsx +++ b/packages/pl-fe/src/components/status-action-bar.tsx @@ -4,12 +4,16 @@ import React, { useCallback, useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { redactStatus } from '@/actions/admin'; -import { directCompose, mentionCompose, quoteCompose, replyCompose } from '@/actions/compose'; import { emojiReact, unEmojiReact } from '@/actions/emoji-reacts'; import { deleteStatusModal, toggleStatusSensitivityModal } from '@/actions/moderation'; import { initReport, ReportableEntities } from '@/actions/reports'; import { changeSetting } from '@/actions/settings'; -import { deleteStatus, editStatus, toggleMuteStatus } from '@/actions/statuses'; +import { + deleteStatus, + deleteStatusFromGroup, + editStatus, + toggleMuteStatus, +} from '@/actions/statuses'; import DropdownMenu from '@/components/dropdown-menu'; import StatusActionButton from '@/components/status-action-button'; import EmojiPickerDropdown from '@/features/emoji/containers/emoji-picker-dropdown-container'; @@ -40,6 +44,7 @@ import { useUnpinStatus, useUnreblogStatus, } from '@/queries/statuses/use-status-interactions'; +import { useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import { useSettings } from '@/stores/settings'; import { useStatusMeta, useStatusMetaActions } from '@/stores/status-meta'; @@ -332,7 +337,7 @@ const ReplyButton: React.FC = ({ onOpenUnauthorizedModal, rebloggedBy, }) => { - const dispatch = useAppDispatch(); + const { replyCompose } = useComposeActions(); const intl = useIntl(); const canReply = useCanInteract(status, 'can_reply'); @@ -354,7 +359,7 @@ const ReplyButton: React.FC = ({ const handleReplyClick: React.MouseEventHandler = (e) => { if (me) { - dispatch(replyCompose(status, rebloggedBy, canReply.approvalRequired ?? false)); + replyCompose(status, rebloggedBy, canReply.approvalRequired ?? false); } else { onOpenUnauthorizedModal('REPLY'); } @@ -405,7 +410,7 @@ const ReblogButton: React.FC = ({ onOpenUnauthorizedModal, publicStatus, }) => { - const dispatch = useAppDispatch(); + const { quoteCompose } = useComposeActions(); const features = useFeatures(); const intl = useIntl(); @@ -486,7 +491,7 @@ const ReblogButton: React.FC = ({ const handleQuoteClick: React.EventHandler = (e) => { if (me) { - dispatch(quoteCompose(status, canQuote.approvalRequired || false)); + quoteCompose(status, canQuote.approvalRequired || false); } else { onOpenUnauthorizedModal('REBLOG'); } @@ -723,6 +728,7 @@ const MenuButton: React.FC = ({ const intl = useIntl(); const navigate = useNavigate(); const dispatch = useAppDispatch(); + const { mentionCompose, directCompose } = useComposeActions(); const match = useMatch({ from: layouts.group.id, shouldThrow: false }); const { boostModal } = useSettings(); const client = useClient(); @@ -788,7 +794,7 @@ const MenuButton: React.FC = ({ const doDeleteStatus = (withRedraft = false) => { if (!deleteModal) { - dispatch(deleteStatus(status.id, undefined, withRedraft)); + dispatch(deleteStatus(status.id, withRedraft)); } else { openModal('CONFIRM', { heading: intl.formatMessage( @@ -800,7 +806,7 @@ const MenuButton: React.FC = ({ confirm: intl.formatMessage( withRedraft ? messages.redraftConfirm : messages.deleteConfirm, ), - onConfirm: () => dispatch(deleteStatus(status.id, undefined, withRedraft)), + onConfirm: () => dispatch(deleteStatus(status.id, withRedraft)), }); } }; @@ -840,11 +846,11 @@ const MenuButton: React.FC = ({ }; const handleMentionClick: React.EventHandler = (e) => { - dispatch(mentionCompose(status.account)); + mentionCompose(status.account); }; const handleDirectClick: React.EventHandler = (e) => { - dispatch(directCompose(status.account)); + directCompose(status.account); }; const handleChatClick: React.EventHandler = (e) => { @@ -936,7 +942,7 @@ const MenuButton: React.FC = ({ }), confirm: intl.formatMessage(messages.deleteConfirm), onConfirm: () => { - dispatch(deleteStatus(status.id, group?.id)); + dispatch(deleteStatusFromGroup(status.id, group!.id)); }, }); }; diff --git a/packages/pl-fe/src/components/status.tsx b/packages/pl-fe/src/components/status.tsx index 0cef982c2..47e514140 100644 --- a/packages/pl-fe/src/components/status.tsx +++ b/packages/pl-fe/src/components/status.tsx @@ -3,7 +3,6 @@ import clsx from 'clsx'; import React, { useEffect, useMemo, useRef } from 'react'; import { defineMessages, useIntl, FormattedList, FormattedMessage } from 'react-intl'; -import { mentionCompose, replyCompose } from '@/actions/compose'; import { unfilterStatus } from '@/actions/statuses'; import Card from '@/components/ui/card'; import Icon from '@/components/ui/icon'; @@ -23,6 +22,7 @@ import { useUnreblogStatus, } from '@/queries/statuses/use-status-interactions'; import { makeGetStatus, type SelectedStatus } from '@/selectors'; +import { useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import { useSettings } from '@/stores/settings'; import { useStatusMetaActions } from '@/stores/status-meta'; @@ -197,6 +197,7 @@ const Status: React.FC = (props) => { const { toggleStatusesMediaHidden } = useStatusMetaActions(); const { openModal } = useModalsActions(); + const { replyCompose, mentionCompose } = useComposeActions(); const { boostModal } = useSettings(); const didShowCard = useRef(false); const node = useRef(null); @@ -276,7 +277,7 @@ const Status: React.FC = (props) => { if (status.rss_feed) return; e?.preventDefault(); - dispatch(replyCompose(actualStatus, status.reblog_id ? status.account : undefined)); + replyCompose(actualStatus, status.reblog_id ? status.account : undefined); }; const handleHotkeyFavourite = (e?: KeyboardEvent) => { @@ -305,7 +306,7 @@ const Status: React.FC = (props) => { if (status.rss_feed) return; e?.preventDefault(); - dispatch(mentionCompose(actualStatus.account)); + mentionCompose(actualStatus.account); }; const handleHotkeyOpen = () => { diff --git a/packages/pl-fe/src/components/thumb-navigation.tsx b/packages/pl-fe/src/components/thumb-navigation.tsx index 4c93cd575..86c7b2357 100644 --- a/packages/pl-fe/src/components/thumb-navigation.tsx +++ b/packages/pl-fe/src/components/thumb-navigation.tsx @@ -3,16 +3,15 @@ import { useMatch } from '@tanstack/react-router'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { groupComposeModal } from '@/actions/compose'; import ThumbNavigationLink from '@/components/thumb-navigation-link'; import Icon from '@/components/ui/icon'; import { useStatContext } from '@/contexts/stat-context'; import { layouts } from '@/features/ui/router'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; import { useFeatures } from '@/hooks/use-features'; import { useOwnAccount } from '@/hooks/use-own-account'; import { useNotificationsUnreadCount } from '@/queries/notifications/use-notifications'; +import { useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import { useIsSidebarOpen, useUiStoreActions } from '@/stores/ui'; import { isStandalone } from '@/utils/state'; @@ -31,7 +30,6 @@ const messages = defineMessages({ const ThumbNavigation: React.FC = React.memo((): React.JSX.Element => { const intl = useIntl(); - const dispatch = useAppDispatch(); const { data: account } = useOwnAccount(); const features = useFeatures(); const queryClient = useQueryClient(); @@ -41,6 +39,7 @@ const ThumbNavigation: React.FC = React.memo((): React.JSX.Element => { const isSidebarOpen = useIsSidebarOpen(); const { openSidebar, closeSidebar } = useUiStoreActions(); const { openModal } = useModalsActions(); + const { groupComposeModal } = useComposeActions(); const { unreadChatsCount } = useStatContext(); const standalone = useAppSelector(isStandalone); @@ -48,10 +47,8 @@ const ThumbNavigation: React.FC = React.memo((): React.JSX.Element => { const handleOpenComposeModal = () => { if (match?.params.groupId) { - dispatch((_, getState) => { - const group = queryClient.getQueryData(['groups', match.params.groupId]); - if (group) dispatch(groupComposeModal(group)); - }); + const group = queryClient.getQueryData(['groups', match.params.groupId]); + if (group) groupComposeModal(group); } else { openModal('COMPOSE'); } diff --git a/packages/pl-fe/src/features/account/components/header.tsx b/packages/pl-fe/src/features/account/components/header.tsx index e7beb3b93..6b3a903b8 100644 --- a/packages/pl-fe/src/features/account/components/header.tsx +++ b/packages/pl-fe/src/features/account/components/header.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import * as v from 'valibot'; -import { mentionCompose, directCompose } from '@/actions/compose'; import { initReport, ReportableEntities } from '@/actions/reports'; import Account from '@/components/account'; import AltIndicator from '@/components/alt-indicator'; @@ -43,6 +42,7 @@ import { blockDomainMutationOptions, unblockDomainMutationOptions, } from '@/queries/settings/domain-blocks'; +import { useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import { useSettings } from '@/stores/settings'; import toast from '@/toast'; @@ -166,6 +166,7 @@ const Header: React.FC = ({ account }) => { const intl = useIntl(); const navigate = useNavigate(); const dispatch = useAppDispatch(); + const { mentionCompose, directCompose } = useComposeActions(); const client = useClient(); const features = useFeatures(); @@ -228,11 +229,11 @@ const Header: React.FC = ({ account }) => { }; const onMention = () => { - dispatch(mentionCompose(account)); + mentionCompose(account); }; const onDirect = () => { - dispatch(directCompose(account)); + directCompose(account); }; const onReblogToggle = () => { diff --git a/packages/pl-fe/src/features/compose-event/tabs/edit-event.tsx b/packages/pl-fe/src/features/compose-event/tabs/edit-event.tsx index a67cfe152..b1e5b3460 100644 --- a/packages/pl-fe/src/features/compose-event/tabs/edit-event.tsx +++ b/packages/pl-fe/src/features/compose-event/tabs/edit-event.tsx @@ -2,7 +2,6 @@ import { useNavigate } from '@tanstack/react-router'; import React, { useCallback, useEffect, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { changeUploadCompose, resetCompose } from '@/actions/compose'; import { cancelEventCompose, initEventEdit, submitEvent } from '@/actions/events'; import { uploadFile } from '@/actions/media'; import { fetchStatus } from '@/actions/statuses'; @@ -27,6 +26,7 @@ import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; import { useInstance } from '@/hooks/use-instance'; import { makeGetStatus } from '@/selectors'; +import { useChangeUploadCompose, useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import toast from '@/toast'; @@ -69,6 +69,10 @@ const EditEvent: React.FC = ({ statusId }) => { const navigate = useNavigate(); const { openModal } = useModalsActions(); + const composeId = statusId ? `compose-event-${statusId}` : 'compose-event'; + const { resetCompose } = useComposeActions(); + const changeUploadCompose = useChangeUploadCompose(composeId); + const getStatus = useCallback(makeGetStatus(), []); const status = useAppSelector((state) => statusId ? getStatus(state, { id: statusId }) : undefined, @@ -94,8 +98,6 @@ const EditEvent: React.FC = ({ statusId }) => { const [isDisabled, setIsDisabled] = useState(!!statusId); const [isUploading, setIsUploading] = useState(false); - const composeId = statusId ? `compose-event-${statusId}` : 'compose-event'; - const onChangeName: React.ChangeEventHandler = ({ target }) => { setName(target.value); }; @@ -158,14 +160,12 @@ const EditEvent: React.FC = ({ statusId }) => { previousPosition: [0, 0], descriptionLimit: descriptionLimit, onSubmit: (description: string, position: [number, number]) => - dispatch( - changeUploadCompose(composeId, banner.id, { - description, - focus: position - ? `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}` - : undefined, - }), - ).then((media) => setBanner(media || null)), + changeUploadCompose(banner.id, { + description, + focus: position + ? `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}` + : undefined, + }).then((media) => setBanner(media || null)), }); }; @@ -190,7 +190,7 @@ const EditEvent: React.FC = ({ statusId }) => { to: '/@{$username}/events/$statusId', params: { username: status.account.acct, statusId: status.id }, }); - dispatch(resetCompose(composeId)); + resetCompose(composeId); }) .catch(() => {}); }; @@ -218,7 +218,8 @@ const EditEvent: React.FC = ({ statusId }) => { } return () => { - dispatch(cancelEventCompose()); + resetCompose(composeId); + cancelEventCompose(); }; }, [statusId]); diff --git a/packages/pl-fe/src/features/compose/components/compose-form.tsx b/packages/pl-fe/src/features/compose/components/compose-form.tsx index a5be1a385..4d3d95b0d 100644 --- a/packages/pl-fe/src/features/compose/components/compose-form.tsx +++ b/packages/pl-fe/src/features/compose/components/compose-form.tsx @@ -4,17 +4,6 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { length } from 'stringz'; -import { - submitCompose, - clearComposeSuggestions, - fetchComposeSuggestions, - selectComposeSuggestion, - uploadCompose, - ignoreClearLinkSuggestion, - suggestClearLink, - resetCompose, - changeComposeRedactingOverwrite, -} from '@/actions/compose'; import DropdownMenu from '@/components/dropdown-menu'; import List, { ListItem } from '@/components/list'; import Icon from '@/components/ui/icon'; @@ -22,12 +11,16 @@ import SvgIcon from '@/components/ui/svg-icon'; import Toggle from '@/components/ui/toggle'; import EmojiPickerDropdown from '@/features/emoji/containers/emoji-picker-dropdown-container'; import { ComposeEditor } from '@/features/ui/util/async-components'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; import { useDraggedFiles } from '@/hooks/use-dragged-files'; import { useFeatures } from '@/hooks/use-features'; import { useInstance } from '@/hooks/use-instance'; import { usePersistDraftStatus } from '@/queries/statuses/use-draft-statuses'; +import { + useCompose, + useComposeActions, + useUploadCompose, + useSubmitCompose, +} from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import toast from '@/toast'; @@ -61,7 +54,6 @@ import UploadForm from './upload-form'; import VisualCharacterCounter from './visual-character-counter'; import Warning from './warning'; -import type { AutoSuggestion } from '@/components/autosuggest-input'; import type { Menu } from '@/components/dropdown-menu'; import type { Emoji } from '@/features/emoji'; import type { LinkNode } from '@lexical/link'; @@ -152,11 +144,13 @@ const ComposeForm = ({ compact, }: IComposeForm) => { const intl = useIntl(); - const dispatch = useAppDispatch(); const { configuration } = useInstance(); const { closeModal } = useModalsActions(); + const actions = useComposeActions(); const compose = useCompose(id); + const uploadCompose = useUploadCompose(id); + const submitCompose = useSubmitCompose(id); const maxTootChars = configuration.statuses.max_characters; const features = useFeatures(); const persistDraftStatus = usePersistDraftStatus(); @@ -230,19 +224,17 @@ const ComposeForm = ({ if (!canSubmit) return; e?.preventDefault(); - dispatch( - submitCompose(id, { - onSuccess: () => { - editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); - }, - }), - ); + submitCompose({ + onSuccess: () => { + editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); + }, + }); }; const handlePreview = (e?: React.FormEvent) => { e?.preventDefault(); - dispatch(submitCompose(id, {}, true)); + submitCompose({ preview: true }); }; const handleSaveDraft = (e?: React.FormEvent) => { @@ -250,7 +242,7 @@ const ComposeForm = ({ persistDraftStatus(id); closeModal('COMPOSE'); - dispatch(resetCompose(id)); + actions.resetCompose(id); editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); toast.success(messages.draftSaved, { @@ -259,22 +251,6 @@ const ComposeForm = ({ }); }; - const onSuggestionsClearRequested = () => { - dispatch(clearComposeSuggestions(id)); - }; - - const onSuggestionsFetchRequested = (token: string | number) => { - dispatch(fetchComposeSuggestions(id, token as string)); - }; - - const onSpoilerSuggestionSelected = ( - tokenStart: number, - token: string | null, - value: AutoSuggestion, - ) => { - dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text'])); - }; - const handleEmojiPick = (data: Emoji) => { const editor = editorRef.current; if (!editor) return; @@ -285,7 +261,7 @@ const ComposeForm = ({ }; const onPaste = (files: FileList) => { - dispatch(uploadCompose(id, files, intl)); + uploadCompose(files); }; const onAcceptClearLinkSuggestion = (key: string) => { @@ -307,16 +283,25 @@ const ComposeForm = ({ textNode.setTextContent(suggestion.cleanUrl); } } - dispatch(suggestClearLink(id, null)); + actions.updateCompose(id, (draft) => { + draft.clearLinkSuggestion = null; + }); }); }; const onRejectClearLinkSuggestion = (key: string) => { - dispatch(ignoreClearLinkSuggestion(id, key)); + actions.updateCompose(id, (draft) => { + if (draft.clearLinkSuggestion?.key === key) { + draft.clearLinkSuggestion = null; + } + draft.dismissedClearLinksSuggestions.push(key); + }); }; const handleChangeRedactingOverwrite: React.ChangeEventHandler = (e) => { - dispatch(changeComposeRedactingOverwrite(id, e.target.checked)); + actions.updateCompose(id, (draft) => { + draft.redactingOverwrite = e.target.checked; + }); }; useEffect(() => { @@ -457,13 +442,7 @@ const ComposeForm = ({ )} {features.spoilers && ( - + )}
diff --git a/packages/pl-fe/src/features/compose/components/content-type-button.tsx b/packages/pl-fe/src/features/compose/components/content-type-button.tsx index 3d5407f4c..ac8fbcd32 100644 --- a/packages/pl-fe/src/features/compose/components/content-type-button.tsx +++ b/packages/pl-fe/src/features/compose/components/content-type-button.tsx @@ -1,12 +1,10 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { changeComposeContentType } from '@/actions/compose'; import DropdownMenu from '@/components/dropdown-menu'; import Icon from '@/components/ui/icon'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; import { useInstance } from '@/hooks/use-instance'; +import { useCompose, useComposeActions } from '@/stores/compose'; const messages = defineMessages({ contentTypePlaintext: { @@ -36,13 +34,15 @@ interface IContentTypeButton { const ContentTypeButton: React.FC = ({ composeId, compact }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const instance = useInstance(); const { contentType } = useCompose(composeId); - const handleChange = (contentType: string) => () => - dispatch(changeComposeContentType(composeId, contentType)); + const handleChange = (value: string) => () => + updateCompose(composeId, (draft) => { + draft.contentType = value; + }); const postFormats = instance.pleroma.metadata.post_formats; diff --git a/packages/pl-fe/src/features/compose/components/drive-button.tsx b/packages/pl-fe/src/features/compose/components/drive-button.tsx index 58cfedb9e..6181b9577 100644 --- a/packages/pl-fe/src/features/compose/components/drive-button.tsx +++ b/packages/pl-fe/src/features/compose/components/drive-button.tsx @@ -3,9 +3,8 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import * as v from 'valibot'; -import { uploadComposeSuccess } from '@/actions/compose'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useInstance } from '@/hooks/use-instance'; +import { appendMedia, useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import ComposeFormButton from './compose-form-button'; @@ -20,7 +19,7 @@ interface IDriveButton { const DriveButton: React.FC = ({ composeId }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const { configuration } = useInstance(); const { openModal } = useModalsActions(); @@ -50,7 +49,9 @@ const DriveButton: React.FC = ({ composeId }) => { mime_type: file.content_type, }); - dispatch(uploadComposeSuccess(composeId, mediaAttachment)); + updateCompose(composeId, (draft) => { + appendMedia(draft, mediaAttachment); + }); }, }); }; diff --git a/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx b/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx index 2fd4a2371..8f538d6db 100644 --- a/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx +++ b/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx @@ -1,13 +1,12 @@ import React from 'react'; import { defineMessages, FormattedMessage } from 'react-intl'; -import { ignoreHashtagCasingSuggestion } from '@/actions/compose'; import { changeSetting } from '@/actions/settings'; import Button from '@/components/ui/button'; import HStack from '@/components/ui/hstack'; import Stack from '@/components/ui/stack'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useCompose, useComposeActions } from '@/stores/compose'; import toast from '@/toast'; import Warning from './warning'; @@ -25,12 +24,16 @@ interface IHashtagCasingSuggestion { const HashtagCasingSuggestion = ({ composeId }: IHashtagCasingSuggestion) => { const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const compose = useCompose(composeId); const suggestion = compose.hashtagCasingSuggestion; const onIgnore = () => { - dispatch(ignoreHashtagCasingSuggestion(composeId)); + updateCompose(composeId, (draft) => { + draft.hashtagCasingSuggestion = null; + draft.hashtagCasingSuggestionIgnored = true; + }); }; const onDontAskAgain = () => { @@ -38,7 +41,7 @@ const HashtagCasingSuggestion = ({ composeId }: IHashtagCasingSuggestion) => { changeSetting(['ignoreHashtagCasingSuggestions'], true, { showAlert: false, save: true }), ); toast.info(messages.hashtagCasingSuggestionsDisabled); - dispatch(ignoreHashtagCasingSuggestion(composeId)); + onIgnore(); }; if (!suggestion) return null; diff --git a/packages/pl-fe/src/features/compose/components/language-dropdown.tsx b/packages/pl-fe/src/features/compose/components/language-dropdown.tsx index ef87d8971..368f5cbe1 100644 --- a/packages/pl-fe/src/features/compose/components/language-dropdown.tsx +++ b/packages/pl-fe/src/features/compose/components/language-dropdown.tsx @@ -3,19 +3,12 @@ import fuzzysort from 'fuzzysort'; import React, { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { - addComposeLanguage, - changeComposeLanguage, - changeComposeModifiedLanguage, - deleteComposeLanguage, -} from '@/actions/compose'; import DropdownMenu from '@/components/dropdown-menu'; import Icon from '@/components/ui/icon'; import Input from '@/components/ui/input'; import { type Language, languages as languagesObject } from '@/features/preferences'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; import { useFeatures } from '@/hooks/use-features'; +import { useCompose, useComposeActions } from '@/stores/compose'; import { useSettings } from '@/stores/settings'; const getFrequentlyUsedLanguages = (languageCounters: Record) => @@ -53,7 +46,7 @@ const getLanguageDropdown = ({ handleClose: handleMenuClose }) => { const intl = useIntl(); const features = useFeatures(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const settings = useSettings(); const frequentlyUsedLanguages = useMemo( () => getFrequentlyUsedLanguages(settings.frequentlyUsedLanguages), @@ -75,9 +68,14 @@ const getLanguageDropdown = if (Object.keys(textMap).length) { if (!(value in textMap || language === value)) return; - dispatch(changeComposeModifiedLanguage(composeId, value)); + updateCompose(composeId, (draft) => { + draft.modifiedLanguage = value; + }); } else { - dispatch(changeComposeLanguage(composeId, value)); + updateCompose(composeId, (draft) => { + draft.language = value; + draft.modifiedLanguage = value; + }); } e.preventDefault(); @@ -93,7 +91,15 @@ const getLanguageDropdown = e.preventDefault(); e.stopPropagation(); - dispatch(addComposeLanguage(composeId, value)); + updateCompose(composeId, (draft) => { + draft.editorStateMap[value] = draft.editorState; + draft.textMap[value] = draft.text; + draft.spoilerTextMap[value] = draft.spoilerText; + if (draft.poll) + draft.poll.options_map.forEach( + (option, key) => (option[value] = draft.poll!.options[key]), + ); + }); }; const handleDeleteLanguageClick: React.EventHandler = (e: MouseEvent | KeyboardEvent) => { @@ -104,7 +110,11 @@ const getLanguageDropdown = e.preventDefault(); e.stopPropagation(); - dispatch(deleteComposeLanguage(composeId, value)); + updateCompose(composeId, (draft) => { + delete draft.editorStateMap[value]; + delete draft.textMap[value]; + delete draft.spoilerTextMap[value]; + }); }; const handleClear: React.MouseEventHandler = (e) => { diff --git a/packages/pl-fe/src/features/compose/components/location-button.tsx b/packages/pl-fe/src/features/compose/components/location-button.tsx index 140e1bd07..15740ca8f 100644 --- a/packages/pl-fe/src/features/compose/components/location-button.tsx +++ b/packages/pl-fe/src/features/compose/components/location-button.tsx @@ -1,9 +1,7 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { setComposeShowLocationPicker } from '@/actions/compose'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useCompose, useComposeActions } from '@/stores/compose'; import ComposeFormButton from './compose-form-button'; @@ -24,7 +22,7 @@ interface ILocationButton { const LocationButton: React.FC = ({ composeId }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const compose = useCompose(composeId); @@ -32,11 +30,12 @@ const LocationButton: React.FC = ({ composeId }) => { const active = compose.showLocationPicker; const onClick = () => { - if (active) { - dispatch(setComposeShowLocationPicker(composeId, false)); - } else { - dispatch(setComposeShowLocationPicker(composeId, true)); - } + updateCompose(composeId, (draft) => { + draft.showLocationPicker = !draft.showLocationPicker; + if (!draft.showLocationPicker) { + draft.location = null; + } + }); }; if (unavailable) { diff --git a/packages/pl-fe/src/features/compose/components/location-form.tsx b/packages/pl-fe/src/features/compose/components/location-form.tsx index 1a374c9bf..58aa6a655 100644 --- a/packages/pl-fe/src/features/compose/components/location-form.tsx +++ b/packages/pl-fe/src/features/compose/components/location-form.tsx @@ -2,7 +2,6 @@ import { Location } from 'pl-api'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { setComposeLocation } from '@/actions/compose'; import { ADDRESS_ICONS } from '@/components/autosuggest-location'; import LocationSearch from '@/components/location-search'; import HStack from '@/components/ui/hstack'; @@ -10,8 +9,7 @@ import Icon from '@/components/ui/icon'; import IconButton from '@/components/ui/icon-button'; import Stack from '@/components/ui/stack'; import Text from '@/components/ui/text'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useCompose, useComposeActions } from '@/stores/compose'; const messages = defineMessages({ resetLocation: { id: 'compose_event.reset_location', defaultMessage: 'Reset location' }, @@ -22,13 +20,15 @@ interface ILocationForm { } const LocationForm: React.FC = ({ composeId }) => { - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const intl = useIntl(); const { showLocationPicker, location } = useCompose(composeId); - const onChangeLocation = (location: Location | null) => { - dispatch(setComposeLocation(composeId, location)); + const onChangeLocation = (loc: Location | null) => { + updateCompose(composeId, (draft) => { + draft.location = loc; + }); }; if (!showLocationPicker) { diff --git a/packages/pl-fe/src/features/compose/components/poll-button.tsx b/packages/pl-fe/src/features/compose/components/poll-button.tsx index 54e53262c..8f700518c 100644 --- a/packages/pl-fe/src/features/compose/components/poll-button.tsx +++ b/packages/pl-fe/src/features/compose/components/poll-button.tsx @@ -1,9 +1,7 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { addPoll, removePoll } from '@/actions/compose'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useCompose, useComposeActions, newPoll } from '@/stores/compose'; import ComposeFormButton from './compose-form-button'; @@ -19,7 +17,7 @@ interface IPollButton { const PollButton: React.FC = ({ composeId, disabled }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const compose = useCompose(composeId); @@ -27,11 +25,9 @@ const PollButton: React.FC = ({ composeId, disabled }) => { const active = compose.poll !== null; const onClick = () => { - if (active) { - dispatch(removePoll(composeId)); - } else { - dispatch(addPoll(composeId)); - } + updateCompose(composeId, (draft) => { + draft.poll = active ? null : newPoll(); + }); }; if (unavailable) { diff --git a/packages/pl-fe/src/features/compose/components/polls/poll-form.tsx b/packages/pl-fe/src/features/compose/components/polls/poll-form.tsx index 86a317509..6adb12fea 100644 --- a/packages/pl-fe/src/features/compose/components/polls/poll-form.tsx +++ b/packages/pl-fe/src/features/compose/components/polls/poll-form.tsx @@ -1,16 +1,6 @@ -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { - addPollOption, - changePollOption, - changePollSettings, - clearComposeSuggestions, - fetchComposeSuggestions, - removePoll, - removePollOption, - selectComposeSuggestion, -} from '@/actions/compose'; import AutosuggestInput from '@/components/autosuggest-input'; import Button from '@/components/ui/button'; import Divider from '@/components/ui/divider'; @@ -18,9 +8,9 @@ import HStack from '@/components/ui/hstack'; import Stack from '@/components/ui/stack'; import Text from '@/components/ui/text'; import Toggle from '@/components/ui/toggle'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useComposeSuggestions } from '@/hooks/use-compose-suggestions'; import { useInstance } from '@/hooks/use-instance'; +import { useCompose, useComposeActions } from '@/stores/compose'; import DurationSelector from './duration-selector'; @@ -69,10 +59,12 @@ const Option: React.FC = ({ onRemovePoll, title, }) => { - const dispatch = useAppDispatch(); + const { selectComposeSuggestion } = useComposeActions(); const intl = useIntl(); - const { suggestions, modifiedLanguage: language } = useCompose(composeId); + const [token, setToken] = useState(''); + const suggestions = useComposeSuggestions(token); + const { modifiedLanguage: language } = useCompose(composeId); const handleOptionTitleChange = (event: React.ChangeEvent) => { onChange(index, event.target.value); @@ -86,10 +78,10 @@ const Option: React.FC = ({ } }; - const onSuggestionsClearRequested = () => dispatch(clearComposeSuggestions(composeId)); + const onSuggestionsClearRequested = () => setToken(''); const onSuggestionsFetchRequested = (token: string) => { - dispatch(fetchComposeSuggestions(composeId, token)); + setToken(token); }; const onSuggestionSelected = ( @@ -98,9 +90,7 @@ const Option: React.FC = ({ value: AutoSuggestion, ) => { if (token && typeof token === 'string') { - dispatch( - selectComposeSuggestion(composeId, tokenStart, token, value, ['poll', 'options', index]), - ); + selectComposeSuggestion(composeId, tokenStart, token, value, ['poll', 'options', index]); } }; @@ -146,7 +136,7 @@ interface IPollForm { } const PollForm: React.FC = ({ composeId }) => { - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const intl = useIntl(); const { configuration } = useInstance(); @@ -162,15 +152,40 @@ const PollForm: React.FC = ({ composeId }) => { const { max_options: maxOptions, max_characters_per_option: maxOptionChars } = configuration.polls; - const onRemoveOption = (index: number) => dispatch(removePollOption(composeId, index)); + const onRemoveOption = (index: number) => + updateCompose(composeId, (draft) => { + if (!draft.poll) return; + draft.poll.options = draft.poll.options.filter((_, i) => i !== index); + draft.poll.options_map = draft.poll.options_map.filter((_, i) => i !== index); + }); const onChangeOption = (index: number, title: string) => - dispatch(changePollOption(composeId, index, title)); - const handleAddOption = () => dispatch(addPollOption(composeId, '')); - const onChangeSettings = (expiresIn: number, isMultiple?: boolean) => - dispatch(changePollSettings(composeId, expiresIn, isMultiple)); - const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple); - const handleToggleMultiple = () => onChangeSettings(Number(expiresIn), !isMultiple); - const onRemovePoll = () => dispatch(removePoll(composeId)); + updateCompose(composeId, (draft) => { + if (!draft.poll) return; + if (!draft.modifiedLanguage || draft.modifiedLanguage === draft.language) { + draft.poll.options[index] = title; + if (draft.modifiedLanguage) draft.poll.options_map[index][draft.modifiedLanguage] = title; + } + }); + const handleAddOption = () => + updateCompose(composeId, (draft) => { + if (!draft.poll) return; + draft.poll.options.push(''); + draft.poll.options_map.push( + Object.fromEntries(Object.entries(draft.textMap).map((key) => [key, ''])), + ); + }); + const handleSelectDuration = (value: number) => + updateCompose(composeId, (draft) => { + if (draft.poll) draft.poll.expires_in = value; + }); + const handleToggleMultiple = () => + updateCompose(composeId, (draft) => { + if (draft.poll) draft.poll.multiple = !draft.poll.multiple; + }); + const onRemovePoll = () => + updateCompose(composeId, (draft) => { + draft.poll = null; + }); if (!options) { return null; diff --git a/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx b/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx index 1545d118c..2c0b3afa1 100644 --- a/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx +++ b/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx @@ -1,15 +1,13 @@ import React, { useMemo } from 'react'; import { useIntl, defineMessages, IntlShape } from 'react-intl'; -import { changeComposeFederated, changeComposeVisibility } from '@/actions/compose'; import DropdownMenu, { MenuItem } from '@/components/dropdown-menu'; import Icon from '@/components/ui/icon'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; import { useFeatures } from '@/hooks/use-features'; import { getOrderedLists } from '@/pages/account-lists/lists'; import { useCircles } from '@/queries/accounts/use-circles'; import { useLists } from '@/queries/accounts/use-lists'; +import { useCompose, useComposeActions } from '@/stores/compose'; import type { Circle, Features } from 'pl-api'; @@ -156,7 +154,7 @@ interface IPrivacyDropdown { const PrivacyDropdown: React.FC = ({ composeId, compact }) => { const intl = useIntl(); const features = useFeatures(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const compose = useCompose(composeId); const { data: lists = [] } = useLists(getOrderedLists); @@ -167,7 +165,11 @@ const PrivacyDropdown: React.FC = ({ composeId, compact }) => const value = compose.visibility; const unavailable = !!compose.editedId; - const onChange = (value: string) => value && dispatch(changeComposeVisibility(composeId, value)); + const onChange = (value: string) => + value && + updateCompose(composeId, (draft) => { + draft.visibility = value; + }); const options = useMemo( () => getItems(features, lists, circles, isReply, intl), @@ -191,7 +193,10 @@ const PrivacyDropdown: React.FC = ({ composeId, compact }) => meta: intl.formatMessage(messages.localLong), type: 'toggle', checked: compose.localOnly, - onChange: () => dispatch(changeComposeFederated(composeId)), + onChange: () => + updateCompose(composeId, (draft) => { + draft.localOnly = !draft.localOnly; + }), }); const valueOption = useMemo( diff --git a/packages/pl-fe/src/features/compose/components/reply-group-indicator.tsx b/packages/pl-fe/src/features/compose/components/reply-group-indicator.tsx index fec71c11a..e14d2487b 100644 --- a/packages/pl-fe/src/features/compose/components/reply-group-indicator.tsx +++ b/packages/pl-fe/src/features/compose/components/reply-group-indicator.tsx @@ -7,6 +7,7 @@ import Emojify from '@/features/emoji/emojify'; import { useAppSelector } from '@/hooks/use-app-selector'; import { useGroupQuery } from '@/queries/groups/use-group'; import { makeGetStatus } from '@/selectors'; +import { useCompose } from '@/stores/compose'; interface IReplyGroupIndicator { composeId: string; @@ -16,10 +17,9 @@ const ReplyGroupIndicator = (props: IReplyGroupIndicator) => { const { composeId } = props; const getStatus = useCallback(makeGetStatus(), []); + const { inReplyToId } = useCompose(composeId); - const status = useAppSelector((state) => - getStatus(state, { id: state.compose[composeId]?.inReplyToId! }), - ); + const status = useAppSelector((state) => getStatus(state, { id: inReplyToId! })); const { data: group } = useGroupQuery(status?.group_id ?? undefined); diff --git a/packages/pl-fe/src/features/compose/components/schedule-button.tsx b/packages/pl-fe/src/features/compose/components/schedule-button.tsx index 606e0fb11..978fb538e 100644 --- a/packages/pl-fe/src/features/compose/components/schedule-button.tsx +++ b/packages/pl-fe/src/features/compose/components/schedule-button.tsx @@ -1,9 +1,7 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { addSchedule, removeSchedule } from '@/actions/compose'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useCompose, useComposeActions } from '@/stores/compose'; import ComposeFormButton from './compose-form-button'; @@ -19,7 +17,7 @@ interface IScheduleButton { const ScheduleButton: React.FC = ({ composeId, disabled }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const compose = useCompose(composeId); @@ -27,11 +25,9 @@ const ScheduleButton: React.FC = ({ composeId, disabled }) => { const unavailable = !!compose.editedId; const handleClick = () => { - if (active) { - dispatch(removeSchedule(composeId)); - } else { - dispatch(addSchedule(composeId)); - } + updateCompose(composeId, (draft) => { + draft.scheduledAt = active ? null : new Date(Date.now() + 10 * 60 * 1000); + }); }; if (unavailable) { diff --git a/packages/pl-fe/src/features/compose/components/schedule-form.tsx b/packages/pl-fe/src/features/compose/components/schedule-form.tsx index d42f32b38..ade3e1bf7 100644 --- a/packages/pl-fe/src/features/compose/components/schedule-form.tsx +++ b/packages/pl-fe/src/features/compose/components/schedule-form.tsx @@ -2,13 +2,11 @@ import clsx from 'clsx'; import React, { Suspense, useCallback } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { setSchedule, removeSchedule } from '@/actions/compose'; import IconButton from '@/components/ui/icon-button'; import Input from '@/components/ui/input'; import { DatePicker } from '@/features/ui/util/async-components'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; import { useFeatures } from '@/hooks/use-features'; +import { useCompose, useComposeActions } from '@/stores/compose'; const isCurrentOrFutureDate = (date: Date) => date && new Date().setHours(0, 0, 0, 0) <= new Date(date).setHours(0, 0, 0, 0); @@ -29,7 +27,7 @@ interface IScheduleForm { } const ScheduleForm: React.FC = ({ composeId }) => { - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const intl = useIntl(); const features = useFeatures(); @@ -37,12 +35,15 @@ const ScheduleForm: React.FC = ({ composeId }) => { const active = !!scheduledAt; const onSchedule = (date: Date | null) => { - if (date === null) dispatch(removeSchedule(composeId)); - else dispatch(setSchedule(composeId, date)); + updateCompose(composeId, (draft) => { + draft.scheduledAt = date; + }); }; const handleRemove = (e: React.MouseEvent) => { - dispatch(removeSchedule(composeId)); + updateCompose(composeId, (draft) => { + draft.scheduledAt = null; + }); e.preventDefault(); }; diff --git a/packages/pl-fe/src/features/compose/components/sensitive-media-button.tsx b/packages/pl-fe/src/features/compose/components/sensitive-media-button.tsx index d34d79094..e68158188 100644 --- a/packages/pl-fe/src/features/compose/components/sensitive-media-button.tsx +++ b/packages/pl-fe/src/features/compose/components/sensitive-media-button.tsx @@ -1,9 +1,7 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { changeComposeSpoilerness } from '@/actions/compose'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useCompose, useComposeActions } from '@/stores/compose'; import ComposeFormButton from './compose-form-button'; @@ -21,11 +19,14 @@ interface ISensitiveMediaButton { const SensitiveMediaButton: React.FC = ({ composeId }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const active = useCompose(composeId).sensitive; - const onClick = () => dispatch(changeComposeSpoilerness(composeId)); + const onClick = () => + updateCompose(composeId, (draft) => { + draft.sensitive = !draft.sensitive; + }); return ( { +interface ISpoilerInput { composeId: string extends 'default' ? never : string; + theme?: InputThemes; } /** Text input for content warning in composer. */ -const SpoilerInput: React.FC = ({ - composeId, - onSuggestionsFetchRequested, - onSuggestionsClearRequested, - onSuggestionSelected, - theme, -}) => { +const SpoilerInput: React.FC = ({ composeId, theme }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); - const { language, modifiedLanguage, spoilerText, spoilerTextMap, suggestions } = - useCompose(composeId); + const { selectComposeSuggestion, updateCompose } = useComposeActions(); + const { language, modifiedLanguage, spoilerText, spoilerTextMap } = useCompose(composeId); + + const [token, setToken] = useState(''); + const suggestions = useComposeSuggestions(token); const handleChangeSpoilerText: React.ChangeEventHandler = (e) => { - dispatch(changeComposeSpoilerText(composeId, e.target.value)); + const text = e.target.value; + updateCompose(composeId, (draft) => { + if (!draft.modifiedLanguage || draft.modifiedLanguage === draft.language) { + draft.spoilerText = text; + } else { + draft.spoilerTextMap[draft.modifiedLanguage] = text; + } + }); + }; + + const onSuggestionsFetchRequested = (token: string) => setToken(token); + const onSuggestionsClearRequested = () => setToken(''); + const onSuggestionSelected = ( + tokenStart: number, + token: string | null, + value: AutoSuggestion, + ) => { + if (token && typeof token === 'string') { + selectComposeSuggestion(composeId, tokenStart, token, value, ['spoiler_text']); + } }; const value = diff --git a/packages/pl-fe/src/features/compose/components/upload-form.tsx b/packages/pl-fe/src/features/compose/components/upload-form.tsx index 7a46fe5df..c4608237e 100644 --- a/packages/pl-fe/src/features/compose/components/upload-form.tsx +++ b/packages/pl-fe/src/features/compose/components/upload-form.tsx @@ -1,10 +1,8 @@ import clsx from 'clsx'; import React, { useCallback, useRef } from 'react'; -import { changeMediaOrder } from '@/actions/compose'; import HStack from '@/components/ui/hstack'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useCompose, useComposeActions } from '@/stores/compose'; import Upload from './upload'; import UploadProgress from './upload-progress'; @@ -15,7 +13,7 @@ interface IUploadForm { } const UploadForm: React.FC = ({ composeId, onSubmit }) => { - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const { isUploading, mediaAttachments } = useCompose(composeId); @@ -39,7 +37,12 @@ const UploadForm: React.FC = ({ composeId, onSubmit }) => { ); const handleDragEnd = useCallback(() => { - dispatch(changeMediaOrder(composeId, dragItem.current!, dragOverItem.current!)); + updateCompose(composeId, (draft) => { + const indexA = draft.mediaAttachments.findIndex((x) => x.id === dragItem.current!); + const indexB = draft.mediaAttachments.findIndex((x) => x.id === dragOverItem.current!); + const item = draft.mediaAttachments.splice(indexA, 1)[0]; + draft.mediaAttachments.splice(indexB, 0, item); + }); dragItem.current = null; dragOverItem.current = null; }, [dragItem, dragOverItem]); diff --git a/packages/pl-fe/src/features/compose/components/upload.tsx b/packages/pl-fe/src/features/compose/components/upload.tsx index f868bb1de..810390140 100644 --- a/packages/pl-fe/src/features/compose/components/upload.tsx +++ b/packages/pl-fe/src/features/compose/components/upload.tsx @@ -1,10 +1,8 @@ import React, { useCallback } from 'react'; -import { undoUploadCompose, changeUploadCompose } from '@/actions/compose'; import Upload from '@/components/upload'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; import { useInstance } from '@/hooks/use-instance'; +import { useChangeUploadCompose, useCompose, useComposeActions } from '@/stores/compose'; interface IUploadCompose { id: string; @@ -23,7 +21,8 @@ const UploadCompose: React.FC = ({ onDragEnter, onDragEnd, }) => { - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); + const changeUploadCompose = useChangeUploadCompose(composeId); const { pleroma: { metadata: { description_limit: descriptionLimit }, @@ -33,18 +32,20 @@ const UploadCompose: React.FC = ({ const media = useCompose(composeId).mediaAttachments.find((item) => item.id === id)!; const handleDescriptionChange = (description: string, position?: [number, number]) => { - return dispatch( - changeUploadCompose(composeId, media.id, { - description, - focus: position - ? `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}` - : undefined, - }), - ); + return changeUploadCompose(media.id, { + description, + focus: position + ? `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}` + : undefined, + }); }; const handleDelete = () => { - dispatch(undoUploadCompose(composeId, media.id)); + updateCompose(composeId, (draft) => { + const prevSize = draft.mediaAttachments.length; + draft.mediaAttachments = draft.mediaAttachments.filter((item) => item.id !== media.id); + if (prevSize === 1) draft.sensitive = false; + }); }; const handleDragStart = useCallback(() => { diff --git a/packages/pl-fe/src/features/compose/containers/preview-compose-container.tsx b/packages/pl-fe/src/features/compose/containers/preview-compose-container.tsx index cd638bf32..5e451cb64 100644 --- a/packages/pl-fe/src/features/compose/containers/preview-compose-container.tsx +++ b/packages/pl-fe/src/features/compose/containers/preview-compose-container.tsx @@ -1,7 +1,6 @@ import React, { useMemo } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { cancelPreviewCompose } from '@/actions/compose'; import EventPreview from '@/components/event-preview'; import OutlineBox from '@/components/outline-box'; import QuotedStatusIndicator from '@/components/quoted-status-indicator'; @@ -15,9 +14,8 @@ import IconButton from '@/components/ui/icon-button'; import Stack from '@/components/ui/stack'; import Text from '@/components/ui/text'; import AccountContainer from '@/containers/account-container'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; import { useOwnAccount } from '@/hooks/use-own-account'; +import { useCompose, useComposeActions } from '@/stores/compose'; import type { Status } from '@/normalizers/status'; @@ -34,14 +32,16 @@ interface IQuotedStatusContainer { /** Previewed status shown in post composer. */ const PreviewComposeContainer: React.FC = ({ composeId }) => { - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const intl = useIntl(); const { data: ownAccount } = useOwnAccount(); const previewedStatus = useCompose(composeId).preview as unknown as Status; const handleClose = () => { - dispatch(cancelPreviewCompose(composeId)); + updateCompose(composeId, (draft) => { + draft.preview = null; + }); }; const status = useMemo( diff --git a/packages/pl-fe/src/features/compose/containers/quoted-status-container.tsx b/packages/pl-fe/src/features/compose/containers/quoted-status-container.tsx index 9887194e8..9e44e958b 100644 --- a/packages/pl-fe/src/features/compose/containers/quoted-status-container.tsx +++ b/packages/pl-fe/src/features/compose/containers/quoted-status-container.tsx @@ -1,10 +1,9 @@ import React, { useCallback } from 'react'; -import { cancelQuoteCompose } from '@/actions/compose'; import QuotedStatus from '@/components/quoted-status'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; import { makeGetStatus } from '@/selectors'; +import { useCompose, useComposeActions } from '@/stores/compose'; interface IQuotedStatusContainer { composeId: string; @@ -12,15 +11,17 @@ interface IQuotedStatusContainer { /** QuotedStatus shown in post composer. */ const QuotedStatusContainer: React.FC = ({ composeId }) => { - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const getStatus = useCallback(makeGetStatus(), []); + const { quoteId } = useCompose(composeId); - const status = useAppSelector((state) => - getStatus(state, { id: state.compose[composeId]?.quoteId! }), - ); + const status = useAppSelector((state) => getStatus(state, { id: quoteId! })); const onCancel = () => { - dispatch(cancelQuoteCompose(composeId)); + updateCompose(composeId, (draft) => { + if (draft.quoteId) draft.dismissedQuotes.push(draft.quoteId); + draft.quoteId = null; + }); }; if (!status) { diff --git a/packages/pl-fe/src/features/compose/containers/reply-indicator-container.tsx b/packages/pl-fe/src/features/compose/containers/reply-indicator-container.tsx index 48adced17..9bfad5d34 100644 --- a/packages/pl-fe/src/features/compose/containers/reply-indicator-container.tsx +++ b/packages/pl-fe/src/features/compose/containers/reply-indicator-container.tsx @@ -1,10 +1,8 @@ import React, { useCallback } from 'react'; -import { cancelReplyCompose } from '@/actions/compose'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; -import { useCompose } from '@/hooks/use-compose'; import { makeGetStatus } from '@/selectors'; +import { useCompose, useComposeActions } from '@/stores/compose'; import ReplyIndicator from '../components/reply-indicator'; @@ -17,10 +15,10 @@ const ReplyIndicatorContainer: React.FC = ({ composeId const { inReplyToId, editedId } = useCompose(composeId); const status = useAppSelector((state) => getStatus(state, { id: inReplyToId! })); - const dispatch = useAppDispatch(); + const { resetCompose } = useComposeActions(); const onCancel = () => { - dispatch(cancelReplyCompose()); + resetCompose('compose-modal'); }; if (!status) return null; diff --git a/packages/pl-fe/src/features/compose/containers/upload-button-container.tsx b/packages/pl-fe/src/features/compose/containers/upload-button-container.tsx index 5f7a57e7e..8b6c445c5 100644 --- a/packages/pl-fe/src/features/compose/containers/upload-button-container.tsx +++ b/packages/pl-fe/src/features/compose/containers/upload-button-container.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import { uploadCompose } from '@/actions/compose'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useCompose, useUploadCompose } from '@/stores/compose'; import UploadButton from '../components/upload-button'; @@ -13,11 +11,11 @@ interface IUploadButtonContainer { } const UploadButtonContainer: React.FC = ({ composeId }) => { - const dispatch = useAppDispatch(); const { isUploading, resetFileKey } = useCompose(composeId); + const uploadCompose = useUploadCompose(composeId); const onSelectFile = (files: FileList, intl: IntlShape) => { - dispatch(uploadCompose(composeId, files, intl)); + uploadCompose(files); }; return ( diff --git a/packages/pl-fe/src/features/compose/editor/index.tsx b/packages/pl-fe/src/features/compose/editor/index.tsx index f76ab8976..f9b704cda 100644 --- a/packages/pl-fe/src/features/compose/editor/index.tsx +++ b/packages/pl-fe/src/features/compose/editor/index.tsx @@ -27,9 +27,9 @@ import { import React, { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useCompose } from '@/hooks/use-compose'; import { usePrevious } from '@/hooks/use-previous'; +import { useComposeStore } from '@/stores/compose'; import { useNodes } from './nodes'; import AutosuggestPlugin from './plugins/autosuggest-plugin'; @@ -110,7 +110,6 @@ const ComposeEditor = React.forwardRef( }, ref, ) => { - const dispatch = useAppDispatch(); const { contentType, modifiedLanguage: language } = useCompose(composeId); const isWysiwyg = contentType === 'wysiwyg'; const previouslyWasWysiwyg = usePrevious(isWysiwyg); @@ -125,9 +124,8 @@ const ComposeEditor = React.forwardRef( onError: console.error, nodes, theme, - editorState: dispatch((_, getState) => { - const state = getState(); - const compose = state.compose[composeId]; + editorState: (() => { + const compose = useComposeStore.getState().actions.getCompose(composeId); if (!compose) return; @@ -157,7 +155,7 @@ const ComposeEditor = React.forwardRef( $getRoot().clear().append(paragraph); } }; - }), + })(), }), [composeId, isWysiwyg], ); @@ -228,7 +226,6 @@ const ComposeEditor = React.forwardRef( diff --git a/packages/pl-fe/src/features/compose/editor/plugins/autosuggest-plugin.tsx b/packages/pl-fe/src/features/compose/editor/plugins/autosuggest-plugin.tsx index 62a795e63..f5b9f2e13 100644 --- a/packages/pl-fe/src/features/compose/editor/plugins/autosuggest-plugin.tsx +++ b/packages/pl-fe/src/features/compose/editor/plugins/autosuggest-plugin.tsx @@ -32,11 +32,10 @@ import React, { } from 'react'; import ReactDOM from 'react-dom'; -import { clearComposeSuggestions, fetchComposeSuggestions } from '@/actions/compose'; import { saveSettings } from '@/actions/settings'; import AutosuggestEmoji from '@/components/autosuggest-emoji'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; +import { useComposeSuggestions } from '@/hooks/use-compose-suggestions'; import { queryClient } from '@/queries/client'; import { useSettingsStoreActions } from '@/stores/settings'; import { textAtCursorMatchesToken } from '@/utils/suggestions'; @@ -45,11 +44,10 @@ import AutosuggestAccount from '../../components/autosuggest-account'; import { $createEmojiNode } from '../nodes/emoji-node'; import { $createMentionNode } from '../nodes/mention-node'; +import type { AutoSuggestion } from '@/components/autosuggest-input'; import type { Emoji } from '@/features/emoji'; import type { Account } from 'pl-api'; -type AutoSuggestion = string | Emoji; - type QueryMatch = { leadOffset: number; matchingString: string; @@ -264,23 +262,22 @@ const useMenuAnchorRef = ( }; type AutosuggestPluginProps = { - composeId: string; suggestionsHidden: boolean; setSuggestionsHidden: (value: boolean) => void; }; const AutosuggestPlugin = ({ - composeId, suggestionsHidden, setSuggestionsHidden, }: AutosuggestPluginProps): React.JSX.Element | null => { const { rememberEmojiUse } = useSettingsStoreActions(); - const { suggestions } = useCompose(composeId); const dispatch = useAppDispatch(); const [editor] = useLexicalComposerContext(); const [resolution, setResolution] = useState(null); const [selectedSuggestion, setSelectedSuggestion] = useState(0); + const [token, setToken] = useState(''); + const suggestions = useComposeSuggestions(token); const anchorElementRef = useMenuAnchorRef(resolution, setResolution); const handleSelectSuggestion: React.MouseEventHandler = (e) => { @@ -293,39 +290,39 @@ const AutosuggestPlugin = ({ const suggestion = suggestions[index]; editor.update(() => { - dispatch((dispatch, getState) => { - const state = editor.getEditorState(); - const node = (state._selection as RangeSelection)?.anchor?.getNode(); - const { leadOffset, matchingString } = resolution!.match; - /** Offset for the beginning of the matched text, including the token. */ - const offset = leadOffset - 1; + const state = editor.getEditorState(); + const node = (state._selection as RangeSelection)?.anchor?.getNode(); + const { leadOffset, matchingString } = resolution!.match; + /** Offset for the beginning of the matched text, including the token. */ + const offset = leadOffset - 1; - /** Replace the matched text with the given node. */ - const replaceMatch = (replaceWith: LexicalNode) => { - const result = (node as TextNode).splitText(offset, offset + matchingString.length); - const textNode = result[1] ?? result[0]; - const replacedNode = textNode.replace(replaceWith); - replacedNode.insertAfter(new TextNode(' ')); - replacedNode.selectNext(); - }; + /** Replace the matched text with the given node. */ + const replaceMatch = (replaceWith: LexicalNode) => { + const result = (node as TextNode).splitText(offset, offset + matchingString.length); + const textNode = result[1] ?? result[0]; + const replacedNode = textNode.replace(replaceWith); + replacedNode.insertAfter(new TextNode(' ')); + replacedNode.selectNext(); + }; - if (typeof suggestion === 'object') { - if (!suggestion.id) return; + if (typeof suggestion === 'object' && 'id' in suggestion) { + if (!suggestion.id) return; - rememberEmojiUse(suggestion); - dispatch(saveSettings()); + rememberEmojiUse(suggestion as Emoji); + dispatch(saveSettings()); - replaceMatch($createEmojiNode(suggestion)); - } else if (suggestion[0] === '#') { + replaceMatch($createEmojiNode(suggestion as Emoji)); + } else if (typeof suggestion === 'string') { + if (suggestion[0] === '#') { (node as TextNode).setTextContent(`${suggestion} `); node.select(); } else { const account = queryClient.getQueryData(['accounts', suggestion]); if (account) replaceMatch($createMentionNode(account)); } + } - dispatch(clearComposeSuggestions(composeId)); - }); + setToken(''); }); }; @@ -353,15 +350,19 @@ const AutosuggestPlugin = ({ let inner: string | React.JSX.Element; let key: React.Key; - if (typeof suggestion === 'object') { - inner = ; + if (typeof suggestion === 'object' && 'id' in suggestion) { + inner = ; key = suggestion.id; - } else if (suggestion[0] === '#') { - inner = suggestion; - key = suggestion; + } else if (typeof suggestion === 'string') { + if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; + } else { + inner = ; + key = suggestion; + } } else { - inner = ; - key = suggestion; + return null; } return ( @@ -406,7 +407,7 @@ const AutosuggestPlugin = ({ return; } - dispatch(fetchComposeSuggestions(composeId, match.matchingString.trim())); + setToken(match.matchingString.trim()); if (!isSelectionOnEntityBoundary(editor, match.leadOffset)) { const isRangePositioned = tryToPositionRange(match.leadOffset, range); diff --git a/packages/pl-fe/src/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx b/packages/pl-fe/src/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx index 6676f4208..99c29ce76 100644 --- a/packages/pl-fe/src/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx +++ b/packages/pl-fe/src/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx @@ -22,7 +22,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; import { defineMessages, useIntl } from 'react-intl'; -import { uploadFile } from '@/actions/compose'; +import { uploadFile } from '@/actions/media'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useFeatures } from '@/hooks/use-features'; import { useInstance } from '@/hooks/use-instance'; diff --git a/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx b/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx index 4b092e034..485eba2d4 100644 --- a/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx +++ b/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx @@ -7,20 +7,19 @@ import debounce from 'lodash/debounce'; import { useCallback, useEffect } from 'react'; import { useIntl } from 'react-intl'; -import { - addSuggestedLanguage, - addSuggestedQuote, - setEditorState, - suggestClearLink, - suggestHashtagCasing, -} from '@/actions/compose'; import { fetchStatus } from '@/actions/statuses'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useFeatures } from '@/hooks/use-features'; +import { useComposeStore } from '@/stores/compose'; import { useSettings } from '@/stores/settings'; import { getStatusIdsFromLinksInContent } from '@/utils/status'; import Purify from '@/utils/url-purify'; +import type { store } from '@/store'; + +let lazyStore: typeof store; +import('@/store').then(({ store }) => (lazyStore = store)).catch(() => {}); + import { TRANSFORMERS } from '../transformers'; import type { LanguageIdentificationModel } from 'fasttext.wasm.js/dist/models/language-identification/common.js'; @@ -38,54 +37,55 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { const [editor] = useLexicalComposerContext(); const features = useFeatures(); const { urlPrivacy, ignoreHashtagCasingSuggestions } = useSettings(); + const { actions } = useComposeStore.getState(); const checkUrls = useCallback( debounce((editorState: EditorState) => { - dispatch((_, getState) => { - if (!urlPrivacy.clearLinksInCompose) return; + if (!urlPrivacy.clearLinksInCompose) return; - const state = getState(); - const compose = state.compose[composeId]; + const compose = actions.getCompose(composeId); - editorState.read(() => { - const compareUrl = (url: string) => { - const cleanUrl = Purify.clearUrl(url, true, false); - return { - originalUrl: url, - cleanUrl, - isDirty: cleanUrl !== url, - }; + editorState.read(() => { + const compareUrl = (url: string) => { + const cleanUrl = Purify.clearUrl(url, true, false); + return { + originalUrl: url, + cleanUrl, + isDirty: cleanUrl !== url, }; + }; - if (compose.clearLinkSuggestion?.key) { - const node = $getNodeByKey(compose.clearLinkSuggestion.key); - const url = (node as LinkNode | null)?.getURL?.(); - if (!url || node === null || !compareUrl(url).isDirty) { - dispatch(suggestClearLink(composeId, null)); - } else { - return; - } + if (compose.clearLinkSuggestion?.key) { + const node = $getNodeByKey(compose.clearLinkSuggestion.key); + const url = (node as LinkNode | null)?.getURL?.(); + if (!url || node === null || !compareUrl(url).isDirty) { + actions.updateCompose(composeId, (draft) => { + draft.clearLinkSuggestion = null; + }); + } else { + return; + } + } + + const links = [...$nodesOfType(AutoLinkNode), ...$nodesOfType(LinkNode)]; + + for (const link of links) { + if (compose.dismissedClearLinksSuggestions.includes(link.getKey())) { + continue; } - const links = [...$nodesOfType(AutoLinkNode), ...$nodesOfType(LinkNode)]; - - for (const link of links) { - if (compose.dismissedClearLinksSuggestions.includes(link.getKey())) { - continue; - } - - const { originalUrl, cleanUrl, isDirty } = compareUrl(link.getURL()); - if (!isDirty) { - continue; - } - - if (isDirty) { - return dispatch( - suggestClearLink(composeId, { key: link.getKey(), originalUrl, cleanUrl }), - ); - } + const { originalUrl, cleanUrl, isDirty } = compareUrl(link.getURL()); + if (!isDirty) { + continue; } - }); + + if (isDirty) { + actions.updateCompose(composeId, (draft) => { + draft.clearLinkSuggestion = { key: link.getKey(), originalUrl, cleanUrl }; + }); + return; + } + } }); }, 2000), [urlPrivacy.clearLinksInCompose], @@ -93,27 +93,28 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { const checkHashtagCasingSuggestions = useCallback( debounce((editorState: EditorState) => { - dispatch((_, getState) => { - if (ignoreHashtagCasingSuggestions) return; + if (ignoreHashtagCasingSuggestions) return; - const state = getState(); - const compose = state.compose[composeId]; + const compose = actions.getCompose(composeId); - if (compose.hashtagCasingSuggestionIgnored) return; + if (compose.hashtagCasingSuggestionIgnored) return; - editorState.read(() => { - const hashtagNodes = $nodesOfType(HashtagNode); + editorState.read(() => { + const hashtagNodes = $nodesOfType(HashtagNode); - for (const tag of hashtagNodes) { - const text = tag.getTextContent(); + for (const tag of hashtagNodes) { + const text = tag.getTextContent(); - if (text.length > 10 && text.toLowerCase() === text && !text.match(/[0-9]/)) { - dispatch(suggestHashtagCasing(composeId, text)); - return; - } + if (text.length > 10 && text.toLowerCase() === text && !text.match(/[0-9]/)) { + actions.updateCompose(composeId, (draft) => { + draft.hashtagCasingSuggestion = text; + }); + return; } + } - dispatch(suggestHashtagCasing(composeId, null)); + actions.updateCompose(composeId, (draft) => { + draft.hashtagCasingSuggestion = null; }); }); }, 1000), @@ -122,19 +123,19 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { const getQuoteSuggestions = useCallback( debounce((text: string) => { - dispatch(async (_, getState) => { - const state = getState(); - const compose = state.compose[composeId]; + const compose = actions.getCompose(composeId); - if (!features.quotePosts || compose?.quoteId) return; + if (!features.quotePosts || compose?.quoteId) return; - const ids = getStatusIdsFromLinksInContent(text); + const ids = getStatusIdsFromLinksInContent(text); + (async () => { let quoteId: string | undefined; for (const id of ids) { if (compose?.dismissedQuotes.includes(id)) continue; + const state = lazyStore.getState(); if (state.statuses[id]) { quoteId = id; break; @@ -148,24 +149,26 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { } } - if (quoteId) dispatch(addSuggestedQuote(composeId, quoteId)); - }); + if (quoteId) + actions.updateCompose(composeId, (draft) => { + draft.quoteId = quoteId!; + }); + })(); }, 2000), [], ); const detectLanguage = useCallback( debounce((text: string) => { - dispatch(async (dispatch, getState) => { - const state = getState(); - const compose = state.compose[composeId]; + const compose = actions.getCompose(composeId); - if (!features.postLanguages || features.languageDetection || compose?.language) return; + if (!features.postLanguages || features.languageDetection || compose?.language) return; - const wordsLength = text.split(/\s+/).length; + const wordsLength = text.split(/\s+/).length; - if (wordsLength < 4) return; + if (wordsLength < 4) return; + (async () => { if (!lidModel) { // eslint-disable-next-line import/extensions const { getLIDModel } = await import('fasttext.wasm.js/common'); @@ -175,9 +178,11 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { const { alpha2, possibility } = await lidModel.identify(text.replace(/\s+/i, ' ')); if (alpha2 && possibility > 0.5) { - dispatch(addSuggestedLanguage(composeId, alpha2)); + actions.updateCompose(composeId, (draft) => { + draft.suggestedLanguage = alpha2; + }); } - }); + })(); }, 750), [], ); @@ -192,7 +197,15 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { } const isEmpty = text === ''; const data = isEmpty ? null : JSON.stringify(editorState.toJSON()); - dispatch(setEditorState(composeId, data, text)); + actions.updateCompose(composeId, (draft) => { + if (!draft.modifiedLanguage || draft.modifiedLanguage === draft.language) { + draft.editorState = data as string; + draft.text = text; + } else if (draft.modifiedLanguage) { + draft.editorStateMap[draft.modifiedLanguage] = data as string; + draft.textMap[draft.modifiedLanguage] = text; + } + }); checkUrls(editorState); checkHashtagCasingSuggestions(editorState); getQuoteSuggestions(plainText); diff --git a/packages/pl-fe/src/features/draft-statuses/components/draft-status-action-bar.tsx b/packages/pl-fe/src/features/draft-statuses/components/draft-status-action-bar.tsx index 558d77d00..515d203e0 100644 --- a/packages/pl-fe/src/features/draft-statuses/components/draft-status-action-bar.tsx +++ b/packages/pl-fe/src/features/draft-statuses/components/draft-status-action-bar.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; -import { setComposeToStatus } from '@/actions/compose'; import { fetchStatus } from '@/actions/statuses'; import Button from '@/components/ui/button'; import HStack from '@/components/ui/hstack'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useCancelDraftStatus } from '@/queries/statuses/use-draft-statuses'; +import { useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import { useSettings } from '@/stores/settings'; @@ -34,6 +34,7 @@ const DraftStatusActionBar: React.FC = ({ source, status const intl = useIntl(); const { openModal } = useModalsActions(); + const { setComposeToStatus } = useComposeActions(); const settings = useSettings(); const dispatch = useAppDispatch(); const cancelDraftStatus = useCancelDraftStatus(); @@ -54,18 +55,7 @@ const DraftStatusActionBar: React.FC = ({ source, status const handleEditClick = () => { if (status.in_reply_to_id) dispatch(fetchStatus(status.in_reply_to_id)); - dispatch( - setComposeToStatus( - status, - status.poll, - source.text, - source.spoiler_text, - source.content_type, - false, - source.draft_id, - source.editorState, - ), - ); + setComposeToStatus(status, status.poll, source, false, source.draft_id, source.editorState); openModal('COMPOSE'); }; diff --git a/packages/pl-fe/src/features/event/components/event-header.tsx b/packages/pl-fe/src/features/event/components/event-header.tsx index 0179dca24..041342773 100644 --- a/packages/pl-fe/src/features/event/components/event-header.tsx +++ b/packages/pl-fe/src/features/event/components/event-header.tsx @@ -2,7 +2,6 @@ import { Link, useNavigate } from '@tanstack/react-router'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { directCompose, mentionCompose, quoteCompose } from '@/actions/compose'; import { fetchEventIcs } from '@/actions/events'; import { deleteStatusModal, toggleStatusSensitivityModal } from '@/actions/moderation'; import { initReport, ReportableEntities } from '@/actions/reports'; @@ -29,6 +28,7 @@ import { useUnpinStatus, useUnreblogStatus, } from '@/queries/statuses/use-status-interactions'; +import { useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import { useSettings } from '@/stores/settings'; import copy from '@/utils/copy'; @@ -113,6 +113,7 @@ const EventHeader: React.FC = ({ status }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const navigate = useNavigate(); + const { quoteCompose, mentionCompose, directCompose } = useComposeActions(); const { openModal } = useModalsActions(); const { getOrCreateChatByAccountId } = useChats(); @@ -187,7 +188,7 @@ const EventHeader: React.FC = ({ status }) => { }; const handleQuoteClick = () => { - dispatch(quoteCompose(status)); + quoteCompose(status); }; const handlePinClick = () => { @@ -212,7 +213,7 @@ const EventHeader: React.FC = ({ status }) => { }; const handleMentionClick = () => { - dispatch(mentionCompose(account)); + mentionCompose(account); }; const handleChatClick = () => { @@ -222,7 +223,7 @@ const EventHeader: React.FC = ({ status }) => { }; const handleDirectClick = () => { - dispatch(directCompose(account)); + directCompose(account); }; const handleMuteClick = () => { diff --git a/packages/pl-fe/src/features/notifications/components/notification.tsx b/packages/pl-fe/src/features/notifications/components/notification.tsx index 3607ffd71..db49b3f32 100644 --- a/packages/pl-fe/src/features/notifications/components/notification.tsx +++ b/packages/pl-fe/src/features/notifications/components/notification.tsx @@ -9,7 +9,6 @@ import { MessageDescriptor, } from 'react-intl'; -import { mentionCompose, replyCompose } from '@/actions/compose'; import AttachmentThumbs from '@/components/attachment-thumbs'; import HoverAccountWrapper from '@/components/hover-account-wrapper'; import Icon from '@/components/icon'; @@ -22,7 +21,6 @@ import AccountContainer from '@/containers/account-container'; import StatusContainer from '@/containers/status-container'; import Emojify from '@/features/emoji/emojify'; import { Hotkeys } from '@/features/ui/components/hotkeys'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; import { useInstance } from '@/hooks/use-instance'; import { useLoggedIn } from '@/hooks/use-logged-in'; @@ -33,6 +31,7 @@ import { useUnreblogStatus, } from '@/queries/statuses/use-status-interactions'; import { makeGetNotification } from '@/selectors'; +import { useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import { useSettings } from '@/stores/settings'; import { useStatusMetaActions } from '@/stores/status-meta'; @@ -288,7 +287,7 @@ const getNotificationStatus = ( const Notification: React.FC = (props) => { const { onMoveUp, onMoveDown, compact } = props; - const dispatch = useAppDispatch(); + const { mentionCompose, replyCompose } = useComposeActions(); const getNotification = useCallback(makeGetNotification(), []); @@ -336,7 +335,7 @@ const Notification: React.FC = (props) => { (e?: KeyboardEvent) => { e?.preventDefault(); - dispatch(mentionCompose(account)); + mentionCompose(account); }, [account], ); @@ -346,9 +345,9 @@ const Notification: React.FC = (props) => { e?.preventDefault(); if (status) { - dispatch(replyCompose(status, account)); + replyCompose(status, account); } else { - dispatch(mentionCompose(account)); + mentionCompose(account); } }, [account], diff --git a/packages/pl-fe/src/features/reply-mentions/account.tsx b/packages/pl-fe/src/features/reply-mentions/account.tsx index c5436ddf3..cdecc9587 100644 --- a/packages/pl-fe/src/features/reply-mentions/account.tsx +++ b/packages/pl-fe/src/features/reply-mentions/account.tsx @@ -1,12 +1,10 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { addToMentions, removeFromMentions } from '@/actions/compose'; import AccountComponent from '@/components/account'; import IconButton from '@/components/ui/icon-button'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; import { useAccount } from '@/queries/accounts/use-account'; +import { useCompose, useComposeActions } from '@/stores/compose'; const messages = defineMessages({ remove: { id: 'reply_mentions.account.remove', defaultMessage: 'Remove from mentions' }, @@ -21,14 +19,25 @@ interface IAccount { const Account: React.FC = ({ composeId, accountId, author }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const compose = useCompose(composeId); const { data: account } = useAccount(accountId); const added = !!account && compose.to?.includes(account.acct); - const onRemove = () => dispatch(removeFromMentions(composeId, accountId)); - const onAdd = () => dispatch(addToMentions(composeId, accountId)); + const onRemove = () => + updateCompose(composeId, (draft) => { + if (account) { + draft.to = draft.to?.filter((acct) => acct !== account.acct) || []; + } + }); + const onAdd = () => + updateCompose(composeId, (draft) => { + if (account) { + if (draft.to?.includes(account.acct)) return; + draft.to = [...(draft.to || []), account.acct]; + } + }); if (!account) return null; diff --git a/packages/pl-fe/src/features/status/components/thread.tsx b/packages/pl-fe/src/features/status/components/thread.tsx index 402eca2bc..c072a7603 100644 --- a/packages/pl-fe/src/features/status/components/thread.tsx +++ b/packages/pl-fe/src/features/status/components/thread.tsx @@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { Helmet } from 'react-helmet-async'; import { useIntl } from 'react-intl'; -import { type ComposeReplyAction, mentionCompose, replyCompose } from '@/actions/compose'; import ScrollableList from '@/components/scrollable-list'; import StatusActionBar from '@/components/status-action-bar'; import Tombstone from '@/components/tombstone'; @@ -12,13 +11,13 @@ import Stack from '@/components/ui/stack'; import PlaceholderStatus from '@/features/placeholder/components/placeholder-status'; import { Hotkeys } from '@/features/ui/components/hotkeys'; import PendingStatus from '@/features/ui/components/pending-status'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useFavouriteStatus, useReblogStatus, useUnfavouriteStatus, useUnreblogStatus, } from '@/queries/statuses/use-status-interactions'; +import { useComposeActions } from '@/stores/compose'; import { useThread } from '@/stores/contexts'; import { useModalsActions } from '@/stores/modals'; import { useSettings } from '@/stores/settings'; @@ -49,9 +48,9 @@ const Thread = ({ withMedia = true, setExpandAllStatuses, }: IThread) => { - const dispatch = useAppDispatch(); const navigate = useNavigate(); const intl = useIntl(); + const { replyCompose, mentionCompose } = useComposeActions(); const { expandStatuses, revealStatusesMedia, toggleStatusesMediaHidden } = useStatusMetaActions(); const { openModal } = useModalsActions(); @@ -80,8 +79,8 @@ const Thread = ({ else favouriteStatus(); }; - const handleReplyClick = (status: ComposeReplyAction['status']) => { - dispatch(replyCompose(status)); + const handleReplyClick = (status: Parameters[0]) => { + replyCompose(status); }; const handleReblogClick = (status: SelectedStatus, e?: React.MouseEvent) => { @@ -100,7 +99,7 @@ const Thread = ({ }; const handleMentionClick = (account: Pick) => { - dispatch(mentionCompose(account)); + mentionCompose(account); }; const handleHotkeyOpenMedia = (e?: KeyboardEvent) => { diff --git a/packages/pl-fe/src/features/ui/components/compose-button.tsx b/packages/pl-fe/src/features/ui/components/compose-button.tsx index 3748e352a..bb4f09d6d 100644 --- a/packages/pl-fe/src/features/ui/components/compose-button.tsx +++ b/packages/pl-fe/src/features/ui/components/compose-button.tsx @@ -2,12 +2,11 @@ import { useMatch } from '@tanstack/react-router'; import React from 'react'; import { FormattedMessage } from 'react-intl'; -import { groupComposeModal } from '@/actions/compose'; import Avatar from '@/components/ui/avatar'; import HStack from '@/components/ui/hstack'; import Icon from '@/components/ui/icon'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useGroupQuery } from '@/queries/groups/use-group'; +import { useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import { layouts } from '../router'; @@ -47,14 +46,14 @@ const HomeComposeButton: React.FC = ({ shrink }) => { }; const GroupComposeButton: React.FC = ({ shrink }) => { - const dispatch = useAppDispatch(); + const { groupComposeModal } = useComposeActions(); const match = useMatch({ from: layouts.group.id, shouldThrow: false }); const { data: group } = useGroupQuery(match?.params.groupId); if (!group) return null; const onOpenCompose = () => { - dispatch(groupComposeModal(group)); + groupComposeModal(group); }; return ( diff --git a/packages/pl-fe/src/features/ui/components/modal-root.tsx b/packages/pl-fe/src/features/ui/components/modal-root.tsx index b1a3a3217..a6c3395d6 100644 --- a/packages/pl-fe/src/features/ui/components/modal-root.tsx +++ b/packages/pl-fe/src/features/ui/components/modal-root.tsx @@ -1,8 +1,7 @@ import React, { Suspense, lazy } from 'react'; -import { cancelReplyCompose } from '@/actions/compose'; import Base from '@/components/modal-root'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; +import { useComposeStore } from '@/stores/compose'; import { useModals, useModalsActions } from '@/stores/modals'; import ModalLoading from './modal-loading'; @@ -62,7 +61,6 @@ const ModalRoot: React.FC = () => { const renderLoading = (modalId: string) => !['MEDIA', 'BOOST', 'CONFIRM'].includes(modalId) ? : null; - const dispatch = useAppDispatch(); const modals = useModals(); const { closeModal } = useModalsActions(); const { modalType: type, modalProps: props } = modals.at(-1) ?? { @@ -74,7 +72,7 @@ const ModalRoot: React.FC = () => { const onClickClose = (type?: ModalType, all?: boolean) => { switch (type) { case 'COMPOSE': - dispatch(cancelReplyCompose()); + useComposeStore.getState().actions.resetCompose('compose-modal'); break; default: break; diff --git a/packages/pl-fe/src/features/ui/util/global-hotkeys.tsx b/packages/pl-fe/src/features/ui/util/global-hotkeys.tsx index 4f01fa91a..c9c53b999 100644 --- a/packages/pl-fe/src/features/ui/util/global-hotkeys.tsx +++ b/packages/pl-fe/src/features/ui/util/global-hotkeys.tsx @@ -1,10 +1,9 @@ import { useNavigate, useRouter } from '@tanstack/react-router'; import React, { useMemo } from 'react'; -import { resetCompose } from '@/actions/compose'; import { FOCUS_EDITOR_COMMAND } from '@/features/compose/editor/plugins/focus-plugin'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useOwnAccount } from '@/hooks/use-own-account'; +import { useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import { Hotkeys } from '../components/hotkeys'; @@ -45,9 +44,9 @@ interface IGlobalHotkeys { const GlobalHotkeys: React.FC = ({ children, node }) => { const navigate = useNavigate(); const { history } = useRouter(); - const dispatch = useAppDispatch(); const { data: account } = useOwnAccount(); const { openModal } = useModalsActions(); + const { resetCompose } = useComposeActions(); const handlers = useMemo(() => { const handleHotkeyNew = (e?: KeyboardEvent) => { @@ -84,7 +83,7 @@ const GlobalHotkeys: React.FC = ({ children, node }) => { const handleHotkeyForceNew = (e?: KeyboardEvent) => { const composeId = handleHotkeyNew(e); - dispatch(resetCompose(composeId ?? undefined)); + resetCompose(composeId ?? undefined); }; const handleHotkeyBack = () => { diff --git a/packages/pl-fe/src/hooks/use-compose-suggestions.ts b/packages/pl-fe/src/hooks/use-compose-suggestions.ts new file mode 100644 index 000000000..5b86ded29 --- /dev/null +++ b/packages/pl-fe/src/hooks/use-compose-suggestions.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; + +import { AutoSuggestion } from '@/components/autosuggest-input'; +import emojiSearch from '@/features/emoji/search'; +import { useDebounce } from '@/hooks/use-debounce'; +import { useCustomEmojis } from '@/queries/instance/use-custom-emojis'; +import { useSearchHashtags } from '@/queries/search/use-search'; +import { useAccountSearch } from '@/queries/search/use-search-accounts'; +import useTrends from '@/queries/trends'; + +const useComposeSuggestions = (token: string): Array => { + const debouncedToken = useDebounce(token, 300); + + const searchedType = token.startsWith('@') + ? 'accounts' + : token.startsWith('#') + ? 'hashtags' + : token.startsWith(':') + ? 'emojis' + : null; + + // TODO: fix default selectors across the code + const { data: customEmojis } = useCustomEmojis((emojis) => emojis); + const { data: accountIds } = useAccountSearch(searchedType === 'accounts' ? debouncedToken : '', { + resolve: false, + limit: 5, + }); + const { data: trendingTags } = useTrends(); + const { data: searchResult } = useSearchHashtags( + searchedType === 'hashtags' ? debouncedToken : '', + ); + + return useMemo((): Array => { + if (searchedType === 'emojis') { + return emojiSearch(token.replace(':', ''), { maxResults: 10 }, customEmojis); + } + + if (searchedType === 'accounts') { + return accountIds ?? []; + } + + if (searchedType === 'hashtags') { + if (token.length === 1) { + return (trendingTags ?? []).map(({ name }) => `#${name}`); + } + + return (searchResult ?? []).map(({ name }) => `#${name}`); + } + + return []; + }, [searchedType, token, customEmojis, accountIds, trendingTags, searchResult]); +}; + +export { useComposeSuggestions }; diff --git a/packages/pl-fe/src/hooks/use-compose.ts b/packages/pl-fe/src/hooks/use-compose.ts index d291fe05a..d74444e70 100644 --- a/packages/pl-fe/src/hooks/use-compose.ts +++ b/packages/pl-fe/src/hooks/use-compose.ts @@ -1,9 +1,2 @@ -import { useAppSelector } from './use-app-selector'; - -import type { Compose } from '@/reducers/compose'; - -/** Get compose for given key with fallback to 'default' */ -const useCompose = (composeId: ID extends 'default' ? never : ID): Compose => - useAppSelector((state) => state.compose[composeId] || state.compose.default); - -export { useCompose }; +// Re-export useCompose from the Zustand store +export { useCompose } from '@/stores/compose'; diff --git a/packages/pl-fe/src/layouts/home-layout.tsx b/packages/pl-fe/src/layouts/home-layout.tsx index 80d40fe59..cab57f6f1 100644 --- a/packages/pl-fe/src/layouts/home-layout.tsx +++ b/packages/pl-fe/src/layouts/home-layout.tsx @@ -1,9 +1,7 @@ import { Outlet, Link } from '@tanstack/react-router'; import clsx from 'clsx'; import React, { useRef } from 'react'; -import { useIntl } from 'react-intl'; -import { uploadCompose } from '@/actions/compose'; import { BANNER_HTML } from '@/build-config'; import Avatar from '@/components/ui/avatar'; import Layout from '@/components/ui/layout'; @@ -20,18 +18,15 @@ import { AnnouncementsPanel, ComposeForm, } from '@/features/ui/util/async-components'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; import { useDraggedFiles } from '@/hooks/use-dragged-files'; import { useFeatures } from '@/hooks/use-features'; import { useFrontendConfig } from '@/hooks/use-frontend-config'; import { useOwnAccount } from '@/hooks/use-own-account'; +import { useUploadCompose } from '@/stores/compose'; import { useSettings } from '@/stores/settings'; const HomeLayout = () => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - const me = useAppSelector((state) => state.me); const { data: account } = useOwnAccount(); const features = useFeatures(); @@ -41,11 +36,13 @@ const HomeLayout = () => { const composeId = 'home'; const composeBlock = useRef(null); + const uploadCompose = useUploadCompose(composeId); + const hasCrypto = typeof frontendConfig.cryptoAddresses[0]?.ticker === 'string'; const cryptoLimit = frontendConfig.cryptoDonatePanel.limit; const { isDragging, isDraggedOver } = useDraggedFiles(composeBlock, (files) => { - dispatch(uploadCompose(composeId, files, intl)); + uploadCompose(files); }); const acct = account ? account.acct : ''; diff --git a/packages/pl-fe/src/modals/compose-interaction-policy-modal.tsx b/packages/pl-fe/src/modals/compose-interaction-policy-modal.tsx index 662fba57a..bf0802fd8 100644 --- a/packages/pl-fe/src/modals/compose-interaction-policy-modal.tsx +++ b/packages/pl-fe/src/modals/compose-interaction-policy-modal.tsx @@ -1,17 +1,12 @@ import { Link } from '@tanstack/react-router'; +import { create } from 'mutative'; import React, { useEffect, useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { - changeComposeInteractionPolicyOption, - changeComposeQuotePolicyOption, -} from '@/actions/compose'; import Modal from '@/components/ui/modal'; import Stack from '@/components/ui/stack'; import Warning from '@/features/compose/components/warning'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useClient } from '@/hooks/use-client'; -import { useCompose } from '@/hooks/use-compose'; import { InteractionPolicyConfig, type Policy, @@ -19,9 +14,10 @@ import { type Scope, } from '@/pages/settings/interaction-policies'; import { useInteractionPolicies } from '@/queries/settings/use-interaction-policies'; +import { useCompose, useComposeActions } from '@/stores/compose'; import type { BaseModalProps } from '@/features/ui/components/modal-root'; -import type { CreateStatusParams } from 'pl-api'; +import type { CreateStatusParams, InteractionPolicy } from 'pl-api'; const MANAGABLE_VISIBILITIES = ['public', 'unlisted', 'private']; @@ -33,7 +29,7 @@ const ComposeInteractionPolicyModal: React.FC< BaseModalProps & ComposeInteractionPolicyModalProps > = ({ composeId, onClose }) => { const client = useClient(); - const dispatch = useAppDispatch(); + const { updateCompose } = useComposeActions(); const [initialQuotePolicy, setInitialQuotePolicy] = useState(undefined); const { interactionPolicies: initial } = useInteractionPolicies(); @@ -65,13 +61,25 @@ const ComposeInteractionPolicyModal: React.FC< }; const onChange = (policy: Policy, rule: Rule, value: Scope[]) => { - dispatch( - changeComposeInteractionPolicyOption(composeId, policy, rule, value, interactionPolicy), - ); + updateCompose(composeId, (draft) => { + draft.interactionPolicy ??= JSON.parse(JSON.stringify(interactionPolicy))!; + + draft.interactionPolicy = create( + draft.interactionPolicy ?? interactionPolicy, + (draftPolicy: InteractionPolicy) => { + draftPolicy[policy][rule] = value; + draftPolicy[policy][rule === 'always' ? 'with_approval' : 'always'] = draftPolicy[policy][ + rule === 'always' ? 'with_approval' : 'always' + ].filter((r) => !value.includes(r as any)); + }, + ); + }); }; const onQuotePolicyChange = (value: CreateStatusParams['quote_approval_policy']) => { - dispatch(changeComposeQuotePolicyOption(composeId, value)); + updateCompose(composeId, (draft) => { + draft.quoteApprovalPolicy = value; + }); }; return ( diff --git a/packages/pl-fe/src/modals/compose-modal.tsx b/packages/pl-fe/src/modals/compose-modal.tsx index 71cae843a..8d4f2e224 100644 --- a/packages/pl-fe/src/modals/compose-modal.tsx +++ b/packages/pl-fe/src/modals/compose-modal.tsx @@ -2,14 +2,12 @@ import clsx from 'clsx'; import React, { useRef } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { cancelReplyCompose, uploadCompose } from '@/actions/compose'; import { checkComposeContent } from '@/components/modal-root'; import Modal from '@/components/ui/modal'; import { ComposeForm } from '@/features/ui/util/async-components'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useCompose } from '@/hooks/use-compose'; import { useDraggedFiles } from '@/hooks/use-dragged-files'; import { usePersistDraftStatus } from '@/queries/statuses/use-draft-statuses'; +import { useCompose, useComposeActions, useUploadCompose } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import type { BaseModalProps } from '@/features/ui/components/modal-root'; @@ -29,16 +27,17 @@ const ComposeModal: React.FC = ({ composeId = 'compose-modal', }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); const node = useRef(null); const compose = useCompose(composeId); + const uploadCompose = useUploadCompose(composeId); + const { resetCompose } = useComposeActions(); const { openModal } = useModalsActions(); const persistDraftStatus = usePersistDraftStatus(); const { editedId, visibility, inReplyToId, quoteId, groupId } = compose; const { isDragging, isDraggedOver } = useDraggedFiles(node, (files) => { - dispatch(uploadCompose(composeId, files, intl)); + uploadCompose(files); }); const onClickClose = () => { @@ -76,7 +75,7 @@ const ComposeModal: React.FC = ({ confirm: intl.formatMessage(editedId ? messages.cancelEditing : messages.confirm), onConfirm: () => { onClose('COMPOSE'); - dispatch(cancelReplyCompose()); + resetCompose('compose-modal'); }, secondary: intl.formatMessage(messages.saveDraft), onSecondary: editedId @@ -84,7 +83,7 @@ const ComposeModal: React.FC = ({ : () => { persistDraftStatus(composeId); onClose('COMPOSE'); - dispatch(cancelReplyCompose()); + resetCompose('compose-modal'); }, }); } else { diff --git a/packages/pl-fe/src/modals/reply-mentions-modal.tsx b/packages/pl-fe/src/modals/reply-mentions-modal.tsx index 29ec7294b..748df4487 100644 --- a/packages/pl-fe/src/modals/reply-mentions-modal.tsx +++ b/packages/pl-fe/src/modals/reply-mentions-modal.tsx @@ -6,8 +6,8 @@ import Account from '@/features/reply-mentions/account'; import { useAppSelector } from '@/hooks/use-app-selector'; import { useCompose } from '@/hooks/use-compose'; import { useOwnAccount } from '@/hooks/use-own-account'; -import { statusToMentionsAccountIdsArray } from '@/reducers/compose'; import { makeGetStatus } from '@/selectors'; +import { statusToMentionsAccountIdsArray } from '@/stores/compose'; import type { BaseModalProps } from '@/features/ui/components/modal-root'; diff --git a/packages/pl-fe/src/pages/fun/circle.tsx b/packages/pl-fe/src/pages/fun/circle.tsx index 25608cbb6..611ff3609 100644 --- a/packages/pl-fe/src/pages/fun/circle.tsx +++ b/packages/pl-fe/src/pages/fun/circle.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { processCircle } from '@/actions/circle'; -import { resetCompose, uploadComposeSuccess, uploadFile } from '@/actions/compose'; +import { uploadFile } from '@/actions/media'; import Account from '@/components/account'; import Accordion from '@/components/ui/accordion'; import Avatar from '@/components/ui/avatar'; @@ -17,6 +17,7 @@ import Stack from '@/components/ui/stack'; import Text from '@/components/ui/text'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useOwnAccount } from '@/hooks/use-own-account'; +import { appendMedia, useComposeActions } from '@/stores/compose'; import { useModalsActions } from '@/stores/modals'; import toast from '@/toast'; @@ -76,6 +77,7 @@ const CirclePage: React.FC = () => { const canvasRef = useRef(null); const { openModal } = useModalsActions(); + const { resetCompose, updateCompose } = useComposeActions(); const { data: account } = useOwnAccount(); useEffect(() => {}, []); @@ -92,14 +94,16 @@ const CirclePage: React.FC = () => { const onCompose: React.MouseEventHandler = (e) => { e.preventDefault(); - dispatch(resetCompose('compose-modal')); + resetCompose('compose-modal'); canvasRef.current!.toBlob((blob) => { const file = new File([blob!], 'interactions_circle.png', { type: 'image/png' }); dispatch( uploadFile(file, intl, (data) => { - dispatch(uploadComposeSuccess('compose-modal', data)); + updateCompose('compose-modal', (draft) => { + appendMedia(draft, data); + }); openModal('COMPOSE'); }), ); diff --git a/packages/pl-fe/src/pages/statuses/compose-event.tsx b/packages/pl-fe/src/pages/statuses/compose-event.tsx index bcbab2287..626705f15 100644 --- a/packages/pl-fe/src/pages/statuses/compose-event.tsx +++ b/packages/pl-fe/src/pages/statuses/compose-event.tsx @@ -8,7 +8,6 @@ import Tabs from '@/components/ui/tabs'; import { EditEvent } from '@/features/compose-event/tabs/edit-event'; import { ManagePendingParticipants } from '@/features/compose-event/tabs/manage-pending-participants'; import { eventEditRoute } from '@/features/ui/router'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; const messages = defineMessages({ manageEvent: { id: 'navigation_bar.manage_event', defaultMessage: 'Manage event' }, @@ -19,7 +18,6 @@ const messages = defineMessages({ const EditEventPage = () => { const intl = useIntl(); - const dispatch = useAppDispatch(); const { statusId } = eventEditRoute.useParams(); @@ -27,7 +25,7 @@ const EditEventPage = () => { useEffect( () => () => { - dispatch(cancelEventCompose()); + cancelEventCompose(); }, [statusId], ); @@ -69,11 +67,10 @@ const EditEventPage = () => { const ComposeEventPage = () => { const intl = useIntl(); - const dispatch = useAppDispatch(); useEffect( () => () => { - dispatch(cancelEventCompose()); + cancelEventCompose(); }, [], ); diff --git a/packages/pl-fe/src/pages/statuses/event-discussion.tsx b/packages/pl-fe/src/pages/statuses/event-discussion.tsx index 729316972..531a8338d 100644 --- a/packages/pl-fe/src/pages/statuses/event-discussion.tsx +++ b/packages/pl-fe/src/pages/statuses/event-discussion.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; -import { eventDiscussionCompose } from '@/actions/compose'; import { fetchStatusWithContext } from '@/actions/statuses'; import MissingIndicator from '@/components/missing-indicator'; import ScrollableList from '@/components/scrollable-list'; @@ -15,6 +14,7 @@ import { ComposeForm } from '@/features/ui/util/async-components'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; import { makeGetStatus } from '@/selectors'; +import { useComposeActions } from '@/stores/compose'; import { useDescendantsIds } from '@/stores/contexts'; import { selectChild } from '@/utils/scroll-utils'; @@ -25,6 +25,7 @@ const EventDiscussionPage: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const { eventDiscussionCompose } = useComposeActions(); const getStatus = useCallback(makeGetStatus(), []); const status = useAppSelector((state) => getStatus(state, { id: statusId })); @@ -51,7 +52,7 @@ const EventDiscussionPage: React.FC = () => { }, [statusId]); useEffect(() => { - if (isLoaded && me) dispatch(eventDiscussionCompose(`reply:${statusId}`, status!)); + if (isLoaded && me) eventDiscussionCompose(`reply:${statusId}`, status!); }, [isLoaded, me]); const handleMoveUp = (id: string) => { diff --git a/packages/pl-fe/src/pages/timelines/group-timeline.tsx b/packages/pl-fe/src/pages/timelines/group-timeline.tsx index 7cb151890..4d7fbe037 100644 --- a/packages/pl-fe/src/pages/timelines/group-timeline.tsx +++ b/packages/pl-fe/src/pages/timelines/group-timeline.tsx @@ -1,9 +1,8 @@ import { Link } from '@tanstack/react-router'; import clsx from 'clsx'; import React, { useEffect, useRef } from 'react'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; -import { groupCompose, uploadCompose } from '@/actions/compose'; import { fetchGroupTimeline } from '@/actions/timelines'; import { useGroupStream } from '@/api/hooks/streaming/use-group-stream'; import Avatar from '@/components/ui/avatar'; @@ -18,27 +17,30 @@ import { useDraggedFiles } from '@/hooks/use-dragged-files'; import { useOwnAccount } from '@/hooks/use-own-account'; import { useGroupQuery } from '@/queries/groups/use-group'; import { makeGetStatusIds } from '@/selectors'; +import { useComposeActions, useUploadCompose } from '@/stores/compose'; const getStatusIds = makeGetStatusIds(); const GroupTimelinePage: React.FC = () => { const { groupId } = groupTimelineRoute.useParams(); - const intl = useIntl(); + const composeId = `group:${groupId}`; + const { data: account } = useOwnAccount(); const dispatch = useAppDispatch(); + const uploadCompose = useUploadCompose(composeId); + const { updateCompose } = useComposeActions(); const composer = useRef(null); const { data: group } = useGroupQuery(groupId); - const composeId = `group:${groupId}`; const canComposeGroupStatus = !!account && group?.relationship?.member; const featuredStatusIds = useAppSelector((state) => getStatusIds(state, { type: `group:${group?.id}:pinned` }), ); const { isDragging, isDraggedOver } = useDraggedFiles(composer, (files) => { - dispatch(uploadCompose(composeId, files, intl)); + uploadCompose(files); }); const handleLoadMore = (maxId: string) => { @@ -50,7 +52,11 @@ const GroupTimelinePage: React.FC = () => { useEffect(() => { dispatch(fetchGroupTimeline(groupId, {})); // dispatch(fetchGroupTimeline(groupId, { pinned: true })); - dispatch(groupCompose(composeId, groupId)); + updateCompose(composeId, (draft) => { + draft.visibility = 'group'; + draft.groupId = groupId; + draft.caretPosition = null; + }); }, [groupId]); if (!group) { diff --git a/packages/pl-fe/src/pages/utils/share.tsx b/packages/pl-fe/src/pages/utils/share.tsx index ae2874d04..983ec5835 100644 --- a/packages/pl-fe/src/pages/utils/share.tsx +++ b/packages/pl-fe/src/pages/utils/share.tsx @@ -1,12 +1,11 @@ import { useNavigate } from '@tanstack/react-router'; import React, { useEffect } from 'react'; -import { openComposeWithText } from '@/actions/compose'; import { shareRoute } from '@/features/ui/router'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; +import { useComposeActions } from '@/stores/compose'; const SharePage: React.FC = () => { - const dispatch = useAppDispatch(); + const { openComposeWithText } = useComposeActions(); const navigate = useNavigate(); const params = shareRoute.useSearch(); @@ -17,7 +16,7 @@ const SharePage: React.FC = () => { navigate({ to: '/', replace: true }); if (text) { - dispatch(openComposeWithText('compose-modal', text)); + openComposeWithText('compose-modal', text); } }, []); diff --git a/packages/pl-fe/src/queries/statuses/use-draft-statuses.ts b/packages/pl-fe/src/queries/statuses/use-draft-statuses.ts index ce8532daf..ea2a3750c 100644 --- a/packages/pl-fe/src/queries/statuses/use-draft-statuses.ts +++ b/packages/pl-fe/src/queries/statuses/use-draft-statuses.ts @@ -3,10 +3,10 @@ import { create } from 'mutative'; import { mediaAttachmentSchema } from 'pl-api'; import * as v from 'valibot'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useOwnAccount } from '@/hooks/use-own-account'; import { filteredArray } from '@/schemas/utils'; import KVStore from '@/storage/kv-store'; +import { useComposeStore } from '@/stores/compose'; import { APIEntity } from '@/types/entities'; const draftStatusSchema = v.pipe( @@ -72,27 +72,24 @@ const useDraftStatusesCountQuery = () => const usePersistDraftStatus = () => { const { data: account } = useOwnAccount(); - const dispatch = useAppDispatch(); const queryClient = useQueryClient(); return (composeId: string) => { - dispatch((_, getState) => { - const compose = getState().compose[composeId]; + const compose = useComposeStore.getState().actions.getCompose(composeId); - const draft = { - ...compose, - draft_id: compose.draftId ?? crypto.randomUUID(), - }; + const draft = { + ...compose, + draft_id: compose.draftId ?? crypto.randomUUID(), + }; - const drafts = queryClient.getQueryData>(['draftStatuses']) ?? {}; + const drafts = queryClient.getQueryData>(['draftStatuses']) ?? {}; - const newDrafts: Record = create(drafts, (oldDrafts) => { - oldDrafts[draft.draft_id] = v.parse(draftStatusSchema, draft); - }); - return persistDrafts(account!.url, newDrafts).then(() => - queryClient.invalidateQueries({ queryKey: ['draftStatuses'] }), - ); + const newDrafts: Record = create(drafts, (oldDrafts) => { + oldDrafts[draft.draft_id] = v.parse(draftStatusSchema, draft); }); + return persistDrafts(account!.url, newDrafts).then(() => + queryClient.invalidateQueries({ queryKey: ['draftStatuses'] }), + ); }; }; diff --git a/packages/pl-fe/src/queries/utils/make-paginated-response-query-options.ts b/packages/pl-fe/src/queries/utils/make-paginated-response-query-options.ts index a3ca72f5a..cc48f0c01 100644 --- a/packages/pl-fe/src/queries/utils/make-paginated-response-query-options.ts +++ b/packages/pl-fe/src/queries/utils/make-paginated-response-query-options.ts @@ -1,6 +1,6 @@ import { type InfiniteData, infiniteQueryOptions, type QueryKey } from '@tanstack/react-query'; -import { store } from '@/store'; +import { getClient } from '@/api'; import { PaginatedResponseArray, @@ -23,8 +23,7 @@ const makePaginatedResponseQueryOptions = (...params: T1) => infiniteQueryOptions({ queryKey: typeof queryKey === 'object' ? queryKey : queryKey(...params), - queryFn: ({ pageParam }) => - pageParam.next?.() ?? queryFn(store.getState().auth.client, params), + queryFn: ({ pageParam }) => pageParam.next?.() ?? queryFn(getClient(), params), initialPageParam: { previous: null, next: null, diff --git a/packages/pl-fe/src/reducers/compose.ts b/packages/pl-fe/src/reducers/compose.ts deleted file mode 100644 index 6aca81ec6..000000000 --- a/packages/pl-fe/src/reducers/compose.ts +++ /dev/null @@ -1,888 +0,0 @@ -import { create } from 'mutative'; - -import { INSTANCE_FETCH_SUCCESS, type InstanceAction } from '@/actions/instance'; - -import { - COMPOSE_CHANGE, - COMPOSE_REPLY, - COMPOSE_REPLY_CANCEL, - COMPOSE_QUOTE, - COMPOSE_QUOTE_CANCEL, - COMPOSE_GROUP_POST, - COMPOSE_DIRECT, - COMPOSE_MENTION, - COMPOSE_SUBMIT_REQUEST, - COMPOSE_SUBMIT_SUCCESS, - COMPOSE_SUBMIT_FAIL, - COMPOSE_UPLOAD_REQUEST, - COMPOSE_UPLOAD_SUCCESS, - COMPOSE_UPLOAD_FAIL, - COMPOSE_UPLOAD_UNDO, - COMPOSE_UPLOAD_PROGRESS, - COMPOSE_SUGGESTIONS_CLEAR, - COMPOSE_SUGGESTIONS_READY, - COMPOSE_SUGGESTION_SELECT, - COMPOSE_SUGGESTION_TAGS_UPDATE, - COMPOSE_SPOILERNESS_CHANGE, - COMPOSE_TYPE_CHANGE, - COMPOSE_SPOILER_TEXT_CHANGE, - COMPOSE_VISIBILITY_CHANGE, - COMPOSE_LANGUAGE_CHANGE, - COMPOSE_MODIFIED_LANGUAGE_CHANGE, - COMPOSE_LANGUAGE_ADD, - COMPOSE_LANGUAGE_DELETE, - COMPOSE_ADD_SUGGESTED_LANGUAGE, - COMPOSE_UPLOAD_CHANGE_REQUEST, - COMPOSE_UPLOAD_CHANGE_SUCCESS, - COMPOSE_UPLOAD_CHANGE_FAIL, - COMPOSE_RESET, - COMPOSE_POLL_ADD, - COMPOSE_POLL_REMOVE, - COMPOSE_SCHEDULE_ADD, - COMPOSE_SCHEDULE_SET, - COMPOSE_SCHEDULE_REMOVE, - COMPOSE_POLL_OPTION_ADD, - COMPOSE_POLL_OPTION_CHANGE, - COMPOSE_POLL_OPTION_REMOVE, - COMPOSE_POLL_SETTINGS_CHANGE, - COMPOSE_ADD_TO_MENTIONS, - COMPOSE_REMOVE_FROM_MENTIONS, - COMPOSE_SET_STATUS, - COMPOSE_EVENT_REPLY, - COMPOSE_EDITOR_STATE_SET, - COMPOSE_CHANGE_MEDIA_ORDER, - COMPOSE_ADD_SUGGESTED_QUOTE, - COMPOSE_FEDERATED_CHANGE, - COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, - COMPOSE_CLEAR_LINK_SUGGESTION_CREATE, - COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE, - COMPOSE_PREVIEW_SUCCESS, - COMPOSE_PREVIEW_CANCEL, - COMPOSE_HASHTAG_CASING_SUGGESTION_SET, - COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE, - COMPOSE_REDACTING_OVERWRITE_CHANGE, - COMPOSE_QUOTE_POLICY_OPTION_CHANGE, - COMPOSE_SET_LOCATION, - COMPOSE_SET_SHOW_LOCATION_PICKER, - type ComposeAction, - type ComposeSuggestionSelectAction, -} from '../actions/compose'; -import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events'; -import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, type MeAction } from '../actions/me'; -import { FE_NAME } from '../actions/settings'; -import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines'; -import { unescapeHTML } from '../utils/html'; - -import type { Emoji } from '@/features/emoji'; -import type { Language } from '@/features/preferences'; -import type { Status } from '@/normalizers/status'; -import type { - Account, - CredentialAccount, - Instance, - InteractionPolicy, - Location, - MediaAttachment, - Status as BaseStatus, - Tag, - CreateStatusParams, -} from 'pl-api'; - -const getResetFileKey = () => Math.floor(Math.random() * 0x10000); - -interface ComposePoll { - options: Array; - options_map: Array>; - expires_in: number; - multiple: boolean; - hide_totals: boolean; -} - -const newPoll = (params: Partial = {}): ComposePoll => ({ - options: ['', ''], - options_map: [{}, {}], - expires_in: 24 * 3600, - multiple: false, - hide_totals: false, - ...params, -}); - -interface ClearLinkSuggestion { - key: string; - originalUrl: string; - cleanUrl: string; -} - -interface Compose { - // User-edited text - editorState: string | null; - editorStateMap: Record; - spoilerText: string; - spoilerTextMap: Record; - text: string; - textMap: Record; - - // Non-text content - mediaAttachments: Array; - poll: ComposePoll | null; - location: Location | null; - - // Post settings - contentType: string; - interactionPolicy: InteractionPolicy | null; - quoteApprovalPolicy: CreateStatusParams['quote_approval_policy'] | null; - language: Language | string | null; - localOnly: boolean; - scheduledAt: Date | null; - sensitive: boolean; - visibility: string; - - // References to other posts/groups/users - draftId: string | null; - groupId: string | null; - editedId: string | null; - inReplyToId: string | null; - quoteId: string | null; - to: Array; - parentRebloggedById: string | null; - - // State flags - isChangingUpload: boolean; - isSubmitting: boolean; - isUploading: boolean; - progress: number; - - // Internal - caretPosition: number | null; - idempotencyKey: string; - resetFileKey: number | null; - - // Currently modified language - modifiedLanguage: Language | string | null; - - // Suggestions - approvalRequired: boolean; - clearLinkSuggestion: ClearLinkSuggestion | null; - dismissedClearLinksSuggestions: Array; - dismissedQuotes: Array; - hashtagCasingSuggestion: string | null; - hashtagCasingSuggestionIgnored: boolean | null; - preview: Partial | null; - suggestedLanguage: string | null; - suggestions: Array | Array; - showLocationPicker: boolean; - - // Moderation features - redacting: boolean; - redactingOverwrite: boolean; -} - -const newCompose = (params: Partial = {}): Compose => ({ - editorState: null, - editorStateMap: {}, - spoilerText: '', - spoilerTextMap: {}, - text: '', - textMap: {}, - - mediaAttachments: [], - poll: null, - location: null, - - contentType: 'text/plain', - interactionPolicy: null, - quoteApprovalPolicy: null, - language: null, - localOnly: false, - scheduledAt: null, - sensitive: false, - visibility: 'public', - - draftId: null, - groupId: null, - editedId: null, - inReplyToId: null, - quoteId: null, - to: [], - parentRebloggedById: null, - - isChangingUpload: false, - isSubmitting: false, - isUploading: false, - progress: 0, - - caretPosition: null, - idempotencyKey: '', - resetFileKey: null, - - modifiedLanguage: null, - - approvalRequired: false, - clearLinkSuggestion: null, - dismissedClearLinksSuggestions: [], - dismissedQuotes: [], - hashtagCasingSuggestion: null, - hashtagCasingSuggestionIgnored: null, - preview: null, - suggestedLanguage: null, - suggestions: [], - showLocationPicker: false, - - redacting: false, - redactingOverwrite: false, - - ...params, -}); - -type State = { - default: Compose; - [key: string]: Compose; -}; - -const statusToTextMentions = ( - status: Pick, - account: Pick, -) => { - const author = status.account.acct; - const mentions = status.mentions.map((m) => m.acct) || []; - - return [...new Set([author, ...mentions].filter((acct) => acct !== account.acct))] - .map((m) => `@${m} `) - .join(''); -}; - -const statusToMentionsArray = ( - status: Pick, - account: Pick, - rebloggedBy?: Pick, -) => { - const author = status.account.acct; - const mentions = status.mentions.map((m) => m.acct) || []; - - return [ - ...new Set( - [author, ...(rebloggedBy ? [rebloggedBy.acct] : []), ...mentions].filter( - (acct) => acct !== account.acct, - ), - ), - ]; -}; - -const statusToMentionsAccountIdsArray = ( - status: Pick, - account: Pick, - parentRebloggedBy?: string | null, -) => { - const mentions = status.mentions.map((m) => m.id); - - return [ - ...new Set( - [status.account.id, ...(parentRebloggedBy ? [parentRebloggedBy] : []), ...mentions].filter( - (id) => id !== account.id, - ), - ), - ]; -}; - -const appendMedia = (compose: Compose, media: MediaAttachment, defaultSensitive?: boolean) => { - const prevSize = compose.mediaAttachments.length; - - compose.mediaAttachments.push(media); - compose.isUploading = false; - compose.resetFileKey = Math.floor(Math.random() * 0x10000); - - if (prevSize === 0 && (defaultSensitive || compose.sensitive)) { - compose.sensitive = true; - } -}; - -const removeMedia = (compose: Compose, mediaId: string) => { - const prevSize = compose.mediaAttachments.length; - - compose.mediaAttachments = compose.mediaAttachments.filter((item) => item.id !== mediaId); - - if (prevSize === 1) { - compose.sensitive = false; - } -}; - -const insertSuggestion = ( - compose: Compose, - position: number, - token: string | null, - completion: string, - path: ComposeSuggestionSelectAction['path'], -) => { - const updateText = (oldText?: string) => - `${oldText?.slice(0, position)}${completion} ${oldText?.slice(position + (token?.length ?? 0))}`; - if (path[0] === 'spoiler_text') { - compose.spoilerText = updateText(compose.spoilerText); - } else if (compose.poll) { - compose.poll.options[path[2]] = updateText(compose.poll.options[path[2]]); - } - compose.suggestions = []; -}; - -const updateSuggestionTags = (compose: Compose, token: string, tags: Tag[]) => { - const prefix = token.slice(1); - - compose.suggestions = tags - .filter((tag) => tag.name.toLowerCase().startsWith(prefix.toLowerCase())) - .slice(0, 4) - .map((tag) => '#' + tag.name); -}; - -const privacyPreference = ( - a: string, - b: string, - list_id: number | null, - conversationScope = false, -) => { - if (['private', 'subscribers'].includes(a) && conversationScope) return 'conversation'; - - const order = ['public', 'unlisted', 'mutuals_only', 'private', 'direct', 'local']; - - if (a === 'group') return a; - if (a === 'list' && list_id !== null) return `list:${list_id}`; - - return order[Math.max(order.indexOf(a), order.indexOf(b), 0)]; -}; - -const domParser = new DOMParser(); - -const expandMentions = (status: Pick) => { - const fragment = domParser.parseFromString(status.content, 'text/html').documentElement; - - status.mentions.forEach((mention) => { - const node = fragment.querySelector(`a[href="${mention.url}"]`); - if (node) node.textContent = `@${mention.acct}`; - }); - - return fragment.innerHTML; -}; - -const getExplicitMentions = (me: string, status: Pick) => { - const fragment = domParser.parseFromString(status.content, 'text/html').documentElement; - - const mentions = status.mentions - .filter((mention) => !(fragment.querySelector(`a[href="${mention.url}"]`) ?? mention.id === me)) - .map((m) => m.acct); - - return [...new Set(mentions)]; -}; - -const importAccount = (compose: Compose, account: CredentialAccount) => { - const settings = account.settings_store?.[FE_NAME]; - - if (!settings) return; - - if (settings.defaultPrivacy) compose.visibility = settings.defaultPrivacy; - if (settings.defaultContentType) compose.contentType = settings.defaultContentType; -}; - -// const updateSetting = (compose: Compose, path: string[], value: string) => { -// const pathString = path.join(','); -// switch (pathString) { -// case 'defaultPrivacy': -// return compose.set('privacy', value); -// case 'defaultContentType': -// return compose.set('content_type', value); -// default: -// return compose; -// } -// }; - -const updateDefaultContentType = (compose: Compose, instance: Instance) => { - const postFormats = instance.pleroma.metadata.post_formats; - - compose.contentType = - postFormats.includes(compose.contentType) || - (postFormats.includes('text/markdown') && compose.contentType === 'wysiwyg') - ? compose.contentType - : postFormats.includes('text/markdown') - ? 'text/markdown' - : postFormats[0]; -}; - -const updateCompose = (state: State, key: string, updater: (compose: Compose) => void) => - create(state, (draft) => { - draft[key] = - draft[key] || - create(draft.default, (draft) => { - draft.idempotencyKey = crypto.randomUUID(); - }); - updater(draft[key]); - }); -// state.update(key, state.get('default')!, updater); - -const initialState: State = { - default: newCompose({ idempotencyKey: crypto.randomUUID(), resetFileKey: getResetFileKey() }), -}; - -const compose = ( - state = initialState, - action: ComposeAction | EventsAction | InstanceAction | MeAction | TimelineAction, -): State => { - switch (action.type) { - case COMPOSE_TYPE_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.contentType = action.value; - }); - case COMPOSE_SPOILERNESS_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.sensitive = !compose.sensitive; - }); - case COMPOSE_SPOILER_TEXT_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - if (!compose.modifiedLanguage || compose.modifiedLanguage === compose.language) { - compose.spoilerText = action.text; - } else if (compose.modifiedLanguage) { - compose.spoilerTextMap[compose.modifiedLanguage] = action.text; - } - }); - case COMPOSE_VISIBILITY_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.visibility = action.value; - }); - case COMPOSE_LANGUAGE_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.language = action.value; - compose.modifiedLanguage = action.value; - }); - case COMPOSE_MODIFIED_LANGUAGE_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.modifiedLanguage = action.value; - }); - case COMPOSE_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.text = action.text; - }); - case COMPOSE_REPLY: - return updateCompose(state, action.composeId, (compose) => { - const defaultCompose = state.default; - - const mentions = action.explicitAddressing - ? statusToMentionsArray(action.status, action.account, action.rebloggedBy) - : []; - - compose.groupId = action.status.group_id; - compose.inReplyToId = action.status.id; - compose.to = mentions; - compose.parentRebloggedById = action.rebloggedBy?.id ?? null; - compose.text = !action.explicitAddressing - ? statusToTextMentions(action.status, action.account) - : ''; - compose.visibility = privacyPreference( - action.status.visibility, - defaultCompose.visibility, - action.status.list_id, - action.conversationScope, - ); - compose.localOnly = action.status.local_only === true; - compose.caretPosition = null; - compose.contentType = defaultCompose.contentType; - compose.approvalRequired = action.approvalRequired ?? false; - if (action.preserveSpoilers && action.status.spoiler_text) { - compose.sensitive = true; - compose.spoilerText = action.status.spoiler_text; - } - }); - case COMPOSE_EVENT_REPLY: - return updateCompose(state, action.composeId, (compose) => { - compose.inReplyToId = action.status.id; - compose.to = statusToMentionsArray(action.status, action.account); - }); - case COMPOSE_QUOTE: - return updateCompose(state, 'compose-modal', (compose) => { - const author = action.status.account.acct; - const defaultCompose = state.default; - - compose.quoteId = action.status.id; - compose.to = [author]; - compose.parentRebloggedById = null; - compose.text = ''; - compose.visibility = privacyPreference( - action.status.visibility, - defaultCompose.visibility, - action.status.list_id, - ); - compose.caretPosition = null; - compose.contentType = defaultCompose.contentType; - compose.spoilerText = ''; - compose.approvalRequired = action.approvalRequired ?? false; - - if (action.status.visibility === 'group') { - compose.groupId = action.status.group_id; - compose.visibility = 'group'; - } - }); - case COMPOSE_SUBMIT_REQUEST: - return updateCompose(state, action.composeId, (compose) => { - compose.isSubmitting = true; - }); - case COMPOSE_UPLOAD_CHANGE_REQUEST: - return updateCompose(state, action.composeId, (compose) => { - compose.isChangingUpload = true; - }); - case COMPOSE_REPLY_CANCEL: - case COMPOSE_RESET: - case COMPOSE_SUBMIT_SUCCESS: - return create(state, (draft) => { - draft[action.composeId] = create(state.default, (draft) => ({ - ...draft, - idempotencyKey: crypto.randomUUID(), - in_reply_to_id: action.composeId.startsWith('reply:') ? action.composeId.slice(6) : null, - ...(action.composeId.startsWith('group:') - ? { - visibility: 'group', - group_id: action.composeId.slice(6), - } - : undefined), - })); - }); - case COMPOSE_SUBMIT_FAIL: - return updateCompose(state, action.composeId, (compose) => { - compose.isSubmitting = false; - }); - case COMPOSE_UPLOAD_CHANGE_FAIL: - return updateCompose(state, action.composeId, (compose) => { - compose.isChangingUpload = false; - }); - case COMPOSE_UPLOAD_REQUEST: - return updateCompose(state, action.composeId, (compose) => { - compose.isUploading = true; - }); - case COMPOSE_UPLOAD_SUCCESS: - return updateCompose(state, action.composeId, (compose) => { - appendMedia(compose, action.media, state.default.sensitive); - }); - case COMPOSE_UPLOAD_FAIL: - return updateCompose(state, action.composeId, (compose) => { - compose.isUploading = false; - }); - case COMPOSE_UPLOAD_UNDO: - return updateCompose(state, action.composeId, (compose) => { - removeMedia(compose, action.mediaId); - }); - case COMPOSE_UPLOAD_PROGRESS: - return updateCompose(state, action.composeId, (compose) => { - compose.progress = Math.round((action.loaded / action.total) * 100); - }); - case COMPOSE_MENTION: - return updateCompose(state, 'compose-modal', (compose) => { - compose.text = [compose.text.trim(), `@${action.account.acct} `] - .filter((str) => str.length !== 0) - .join(' '); - compose.caretPosition = null; - }); - case COMPOSE_DIRECT: - return updateCompose(state, 'compose-modal', (compose) => { - compose.text = [compose.text.trim(), `@${action.account.acct} `] - .filter((str) => str.length !== 0) - .join(' '); - compose.visibility = 'direct'; - compose.caretPosition = null; - }); - case COMPOSE_GROUP_POST: - return updateCompose(state, action.composeId, (compose) => { - compose.visibility = 'group'; - compose.groupId = action.groupId; - compose.caretPosition = null; - }); - case COMPOSE_SUGGESTIONS_CLEAR: - return updateCompose(state, action.composeId, (compose) => { - compose.suggestions = []; - }); - case COMPOSE_SUGGESTIONS_READY: - return updateCompose(state, action.composeId, (compose) => { - compose.suggestions = action.accounts - ? action.accounts.map((item) => item.id) - : (action.emojis ?? []); - }); - case COMPOSE_SUGGESTION_SELECT: - return updateCompose(state, action.composeId, (compose) => { - insertSuggestion(compose, action.position, action.token, action.completion, action.path); - }); - case COMPOSE_SUGGESTION_TAGS_UPDATE: - return updateCompose(state, action.composeId, (compose) => { - updateSuggestionTags(compose, action.token, action.tags); - }); - case TIMELINE_DELETE: - return updateCompose(state, 'compose-modal', (compose) => { - if (action.statusId === compose.inReplyToId) { - compose.inReplyToId = null; - } - if (action.statusId === compose.quoteId) { - compose.quoteId = null; - } - }); - case COMPOSE_UPLOAD_CHANGE_SUCCESS: - return updateCompose(state, action.composeId, (compose) => { - compose.isChangingUpload = false; - - compose.mediaAttachments = compose.mediaAttachments.map((item) => { - if (item.id === action.media.id) { - return action.media; - } - - return item; - }); - }); - case COMPOSE_SET_STATUS: - return updateCompose(state, 'compose-modal', (compose) => { - const mentions = action.explicitAddressing - ? getExplicitMentions(action.status.account.id, action.status) - : []; - if (!action.withRedraft && !action.draftId) { - compose.editedId = action.status.id; - } - compose.text = action.rawText || unescapeHTML(expandMentions(action.status)); - compose.to = mentions; - compose.parentRebloggedById = null; - compose.inReplyToId = action.status.in_reply_to_id; - compose.visibility = action.status.visibility; - compose.caretPosition = null; - const contentType = - action.contentType === 'text/markdown' && state.default.contentType === 'wysiwyg' - ? 'wysiwyg' - : action.contentType || 'text/plain'; - compose.contentType = contentType; - compose.quoteId = action.status.quote_id; - compose.groupId = action.status.group_id; - compose.language = action.status.language; - - compose.mediaAttachments = action.status.media_attachments; - compose.sensitive = action.status.sensitive; - - compose.redacting = action.redacting ?? false; - - if (action.status.spoiler_text.length > 0) { - compose.spoilerText = action.status.spoiler_text; - } else { - compose.spoilerText = ''; - } - - if (action.poll) { - compose.poll = newPoll({ - options: action.poll.options.map(({ title }) => title), - multiple: action.poll.multiple, - expires_in: 24 * 3600, - }); - } - - if (action.draftId) { - compose.draftId = action.draftId; - } - - if (action.editorState) { - compose.editorState = action.editorState; - } - }); - case COMPOSE_POLL_ADD: - return updateCompose(state, action.composeId, (compose) => { - compose.poll = newPoll(); - }); - case COMPOSE_POLL_REMOVE: - return updateCompose(state, action.composeId, (compose) => { - compose.poll = null; - }); - case COMPOSE_SCHEDULE_ADD: - return updateCompose(state, action.composeId, (compose) => { - compose.scheduledAt = new Date(Date.now() + 10 * 60 * 1000); - }); - case COMPOSE_SCHEDULE_SET: - return updateCompose(state, action.composeId, (compose) => { - compose.scheduledAt = action.date; - }); - case COMPOSE_SCHEDULE_REMOVE: - return updateCompose(state, action.composeId, (compose) => { - compose.scheduledAt = null; - }); - case COMPOSE_POLL_OPTION_ADD: - return updateCompose(state, action.composeId, (compose) => { - if (!compose.poll) return; - compose.poll.options.push(action.title); - compose.poll.options_map.push( - Object.fromEntries(Object.entries(compose.textMap).map((key) => [key, action.title])), - ); - }); - case COMPOSE_POLL_OPTION_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - if (!compose.poll) return; - if (!compose.modifiedLanguage || compose.modifiedLanguage === compose.language) { - compose.poll.options[action.index] = action.title; - if (compose.modifiedLanguage) - compose.poll.options_map[action.index][compose.modifiedLanguage] = action.title; - } - }); - case COMPOSE_POLL_OPTION_REMOVE: - return updateCompose(state, action.composeId, (compose) => { - if (!compose.poll) return; - compose.poll.options = compose.poll.options.filter((_, index) => index !== action.index); - compose.poll.options_map = compose.poll.options_map.filter( - (_, index) => index !== action.index, - ); - }); - case COMPOSE_POLL_SETTINGS_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - if (!compose.poll) return null; - if (action.expiresIn) { - compose.poll.expires_in = action.expiresIn; - } - if (typeof action.isMultiple === 'boolean') { - compose.poll.multiple = action.isMultiple; - } - }); - case COMPOSE_ADD_TO_MENTIONS: - return updateCompose(state, action.composeId, (compose) => { - compose.to = [...new Set([...compose.to, action.account])]; - }); - case COMPOSE_REMOVE_FROM_MENTIONS: - return updateCompose(state, action.composeId, (compose) => { - compose.to = compose.to.filter((acct) => acct !== action.account); - }); - case ME_FETCH_SUCCESS: - case ME_PATCH_SUCCESS: - return updateCompose(state, 'default', (compose) => { - importAccount(compose, action.me); - }); - // case SETTING_CHANGE: - // return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value)); - case COMPOSE_EDITOR_STATE_SET: - return updateCompose(state, action.composeId, (compose) => { - if (!compose.modifiedLanguage || compose.modifiedLanguage === compose.language) { - compose.editorState = action.editorState as string; - compose.text = action.text as string; - } else if (compose.modifiedLanguage) { - compose.editorStateMap[compose.modifiedLanguage] = action.editorState as string; - compose.textMap[compose.modifiedLanguage] = action.text as string; - } - }); - case EVENT_COMPOSE_CANCEL: - return updateCompose(state, 'event-compose-modal', (compose) => { - compose.text = ''; - }); - case EVENT_FORM_SET: - return updateCompose(state, action.composeId, (compose) => { - compose.text = action.text; - }); - case COMPOSE_CHANGE_MEDIA_ORDER: - return updateCompose(state, action.composeId, (compose) => { - const indexA = compose.mediaAttachments.findIndex((x) => x.id === action.a); - const indexB = compose.mediaAttachments.findIndex((x) => x.id === action.b); - - const item = compose.mediaAttachments.splice(indexA, 1)[0]; - compose.mediaAttachments.splice(indexB, 0, item); - }); - case COMPOSE_ADD_SUGGESTED_QUOTE: - return updateCompose(state, action.composeId, (compose) => { - compose.quoteId = action.quoteId; - }); - case COMPOSE_ADD_SUGGESTED_LANGUAGE: - return updateCompose(state, action.composeId, (compose) => { - compose.suggestedLanguage = action.language; - }); - case COMPOSE_LANGUAGE_ADD: - return updateCompose(state, action.composeId, (compose) => { - compose.editorStateMap[action.value] = compose.editorState; - compose.textMap[action.value] = compose.text; - compose.spoilerTextMap[action.value] = compose.spoilerText; - if (compose.poll) - compose.poll.options_map.forEach( - (option, key) => (option[action.value] = compose.poll!.options[key]), - ); - }); - case COMPOSE_LANGUAGE_DELETE: - return updateCompose(state, action.composeId, (compose) => { - delete compose.editorStateMap[action.value]; - delete compose.textMap[action.value]; - delete compose.spoilerTextMap[action.value]; - }); - case COMPOSE_QUOTE_CANCEL: - return updateCompose(state, action.composeId, (compose) => { - if (compose.quoteId) compose.dismissedQuotes.push(compose.quoteId); - compose.quoteId = null; - }); - case COMPOSE_FEDERATED_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.localOnly = !compose.localOnly; - }); - case COMPOSE_INTERACTION_POLICY_OPTION_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.interactionPolicy ??= JSON.parse(JSON.stringify(action.initial))!; - - compose.interactionPolicy = create( - compose.interactionPolicy ?? action.initial, - (interactionPolicy) => { - interactionPolicy[action.policy][action.rule] = action.value; - interactionPolicy[action.policy][ - action.rule === 'always' ? 'with_approval' : 'always' - ] = interactionPolicy[action.policy][ - action.rule === 'always' ? 'with_approval' : 'always' - ].filter((rule) => !action.value.includes(rule as any)); - }, - ); - }); - case COMPOSE_QUOTE_POLICY_OPTION_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.quoteApprovalPolicy = action.value; - }); - case INSTANCE_FETCH_SUCCESS: - return updateCompose(state, 'default', (compose) => { - updateDefaultContentType(compose, action.instance); - }); - case COMPOSE_CLEAR_LINK_SUGGESTION_CREATE: - return updateCompose(state, action.composeId, (compose) => { - compose.clearLinkSuggestion = action.suggestion; - }); - case COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE: - return updateCompose(state, action.composeId, (compose) => { - if (compose.clearLinkSuggestion?.key === action.key) { - compose.clearLinkSuggestion = null; - } - compose.dismissedClearLinksSuggestions.push(action.key); - }); - case COMPOSE_PREVIEW_SUCCESS: - return updateCompose(state, action.composeId, (compose) => { - compose.preview = action.status; - }); - case COMPOSE_PREVIEW_CANCEL: - return updateCompose(state, action.composeId, (compose) => { - compose.preview = null; - }); - case COMPOSE_HASHTAG_CASING_SUGGESTION_SET: - return updateCompose(state, action.composeId, (compose) => { - compose.hashtagCasingSuggestion = action.suggestion; - }); - case COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE: - return updateCompose(state, action.composeId, (compose) => { - compose.hashtagCasingSuggestion = null; - compose.hashtagCasingSuggestionIgnored = true; - }); - case COMPOSE_REDACTING_OVERWRITE_CHANGE: - return updateCompose(state, action.composeId, (compose) => { - compose.redactingOverwrite = action.value; - }); - case COMPOSE_SET_LOCATION: - return updateCompose(state, action.composeId, (compose) => { - compose.location = action.location; - }); - case COMPOSE_SET_SHOW_LOCATION_PICKER: - return updateCompose(state, action.composeId, (compose) => { - compose.showLocationPicker = action.showLocation; - if (!action.showLocation) { - compose.location = null; - } - }); - default: - return state; - } -}; - -export { - type Compose, - type ClearLinkSuggestion, - statusToMentionsAccountIdsArray, - initialState, - compose as default, -}; diff --git a/packages/pl-fe/src/reducers/index.ts b/packages/pl-fe/src/reducers/index.ts index 8779c276c..11b628f8f 100644 --- a/packages/pl-fe/src/reducers/index.ts +++ b/packages/pl-fe/src/reducers/index.ts @@ -5,7 +5,6 @@ import * as BuildConfig from '@/build-config'; import admin from './admin'; import auth from './auth'; -import compose from './compose'; import filters from './filters'; import frontendConfig from './frontend-config'; import instance from './instance'; @@ -18,7 +17,6 @@ import timelines from './timelines'; const reducers = { admin, auth, - compose, filters, frontendConfig, instance, diff --git a/packages/pl-fe/src/stores/compose.ts b/packages/pl-fe/src/stores/compose.ts new file mode 100644 index 000000000..01e6f1bfc --- /dev/null +++ b/packages/pl-fe/src/stores/compose.ts @@ -0,0 +1,1038 @@ +import { useCallback } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { create } from 'zustand'; +import { mutative } from 'zustand-mutative'; + +import { uploadFile, updateMedia } from '@/actions/media'; +import { saveSettings } from '@/actions/settings'; +import { FE_NAME } from '@/actions/settings'; +import { createStatus } from '@/actions/statuses'; +import { getClient } from '@/api'; +import { isNativeEmoji } from '@/features/emoji'; +import { useAppDispatch } from '@/hooks/use-app-dispatch'; +import { useClient } from '@/hooks/use-client'; +import { useFeatures } from '@/hooks/use-features'; +import { useInstance } from '@/hooks/use-instance'; +import { selectAccount, selectOwnAccount } from '@/queries/accounts/selectors'; +import { queryClient } from '@/queries/client'; +import { cancelDraftStatus } from '@/queries/statuses/use-draft-statuses'; +import { useModalsActions, useModalsStore } from '@/stores/modals'; +import { useSettings, useSettingsStore } from '@/stores/settings'; +import toast from '@/toast'; + +import type { AutoSuggestion } from '@/components/autosuggest-input'; +import type { Language } from '@/features/preferences'; +import type { Status } from '@/normalizers/status'; +import type { AppDispatch, RootState } from '@/store'; +import type { LinkOptions } from '@tanstack/react-router'; +import type { + Account, + CreateStatusParams, + Group, + MediaAttachment, + Status as BaseStatus, + Poll, + InteractionPolicy, + UpdateMediaParams, + Location, + EditStatusParams, + CredentialAccount, + Instance, + StatusSource, +} from 'pl-api'; + +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!' }, + editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, + redactSuccess: { id: 'compose.redact_success', defaultMessage: 'The post was redacted' }, + scheduledSuccess: { id: 'compose.scheduled_success', defaultMessage: 'Your post was scheduled' }, + uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, + uploadErrorPoll: { + id: 'upload_error.poll', + defaultMessage: 'File upload not allowed with polls.', + }, + view: { id: 'toast.view', defaultMessage: 'View' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { + id: 'confirmations.reply.message', + defaultMessage: + 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?', + }, +}); + +const getResetFileKey = () => Math.floor(Math.random() * 0x10000); + +interface ComposePoll { + options: Array; + options_map: Array>; + expires_in: number; + multiple: boolean; + hide_totals: boolean; +} + +interface ClearLinkSuggestion { + key: string; + originalUrl: string; + cleanUrl: string; +} + +interface Compose { + // User-edited text + editorState: string | null; + editorStateMap: Record; + spoilerText: string; + spoilerTextMap: Record; + text: string; + textMap: Record; + + // Non-text content + mediaAttachments: Array; + poll: ComposePoll | null; + location: Location | null; + + // Post settings + contentType: string; + interactionPolicy: InteractionPolicy | null; + quoteApprovalPolicy: CreateStatusParams['quote_approval_policy'] | null; + language: Language | string | null; + localOnly: boolean; + scheduledAt: Date | null; + sensitive: boolean; + visibility: string; + + // References to other posts/groups/users + draftId: string | null; + groupId: string | null; + editedId: string | null; + inReplyToId: string | null; + quoteId: string | null; + to: Array; + parentRebloggedById: string | null; + + // State flags + isChangingUpload: boolean; + isSubmitting: boolean; + isUploading: boolean; + progress: number; + + // Internal + caretPosition: number | null; + idempotencyKey: string; + resetFileKey: number | null; + + // Currently modified language + modifiedLanguage: Language | string | null; + + // Suggestions + approvalRequired: boolean; + clearLinkSuggestion: ClearLinkSuggestion | null; + dismissedClearLinksSuggestions: Array; + dismissedQuotes: Array; + hashtagCasingSuggestion: string | null; + hashtagCasingSuggestionIgnored: boolean | null; + preview: Partial | null; + suggestedLanguage: string | null; + showLocationPicker: boolean; + + // Moderation features + redacting: boolean; + redactingOverwrite: boolean; +} + +const newCompose = (params: Partial = {}): Compose => ({ + editorState: null, + editorStateMap: {}, + spoilerText: '', + spoilerTextMap: {}, + text: '', + textMap: {}, + + mediaAttachments: [], + poll: null, + location: null, + + contentType: 'text/plain', + interactionPolicy: null, + quoteApprovalPolicy: null, + language: null, + localOnly: false, + scheduledAt: null, + sensitive: false, + visibility: 'public', + + draftId: null, + groupId: null, + editedId: null, + inReplyToId: null, + quoteId: null, + to: [], + parentRebloggedById: null, + + isChangingUpload: false, + isSubmitting: false, + isUploading: false, + progress: 0, + + caretPosition: null, + idempotencyKey: '', + resetFileKey: null, + + modifiedLanguage: null, + + approvalRequired: false, + clearLinkSuggestion: null, + dismissedClearLinksSuggestions: [], + dismissedQuotes: [], + hashtagCasingSuggestion: null, + hashtagCasingSuggestionIgnored: null, + preview: null, + suggestedLanguage: null, + showLocationPicker: false, + + redacting: false, + redactingOverwrite: false, + + ...params, +}); + +const newPoll = (params: Partial = {}): ComposePoll => ({ + options: ['', ''], + options_map: [{}, {}], + expires_in: 24 * 3600, + multiple: false, + hide_totals: false, + ...params, +}); + +const statusToTextMentions = ( + status: Pick, + account: Pick, +) => { + const author = status.account.acct; + const mentions = status.mentions.map((m) => m.acct); + + return [...new Set([author, ...mentions].filter((acct) => acct !== account.acct))] + .map((m) => `@${m} `) + .join(''); +}; + +const statusToMentionsArray = ( + status: Pick, + account: Pick, + rebloggedBy?: Pick, +) => { + const author = status.account.acct; + const mentions = status.mentions.map((m) => m.acct); + + return [ + ...new Set( + [author, ...(rebloggedBy ? [rebloggedBy.acct] : []), ...mentions].filter( + (acct) => acct !== account.acct, + ), + ), + ]; +}; + +const statusToMentionsAccountIdsArray = ( + status: Pick, + account: Pick, + parentRebloggedBy?: string | null, +) => { + const mentions = status.mentions.map((m) => m.id); + + return [ + ...new Set( + [status.account.id, ...(parentRebloggedBy ? [parentRebloggedBy] : []), ...mentions].filter( + (id) => id !== account.id, + ), + ), + ]; +}; + +const privacyPreference = ( + a: string, + b: string, + list_id: number | null, + conversationScope = false, +) => { + if (['private', 'subscribers'].includes(a) && conversationScope) return 'conversation'; + + const order = ['public', 'unlisted', 'mutuals_only', 'private', 'direct', 'local']; + + if (a === 'group') return a; + if (a === 'list' && list_id !== null) return `list:${list_id}`; + + return order[Math.max(order.indexOf(a), order.indexOf(b), 0)]; +}; + +const domParser = new DOMParser(); + +const getExplicitMentions = (me: string, status: Pick) => { + const fragment = domParser.parseFromString(status.content, 'text/html').documentElement; + + const mentions = status.mentions + .filter((mention) => !(fragment.querySelector(`a[href="${mention.url}"]`) ?? mention.id === me)) + .map((m) => m.acct); + + return [...new Set(mentions)]; +}; + +const appendMedia = (compose: Compose, media: MediaAttachment) => { + const prevSize = compose.mediaAttachments.length; + + compose.mediaAttachments.push(media); + compose.isUploading = false; + compose.resetFileKey = Math.floor(Math.random() * 0x10000); + + if (prevSize === 0 && compose.sensitive) { + compose.sensitive = true; + } +}; + +interface ComposeState { + default: Compose; + composers: Record; +} + +interface ComposeActions { + updateCompose: (composeId: string, updater: (draft: Compose) => void) => void; + updateAllCompose: (updater: (draft: Compose) => void) => void; + getCompose: (composeId: string) => Compose; + + setComposeToStatus: ( + status: Pick< + Status, + | 'id' + | 'account' + | 'content' + | 'group_id' + | 'in_reply_to_id' + | 'language' + | 'media_attachments' + | 'mentions' + | 'quote_id' + | 'sensitive' + | 'spoiler_text' + | 'visibility' + >, + poll: Poll | null | undefined, + source: Pick, + withRedraft?: boolean, + draftId?: string | null, + editorState?: string | null, + redacting?: boolean, + ) => void; + replyCompose: ( + status: Pick< + Status, + | 'id' + | 'account' + | 'group_id' + | 'list_id' + | 'local_only' + | 'mentions' + | 'spoiler_text' + | 'visibility' + >, + rebloggedBy?: Pick, + approvalRequired?: boolean, + ) => void; + quoteCompose: ( + status: Pick, + approvalRequired?: boolean, + ) => void; + mentionCompose: (account: Pick) => void; + directCompose: (account: Pick) => void; + groupComposeModal: (group: Pick) => void; + openComposeWithText: (composeId: string, text?: string) => void; + eventDiscussionCompose: ( + composeId: string, + status: Pick, + ) => void; + resetCompose: (composeId?: string) => void; + selectComposeSuggestion: ( + composeId: string, + position: number, + token: string | null, + suggestion: AutoSuggestion, + path: ['spoiler_text'] | ['poll', 'options', number], + ) => void; + + importDefaultSettings: (account: CredentialAccount) => void; + importDefaultContentType: (instance: Instance) => void; + handleTimelineDelete: (statusId: string) => void; +} + +type ComposeStore = ComposeState & { actions: ComposeActions }; + +let lazyStore: { dispatch: AppDispatch; getState: () => RootState }; +import('@/store').then(({ store }) => (lazyStore = store)).catch(() => {}); + +const useComposeStore = create()( + mutative( + (set, get) => ({ + default: newCompose({ idempotencyKey: crypto.randomUUID(), resetFileKey: getResetFileKey() }), + composers: {}, + + actions: { + updateCompose: (composeId, updater) => { + set((state) => { + if (!state.composers[composeId]) { + state.composers[composeId] = { + ...state.default, + idempotencyKey: crypto.randomUUID(), + }; + } + updater(state.composers[composeId]); + }); + }, + + updateAllCompose: (updater) => { + set((state) => { + Object.values(state.composers).forEach((compose) => { + updater(compose); + }); + }); + }, + + getCompose: (composeId) => get().composers[composeId] ?? get().default, + + setComposeToStatus: ( + status, + poll, + source, + withRedraft = false, + draftId = null, + editorState = null, + redacting = false, + ) => { + const { features } = getClient(lazyStore.getState); + const explicitAddressing = + features.createStatusExplicitAddressing && + !useSettingsStore.getState().settings.forceImplicitAddressing; + + set((state) => { + state.composers['compose-modal'] = { + ...state.default, + idempotencyKey: crypto.randomUUID(), + }; + + const compose = state.composers['compose-modal']; + const mentions = explicitAddressing + ? getExplicitMentions(status.account.id, status) + : []; + if (!withRedraft && !draftId) { + compose.editedId = status.id; + } + compose.text = source.text; + compose.to = mentions; + compose.parentRebloggedById = null; + compose.inReplyToId = status.in_reply_to_id; + compose.visibility = status.visibility; + compose.caretPosition = null; + const contentType = + source.content_type === 'text/markdown' && state.default.contentType === 'wysiwyg' + ? 'wysiwyg' + : source.content_type || 'text/plain'; + compose.contentType = contentType; + compose.quoteId = status.quote_id; + compose.groupId = status.group_id; + compose.language = status.language; + + compose.mediaAttachments = status.media_attachments; + compose.sensitive = status.sensitive; + + compose.redacting = redacting ?? false; + + compose.spoilerText = source.spoiler_text; + + if (poll) { + compose.poll = newPoll({ + options: poll.options.map(({ title }) => title), + multiple: poll.multiple, + expires_in: 24 * 3600, + }); + } + + if (draftId) { + compose.draftId = draftId; + } + + if (editorState) { + compose.editorState = editorState; + } + }); + }, + + replyCompose: (status, rebloggedBy, approvalRequired) => { + const state = lazyStore.getState(); + const { features } = getClient(lazyStore.getState); + const { forceImplicitAddressing, preserveSpoilers } = + useSettingsStore.getState().settings; + const explicitAddressing = + features.createStatusExplicitAddressing && !forceImplicitAddressing; + const account = selectOwnAccount(state); + + if (!account) return; + + set((draft) => { + if (!draft.composers['compose-modal']) { + draft.composers['compose-modal'] = { + ...draft.default, + idempotencyKey: crypto.randomUUID(), + }; + } + const compose = draft.composers['compose-modal']; + + const mentions = explicitAddressing + ? statusToMentionsArray(status, account, rebloggedBy) + : []; + + compose.groupId = status.group_id; + compose.inReplyToId = status.id; + compose.to = mentions; + compose.parentRebloggedById = rebloggedBy?.id ?? null; + compose.text = !explicitAddressing ? statusToTextMentions(status, account) : ''; + compose.visibility = privacyPreference( + status.visibility, + draft.default.visibility, + status.list_id, + features.createStatusConversationScope, + ); + compose.localOnly = status.local_only === true; + compose.caretPosition = null; + compose.contentType = draft.default.contentType; + compose.approvalRequired = approvalRequired ?? false; + if (preserveSpoilers && status.spoiler_text) { + compose.sensitive = true; + compose.spoilerText = status.spoiler_text; + } + }); + + useModalsStore.getState().actions.openModal('COMPOSE'); + }, + + quoteCompose: (status, approvalRequired) => { + set((draft) => { + if (!draft.composers['compose-modal']) { + draft.composers['compose-modal'] = { + ...draft.default, + idempotencyKey: crypto.randomUUID(), + }; + } + const compose = draft.composers['compose-modal']; + + const author = status.account.acct; + + compose.quoteId = status.id; + compose.to = [author]; + compose.parentRebloggedById = null; + compose.text = ''; + compose.visibility = privacyPreference( + status.visibility, + draft.default.visibility, + status.list_id, + ); + compose.caretPosition = null; + compose.contentType = draft.default.contentType; + compose.spoilerText = ''; + compose.approvalRequired = approvalRequired ?? false; + + if (status.visibility === 'group') { + compose.groupId = status.group_id; + compose.visibility = 'group'; + } + }); + + useModalsStore.getState().actions.openModal('COMPOSE'); + }, + + mentionCompose: (account) => { + if (!lazyStore.getState().me) return; + + get().actions.updateCompose('compose-modal', (compose) => { + compose.text = [compose.text.trim(), `@${account.acct} `] + .filter((str) => str.length !== 0) + .join(' '); + compose.caretPosition = null; + }); + useModalsStore.getState().actions.openModal('COMPOSE'); + }, + + directCompose: (account) => { + get().actions.updateCompose('compose-modal', (compose) => { + compose.text = [compose.text.trim(), `@${account.acct} `] + .filter((str) => str.length !== 0) + .join(' '); + compose.visibility = 'direct'; + compose.caretPosition = null; + }); + useModalsStore.getState().actions.openModal('COMPOSE'); + }, + + groupComposeModal: (group) => { + const composeId = `group:${group.id}`; + get().actions.updateCompose(composeId, (draft) => { + draft.visibility = 'group'; + draft.groupId = group.id; + draft.caretPosition = null; + }); + useModalsStore.getState().actions.openModal('COMPOSE', { composeId }); + }, + + openComposeWithText: (composeId, text = '') => { + set((state) => { + state.composers[composeId] = { + ...state.default, + idempotencyKey: crypto.randomUUID(), + resetFileKey: getResetFileKey(), + ...(composeId.startsWith('reply:') ? { inReplyToId: composeId.slice(6) } : undefined), + ...(composeId.startsWith('group:') + ? { visibility: 'group', groupId: composeId.slice(6) } + : undefined), + text, + }; + }); + useModalsStore.getState().actions.openModal('COMPOSE'); + }, + + eventDiscussionCompose: (composeId, status) => { + const state = lazyStore.getState(); + const account = selectOwnAccount(state); + + if (!account) return; + + get().actions.updateCompose(composeId, (compose) => { + compose.inReplyToId = status.id; + compose.to = statusToMentionsArray(status, account); + }); + }, + + resetCompose: (composeId = 'compose-modal') => { + set((state) => { + state.composers[composeId] = { + ...state.default, + idempotencyKey: crypto.randomUUID(), + resetFileKey: getResetFileKey(), + ...(composeId.startsWith('reply:') ? { inReplyToId: composeId.slice(6) } : undefined), + ...(composeId.startsWith('group:') + ? { visibility: 'group', groupId: composeId.slice(6) } + : undefined), + }; + }); + }, + + selectComposeSuggestion: (composeId, position, token, suggestion, path) => { + let completion = ''; + let startPosition = position; + + if (typeof suggestion === 'object' && 'id' in suggestion) { + completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons; + startPosition = position - 1; + + useSettingsStore.getState().actions.rememberEmojiUse(suggestion); + lazyStore.dispatch(saveSettings()); + } else if (typeof suggestion === 'string' && suggestion[0] === '#') { + completion = suggestion; + startPosition = position - 1; + } else if (typeof suggestion === 'string') { + completion = selectAccount(suggestion)!.acct; + startPosition = position; + } + + get().actions.updateCompose(composeId, (compose) => { + const updateText = (oldText?: string) => + `${oldText?.slice(0, startPosition)}${completion} ${oldText?.slice(startPosition + (token?.length ?? 0))}`; + if (path[0] === 'spoiler_text') { + compose.spoilerText = updateText(compose.spoilerText); + } else if (compose.poll) { + compose.poll.options[path[2]] = updateText(compose.poll.options[path[2]]); + } + }); + }, + + importDefaultSettings: (account) => { + get().actions.updateCompose('default', (compose) => { + const settings = account.settings_store?.[FE_NAME]; + + if (!settings) return; + + if (settings.defaultPrivacy) compose.visibility = settings.defaultPrivacy; + if (settings.defaultContentType) compose.contentType = settings.defaultContentType; + }); + }, + + importDefaultContentType: (instance) => { + get().actions.updateCompose('default', (compose) => { + const postFormats = instance.pleroma.metadata.post_formats; + + compose.contentType = + postFormats.includes(compose.contentType) || + (postFormats.includes('text/markdown') && compose.contentType === 'wysiwyg') + ? compose.contentType + : postFormats.includes('text/markdown') + ? 'text/markdown' + : postFormats[0]; + }); + }, + + handleTimelineDelete: (statusId) => { + get().actions.updateAllCompose((compose) => { + if (statusId === compose.inReplyToId) { + compose.inReplyToId = null; + } + if (statusId === compose.quoteId) { + compose.quoteId = null; + } + }); + }, + }, + }), + { + enableAutoFreeze: false, + }, + ), +); + +const useSubmitCompose = (composeId: string) => { + const actions = useComposeActions(); + const client = useClient(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + const { openModal, closeModal } = useModalsActions(); + const settings = useSettings(); + + const submitCompose = useCallback( + async (opts: { force?: boolean; preview?: boolean; onSuccess?: () => void } = {}) => { + const { force = false, preview = false, onSuccess } = opts; + + const compose = actions.getCompose(composeId); + + const statusText = compose.text; + const media = compose.mediaAttachments; + const editedId = compose.editedId; + let to = compose.to; + const { forceImplicitAddressing } = settings; + const explicitAddressing = + features.createStatusExplicitAddressing && !forceImplicitAddressing; + + if (!preview) { + const scheduledAt = compose.scheduledAt; + if (scheduledAt) { + const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); + const valid = + scheduledAt.getTime() > fiveMinutesFromNow.getTime() || + (features.scheduledStatusesBackwards && scheduledAt.getTime() < new Date().getTime()); + if (!valid) { + toast.error(messages.scheduleError); + return; + } + } + + if ((!statusText || !statusText.length) && media.length === 0) { + return; + } + + if (!force) { + const missingDescriptionModal = settings.missingDescriptionModal; + const hasMissing = media.some((item) => !item.description); + if (missingDescriptionModal && hasMissing) { + openModal('MISSING_DESCRIPTION', { + onContinue: () => { + closeModal('MISSING_DESCRIPTION'); + submitCompose({ force: true, onSuccess }); + }, + }); + return; + } + } + } + + const mentionsMatch: string[] | null = statusText.match( + /(?:^|\s)@([a-z\d_-]+(?:@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]+)?)/gi, + ); + + if (mentionsMatch) { + to = [ + ...new Set([ + ...to, + ...mentionsMatch.map((mention) => + mention + .replace(/ /g, '') + .trim() + .slice(1), + ), + ]), + ]; + } + + if (!preview) { + actions.updateCompose(composeId, (draft) => { + draft.isSubmitting = true; + }); + + closeModal('COMPOSE'); + + if (compose.language && !editedId) { + useSettingsStore.getState().actions.rememberLanguageUse(compose.language); + dispatch(saveSettings()); + } + } + + const idempotencyKey = compose.idempotencyKey; + const contentType = compose.contentType === 'wysiwyg' ? 'text/markdown' : compose.contentType; + + const params: CreateStatusParams = { + status: statusText, + in_reply_to_id: compose.inReplyToId ?? undefined, + quote_id: compose.quoteId ?? undefined, + media_ids: media.map((item) => item.id), + sensitive: compose.sensitive, + spoiler_text: compose.spoilerText, + visibility: compose.visibility, + content_type: contentType, + scheduled_at: preview ? undefined : compose.scheduledAt?.toISOString(), + language: compose.language ?? compose.suggestedLanguage ?? undefined, + to: explicitAddressing && to.length ? to : undefined, + local_only: compose.localOnly, + interaction_policy: + (['public', 'unlisted', 'private'].includes(compose.visibility) && + compose.interactionPolicy) || + undefined, + quote_approval_policy: compose.quoteApprovalPolicy ?? undefined, + location_id: compose.location?.origin_id ?? undefined, + }; + + if (compose.editedId) { + // @ts-ignore + params.media_attributes = media.map((item) => { + const focalPoint = (item.type === 'image' || item.type === 'gifv') && item.meta?.focus; + const focus = focalPoint + ? `${focalPoint.x.toFixed(2)},${focalPoint.y.toFixed(2)}` + : undefined; + + return { id: item.id, description: item.description, focus }; + }) as EditStatusParams['media_attributes']; + } + + if (compose.poll) { + params.poll = { + options: compose.poll.options, + expires_in: compose.poll.expires_in, + multiple: compose.poll.multiple, + hide_totals: compose.poll.hide_totals, + options_map: compose.poll.options_map, + }; + } + + if (compose.language && Object.keys(compose.textMap).length) { + params.status_map = compose.textMap; + params.status_map[compose.language] = statusText; + + if (params.spoiler_text) { + params.spoiler_text_map = compose.spoilerTextMap; + params.spoiler_text_map[compose.language] = compose.spoilerText; + } + + const pollParams = params.poll; + if (pollParams?.options_map) { + pollParams.options.forEach( + (option, index: number) => (pollParams.options_map![index][compose.language!] = option), + ); + } + } + + if (compose.visibility === 'group' && compose.groupId) { + params.group_id = compose.groupId; + } + + if (preview) { + try { + const data = await client.statuses.previewStatus(params); + actions.updateCompose(composeId, (draft) => { + draft.preview = data; + }); + onSuccess?.(); + } catch {} + } else { + if (compose.redacting) { + // @ts-ignore + params.overwrite = compose.redactingOverwrite; + } + + try { + const data = await dispatch( + createStatus(params, idempotencyKey, editedId, compose.redacting), + ); + + const draftIdToCancel = compose.draftId; + + actions.resetCompose(composeId); + + if (draftIdToCancel) { + dispatch((_, getState) => { + const accountUrl = selectOwnAccount(getState())!.url; + cancelDraftStatus(queryClient, accountUrl, draftIdToCancel); + }); + } + + if (data.scheduled_at === null) { + const linkOptions: LinkOptions = + data.visibility === 'direct' && features.conversations + ? { to: '/conversations' } + : { + to: '/@{$username}/posts/$statusId', + params: { username: data.account.acct, statusId: data.id }, + }; + toast.success( + compose.redacting + ? messages.redactSuccess + : editedId + ? messages.editSuccess + : messages.success, + { actionLabel: messages.view, actionLinkOptions: linkOptions }, + ); + } else { + toast.success(messages.scheduledSuccess, { + actionLabel: messages.view, + actionLinkOptions: { to: '/scheduled_statuses' }, + }); + } + + onSuccess?.(); + } catch (error) { + actions.updateCompose(composeId, (draft) => { + draft.isSubmitting = false; + }); + } + } + }, + [composeId], + ); + + return submitCompose; +}; + +const useCompose = (composeId: ID extends 'default' ? never : ID): Compose => + useComposeStore((state) => state.composers[composeId] ?? state.default); + +const useComposeActions = () => useComposeStore((state) => state.actions); + +const useUploadCompose = (composeId: string) => { + const { updateCompose } = useComposeActions(); + const instance = useInstance(); + const dispatch = useAppDispatch(); + const intl = useIntl(); + + return useCallback( + (files: FileList) => { + const compose = + useComposeStore.getState().composers[composeId] || useComposeStore.getState().default; + + const attachmentLimit = instance.configuration.statuses.max_media_attachments; + const media = compose.mediaAttachments; + const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); + const mediaCount = media ? media.length : 0; + + if (files.length + mediaCount > attachmentLimit) { + toast.error(messages.uploadErrorLimit); + return; + } + + updateCompose(composeId, (draft) => { + draft.isUploading = true; + }); + + Array.from(files).forEach((f, i) => { + if (mediaCount + i > attachmentLimit - 1) return; + + dispatch( + uploadFile( + f, + intl, + (data) => + updateCompose(composeId, (draft) => { + appendMedia(draft, data); + }), + () => + updateCompose(composeId, (draft) => { + draft.isUploading = false; + }), + ({ loaded }) => { + progress[i] = loaded; + updateCompose(composeId, (draft) => { + draft.progress = Math.round((progress.reduce((a, v) => a + v, 0) / total) * 100); + }); + }, + (value) => { + total += value; + }, + ), + ); + }); + }, + [instance, composeId], + ); +}; + +const useChangeUploadCompose = (composeId: string) => { + const { updateCompose } = useComposeActions(); + const dispatch = useAppDispatch(); + + return useCallback( + async (mediaId: string, params: UpdateMediaParams) => { + const compose = + useComposeStore.getState().composers[composeId] || useComposeStore.getState().default; + + updateCompose(composeId, (draft) => { + draft.isChangingUpload = true; + }); + + try { + const response = await dispatch(updateMedia(mediaId, params)); + updateCompose(composeId, (draft) => { + draft.isChangingUpload = false; + draft.mediaAttachments = draft.mediaAttachments.map((item) => + item.id === response.id ? response : item, + ); + }); + return response; + } catch (error: any) { + if (error.response?.status === 404 && compose.editedId) { + const previousMedia = compose.mediaAttachments.find((m) => m.id === mediaId); + if (previousMedia) { + updateCompose(composeId, (draft) => { + draft.isChangingUpload = false; + draft.mediaAttachments = draft.mediaAttachments.map((item) => + item.id === mediaId ? { ...previousMedia, ...params } : item, + ); + }); + return; + } + } + updateCompose(composeId, (draft) => { + draft.isChangingUpload = false; + }); + } + }, + [composeId], + ); +}; + +export { + type Compose, + appendMedia, + newPoll, + statusToMentionsAccountIdsArray, + useComposeStore, + useCompose, + useComposeActions, + useSubmitCompose, + useUploadCompose, + useChangeUploadCompose, +};