pl-fe: post previews

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-03-28 00:35:40 +01:00
parent 54e1e21520
commit 6dc7a23991
9 changed files with 240 additions and 43 deletions

View File

@ -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(/&#x20;/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<typeof submitComposeRequest>
| ReturnType<typeof submitComposeSuccess>
| ReturnType<typeof submitComposeFail>
| ReturnType<typeof previewComposeSuccess>
| ReturnType<typeof cancelPreviewCompose>
| ReturnType<typeof changeUploadComposeRequest>
| ReturnType<typeof changeUploadComposeSuccess>
| ReturnType<typeof changeUploadComposeFail>
@ -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,

View File

@ -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<StatusesAction>({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId });
if (!params.preview) dispatch<StatusesAction>({ 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);

View File

@ -216,10 +216,9 @@ const DropdownMenu = (props: IDropdownMenu) => {
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
> = (event) => {
event.stopPropagation();
event.preventDefault();
if (onShiftClick && event.shiftKey) {
event.preventDefault();
onShiftClick(event);
return;
}

View File

@ -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<HTMLButtonElement, IButton>(({
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<HTMLButtonElement, IButton>(({
const renderButton = () => (
<button
{...props}
className={clsx('rtl:space-x-reverse', themeClass, className)}
className={clsx('rtl:space-x-reverse', {
[outerStyle]: !actionsMenu,
[innerStyle]: true,
[className || '']: true,
})}
disabled={disabled}
onClick={handleClick}
ref={ref}
@ -85,23 +95,39 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>(({
</button>
);
let button = renderButton();
if (to) {
return (
button = (
<Link to={to} tabIndex={-1} className='inline-flex'>
{renderButton()}
{button}
</Link>
);
}
if (href) {
return (
button = (
<a href={href} target='_blank' rel='noopener' tabIndex={-1} className='inline-flex'>
{renderButton()}
{button}
</a>
);
}
return renderButton();
if (actionsMenu?.length) {
button = (
<div className={outerStyle}>
{button}
<div className='h-5 w-px bg-gray-200/50' />
<DropdownMenu items={actionsMenu} placement='bottom'>
<Icon src={require('@tabler/icons/filled/caret-down.svg')} className='size-4' />
</DropdownMenu>
</div>
);
}
return button;
});
export { Button as default };

View File

@ -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 };

View File

@ -25,11 +25,12 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
}
/** Renders and SVG icon with optional counter. */
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, containerClassName, title, ...filteredProps }): JSX.Element => (
const Icon: React.FC<IIcon> = React.forwardRef<HTMLDivElement, IIcon>(({ src, alt, count, size, countMax, containerClassName, title, ...filteredProps }, ref): JSX.Element => (
<div
className={clsx('relative flex shrink-0 flex-col', containerClassName)}
data-testid={filteredProps['data-testid'] || 'icon'}
title={title}
ref={ref}
>
{count ? (
<span className='absolute -right-3 -top-2 flex h-5 min-w-[20px] shrink-0 items-center justify-center whitespace-nowrap break-words'>
@ -39,6 +40,6 @@ const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, containerClass
<SvgIcon src={src} size={size} alt={alt} {...filteredProps} />
</div>
);
));
export { Icon as default };

View File

@ -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<ID extends string> {
@ -151,6 +154,12 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
} }));
};
const handlePreview = (e?: React.FormEvent<Element>) => {
e?.preventDefault();
dispatch(submitCompose(id, { history }, true));
};
const onSuggestionsClearRequested = () => {
dispatch(clearComposeSuggestions(id));
};
@ -253,6 +262,14 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
if (features.richText) selectButtons.push(<ContentTypeButton key='compose-type-button' composeId={id} />);
if (features.postLanguages) selectButtons.push(<LanguageDropdown key='language-dropdown' composeId={id} />);
const actionsMenu: Menu | undefined = features.createStatusPreview ? [
{
text: intl.formatMessage(messages.preview),
action: handlePreview,
icon: require('@tabler/icons/outline/eye.svg'),
},
] : undefined;
return (
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
{!!compose.in_reply_to && compose.approvalRequired && (
@ -314,6 +331,8 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
<QuotedStatusContainer composeId={id} />
<PreviewComposeContainer composeId={id} />
<div
className={clsx('flex flex-wrap items-center justify-between', {
'hidden': condensed,
@ -330,7 +349,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
</HStack>
)}
<Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={!canSubmit} />
<Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={!canSubmit} actionsMenu={actionsMenu} />
</HStack>
</div>
</Stack>

View File

@ -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<IQuotedStatusContainer> = ({ 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 (
<OutlineBox>
<Stack space={2}>
<HStack space={1} alignItems='center'>
<Icon className='size-4 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/outline/eye.svg')} />
<Text theme='muted' size='sm' className='grow'>
<FormattedMessage id='compose_form.preview_label' defaultMessage='Preview' />
</Text>
<IconButton
src={require('@tabler/icons/outline/x.svg')}
title={intl.formatMessage(messages.close)}
onClick={handleClose}
className='bg-transparent text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
iconClassName='h-4 w-4'
/>
</HStack>
<AccountContainer
id={account.id}
timestamp={status.created_at}
withRelationship={false}
showAccountHoverCard={false}
withLinkToProfile={!false}
/>
<StatusReplyMentions status={status} hoverable={false} />
{status.event ? <EventPreview status={status} hideAction /> : (
<Stack className='relative z-0'>
<Stack space={4}>
<StatusContent status={status} isQuote />
{status.quote_id && <QuotedStatusIndicator statusId={status.quote_id} />}
{status.media_attachments.length > 0 && (
<div className='relative'>
<SensitiveContentOverlay status={status} />
<StatusMedia status={status} muted />
</div>
)}
</Stack>
</Stack>
)}
</Stack>
</OutlineBox>
);
};
export { PreviewComposeContainer as default };

View File

@ -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<string>;
clear_link_suggestion: ClearLinkSuggestion | null;
preview: BaseStatus | null;
}
const newCompose = (params: Partial<Compose> = {}): Compose => ({
@ -182,6 +185,7 @@ const newCompose = (params: Partial<Compose> = {}): 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;
}