mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-13 01:06:15 -05:00
Composer redesign: Quote post start (#40315)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 •
|
||||
<span>
|
||||
•
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
106
app/javascript/mastodon/features/compose/redesign/quote.tsx
Normal file
106
app/javascript/mastodon/features/compose/redesign/quote.tsx
Normal 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'
|
||||
/>
|
||||
|
||||
{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;
|
||||
};
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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'>;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user