mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 04:16:50 -05:00
Status redesign: Quotes (#40429)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
63
app/javascript/mastodon/components/status/image.tsx
Normal file
63
app/javascript/mastodon/components/status/image.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
108
app/javascript/mastodon/components/status/quote.module.scss
Normal file
108
app/javascript/mastodon/components/status/quote.module.scss
Normal 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);
|
||||
}
|
||||
536
app/javascript/mastodon/components/status/quote.tsx
Normal file
536
app/javascript/mastodon/components/status/quote.tsx
Normal 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'
|
||||
/>
|
||||
|
||||
<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: <>•</>,
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'> {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
|
||||
<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: <>•</>,
|
||||
}}
|
||||
/>
|
||||
</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} />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user