diff --git a/app/javascript/mastodon/api_types/quotes.ts b/app/javascript/mastodon/api_types/quotes.ts index de69394b98e..23740bb7cc7 100644 --- a/app/javascript/mastodon/api_types/quotes.ts +++ b/app/javascript/mastodon/api_types/quotes.ts @@ -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; + state: Exclude; quoted_status: null; } interface ApiNestedQuoteJSON { - state: 'accepted'; + state: ApiQuoteStateValid; quoted_status_id: string; } @@ -24,7 +38,7 @@ export type ApiQuotedStatusJSON = Omit & { }; interface ApiQuoteAcceptedJSON { - state: 'accepted'; + state: ApiQuoteStateValid; quoted_status: ApiQuotedStatusJSON; } diff --git a/app/javascript/mastodon/components/card/styles.module.scss b/app/javascript/mastodon/components/card/styles.module.scss index 020d8ae5fae..aaa9bb84b6d 100644 --- a/app/javascript/mastodon/components/card/styles.module.scss +++ b/app/javascript/mastodon/components/card/styles.module.scss @@ -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); diff --git a/app/javascript/mastodon/components/status/attachments.tsx b/app/javascript/mastodon/components/status/attachments.tsx index a91670adc73..e59e6deed5c 100644 --- a/app/javascript/mastodon/components/status/attachments.tsx +++ b/app/javascript/mastodon/components/status/attachments.tsx @@ -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 ; } const card = status.card; diff --git a/app/javascript/mastodon/components/status/content.tsx b/app/javascript/mastodon/components/status/content.tsx index b43b4909d5d..32f7c6080e2 100644 --- a/app/javascript/mastodon/components/status/content.tsx +++ b/app/javascript/mastodon/components/status/content.tsx @@ -80,7 +80,7 @@ export const StatusContent: React.FC< ); return ( - + + {children} {renderTranslate && ( @@ -115,7 +121,7 @@ export const StatusContent: React.FC< )} - + ); }; diff --git a/app/javascript/mastodon/components/status/image.tsx b/app/javascript/mastodon/components/status/image.tsx new file mode 100644 index 00000000000..a5d84947ad2 --- /dev/null +++ b/app/javascript/mastodon/components/status/image.tsx @@ -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; + 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 ( +
+ {sensitive && attachment.blurhash && ( + + )} + + {children} +
+ ); +}; diff --git a/app/javascript/mastodon/components/status/quote.module.scss b/app/javascript/mastodon/components/status/quote.module.scss new file mode 100644 index 00000000000..7fc95604f78 --- /dev/null +++ b/app/javascript/mastodon/components/status/quote.module.scss @@ -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); +} diff --git a/app/javascript/mastodon/components/status/quote.tsx b/app/javascript/mastodon/components/status/quote.tsx new file mode 100644 index 00000000000..f2b4d0d7571 --- /dev/null +++ b/app/javascript/mastodon/components/status/quote.tsx @@ -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 = (props) => { + const quoteError = useQuoteError(props); + + if (quoteError) { + return ( + + + {quoteError} + + + ); + } + + const quotedId = props.quoted_status; + if (!quotedId) { + return null; + } + + return ; +}; + +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 ( + + + } + afterContent={ + + + + } + > + + + + + + + + {!status.spoilerHtml && } + + ); +}; + +const QuotedStatusBody: React.FC<{ + status: AccountStatusShape; +}> = ({ status }) => { + const attachments = status.media_attachments; + const mainAttachment = attachments.find( + ( + attachment, + ): attachment is + | MediaAttachmentShape + | MediaAttachmentShape => + 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 ( +
+ +   + +
+ ); + } + + // Show reply or thread text. + let reply: React.ReactNode = null; + if (status.in_reply_to_account_id === status.account.id) { + reply = ( + + ); + } else if (inReplyToAccount) { + reply = ( + , + }} + /> + ); + } + + return ( + <> + {!!reply &&

{reply}

} + + + + {poll && ( +
+ •, + }} + /> +
+ )} + + {mainAttachment?.type === 'audio' && ( +
+ +
+ )} + + {mainAttachment?.type === 'video' && ( +
+ +
+ + + {duration} +
+
+
+ )} + + {imageAttachments.length > 0 && ( +
1 && classes.mediaGrid, + )} + data-number={imageAttachments.length} + > + {imageAttachments.map((attachment) => ( + + ))} +
+ )} + + ); +}; + +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 = ( + + {quotedPost.uri} + + ); + } + + if (status.card) { + if (new URL(status.card.url).host === domain) { + link = {status.card.url}; + } else { + link = ( + + {status.card.url} + + ); + } + } + + const collection = status.tagged_collections[0]; + if (collection) { + link = {collection.url}; + } + + if (!link) { + return null; + } + + return {link}; +}; + +const onStatusLinks: OnElementHandler = ( + 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 {children}; + } + 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(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 ( + <> + + + + + + + + + ); + } + + if (loadingState === 'filtered') { + message = ( + + ); + } else if (quoteState === 'revoked') { + message = ( + + ); + } else if ( + (quoteState === 'blocked_account' || + quoteState === 'blocked_domain' || + quoteState === 'muted_account') && + !revealed && + accountId + ) { + action = onRevealQuote; + + switch (quoteState) { + case 'blocked_account': + message = ( + + ); + break; + case 'blocked_domain': + message = ( + + ); + break; + case 'muted_account': + message = ( + + ); + } + } else if ( + !quote?.id || + quoteState === 'deleted' || + quoteState === 'rejected' || + quoteState === 'unauthorized' + ) { + message = ( + + ); + } else if (hiddenAccount && accountId) { + action = onRevealAccount; + message = ( + + ); + } + + if (!message) { + return null; + } + + return ( + <> + {message} + + {action && ( + + )} + + ); +} diff --git a/app/javascript/mastodon/components/status/styles.module.scss b/app/javascript/mastodon/components/status/styles.module.scss index fe1627c0f69..bd28b4f2dde 100644 --- a/app/javascript/mastodon/components/status/styles.module.scss +++ b/app/javascript/mastodon/components/status/styles.module.scss @@ -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; diff --git a/app/javascript/mastodon/components/status/types.ts b/app/javascript/mastodon/components/status/types.ts index e2ab7bdd99a..32f661e96f2 100644 --- a/app/javascript/mastodon/components/status/types.ts +++ b/app/javascript/mastodon/components/status/types.ts @@ -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); +export const TypedStatusContainer = + StatusContainer as ComponentType; // Taken from the Status component. export interface StatusProps extends Omit { diff --git a/app/javascript/mastodon/components/status_quoted.tsx b/app/javascript/mastodon/components/status_quoted.tsx index d66d70d86ea..9fe6c761bf1 100644 --- a/app/javascript/mastodon/components/status_quoted.tsx +++ b/app/javascript/mastodon/components/status_quoted.tsx @@ -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 ( + }> + + + ); + } + if (quote) { return ( @@ -399,3 +418,9 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => { return ; }; + +const LazyStatusRedesign = lazy(() => + import('./status/status').then(({ StatusRedesign }) => ({ + default: StatusRedesign, + })), +); diff --git a/app/javascript/mastodon/features/compose/redesign/attachments.module.scss b/app/javascript/mastodon/features/compose/redesign/attachments.module.scss index c51162fc615..e6a27a403ab 100644 --- a/app/javascript/mastodon/features/compose/redesign/attachments.module.scss +++ b/app/javascript/mastodon/features/compose/redesign/attachments.module.scss @@ -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); diff --git a/app/javascript/mastodon/features/compose/redesign/quote.tsx b/app/javascript/mastodon/features/compose/redesign/quote.tsx index 4f1ba383793..141988d67d9 100644 --- a/app/javascript/mastodon/features/compose/redesign/quote.tsx +++ b/app/javascript/mastodon/features/compose/redesign/quote.tsx @@ -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 ( - - - } - afterContent={ - - - - } - > - - - - - - - - {!status.spoilerHtml && } - - ); -}; - -const ComposeQuoteBody: React.FC<{ - status: AccountStatusShape; -}> = ({ status }) => { - const attachments = status.media_attachments; - const mainAttachment = attachments.find( - ( - attachment, - ): attachment is - | MediaAttachmentShape - | MediaAttachmentShape => - 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 ( -
- -   - -
- ); - } - - // Show reply or thread text. - let reply: React.ReactNode = null; - if (status.in_reply_to_account_id === status.account.id) { - reply = ( - - ); - } else if (inReplyToAccount) { - reply = ( - , - }} - /> - ); - } - - return ( - <> - {!!reply &&

{reply}

} - - - - {poll && ( -
- •, - }} - /> -
- )} - - {mainAttachment?.type === 'audio' && ( -
- -
- )} - - {mainAttachment?.type === 'video' && ( -
- -
- - - {duration} -
-
-
- )} - - {imageAttachments.length > 0 && ( -
1 && classes.mediaGrid, - )} - data-number={imageAttachments.length} - > - {imageAttachments.map((attachment) => ( - - ))} -
- )} - - ); -}; - -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 = ( - - {quotedPost.uri} - - ); - } - - if (status.card) { - if (new URL(status.card.url).host === domain) { - link = {status.card.url}; - } else { - link = ( - - {status.card.url} - - ); - } - } - - const collection = status.tagged_collections[0]; - if (collection) { - link = {collection.url}; - } - - if (!link) { - return null; - } - - return {link}; -}; - -const onStatusLinks: OnElementHandler = ( - 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 {children}; - } - return undefined; + return ; }; diff --git a/app/javascript/mastodon/features/compose/redesign/upload.tsx b/app/javascript/mastodon/features/compose/redesign/upload.tsx index b67bbd1bef7..8bd7869f064 100644 --- a/app/javascript/mastodon/features/compose/redesign/upload.tsx +++ b/app/javascript/mastodon/features/compose/redesign/upload.tsx @@ -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 ( - )} - - ); -}; - -type ComposeImageAttachmentJSON = - | ApiImageAttachmentJSON - | ApiGifvAttachmentJSON - | ApiVideoAttachmentJSON; - -export const ComposeImage: React.FC< - { - attachment: - | ComposeImageAttachmentJSON - | MediaAttachmentShape; - 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 ( -
- {sensitive && attachment.blurhash && ( - - )} - - {children} -
+ ); }; diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts index 2a73013dc70..8999f19c0fb 100644 --- a/app/javascript/mastodon/models/status.ts +++ b/app/javascript/mastodon/models/status.ts @@ -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 & { - 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 & { + quoted_status?: string; + } +>; + export type CardShape = Omit & { authors: (Omit & { accountId?: string; diff --git a/app/javascript/mastodon/selectors/filters.ts b/app/javascript/mastodon/selectors/filters.ts index 1436e358a27..73913c56d53 100644 --- a/app/javascript/mastodon/selectors/filters.ts +++ b/app/javascript/mastodon/selectors/filters.ts @@ -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) => diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts index bbaf205f856..3e4f4a8787c 100644 --- a/app/javascript/mastodon/selectors/statuses.ts +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -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,