Composer redesign: Quotes (#40364)

Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
Echo
2026-09-03 10:04:22 +00:00
committed by GitHub
parent 171438e185
commit fd5bcf6189
8 changed files with 404 additions and 95 deletions

View File

@@ -39,7 +39,6 @@ a.root {
color: var(--color-text-secondary);
display: flex;
margin-bottom: var(--space-2xs);
a {
color: inherit;
@@ -66,6 +65,7 @@ a.root {
overflow-wrap: break-word;
flex-grow: 1;
margin-block-start: var(--space-xs);
a {
color: var(--color-text-brand);
@@ -80,9 +80,8 @@ a.root {
// Linked body
a.body {
padding: 0 var(--space-sm) var(--space-sm);
margin: calc(-1 * var(--space-sm));
margin-top: 0;
padding-inline: var(--space-sm);
margin-inline: calc(-1 * var(--space-sm));
.root:has(&:hover) {
background-color: var(--color-bg-highlight);

View File

@@ -6,38 +6,50 @@ import type {
OnElementHandler,
} from '@/mastodon/utils/html';
import { htmlStringToComponents } from '@/mastodon/utils/html';
import { polymorphicForwardRef } from '@/types/polymorphic';
import type { PolymorphicProps } from '@/types/polymorphic';
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
import { textToEmojis } from './index';
export interface EmojiHTMLProps {
export interface EmojiHTMLProps<
Arg extends Record<string, unknown> = Record<string, unknown>,
> {
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
className?: string;
onElement?: OnElementHandler;
onAttribute?: OnAttributeHandler;
onElement?: OnElementHandler<Arg>;
onAttribute?: OnAttributeHandler<Arg>;
extraArgs?: Arg;
}
export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
({ extraEmojis, htmlString, onElement, onAttribute, ...props }, ref) => {
const contents = useMemo(
() =>
htmlStringToComponents(htmlString, {
onText: textToEmojis,
onElement,
onAttribute,
}),
[htmlString, onAttribute, onElement],
);
export const EmojiHTML = <
As extends React.ElementType = 'div',
Arg extends Record<string, unknown> = Record<string, unknown>,
>({
extraEmojis,
htmlString,
onElement,
onAttribute,
extraArgs,
ref,
...props
}: PolymorphicProps<EmojiHTMLProps<Arg>, As>) => {
const contents = useMemo(
() =>
htmlStringToComponents(htmlString, {
onText: textToEmojis,
onElement,
onAttribute,
extraArgs,
}),
[extraArgs, htmlString, onAttribute, onElement],
);
return (
<CustomEmojiProvider emojis={extraEmojis}>
<AnimateEmojiProvider {...props} ref={ref}>
{contents}
</AnimateEmojiProvider>
</CustomEmojiProvider>
);
},
);
EmojiHTML.displayName = 'EmojiHTML';
return (
<CustomEmojiProvider emojis={extraEmojis}>
<AnimateEmojiProvider {...props} ref={ref}>
{contents}
</AnimateEmojiProvider>
</CustomEmojiProvider>
);
};

View File

@@ -1,5 +1,7 @@
import classNames from 'classnames';
import type { IconWeight } from '@phosphor-icons/react';
import CheckBoxOutlineBlankIcon from '@/material-icons/400-24px/check_box_outline_blank.svg?react';
import { isProduction } from 'mastodon/utils/environment';
@@ -14,6 +16,7 @@ interface Props extends React.SVGProps<SVGSVGElement> {
id?: string;
icon: IconProp;
noFill?: boolean;
weight?: IconWeight;
}
export const Icon: React.FC<Props> = ({

View File

@@ -6,6 +6,7 @@
min-width: 120px;
max-width: min(100%, 400px);
max-height: var(--max-media-height-large);
aspect-ratio: var(--aspect);
}
.mediaGrid {
@@ -168,11 +169,6 @@
// Quotes
.quoteBody {
color: inherit;
text-decoration: none;
}
// If hovering over an account link, underline all account links.
.quoteTitle:has(.quoteAccountLink:hover) {
.quoteAccountLink {
@@ -186,3 +182,56 @@
margin: var(--space-2xs) 0;
padding: var(--space-xs);
}
.quoteBody {
color: inherit;
text-decoration: none;
// If a body only has an empty div (such as when quoting just a link), just hide it.
&:has(> div:only-child:empty) {
display: none;
}
strong {
font-weight: bold;
}
}
.quoteReply {
color: var(--color-text-tertiary);
margin-bottom: var(--space-xs);
}
.quoteMedia {
margin-top: var(--space-2xs);
&.mediaGrid {
height: 260px;
}
.mediaUpload {
max-height: 260px;
border-radius: var(--radius-xs);
}
}
.quoteVideoDuration {
@include mixins.type-label-md;
display: flex;
gap: var(--space-2xs);
align-items: center;
position: absolute;
inset-block-end: var(--space-xs);
inset-inline-start: var(--space-xs);
padding: var(--space-2xs) var(--space-3xs);
background: var(--color-bg-highlight);
border-radius: var(--radius-xs);
color: var(--color-text-primary);
svg {
width: 16px;
height: 16px;
opacity: 0.7;
}
}

View File

@@ -113,7 +113,10 @@ export const ComposePoll: React.FC = () => {
useCallback(
(event) => {
dispatch(
changePollSettings(Number.parseInt(event.target.value), multiple),
changePollSettings(
Number.parseInt(event.target.value) / 1000,
multiple,
),
);
},
[dispatch, multiple],
@@ -189,7 +192,7 @@ export const ComposePoll: React.FC = () => {
{pollDurationOptions.map((duration) => {
const { message, multiplier } = durationToMessage(duration);
return (
<option key={duration} value={duration}>
<option key={duration} value={duration / 1000}>
{intl.formatMessage(message, {
number: duration / multiplier,
})}

View File

@@ -1,23 +1,39 @@
import type React from 'react';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { PlayIcon } from '@phosphor-icons/react';
import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
import type {
ApiAudioAttachmentJSON,
ApiGifvAttachmentJSON,
ApiImageAttachmentJSON,
ApiVideoAttachmentJSON,
} from '@/mastodon/api_types/media_attachments';
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 { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { Icon } from '@/mastodon/components/icon';
import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
import type { AccountStatusShape } from '@/mastodon/models/status';
import { domain } from '@/mastodon/initial_state';
import type {
MediaAttachmentShape,
AccountStatusShape,
} from '@/mastodon/models/status';
import { selectPlainAccount } from '@/mastodon/selectors/accounts';
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';
import { ComposeImage } from './upload';
export const ComposeQuote: React.FC<{ id: string }> = ({ id }) => {
const status = useAppSelector((state) => selectAccountStatus(state, id));
@@ -31,20 +47,10 @@ export const ComposeQuote: React.FC<{ id: string }> = ({ id }) => {
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}>
<Card onDelete={handleDelete}>
<CardTitle
className={classes.quoteTitle}
image={
@@ -67,20 +73,10 @@ export const ComposeQuote: React.FC<{ id: string }> = ({ id }) => {
</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>
)}
<ComposeQuoteBody status={status} />
</CardBody>
{!status.spoilerHtml && <ComposeQuoteLink status={status} />}
</Card>
);
};
@@ -88,19 +84,227 @@ export const ComposeQuote: React.FC<{ id: string }> = ({ id }) => {
const ComposeQuoteBody: React.FC<{
status: AccountStatusShape;
}> = ({ status }) => {
const attachments = status.media_attachments;
const mainAttachment = attachments.find(
(
attachment,
): attachment is
| MediaAttachmentShape<ApiAudioAttachmentJSON>
| MediaAttachmentShape<ApiVideoAttachmentJSON> =>
attachment.type === 'audio' || attachment.type === 'video',
);
const imageAttachments = attachments.filter(
(
attachment,
): attachment is MediaAttachmentShape<
ApiImageAttachmentJSON | ApiGifvAttachmentJSON
> => attachment.type === 'gifv' || attachment.type === 'image',
);
const duration = useMemo(() => {
const duration = mainAttachment?.meta.original.duration;
if (!duration) {
return '';
}
const locale = document.documentElement.lang;
const formatter = new Intl.DurationFormat(locale, {
style: 'digital',
hoursDisplay: 'auto',
});
const totalSeconds = Math.floor(duration);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return formatter.format({ hours, minutes, seconds });
}, [mainAttachment?.meta.original.duration]);
const sensitive = status.sensitive;
const poll = useAppSelector((state) => state.polls[status.poll ?? '']);
const inReplyToAccount = useAppSelector((state) =>
selectPlainAccount(state, status.in_reply_to_account_id),
);
if (status.spoilerHtml) {
return (
<div className={classes.quoteSpoiler}>
<FormattedMessage
id='compose.quote.spoiler'
defaultMessage='Content:'
description='Comes before user-provided spoiler description'
/>
&nbsp;
<EmojiHTML
as='span'
htmlString={status.translation?.spoilerHtml ?? status.spoilerHtml}
lang={status.translation?.language ?? status.language}
extraEmojis={status.emojis}
/>
</div>
);
}
// Show reply or thread text.
let reply: React.ReactNode = null;
if (status.in_reply_to_account_id === status.account.id) {
reply = (
<FormattedMessage
id='status.continued_thread'
defaultMessage='Continued thread'
/>
);
} else if (inReplyToAccount) {
reply = (
<FormattedMessage
id='status.replied_to'
defaultMessage='Replied to {name}'
values={{
name: <DisplayNameSimple account={inReplyToAccount} />,
}}
/>
);
}
return (
<EmojiHTML
htmlString={status.translation?.contentHtml ?? status.contentHtml}
extraEmojis={status.emojis}
lang={status.translation?.language ?? status.language}
onElement={onStatusLinks}
/>
<>
{!!reply && <p className={classes.quoteReply}>{reply}</p>}
<EmojiHTML
htmlString={status.translation?.contentHtml ?? status.contentHtml}
extraEmojis={status.emojis}
lang={status.translation?.language ?? status.language}
onElement={onStatusLinks}
extraArgs={status}
/>
{poll && (
<div className={classes.quoteSpoiler}>
<FormattedMessage
id='compose.quote.poll'
defaultMessage='Poll {sep} {isOpen, select, open {Accepting responses} other {Closed}}'
values={{
isOpen: poll.expired ? 'closed' : 'open',
// eslint-disable-next-line no-restricted-syntax
sep: <>&bull;</>,
}}
/>
</div>
)}
{mainAttachment?.type === 'audio' && (
<div
className={classNames(classes.quoteMedia, classes.quoteSpoiler)}
aria-label={mainAttachment.description}
>
<FormattedMessage
id='compose.quote.audio'
defaultMessage='Audio file {duration}'
values={{
duration: `(${duration})`,
}}
/>
</div>
)}
{mainAttachment?.type === 'video' && (
<div className={classNames(classes.quoteMedia, classes.mediaSingle)}>
<ComposeImage
attachment={mainAttachment}
sensitive={sensitive}
aria-label={mainAttachment.description}
>
<div className={classes.quoteVideoDuration}>
<Icon icon={PlayIcon} weight='fill' />
{duration}
</div>
</ComposeImage>
</div>
)}
{imageAttachments.length > 0 && (
<div
className={classNames(
classes.quoteMedia,
imageAttachments.length === 1 && classes.mediaSingle,
imageAttachments.length > 1 && classes.mediaGrid,
)}
data-number={imageAttachments.length}
>
{imageAttachments.map((attachment) => (
<ComposeImage
attachment={attachment}
sensitive={sensitive}
key={attachment.id}
aria-label={attachment.description}
/>
))}
</div>
)}
</>
);
};
const onStatusLinks: OnElementHandler = (element, { key }, children) => {
if (element instanceof HTMLAnchorElement) {
return <span key={key as string}>{children}</span>;
const ComposeQuoteLink: React.FC<{ status: AccountStatusShape }> = ({
status,
}) => {
const quotedPost = useAppSelector((state) =>
selectAccountStatus(state, status.quote?.quoted_status),
);
let link: React.ReactNode = null;
if (quotedPost) {
link = (
<Link to={`/@${quotedPost.account.acct}/${quotedPost.id}`}>
{quotedPost.uri}
</Link>
);
}
if (status.card) {
if (new URL(status.card.url).host === domain) {
link = <Link to={status.card.url}>{status.card.url}</Link>;
} else {
link = (
<a href={status.card.url} target='_blank' rel='noopener'>
{status.card.url}
</a>
);
}
}
const collection = status.tagged_collections[0];
if (collection) {
link = <Link to={`/collections/${collection.id}`}>{collection.url}</Link>;
}
if (!link) {
return null;
}
return <CardBody>{link}</CardBody>;
};
const onStatusLinks: OnElementHandler<AccountStatusShape> = (
element,
{ key, href },
children,
status,
) => {
// If this is a paragraph with just a link and it matches the card, don't add it.
if (
element instanceof HTMLParagraphElement &&
element.children.length === 1 &&
element.firstChild instanceof HTMLAnchorElement &&
element.firstChild.href === status.card?.url
) {
return null;
} else if (element instanceof HTMLAnchorElement) {
if (href === status.card?.url) {
return null;
}
return <strong key={key as string}>{children}</strong>;
}
return undefined;
};

View File

@@ -9,7 +9,12 @@ import { DotsThreeIcon, TrashIcon } from '@phosphor-icons/react';
import { undoUploadCompose } from '@/mastodon/actions/compose';
import { openModal } from '@/mastodon/actions/modal';
import type { ApiAudioAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import type {
ApiAudioAttachmentJSON,
ApiGifvAttachmentJSON,
ApiImageAttachmentJSON,
ApiVideoAttachmentJSON,
} from '@/mastodon/api_types/media_attachments';
import { Blurhash } from '@/mastodon/components/blurhash';
import { IconButton } from '@/mastodon/components/button/redesign';
import {
@@ -19,6 +24,7 @@ import {
MenuItemDivider,
MenuList,
} from '@/mastodon/components/menu';
import type { MediaAttachmentShape } from '@/mastodon/models/status';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import classes from './attachments.module.scss';
@@ -43,29 +49,11 @@ export const ComposeUpload: React.FC<{
return <ComposeAudioUpload attachment={attachment} />;
}
let x = 50;
let y = 50;
const focusX = attachment.meta.focus?.x;
const focusY = attachment.meta.focus?.y;
if (focusX && focusY) {
x = (focusX / 2 + 0.5) * 100;
y = (focusY / -2 + 0.5) * 100;
}
return (
<div
className={classNames(classes.mediaUpload, className)}
style={{
backgroundImage:
!sensitive && attachment.preview_url
? `url(${attachment.preview_url})`
: undefined,
backgroundPosition: `${x}% ${y}%`,
aspectRatio: single
? `${attachment.meta.original.width} / ${attachment.meta.original.height}`
: undefined,
}}
data-color-scheme='dark'
<ComposeImage
attachment={attachment}
className={className}
sensitive={sensitive}
>
{sensitive && attachment.blurhash && (
<Blurhash hash={attachment.blurhash} className={classes.blurHash} />
@@ -93,6 +81,55 @@ export const ComposeUpload: React.FC<{
<FormattedMessage id='compose.upload.alt' defaultMessage='Alt' />
</span>
)}
</ComposeImage>
);
};
type ComposeImageAttachmentJSON =
| ApiImageAttachmentJSON
| ApiGifvAttachmentJSON
| ApiVideoAttachmentJSON;
export const ComposeImage: React.FC<
{
attachment:
| ComposeImageAttachmentJSON
| MediaAttachmentShape<ComposeImageAttachmentJSON>;
children?: React.ReactNode;
sensitive?: boolean;
} & React.ComponentPropsWithRef<'div'>
> = ({ attachment, children, sensitive, className, style, ...props }) => {
let x = 50;
let y = 50;
const focusX = attachment.meta.focus?.x;
const focusY = attachment.meta.focus?.y;
if (focusX && focusY) {
x = (focusX / 2 + 0.5) * 100;
y = (focusY / -2 + 0.5) * 100;
}
const imgStyle = {
backgroundImage:
!sensitive && attachment.preview_url
? `url(${attachment.preview_url})`
: undefined,
backgroundPosition: `${x}% ${y}%`,
'--aspect': `${attachment.meta.original.width} / ${attachment.meta.original.height}`,
...style,
};
return (
<div
{...props}
className={classNames(classes.mediaUpload, className)}
style={imgStyle}
data-color-scheme='dark'
>
{sensitive && attachment.blurhash && (
<Blurhash hash={attachment.blurhash} className={classes.blurHash} />
)}
{children}
</div>
);
};

View File

@@ -534,6 +534,8 @@
"compose.published.body": "Post published.",
"compose.published.open": "Open",
"compose.quotable": "Allow others to quote",
"compose.quote.audio": "Audio file {duration}",
"compose.quote.poll": "Poll {sep} {isOpen, select, open {Accepting responses} other {Closed}}",
"compose.quote.spoiler": "Content:",
"compose.rearrange_modal.cancel": "Cancel",
"compose.rearrange_modal.drag_cancel": "Dragging was cancelled. Attachment index {item, number} was dropped.",