Composer redesign: Quote post start (#40315)

This commit is contained in:
Echo
2026-08-28 14:48:19 +00:00
committed by GitHub
parent c9537f15fb
commit 0f1610a15c
8 changed files with 194 additions and 61 deletions

View File

@@ -1,9 +1,7 @@
import { useMemo } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { Card, CardBody, CardImage, CardTitle } from './index';
import { Card, CardBody, CardTitle } from './index';
interface StoryProps {
avatar: boolean;
@@ -42,14 +40,6 @@ const meta = {
timestamp,
titleText,
}) {
const tsString = useMemo(
() =>
timestamp
? new Date(new Date().setMonth(0, 1)).toDateString()
: undefined,
[timestamp],
);
const body = bodyText.includes('\n')
? bodyText.split('\n').map((line, index) => <p key={index}>{line}</p>)
: bodyText;
@@ -63,12 +53,18 @@ const meta = {
: {};
return (
<Card {...props} onDelete={deleteBtn ? deleteCb : undefined}>
<CardTitle timestamp={tsString} imageSrc={avatar ? img : undefined}>
<Card
{...props}
onDelete={deleteBtn ? deleteCb : undefined}
image={image && <img src={img} alt='' />}
>
<CardTitle
image={avatar && <img src={img} alt='' />}
afterContent={timestamp && '1 Jan'}
>
{titleText}
</CardTitle>
<CardBody noClamp={!clamp}>{body}</CardBody>
{image && <CardImage src={img} />}
</Card>
);
},

View File

@@ -1,5 +1,5 @@
import type React from 'react';
import { createContext, use, useId, useState } from 'react';
import { createContext, use, useId } from 'react';
import { FormattedMessage } from 'react-intl';
@@ -10,7 +10,6 @@ import { TrashIcon } from '@phosphor-icons/react';
import type { PolymorphicProps } from '@/types/polymorphic';
import { IconButton } from '../button/redesign';
import { RelativeTimestamp } from '../relative_timestamp';
import classes from './styles.module.scss';
@@ -18,6 +17,7 @@ type CardProps<As extends React.ElementType> = PolymorphicProps<
{
children: React.ReactNode;
className?: string;
image?: React.ReactNode;
onDelete?: React.MouseEventHandler<HTMLButtonElement>;
},
As
@@ -25,43 +25,49 @@ type CardProps<As extends React.ElementType> = PolymorphicProps<
interface CardContext {
id: string;
registerImage?: (ref: HTMLImageElement) => void;
}
const CardContext = createContext<CardContext>({ id: '' });
export const Card = <As extends React.ElementType>({
export const Card = <As extends React.ElementType = 'div'>({
as: asComp,
children,
className,
image,
onDelete,
...props
}: CardProps<As>) => {
const Comp = asComp ?? 'div';
const id = useId();
const [hasImage, registerImage] = useState<HTMLImageElement | null>(null);
// Disable the button if this is an interactive element, including a Link component.
const hideButton =
!onDelete || asComp === 'a' || asComp === 'button' || 'to' in props;
const imageComp =
typeof image === 'string' ? (
<img src={image} alt='' className={classes.image} />
) : (
image && <div className={classes.image}>{image}</div>
);
return (
<Comp {...props} className={classNames(className, classes.root)}>
<CardContext.Provider value={{ id, registerImage }}>
{children}
</CardContext.Provider>
<CardContext.Provider value={{ id }}>{children}</CardContext.Provider>
{imageComp}
{!hideButton && (
<IconButton
variant='solid'
size='sm'
icon={TrashIcon}
onClick={onDelete}
color='destructive'
className={classes.delete}
size={hasImage ? 'sm' : 'xs'}
aria-describedby={`${id}_title`}
data-color-scheme={hasImage ? 'dark' : undefined}
variant={image ? 'solid' : 'ghost'}
data-color-scheme={image ? 'dark' : undefined}
>
<FormattedMessage id='card.delete' defaultMessage='Remove this' />
</IconButton>
@@ -72,32 +78,26 @@ export const Card = <As extends React.ElementType>({
interface CardTitleProps {
children: React.ReactNode;
imageSrc?: string;
imageAlt?: string;
timestamp?: string;
image?: React.ReactNode;
afterContent?: React.ReactNode;
}
export const CardTitle: React.FC<CardTitleProps> = ({
children,
imageSrc,
imageAlt = '',
timestamp,
}) => {
export const CardTitle: React.FC<
CardTitleProps & React.ComponentPropsWithRef<'div'>
> = ({ children, image, afterContent, className, ...props }) => {
const { id } = use(CardContext);
return (
<div className={classes.title}>
{imageSrc && (
<img src={imageSrc} alt={imageAlt} className={classes.titleImage} />
)}
<div {...props} className={classNames(className, classes.title)}>
{image && <span className={classes.titleImage}>{image}</span>}
<span id={`${id}_title`}>{children}</span>
{timestamp && (
{afterContent && (
// eslint-disable-next-line no-restricted-syntax -- Allow &bull;
<span>
&nbsp;&bull;&nbsp;
<RelativeTimestamp timestamp={timestamp} />
{afterContent}
</span>
)}
</div>
@@ -130,16 +130,3 @@ export const CardBody = <As extends React.ElementType>({
</Comp>
);
};
export const CardImage: React.FC<React.ComponentPropsWithRef<'img'>> = ({
alt,
...props
}) => {
const { registerImage } = use(CardContext);
return (
<div className={classes.image} ref={registerImage}>
<img {...props} alt={alt} />
</div>
);
};

View File

@@ -3,10 +3,14 @@
.root {
border: 0.5px solid var(--color-border-primary);
border-radius: var(--radius-md);
display: block;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 90px;
overflow: hidden;
padding: var(--space-sm);
position: relative;
transition: background 200ms ease-in-out;
&:has(.image) {
column-gap: var(--space-sm);
@@ -40,11 +44,17 @@ a.root {
a {
color: inherit;
text-decoration: none;
&:hover,
&:active {
text-decoration: underline;
}
}
}
.titleImage {
border-radius: var(--radius-round);
display: block;
height: var(--space-lg);
margin-inline-end: var(--space-xs);
overflow: hidden;
@@ -55,6 +65,7 @@ a.root {
@include mixins.type-body-compact;
overflow-wrap: break-word;
flex-grow: 1;
a {
color: var(--color-text-brand);
@@ -67,13 +78,25 @@ a.root {
}
}
// Linked body
a.body {
padding: 0 var(--space-sm) var(--space-sm);
margin: calc(-1 * var(--space-sm));
margin-top: 0;
.root:has(&:hover) {
background-color: var(--color-bg-highlight);
}
}
.image {
contain: size;
grid-column: 2;
grid-row: 1 / span 2;
margin-block: calc(-1 * var(--space-sm));
img {
&,
& > img {
height: 100%;
object-fit: cover;
width: 100%;

View File

@@ -165,3 +165,24 @@
.pollDurationSelect {
text-align: center;
}
// Quotes
.quoteBody {
color: inherit;
text-decoration: none;
}
// If hovering over an account link, underline all account links.
.quoteTitle:has(.quoteAccountLink:hover) {
.quoteAccountLink {
text-decoration: underline;
}
}
.quoteSpoiler {
background: var(--color-bg-highlight);
border-radius: var(--radius-sm);
margin: var(--space-2xs) 0;
padding: var(--space-xs);
}

View File

@@ -4,6 +4,7 @@ import { useAppSelector } from '@/mastodon/store';
import classes from './attachments.module.scss';
import { ComposePoll } from './poll';
import { ComposeQuote } from './quote';
import {
selectComposeAttachments,
selectComposeHasAttachments,
@@ -25,7 +26,7 @@ export const ComposeAttachments: React.FC<{ className?: string }> = ({
<div className={className}>
{hasPoll && <ComposePoll />}
{hasAttachments && <ComposeMediaAttachments />}
{quotedStatusId && <ComposeQuotedStatus id={quotedStatusId} />}
{quotedStatusId && <ComposeQuote id={quotedStatusId} />}
</div>
);
};
@@ -56,7 +57,3 @@ const ComposeMediaAttachments: React.FC = () => {
</div>
);
};
const ComposeQuotedStatus: React.FC<{ id: string }> = ({ id }) => {
return <div>Quoting status {id}</div>;
};

View File

@@ -0,0 +1,106 @@
import type React from 'react';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
import { Avatar } from '@/mastodon/components/avatar';
import { Blurhash } from '@/mastodon/components/blurhash';
import { Card, CardBody, CardTitle } from '@/mastodon/components/card';
import { LinkedDisplayName } from '@/mastodon/components/display_name';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
import type { AccountStatusShape } from '@/mastodon/models/status';
import { selectAccountStatus } from '@/mastodon/selectors/statuses';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import type { OnElementHandler } from '@/mastodon/utils/html';
import classes from './attachments.module.scss';
export const ComposeQuote: React.FC<{ id: string }> = ({ id }) => {
const status = useAppSelector((state) => selectAccountStatus(state, id));
const dispatch = useAppDispatch();
const handleDelete = useCallback(() => {
dispatch(quoteComposeCancel());
}, [dispatch]);
if (!status) {
return null;
}
let imageEle: React.ReactNode = null;
const image = status.media_attachments.find(({ type }) => type !== 'unknown');
if (image) {
imageEle = !status.sensitive ? (
<img src={image.preview_url} alt={image.description} />
) : (
<Blurhash hash={image.blurhash} width={120} />
);
}
const statusTo = `/@${status.account.acct}/${status.id}`;
return (
<Card image={imageEle} onDelete={handleDelete}>
<CardTitle
className={classes.quoteTitle}
image={
<Avatar
account={status.account}
className={classes.quoteAccountLink}
withLink
/>
}
afterContent={
<Link to={statusTo}>
<RelativeTimestamp timestamp={status.created_at} />
</Link>
}
>
<LinkedDisplayName
displayProps={{ account: status.account, variant: 'noDomain' }}
className={classes.quoteAccountLink}
/>
</CardTitle>
<CardBody className={classes.quoteBody} as={Link} to={statusTo}>
{!status.spoiler_text ? (
<ComposeQuoteBody status={status} />
) : (
<div className={classes.quoteSpoiler}>
<FormattedMessage
id='compose.quote.spoiler'
defaultMessage='Content:'
description='Comes before user-provided spoiler description'
/>
&nbsp;
{status.spoiler_text}
</div>
)}
</CardBody>
</Card>
);
};
const ComposeQuoteBody: React.FC<{
status: AccountStatusShape;
}> = ({ status }) => {
return (
<EmojiHTML
htmlString={status.translation?.contentHtml ?? status.contentHtml}
extraEmojis={status.emojis}
lang={status.translation?.language ?? status.language}
onElement={onStatusLinks}
/>
);
};
const onStatusLinks: OnElementHandler = (element, { key }, children) => {
if (element instanceof HTMLAnchorElement) {
return <span key={key as string}>{children}</span>;
}
return undefined;
};

View File

@@ -526,6 +526,7 @@
"compose.published.body": "Post published.",
"compose.published.open": "Open",
"compose.quotable": "Allow others to quote",
"compose.quote.spoiler": "Content:",
"compose.rearrange_modal.cancel": "Cancel",
"compose.rearrange_modal.drag_cancel": "Dragging was cancelled. Attachment index {item, number} was dropped.",
"compose.rearrange_modal.drag_end": "Attachment index {item, number} was moved to index {over, number}.",

View File

@@ -86,8 +86,10 @@ export interface StatusShape {
replies_count: number;
visibility: StatusVisibility;
}
export type ExpandedStatusShape = Omit<StatusShape, 'account' | 'reblog'> & {
export type AccountStatusShape = Omit<StatusShape, 'account'> & {
account: AccountShapeFull;
};
export type ExpandedStatusShape = Omit<AccountStatusShape, 'reblog'> & {
reblog?: Omit<ExpandedStatusShape, 'reblog'>;
};