pl-fe: compose previews cleanup

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-10 22:29:30 +02:00
parent 839b292eed
commit 626a037b4b
13 changed files with 134 additions and 79 deletions

View File

@@ -57,6 +57,7 @@ import {
notificationRequestSchema,
notificationSchema,
oauthTokenSchema,
partialStatusSchema,
pleromaConfigSchema,
pollSchema,
relationshipSchema,
@@ -84,7 +85,7 @@ import { circleSchema } from './entities/circle';
import { type GroupedNotificationsResults, groupedNotificationsResultsSchema, type NotificationGroup } from './entities/grouped-notifications-results';
import { ShoutMessage, shoutMessageSchema } from './entities/shout-message';
import { filteredArray } from './entities/utils';
import { AKKOMA, type Features, getFeatures, GOTOSOCIAL, MITRA, PIXELFED } from './features';
import { AKKOMA, type Features, getFeatures, GOTOSOCIAL, MITRA, PIXELFED, PLEROMA } from './features';
import request, { getNextLink, getPrevLink, type RequestBody, type RequestMeta } from './request';
import { buildFullPath } from './utils/url';
@@ -2166,6 +2167,26 @@ class PlApiClient {
return v.parse(statusSchema, response.json);
},
/**
* Requires features{@link Features['createStatusPreview']}.
*/
previewStatus: async (params: CreateStatusParams) => {
const input = this.features.version.software === PLEROMA || this.features.version.software === AKKOMA
? '/api/v1/statuses'
: '/api/v1/statuses/preview';
if (this.features.version.software === PLEROMA || this.features.version.software === AKKOMA) {
params.preview = true;
}
const response = await this.request(input, {
method: 'POST',
body: params,
});
return v.parse(v.partial(partialStatusSchema), response.json);
},
/**
* View a single status
* Obtain information about a status.

View File

@@ -166,6 +166,12 @@ const statusWithoutAccountSchema = v.pipe(v.any(), v.transform(preprocess), v.ob
quote: v.fallback(v.nullable(v.lazy(() => statusSchema)), null),
}));
const partialStatusSchema = v.partial(v.object({
...baseStatusSchema.entries,
reblog: v.fallback(v.nullable(v.lazy(() => statusSchema)), null),
quote: v.fallback(v.nullable(v.lazy(() => statusSchema)), null),
}));
/**
* @category Entity types
*/
@@ -183,4 +189,4 @@ type Status = v.InferOutput<typeof baseStatusSchema> & {
quote: Status | null;
}
export { statusSchema, statusWithoutAccountSchema, type Status, type StatusWithoutAccount };
export { statusSchema, statusWithoutAccountSchema, partialStatusSchema, type Status, type StatusWithoutAccount };

View File

@@ -1,6 +1,6 @@
{
"name": "pl-api",
"version": "1.0.0-rc.45",
"version": "1.0.0-rc.46",
"type": "module",
"homepage": "https://github.com/mkljczk/pl-fe/tree/develop/packages/pl-api",
"repository": {

View File

@@ -104,7 +104,7 @@
"multiselect-react-dropdown": "^2.0.25",
"mutative": "^1.1.0",
"path-browserify": "^1.0.1",
"pl-api": "^1.0.0-rc.45",
"pl-api": "^1.0.0-rc.46",
"postcss": "^8.4.49",
"process": "^0.11.10",
"punycode": "^2.1.1",

View File

@@ -447,13 +447,19 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}, preview
params.group_id = compose.group_id;
}
return dispatch(createStatus(params, idempotencyKey, statusId)).then((data) => {
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));
});
if (preview) {
getClient(state).statuses.previewStatus(params).then((data) => {
dispatch(previewComposeSuccess(composeId, data));
onSuccess?.();
}).catch(() => {});
} else {
return dispatch(createStatus(params, idempotencyKey, statusId)).then((data) => {
handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId);
onSuccess?.();
}).catch((error) => {
dispatch(submitComposeFail(composeId, error));
});
}
};
const submitComposeRequest = (composeId: string) => ({
@@ -475,7 +481,7 @@ const submitComposeFail = (composeId: string, error: unknown) => ({
error,
});
const previewComposeSuccess = (composeId: string, status: BaseStatus) => ({
const previewComposeSuccess = (composeId: string, status: Partial<BaseStatus>) => ({
type: COMPOSE_PREVIEW_SUCCESS,
composeId,
status,

View File

@@ -96,7 +96,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
const statusMeta = statusesMeta[status.id] || {};
const { data: translation } = useStatusTranslation(status.id, statusMeta.targetLanguage);
const withSpoiler = status.spoiler_text.length > 0;
const withSpoiler = status.spoiler_text?.length > 0;
const expanded = !withSpoiler || statusMeta.expanded || false;
const maybeSetCollapsed = (): void => {

View File

@@ -2,14 +2,11 @@ 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'>,
@@ -33,8 +30,6 @@ 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. */
@@ -53,12 +48,11 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>(({
href,
type = 'button',
className,
actionsMenu,
...props
}, ref): JSX.Element => {
const body = text || children;
const { innerStyle, outerStyle } = useButtonStyles({
const themeClass = useButtonStyles({
theme,
block,
disabled,
@@ -74,11 +68,7 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>(({
const renderButton = () => (
<button
{...props}
className={clsx('rtl:space-x-reverse', {
[outerStyle]: !actionsMenu,
[innerStyle]: true,
[className || '']: true,
})}
className={clsx('rtl:space-x-reverse', themeClass, className)}
disabled={disabled}
onClick={handleClick}
ref={ref}
@@ -95,39 +85,23 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>(({
</button>
);
let button = renderButton();
if (to) {
button = (
return (
<Link to={to} tabIndex={-1} className='inline-flex'>
{button}
{renderButton()}
</Link>
);
}
if (href) {
button = (
return (
<a href={href} target='_blank' rel='noopener' tabIndex={-1} className='inline-flex'>
{button}
{renderButton()}
</a>
);
}
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;
return renderButton();
});
export { Button as default };

View File

@@ -14,18 +14,11 @@ 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: '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',
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',
};
type ButtonSizes = keyof typeof sizes
@@ -45,22 +38,15 @@ const useButtonStyles = ({
disabled,
size,
}: IButtonStyles) => {
const outerStyle = clsx({
const buttonStyle = 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,
});
const innerStyle = clsx({
'inline-flex items-center': true,
[`${gaps[size]}`]: true,
'flex w-full justify-center': block,
});
return { innerStyle, outerStyle };
return buttonStyle;
};
export { useButtonStyles, ButtonSizes, ButtonThemes };

View File

@@ -14,9 +14,11 @@ import {
ignoreClearLinkSuggestion,
suggestClearLink,
} from 'pl-fe/actions/compose';
import Button from 'pl-fe/components/ui/button';
import DropdownMenu from 'pl-fe/components/dropdown-menu';
import HStack from 'pl-fe/components/ui/hstack';
import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack';
import SvgIcon from 'pl-fe/components/ui/svg-icon';
import EmojiPickerDropdown from 'pl-fe/features/emoji/containers/emoji-picker-dropdown-container';
import { ComposeEditor } from 'pl-fe/features/ui/util/async-components';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
@@ -66,8 +68,59 @@ const messages = defineMessages({
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
preview: { id: 'compose_form.preview', defaultMessage: 'Preview post' },
more: { id: 'compose_form.more', defaultMessage: 'More' },
});
interface IComposeButton extends Pick<
React.ComponentProps<'button'>,
'children' | 'disabled' | 'onClick' | 'onMouseDown' | 'onKeyDown' | 'onKeyPress' | 'title' | 'type'
> {
/** URL to an SVG icon to render inside the button. */
icon?: string;
/** Text inside the button. Takes precedence over `children`. */
text?: React.ReactNode;
/** Menu items to display as a secondary action. */
actionsMenu?: Menu;
}
const ComposeButton: React.FC<IComposeButton> = ({ actionsMenu, disabled, icon, text, ...props }) => {
const intl = useIntl();
const containerClassName = 'flex items-center gap-px overflow-hidden rounded-full text-sm font-medium text-gray-100';
const buttonClassName = 'inline-flex select-none appearance-none border border-transparent bg-primary-500 transition-all hover:bg-primary-400 focus:bg-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-300 focus:ring-offset-2 disabled:cursor-default disabled:opacity-75 dark:hover:bg-primary-600';
const button = (
<button
{...props}
disabled={disabled}
className={clsx({
'place-content-center items-center gap-x-2 px-4 py-2 rtl:space-x-reverse': true,
[buttonClassName]: true,
'pr-2': actionsMenu,
[containerClassName]: !actionsMenu,
})}
>
{icon ? <Icon src={icon} className='size-4' /> : null}
<span>{text}</span>
</button>
);
if (actionsMenu) {
return (
<div className={containerClassName}>
{button}
<DropdownMenu items={actionsMenu} placement='bottom' disabled={disabled}>
<button className={clsx('h-full cursor-pointer py-2.5 pl-1 pr-3', buttonClassName)} title={intl.formatMessage(messages.more)}>
<SvgIcon src={require('@tabler/icons/filled/caret-down.svg')} className='size-4' />
</button>
</DropdownMenu>
</div>
);
}
return button;
};
interface IComposeForm<ID extends string> {
id: ID extends 'default' ? never : ID;
shouldCondense?: boolean;
@@ -349,7 +402,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
</HStack>
)}
<Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={!canSubmit} actionsMenu={actionsMenu} />
<ComposeButton type='submit' icon={publishIcon} text={publishText} disabled={!canSubmit} actionsMenu={actionsMenu} />
</HStack>
</div>
</Stack>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { cancelPreviewCompose } from 'pl-fe/actions/compose';
@@ -17,6 +17,7 @@ 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 { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import type { Status } from 'pl-fe/normalizers/status';
@@ -35,19 +36,23 @@ interface IQuotedStatusContainer {
const PreviewComposeContainer: React.FC<IQuotedStatusContainer> = ({ composeId }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { account: ownAccount } = useOwnAccount();
const status = useCompose(composeId).preview as unknown as Status;
const previewedStatus = useCompose(composeId).preview as unknown as Status;
const handleClose = () => {
dispatch(cancelPreviewCompose(composeId));
};
const status = useMemo(() => previewedStatus ? ({
...previewedStatus,
account: previewedStatus.account || ownAccount,
}) : null, [previewedStatus, ownAccount]);
if (!status) {
return null;
}
const account = status.account;
return (
<OutlineBox>
<Stack space={2}>
@@ -66,7 +71,7 @@ const PreviewComposeContainer: React.FC<IQuotedStatusContainer> = ({ composeId }
/>
</HStack>
<AccountContainer
id={account.id}
id={status.account.id}
timestamp={status.created_at}
withRelationship={false}
showAccountHoverCard={false}
@@ -82,7 +87,7 @@ const PreviewComposeContainer: React.FC<IQuotedStatusContainer> = ({ composeId }
{status.quote_id && <QuotedStatusIndicator statusId={status.quote_id} />}
{status.media_attachments.length > 0 && (
{status.media_attachments?.length > 0 && (
<div className='relative'>
<SensitiveContentOverlay status={status} />
<StatusMedia status={status} muted />

View File

@@ -489,6 +489,7 @@
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
"compose_form.message": "Message",
"compose_form.more": "More",
"compose_form.placeholder": "What's on your mind?",
"compose_form.poll.add_option": "Add an answer",
"compose_form.poll.duration": "Poll duration",
@@ -500,6 +501,9 @@
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple answers",
"compose_form.poll.switch_to_single": "Change poll to allow for a single answer",
"compose_form.poll_placeholder": "Add a poll topic…",
"compose_form.preview": "Preview post",
"compose_form.preview.close": "Hide preview",
"compose_form.preview_label": "Preview",
"compose_form.publish": "Post",
"compose_form.publish_loud": "{publish}!",
"compose_form.save_changes": "Save changes",

View File

@@ -141,7 +141,7 @@ interface Compose {
interactionPolicy: InteractionPolicy | null;
dismissed_clear_links_suggestions: Array<string>;
clear_link_suggestion: ClearLinkSuggestion | null;
preview: BaseStatus | null;
preview: Partial<BaseStatus> | null;
}
const newCompose = (params: Partial<Compose> = {}): Compose => ({

View File

@@ -6833,10 +6833,10 @@ pkg-dir@^4.1.0:
dependencies:
find-up "^4.0.0"
pl-api@^1.0.0-rc.45:
version "1.0.0-rc.45"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-1.0.0-rc.45.tgz#6c1986e850ab36ee1ba31248a2b90638bec06170"
integrity sha512-NM5QZ9x9sjK3sOxThqO48926Lr+Uw/P911jaLl4CcKEQV+IuLYEdOheYdpeH2Ub5LErcUwxiiriv5Xepx+FEEA==
pl-api@^1.0.0-rc.46:
version "1.0.0-rc.46"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-1.0.0-rc.46.tgz#48c350b7c224d0b150075001faf9bfa7238cd37c"
integrity sha512-izay+cl5QlxNdPaYwAtnCw9XNBo35tTvRrkIB8FFQ8+yqnvv8usjno8CMLZVslk7Ujjc2wmATesIWLhyWJorhg==
dependencies:
blurhash "^2.0.5"
http-link-header "^1.1.3"