mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-15 05:46:47 -05:00
Composer redesign: Quotes (#40364)
Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
|
||||
{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'
|
||||
/>
|
||||
|
||||
<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: <>•</>,
|
||||
}}
|
||||
/>
|
||||
</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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user