Status redesign: Quotes (#40429)

This commit is contained in:
Echo
2026-09-08 13:54:16 +00:00
committed by GitHub
parent 5dd2361cdd
commit c5a69e523e
16 changed files with 852 additions and 426 deletions

View File

@@ -1,6 +1,20 @@
import type { ApiStatusJSON } from './statuses';
export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized';
export type ApiQuoteState =
| 'accepted'
| 'pending'
| 'revoked'
| 'unauthorized'
| 'deleted'
| 'rejected'
| 'blocked_account'
| 'blocked_domain'
| 'muted_account';
export type ApiQuoteStateValid =
| 'accepted'
| 'blocked_account'
| 'blocked_domain'
| 'muted_account';
export type ApiQuotePolicy =
| 'public'
| 'followers'
@@ -10,12 +24,12 @@ export type ApiQuotePolicy =
export type ApiUserQuotePolicy = 'automatic' | 'manual' | 'denied' | 'unknown';
interface ApiQuoteEmptyJSON {
state: Exclude<ApiQuoteState, 'accepted'>;
state: Exclude<ApiQuoteState, ApiQuoteStateValid>;
quoted_status: null;
}
interface ApiNestedQuoteJSON {
state: 'accepted';
state: ApiQuoteStateValid;
quoted_status_id: string;
}
@@ -24,7 +38,7 @@ export type ApiQuotedStatusJSON = Omit<ApiStatusJSON, 'quote'> & {
};
interface ApiQuoteAcceptedJSON {
state: 'accepted';
state: ApiQuoteStateValid;
quoted_status: ApiQuotedStatusJSON;
}

View File

@@ -6,7 +6,6 @@
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 90px;
overflow: hidden;
padding: var(--space-sm);
position: relative;
@@ -65,7 +64,10 @@ a.root {
overflow-wrap: break-word;
flex-grow: 1;
margin-block-start: var(--space-xs);
* + & {
margin-block-start: var(--space-xs);
}
a {
color: var(--color-text-brand);

View File

@@ -20,6 +20,8 @@ import { compareUrls } from '@/mastodon/utils/compare_urls';
import { PictureInPicturePlaceholder } from '../picture_in_picture_placeholder';
import { StatusQuote } from './quote';
export const StatusAttachments: React.FC<{
statusId: string;
contextType?: string;
@@ -48,7 +50,7 @@ export const StatusAttachments: React.FC<{
// Don't display the card or collection if this is a quote.
if (status.quote) {
return null;
return <StatusQuote {...status.quote} parentId={statusId} />;
}
const card = status.card;

View File

@@ -80,7 +80,7 @@ export const StatusContent: React.FC<
);
return (
<EmojiHTML
<div
{...props}
className={classNames(
className,
@@ -88,13 +88,19 @@ export const StatusContent: React.FC<
isCollapsed && classes.collapsed,
)}
ref={onRef}
lang={language}
htmlString={
statusContent ?? status.translation?.contentHtml ?? status.contentHtml
}
extraEmojis={status.emojis}
{...htmlHandlers}
>
<EmojiHTML
{...props}
className={classes.contentText}
ref={onRef}
lang={language}
htmlString={
statusContent ?? status.translation?.contentHtml ?? status.contentHtml
}
extraEmojis={status.emojis}
{...htmlHandlers}
/>
{children}
{renderTranslate && (
@@ -115,7 +121,7 @@ export const StatusContent: React.FC<
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
</Button>
)}
</EmojiHTML>
</div>
);
};

View File

@@ -0,0 +1,63 @@
import classNames from 'classnames';
import type {
ApiGifvAttachmentJSON,
ApiImageAttachmentJSON,
ApiVideoAttachmentJSON,
} from '@/mastodon/api_types/media_attachments';
import { Blurhash } from '@/mastodon/components/blurhash';
import type { MediaAttachmentShape } from '@/mastodon/models/status';
import classes from './styles.module.scss';
type StatusImageAttachmentJSON =
| ApiImageAttachmentJSON
| ApiGifvAttachmentJSON
| ApiVideoAttachmentJSON;
export const StatusImage: React.FC<
{
attachment:
| StatusImageAttachmentJSON
| MediaAttachmentShape<StatusImageAttachmentJSON>;
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.contentImage, className)}
style={imgStyle}
data-color-scheme='dark'
>
{sensitive && attachment.blurhash && (
<Blurhash
hash={attachment.blurhash}
className={classes.contentBlurHash}
/>
)}
{children}
</div>
);
};

View File

@@ -0,0 +1,108 @@
@use '@/styles/mastodon/mixins';
// If hovering over an account link, underline all account links.
.title:has(.quoteAccountLink:hover) {
.accountLink {
text-decoration: underline;
}
}
.spoiler {
background: var(--color-bg-highlight);
border-radius: var(--radius-sm);
margin: var(--space-2xs) 0;
padding: var(--space-xs);
}
.body {
@include mixins.type-body;
color: inherit;
text-decoration: none;
p:not(:last-child) {
margin-block-end: 1em;
}
// 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;
}
}
.reply {
color: var(--color-text-tertiary);
margin-bottom: var(--space-xs);
}
.media {
margin-top: var(--space-2xs);
}
.mediaGrid {
display: grid;
height: 260px;
gap: var(--space-3xs);
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
aspect-ratio: 3/2;
&[data-number='2'] {
grid-template-rows: 1fr;
}
&[data-number='3'] .mediaUpload:first-child {
grid-row: span 2;
}
}
.mediaSingle .image {
min-width: 120px;
max-width: min(100%, 400px);
aspect-ratio: var(--aspect);
}
.image {
max-height: 260px;
border-radius: var(--radius-xs);
}
.videoDuration {
@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;
}
}
.error {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.errorButton {
flex-shrink: 0;
}
.infoPopup {
padding: var(--space-sm);
}

View File

@@ -0,0 +1,536 @@
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { PlayIcon } from '@phosphor-icons/react';
import { revealAccount } from '@/mastodon/actions/accounts_typed';
import { fetchStatus } from '@/mastodon/actions/statuses';
import type {
ApiAudioAttachmentJSON,
ApiGifvAttachmentJSON,
ApiImageAttachmentJSON,
ApiVideoAttachmentJSON,
} from '@/mastodon/api_types/media_attachments';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { domain } from '@/mastodon/initial_state';
import type {
AccountStatusShape,
MediaAttachmentShape,
QuotedStatus as TQuotedStatus,
} from '@/mastodon/models/status';
import {
getAccountHidden as selectAccountHidden,
selectPlainAccount,
} from '@/mastodon/selectors/accounts';
import {
selectAccountStatus,
selectStatusLoadingState,
} from '@/mastodon/selectors/statuses';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import type { OnElementHandler } from '@/mastodon/utils/html';
import { Avatar } from '../avatar';
import { Button } from '../button/redesign';
import { Card, CardBody, CardTitle } from '../card';
import { LinkedDisplayName } from '../display_name';
import { DisplayNameSimple } from '../display_name/simple';
import { EmojiHTML } from '../emoji/html';
import { Icon } from '../icon';
import { PopoverMenuCard } from '../menu/card';
import { RelativeTimestamp } from '../relative_timestamp';
import { StatusImage } from './image';
import classes from './quote.module.scss';
type StatusQuoteProps = TQuotedStatus & { parentId: string };
export const StatusQuote: React.FC<StatusQuoteProps> = (props) => {
const quoteError = useQuoteError(props);
if (quoteError) {
return (
<Card>
<CardBody className={classNames(classes.body, classes.error)} noClamp>
{quoteError}
</CardBody>
</Card>
);
}
const quotedId = props.quoted_status;
if (!quotedId) {
return null;
}
return <QuotedStatus id={quotedId} />;
};
export const QuotedStatus: React.FC<{
id: string;
clamp?: boolean;
onDelete?: () => void;
}> = ({ id, clamp, onDelete }) => {
const status = useAppSelector((state) => selectAccountStatus(state, id));
if (!status) {
return null;
}
const statusTo = `/@${status.account.acct}/${status.id}`;
return (
<Card onDelete={onDelete}>
<CardTitle
className={classes.title}
image={
<Avatar
account={status.account}
className={classes.accountLink}
withLink
/>
}
afterContent={
<Link to={statusTo}>
<RelativeTimestamp timestamp={status.created_at} />
</Link>
}
>
<LinkedDisplayName
displayProps={{ account: status.account, variant: 'noDomain' }}
className={classes.accountLink}
/>
</CardTitle>
<CardBody
className={classes.body}
as={Link}
to={statusTo}
noClamp={!clamp}
>
<QuotedStatusBody status={status} />
</CardBody>
{!status.spoilerHtml && <QuotedStatusLink status={status} />}
</Card>
);
};
const QuotedStatusBody: 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.spoiler}>
<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 (
<>
{!!reply && <p className={classes.reply}>{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.spoiler}>
<FormattedMessage
id='compose.quote.poll'
defaultMessage='Poll {sep} {isOpen, select, open {Accepting responses} other {Closed}}'
values={{
isOpen: poll.expired ? 'closed' : 'open',
sep: <>&bull;</>,
}}
/>
</div>
)}
{mainAttachment?.type === 'audio' && (
<div
className={classNames(classes.media, classes.spoiler)}
aria-label={mainAttachment.description}
>
<FormattedMessage
id='compose.quote.audio'
defaultMessage='Audio file {duration}'
values={{
duration: `(${duration})`,
}}
/>
</div>
)}
{mainAttachment?.type === 'video' && (
<div className={classNames(classes.media, classes.mediaSingle)}>
<StatusImage
className={classes.image}
attachment={mainAttachment}
sensitive={sensitive}
aria-label={mainAttachment.description}
>
<div className={classes.videoDuration}>
<Icon icon={PlayIcon} weight='fill' />
{duration}
</div>
</StatusImage>
</div>
)}
{imageAttachments.length > 0 && (
<div
className={classNames(
classes.media,
imageAttachments.length === 1 && classes.mediaSingle,
imageAttachments.length > 1 && classes.mediaGrid,
)}
data-number={imageAttachments.length}
>
{imageAttachments.map((attachment) => (
<StatusImage
className={classes.image}
attachment={attachment}
sensitive={sensitive}
key={attachment.id}
aria-label={attachment.description}
/>
))}
</div>
)}
</>
);
};
const QuotedStatusLink: 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;
};
function useQuoteError({
quoted_status: quoteId,
state: quoteState,
parentId,
}: StatusQuoteProps) {
const { state: loadingState, status: quote } = useAppSelector((state) =>
selectStatusLoadingState(state, { statusId: quoteId }),
);
const accountId = quote?.account.id;
const account = useAppSelector((state) =>
selectPlainAccount(state, accountId),
);
const quoteAuthorName = account?.acct;
const domain = quoteAuthorName?.split('@')[1];
const dispatch = useAppDispatch();
const onRevealAccount = useCallback(() => {
if (accountId) {
dispatch(revealAccount({ id: accountId }));
}
}, [dispatch, accountId]);
const hiddenAccount = useAppSelector(
(state) => accountId && selectAccountHidden(state, accountId),
);
let message: React.ReactNode = null;
let action: VoidFunction | null = null;
const [reference, setReference] = useState<HTMLButtonElement | null>(null);
const [revealed, { onTrue: onRevealQuote }] = useToggle();
const [showInfo, { onFalse: onHideInfo, onToggle: onInfoToggle }] =
useToggle();
const shouldFetchQuote =
!quote?.isLoading &&
quoteState !== 'deleted' &&
loadingState === 'not-found';
useEffect(() => {
if (shouldFetchQuote && quoteId) {
dispatch(
fetchStatus(quoteId, {
parentQuotePostId: parentId,
alsoFetchContext: false,
}),
);
}
}, [shouldFetchQuote, dispatch, quoteId, parentId]);
if (quoteState === 'pending') {
return (
<>
<FormattedMessage
id='status.quote_error.pending_approval'
defaultMessage='Post pending'
/>
<Button
size='xs'
variant='ghost'
onClick={onInfoToggle}
ref={setReference}
aria-expanded={showInfo}
className={classes.errorButton}
>
<FormattedMessage
id='learn_more_link.learn_more'
defaultMessage='Learn more'
/>
</Button>
<PopoverMenuCard
reference={reference}
onClose={onHideInfo}
isOpen={showInfo}
maxWidth={200}
className={classes.infoPopup}
placement='bottom-end'
>
<FormattedMessage
id='status.quote_error.pending_approval_popout.body'
defaultMessage="On Mastodon, you can control whether someone can quote you. This post is pending while we're getting the original author's approval."
tagName='p'
/>
</PopoverMenuCard>
</>
);
}
if (loadingState === 'filtered') {
message = (
<FormattedMessage
id='status.quote_error.filtered'
defaultMessage='Hidden due to one of your filters'
/>
);
} else if (quoteState === 'revoked') {
message = (
<FormattedMessage
id='status.quote_error.revoked'
defaultMessage='Post removed by author'
/>
);
} else if (
(quoteState === 'blocked_account' ||
quoteState === 'blocked_domain' ||
quoteState === 'muted_account') &&
!revealed &&
accountId
) {
action = onRevealQuote;
switch (quoteState) {
case 'blocked_account':
message = (
<FormattedMessage
id='status.quote_error.blocked_account_hint.title'
defaultMessage="This post is hidden because you've blocked @{name}."
values={{ name: quoteAuthorName }}
/>
);
break;
case 'blocked_domain':
message = (
<FormattedMessage
id='status.quote_error.blocked_domain_hint.title'
defaultMessage="This post is hidden because you've blocked {domain}."
values={{ domain }}
/>
);
break;
case 'muted_account':
message = (
<FormattedMessage
id='status.quote_error.muted_account_hint.title'
defaultMessage="This post is hidden because you've muted @{name}."
values={{ name: quoteAuthorName }}
/>
);
}
} else if (
!quote?.id ||
quoteState === 'deleted' ||
quoteState === 'rejected' ||
quoteState === 'unauthorized'
) {
message = (
<FormattedMessage
id='status.quote_error.not_available'
defaultMessage='Post unavailable'
/>
);
} else if (hiddenAccount && accountId) {
action = onRevealAccount;
message = (
<FormattedMessage
id='status.quote_error.limited_account_hint.title'
defaultMessage='This account has been hidden by the moderators of {domain}.'
values={{ domain }}
/>
);
}
if (!message) {
return null;
}
return (
<>
{message}
{action && (
<Button
size='xs'
variant='ghost'
onClick={action}
className={classes.errorButton}
>
<FormattedMessage
id='status.quote_error.limited_account_hint.action'
defaultMessage='Show anyway'
/>
</Button>
)}
</>
);
}

View File

@@ -44,15 +44,9 @@
}
.content {
@include mixins.type-body;
p:not(:last-child) {
margin-block-end: 1em;
}
a {
color: var(--color-text-brand);
}
display: flex;
flex-direction: column;
gap: var(--space-xs);
&.collapsed {
position: relative;
@@ -75,6 +69,32 @@
}
}
.contentText {
@include mixins.type-body;
p:not(:last-child) {
margin-block-end: 1em;
}
a {
color: var(--color-text-brand);
}
}
.contentImage {
border-radius: var(--radius-md);
position: relative;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
overflow: hidden;
}
.contentBlurHash {
width: 100%;
height: 100%;
}
.contentReadMore {
position: absolute;
inset-block-end: 0;

View File

@@ -3,12 +3,10 @@ import type { ComponentType, ReactNode } from 'react';
import StatusContainer from '@/mastodon/containers/status_container';
import type { Account as TAccount } from '@/mastodon/models/account';
import type { Status as TStatus } from '@/mastodon/models/status';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
import Status from '../status';
import type { StatusHeaderRenderFn } from './header';
import { StatusRedesign } from './status';
export type StatusContextType =
| 'account'
@@ -53,9 +51,8 @@ export interface StatusContainerProps {
withDismiss?: boolean;
}
export const TypedStatusContainer = isRedesignEnabled()
? StatusRedesign
: (StatusContainer as ComponentType<StatusContainerProps>);
export const TypedStatusContainer =
StatusContainer as ComponentType<StatusContainerProps>;
// Taken from the Status component.
export interface StatusProps extends Omit<StatusContainerProps, 'nextId'> {

View File

@@ -1,4 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
@@ -19,8 +27,11 @@ import { getAccountHidden } from 'mastodon/selectors/accounts';
import type { RootState } from 'mastodon/store';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { isRedesignEnabled } from '../utils/environment';
import { Button } from './button';
import { IconButton } from './icon_button';
import { LoadingIndicator } from './loading_indicator';
import type { StatusHeaderRenderFn } from './status/header';
import { StatusHeader } from './status/header';
import { TypedStatusContainer } from './status/types';
@@ -385,6 +396,14 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
});
const quote = status?.get('quote') as QuoteMap | undefined;
if (isRedesignEnabled()) {
return (
<Suspense fallback={<LoadingIndicator />}>
<LazyStatusRedesign {...props} />
</Suspense>
);
}
if (quote) {
return (
<TypedStatusContainer {...props}>
@@ -399,3 +418,9 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
return <TypedStatusContainer {...props} />;
};
const LazyStatusRedesign = lazy(() =>
import('./status/status').then(({ StatusRedesign }) => ({
default: StatusRedesign,
})),
);

View File

@@ -34,11 +34,6 @@
overflow: hidden;
}
.blurHash {
width: 100%;
height: 100%;
}
.mediaUploadPending {
animation: pulse 1s ease-in-out infinite;
background-color: var(--color-bg-disabled);

View File

@@ -1,309 +1,15 @@
import type React 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 { useCallback } from '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 { 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 { 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';
import { QuotedStatus } from '@/mastodon/components/status/quote';
import { useAppDispatch } from '@/mastodon/store';
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;
}
const statusTo = `/@${status.account.acct}/${status.id}`;
return (
<Card 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}>
<ComposeQuoteBody status={status} />
</CardBody>
{!status.spoilerHtml && <ComposeQuoteLink status={status} />}
</Card>
);
};
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 (
<>
{!!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',
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 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;
return <QuotedStatus id={id} clamp onDelete={handleDelete} />;
};

View File

@@ -9,12 +9,7 @@ import { DotsThreeIcon, TrashIcon } from '@phosphor-icons/react';
import { undoUploadCompose } from '@/mastodon/actions/compose';
import { openModal } from '@/mastodon/actions/modal';
import type {
ApiAudioAttachmentJSON,
ApiGifvAttachmentJSON,
ApiImageAttachmentJSON,
ApiVideoAttachmentJSON,
} from '@/mastodon/api_types/media_attachments';
import type { ApiAudioAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import { Blurhash } from '@/mastodon/components/blurhash';
import { IconButton } from '@/mastodon/components/button/redesign';
import {
@@ -24,7 +19,7 @@ import {
MenuItemDivider,
MenuList,
} from '@/mastodon/components/menu';
import type { MediaAttachmentShape } from '@/mastodon/models/status';
import { StatusImage } from '@/mastodon/components/status/image';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import classes from './attachments.module.scss';
@@ -50,7 +45,7 @@ export const ComposeUpload: React.FC<{
}
return (
<ComposeImage
<StatusImage
attachment={attachment}
className={className}
sensitive={sensitive}
@@ -81,56 +76,7 @@ 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>
</StatusImage>
);
};

View File

@@ -1,5 +1,7 @@
import type { RecordOf } from 'immutable';
import type { Simplify } from 'type-fest';
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type {
@@ -67,9 +69,7 @@ export interface StatusShape {
media_attachments: MediaAttachmentShape[];
mentions: ApiMentionJSON[];
poll?: string;
quote?: Omit<ApiQuoteJSON, 'quoted_status'> & {
quoted_status?: string;
};
quote?: QuotedStatus;
reblog?: string;
tagged_collections: ApiCollectionJSON[];
tags: ApiTagJSON[];
@@ -98,6 +98,12 @@ export type AnyStatusShape =
| AccountStatusShape
| ExpandedStatusShape;
export type QuotedStatus = Simplify<
Omit<ApiQuoteJSON, 'quoted_status'> & {
quoted_status?: string;
}
>;
export type CardShape = Omit<ApiPreviewCardJSON, 'authors'> & {
authors: (Omit<ApiPreviewCardAuthorJSON, 'author'> & {
accountId?: string;

View File

@@ -1,7 +1,6 @@
import { createAppSelector } from 'mastodon/store';
import { toServerSideType } from 'mastodon/utils/filters';
import type { StatusContextType } from '../components/status/types';
import type { StatusContextType } from '@/mastodon/components/status/types';
import { createAppSelector } from '@/mastodon/store/typed_functions';
import { toServerSideType } from '@/mastodon/utils/filters';
import { selectExpandedStatus } from './statuses';
@@ -77,34 +76,6 @@ export const selectStatusFilters = createAppSelector(
},
);
export const selectStatusLoadingState = createAppSelector(
[
(state, { statusId }: { statusId?: string | null }) =>
selectExpandedStatus(state, statusId ?? undefined),
selectStatusFilters,
(_, { warnInsteadOfHide }: { warnInsteadOfHide?: boolean }) =>
warnInsteadOfHide,
],
(status, filters, warnInsteadOfHide) => {
if (!status) {
return { state: 'not-found', status: null };
}
if (status.isLoading) {
return { state: 'loading', status: null };
}
if (
!warnInsteadOfHide &&
filters.some((filter) => filter.filter_action === 'hide')
) {
return { state: 'filtered', status: null };
}
return { state: 'loaded', status };
},
);
export const selectMediaFilters = createAppSelector(
[selectStatusFilters],
(filters) =>

View File

@@ -8,6 +8,7 @@ import type {
import { createAppSelector } from '@/mastodon/store/typed_functions';
import { selectIsAccountLocal, selectPlainAccount } from './accounts';
import { selectStatusFilters } from './filters';
export const getStatusList = createAppSelector(
[
@@ -72,6 +73,34 @@ export const selectExpandedStatus = createAppSelector(
},
);
export const selectStatusLoadingState = createAppSelector(
[
(state, { statusId }: { statusId?: string | null }) =>
selectExpandedStatus(state, statusId ?? undefined),
selectStatusFilters,
(_, { warnInsteadOfHide }: { warnInsteadOfHide?: boolean }) =>
warnInsteadOfHide,
],
(status, filters, warnInsteadOfHide) => {
if (!status) {
return { state: 'not-found', status: null } as const;
}
if (status.isLoading) {
return { state: 'loading', status: null } as const;
}
if (
!warnInsteadOfHide &&
filters.some((filter) => filter.filter_action === 'hide')
) {
return { state: 'filtered', status: null } as const;
}
return { state: 'loaded', status } as const;
},
);
export const selectStatusConditions = createAppSelector(
[
selectPlainStatus,