diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts index b6cb9184c..4288d1a53 100644 --- a/packages/pl-fe/src/actions/compose.ts +++ b/packages/pl-fe/src/actions/compose.ts @@ -35,6 +35,8 @@ 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; @@ -341,7 +343,7 @@ interface SubmitComposeOpts { onSuccess?: () => void; } -const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => +const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}, preview = false) => async (dispatch: AppDispatch, getState: () => RootState) => { const { history, force = false, onSuccess } = opts; @@ -357,23 +359,25 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => const { forceImplicitAddressing } = useSettingsStore.getState().settings; const explicitAddressing = state.auth.client.features.createStatusExplicitAddressing && !forceImplicitAddressing; - if (!validateSchedule(state, composeId)) { - toast.error(messages.scheduleError); - return; - } + if (!preview) { + if (!validateSchedule(state, composeId)) { + toast.error(messages.scheduleError); + return; + } - if ((!status || !status.length) && media.length === 0) { - return; - } + if ((!status || !status.length) && media.length === 0) { + return; + } - if (!force && needsDescriptions(state, composeId)) { - useModalsStore.getState().openModal('MISSING_DESCRIPTION', { - onContinue: () => { - useModalsStore.getState().closeModal('MISSING_DESCRIPTION'); - dispatch(submitCompose(composeId, { history, force: true, onSuccess })); - }, - }); - return; + if (!force && needsDescriptions(state, composeId)) { + useModalsStore.getState().openModal('MISSING_DESCRIPTION', { + onContinue: () => { + useModalsStore.getState().closeModal('MISSING_DESCRIPTION'); + dispatch(submitCompose(composeId, { history, force: true, onSuccess })); + }, + }); + return; + } } // https://stackoverflow.com/a/30007882 for domain regex @@ -383,12 +387,15 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => to = [...new Set([...to, ...mentions.map(mention => mention.replace(/ /g, '').trim().slice(1))])]; } - dispatch(submitComposeRequest(composeId)); - useModalsStore.getState().closeModal('COMPOSE'); + if (!preview) { + dispatch(submitComposeRequest(composeId)); - if (compose.language && !statusId) { - useSettingsStore.getState().rememberLanguageUse(compose.language); - dispatch(saveSettings()); + useModalsStore.getState().closeModal('COMPOSE'); + + if (compose.language && !statusId && !preview) { + useSettingsStore.getState().rememberLanguageUse(compose.language); + dispatch(saveSettings()); + } } const idempotencyKey = compose.idempotencyKey; @@ -403,11 +410,12 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => spoiler_text: compose.spoiler_text, visibility: compose.privacy, content_type: contentType, - scheduled_at: compose.schedule?.toISOString(), + scheduled_at: preview ? undefined : compose.schedule?.toISOString(), language: compose.language || compose.suggested_language || undefined, to: explicitAddressing && to.length ? to : undefined, local_only: !compose.federated, interaction_policy: ['public', 'unlisted', 'private'].includes(compose.privacy) && compose.interactionPolicy || undefined, + preview, }; if (compose.poll) { @@ -440,7 +448,8 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => } return dispatch(createStatus(params, idempotencyKey, statusId)).then((data) => { - handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId); + if (!preview) handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId); + else if (data.scheduled_at === null) dispatch(previewComposeSuccess(composeId, data)); onSuccess?.(); }).catch((error) => { dispatch(submitComposeFail(composeId, error)); @@ -466,6 +475,17 @@ const submitComposeFail = (composeId: string, error: unknown) => ({ error, }); +const previewComposeSuccess = (composeId: string, status: BaseStatus) => ({ + 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; @@ -971,6 +991,8 @@ type ComposeAction = | ReturnType | ReturnType | ReturnType + | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -1019,6 +1041,8 @@ export { COMPOSE_SUBMIT_REQUEST, COMPOSE_SUBMIT_SUCCESS, COMPOSE_SUBMIT_FAIL, + COMPOSE_PREVIEW_SUCCESS, + COMPOSE_PREVIEW_CANCEL, COMPOSE_REPLY, COMPOSE_REPLY_CANCEL, COMPOSE_EVENT_REPLY, @@ -1118,6 +1142,7 @@ export { changeComposeInteractionPolicyOption, suggestClearLink, ignoreClearLinkSuggestion, + cancelPreviewCompose, type ComposeReplyAction, type ComposeSuggestionSelectAction, type ComposeAction, diff --git a/packages/pl-fe/src/actions/statuses.ts b/packages/pl-fe/src/actions/statuses.ts index 494a40398..c38e7824e 100644 --- a/packages/pl-fe/src/actions/statuses.ts +++ b/packages/pl-fe/src/actions/statuses.ts @@ -38,10 +38,12 @@ const STATUS_UNFILTER = 'STATUS_UNFILTER' as const; const createStatus = (params: CreateStatusParams, idempotencyKey: string, statusId: string | null) => (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId }); + if (!params.preview) dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId }); return (statusId === null ? getClient(getState()).statuses.createStatus(params) : getClient(getState()).statuses.editStatus(statusId, params)) .then((status) => { + if (params.preview) return status; + // The backend might still be processing the rich media attachment const expectsCard = status.scheduled_at === null && !status.card && shouldHaveCard(status); diff --git a/packages/pl-fe/src/components/dropdown-menu/dropdown-menu.tsx b/packages/pl-fe/src/components/dropdown-menu/dropdown-menu.tsx index 3b21ea994..550946666 100644 --- a/packages/pl-fe/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/pl-fe/src/components/dropdown-menu/dropdown-menu.tsx @@ -216,10 +216,9 @@ const DropdownMenu = (props: IDropdownMenu) => { React.MouseEvent | React.KeyboardEvent > = (event) => { event.stopPropagation(); + event.preventDefault(); if (onShiftClick && event.shiftKey) { - event.preventDefault(); - onShiftClick(event); return; } diff --git a/packages/pl-fe/src/components/ui/button/index.tsx b/packages/pl-fe/src/components/ui/button/index.tsx index 501b3a4f9..9849d8ae9 100644 --- a/packages/pl-fe/src/components/ui/button/index.tsx +++ b/packages/pl-fe/src/components/ui/button/index.tsx @@ -2,11 +2,14 @@ import clsx from 'clsx'; import React from 'react'; import { Link } from 'react-router-dom'; +import DropdownMenu from 'pl-fe/components/dropdown-menu'; + import Icon from '../icon'; import { useButtonStyles } from './useButtonStyles'; import type { ButtonSizes, ButtonThemes } from './useButtonStyles'; +import type { Menu } from 'pl-fe/components/dropdown-menu'; interface IButton extends Pick< React.ComponentProps<'button'>, @@ -30,6 +33,8 @@ interface IButton extends Pick< href?: string; /** Styles the button visually with a predefined theme. */ theme?: ButtonThemes; + /** Menu items to display as a secondary action. */ + actionsMenu?: Menu; } /** Customizable button element with various themes. */ @@ -48,11 +53,12 @@ const Button = React.forwardRef(({ href, type = 'button', className, + actionsMenu, ...props }, ref): JSX.Element => { const body = text || children; - const themeClass = useButtonStyles({ + const { innerStyle, outerStyle } = useButtonStyles({ theme, block, disabled, @@ -68,7 +74,11 @@ const Button = React.forwardRef(({ const renderButton = () => ( ); + let button = renderButton(); + if (to) { - return ( + button = ( - {renderButton()} + {button} ); } if (href) { - return ( + button = ( - {renderButton()} + {button} ); } - return renderButton(); + if (actionsMenu?.length) { + button = ( +
+ {button} + +
+ + + + +
+ ); + } + + return button; }); export { Button as default }; diff --git a/packages/pl-fe/src/components/ui/button/useButtonStyles.ts b/packages/pl-fe/src/components/ui/button/useButtonStyles.ts index 86eb53ed9..a02b5fec9 100644 --- a/packages/pl-fe/src/components/ui/button/useButtonStyles.ts +++ b/packages/pl-fe/src/components/ui/button/useButtonStyles.ts @@ -14,11 +14,18 @@ const themes = { muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-800 dark:text-gray-100 focus:ring-primary-500', }; +const gaps = { + xs: 'gap-x-1.5', + sm: 'gap-x-2', + md: 'gap-x-2', + lg: 'gap-x-2', +}; + const sizes = { - xs: 'gap-x-1.5 px-2 py-1 text-xs', - sm: 'gap-x-2 px-3 py-1.5 text-xs leading-4', - md: 'gap-x-2 px-4 py-2 text-sm', - lg: 'gap-x-2 px-6 py-3 text-base', + xs: 'px-2 py-1 text-xs', + sm: 'px-3 py-1.5 text-xs leading-4', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', }; type ButtonSizes = keyof typeof sizes @@ -38,15 +45,22 @@ const useButtonStyles = ({ disabled, size, }: IButtonStyles) => { - const buttonStyle = clsx({ + const outerStyle = clsx({ 'inline-flex items-center place-content-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true, 'select-none disabled:opacity-75 disabled:cursor-default': disabled, [`${themes[theme]}`]: true, [`${sizes[size]}`]: true, + [`${gaps[size]}`]: true, 'flex w-full justify-center': block, }); - return buttonStyle; + const innerStyle = clsx({ + 'inline-flex items-center': true, + [`${gaps[size]}`]: true, + 'flex w-full justify-center': block, + }); + + return { innerStyle, outerStyle }; }; export { useButtonStyles, ButtonSizes, ButtonThemes }; diff --git a/packages/pl-fe/src/components/ui/icon.tsx b/packages/pl-fe/src/components/ui/icon.tsx index 0c9c5004e..37f4568d9 100644 --- a/packages/pl-fe/src/components/ui/icon.tsx +++ b/packages/pl-fe/src/components/ui/icon.tsx @@ -25,11 +25,12 @@ interface IIcon extends Pick, 'strokeWidth'> { } /** Renders and SVG icon with optional counter. */ -const Icon: React.FC = ({ src, alt, count, size, countMax, containerClassName, title, ...filteredProps }): JSX.Element => ( +const Icon: React.FC = React.forwardRef(({ src, alt, count, size, countMax, containerClassName, title, ...filteredProps }, ref): JSX.Element => (
{count ? ( @@ -39,6 +40,6 @@ const Icon: React.FC = ({ src, alt, count, size, countMax, containerClass
-); +)); export { Icon as default }; 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 523b00f4c..704565dad 100644 --- a/packages/pl-fe/src/features/compose/components/compose-form.tsx +++ b/packages/pl-fe/src/features/compose/components/compose-form.tsx @@ -25,6 +25,7 @@ import { useDraggedFiles } from 'pl-fe/hooks/use-dragged-files'; import { useFeatures } from 'pl-fe/hooks/use-features'; import { useInstance } from 'pl-fe/hooks/use-instance'; +import PreviewComposeContainer from '../containers/preview-compose-container'; import QuotedStatusContainer from '../containers/quoted-status-container'; import ReplyIndicatorContainer from '../containers/reply-indicator-container'; import UploadButtonContainer from '../containers/upload-button-container'; @@ -52,6 +53,7 @@ import Warning from './warning'; import type { LinkNode } from '@lexical/link'; import type { AutoSuggestion } from 'pl-fe/components/autosuggest-input'; +import type { Menu } from 'pl-fe/components/dropdown-menu'; import type { Emoji } from 'pl-fe/features/emoji'; const messages = defineMessages({ @@ -63,6 +65,7 @@ const messages = defineMessages({ message: { id: 'compose_form.message', defaultMessage: 'Message' }, schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' }, saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, + preview: { id: 'compose_form.preview', defaultMessage: 'Preview post' }, }); interface IComposeForm { @@ -151,6 +154,12 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab } })); }; + const handlePreview = (e?: React.FormEvent) => { + e?.preventDefault(); + + dispatch(submitCompose(id, { history }, true)); + }; + const onSuggestionsClearRequested = () => { dispatch(clearComposeSuggestions(id)); }; @@ -253,6 +262,14 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab if (features.richText) selectButtons.push(); if (features.postLanguages) selectButtons.push(); + const actionsMenu: Menu | undefined = features.createStatusPreview ? [ + { + text: intl.formatMessage(messages.preview), + action: handlePreview, + icon: require('@tabler/icons/outline/eye.svg'), + }, + ] : undefined; + return ( {!!compose.in_reply_to && compose.approvalRequired && ( @@ -314,6 +331,8 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab + +
({ id, shouldCondense, autoFocus, clickab )} -
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 new file mode 100644 index 000000000..9c052c54c --- /dev/null +++ b/packages/pl-fe/src/features/compose/containers/preview-compose-container.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { cancelPreviewCompose } from 'pl-fe/actions/compose'; +import EventPreview from 'pl-fe/components/event-preview'; +import OutlineBox from 'pl-fe/components/outline-box'; +import QuotedStatusIndicator from 'pl-fe/components/quoted-status-indicator'; +import StatusContent from 'pl-fe/components/status-content'; +import StatusMedia from 'pl-fe/components/status-media'; +import StatusReplyMentions from 'pl-fe/components/status-reply-mentions'; +import SensitiveContentOverlay from 'pl-fe/components/statuses/sensitive-content-overlay'; +import HStack from 'pl-fe/components/ui/hstack'; +import Icon from 'pl-fe/components/ui/icon'; +import IconButton from 'pl-fe/components/ui/icon-button'; +import Stack from 'pl-fe/components/ui/stack'; +import Text from 'pl-fe/components/ui/text'; +import AccountContainer from 'pl-fe/containers/account-container'; +import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; +import { useCompose } from 'pl-fe/hooks/use-compose'; + +import type { Status } from 'pl-fe/normalizers/status'; + +const messages = defineMessages({ + close: { + id: 'compose_form.preview.close', + defaultMessage: 'Hide preview', + }, +}); + +interface IQuotedStatusContainer { + composeId: string; +} + +/** Previewed status shown in post composer. */ +const PreviewComposeContainer: React.FC = ({ composeId }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + const status = useCompose(composeId).preview as unknown as Status; + + const handleClose = () => { + dispatch(cancelPreviewCompose(composeId)); + }; + + if (!status) { + return null; + } + + const account = status.account; + + return ( + + + + + + + + + + + + + + + {status.event ? : ( + + + + + {status.quote_id && } + + {status.media_attachments.length > 0 && ( +
+ + +
+ )} +
+
+ )} +
+
+ ); +}; + +export { PreviewComposeContainer as default }; diff --git a/packages/pl-fe/src/reducers/compose.ts b/packages/pl-fe/src/reducers/compose.ts index 5f3d997ae..55c000d5d 100644 --- a/packages/pl-fe/src/reducers/compose.ts +++ b/packages/pl-fe/src/reducers/compose.ts @@ -58,6 +58,8 @@ import { COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, COMPOSE_CLEAR_LINK_SUGGESTION_CREATE, COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE, + COMPOSE_PREVIEW_SUCCESS, + COMPOSE_PREVIEW_CANCEL, type ComposeAction, type ComposeSuggestionSelectAction, } from '../actions/compose'; @@ -67,7 +69,7 @@ import { FE_NAME } from '../actions/settings'; import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines'; import { unescapeHTML } from '../utils/html'; -import type { InteractionPolicy, CredentialAccount, Instance, MediaAttachment, Tag } from 'pl-api'; +import type { CredentialAccount, Instance, InteractionPolicy, MediaAttachment, Status as BaseStatus, Tag } from 'pl-api'; import type { Emoji } from 'pl-fe/features/emoji'; import type { Language } from 'pl-fe/features/preferences'; import type { Account } from 'pl-fe/normalizers/account'; @@ -139,6 +141,7 @@ interface Compose { interactionPolicy: InteractionPolicy | null; dismissed_clear_links_suggestions: Array; clear_link_suggestion: ClearLinkSuggestion | null; + preview: BaseStatus | null; } const newCompose = (params: Partial = {}): Compose => ({ @@ -182,6 +185,7 @@ const newCompose = (params: Partial = {}): Compose => ({ interactionPolicy: null, dismissed_clear_links_suggestions: [], clear_link_suggestion: null, + preview: null, ...params, }); @@ -718,6 +722,14 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | In } compose.dismissed_clear_links_suggestions.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; + }); default: return state; }