From 3f98bee57f25a025b613d62cb75194ef0cd01d81 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 25 Jun 2026 18:34:11 +0200 Subject: [PATCH] Status media attachments refactor (#39601) --- .../mastodon/actions/picture_in_picture.ts | 5 + .../components/status/attachments.tsx | 279 ++++++++++++++++++ .../mastodon/features/audio/index.tsx | 15 +- .../features/status/components/card.tsx | 90 +++--- .../mastodon/features/video/index.tsx | 13 +- app/javascript/mastodon/models/account.ts | 8 + app/javascript/mastodon/models/status.ts | 54 +++- .../mastodon/reducers/picture_in_picture.ts | 10 +- app/javascript/mastodon/selectors/accounts.ts | 11 +- app/javascript/mastodon/selectors/filters.ts | 10 +- app/javascript/mastodon/selectors/statuses.ts | 87 +++++- 11 files changed, 494 insertions(+), 88 deletions(-) create mode 100644 app/javascript/mastodon/components/status/attachments.tsx diff --git a/app/javascript/mastodon/actions/picture_in_picture.ts b/app/javascript/mastodon/actions/picture_in_picture.ts index d34b508a33f..26cdb17fa70 100644 --- a/app/javascript/mastodon/actions/picture_in_picture.ts +++ b/app/javascript/mastodon/actions/picture_in_picture.ts @@ -29,3 +29,8 @@ export const deployPictureInPicture = createAppAsyncThunk( } }, ); + +export type DeployPictureInPictureCallback = ( + type: 'audio' | 'video', + props: PIPMediaProps, +) => void; diff --git a/app/javascript/mastodon/components/status/attachments.tsx b/app/javascript/mastodon/components/status/attachments.tsx new file mode 100644 index 00000000000..f830a1e0dd8 --- /dev/null +++ b/app/javascript/mastodon/components/status/attachments.tsx @@ -0,0 +1,279 @@ +import { lazy, Suspense, useCallback, useState } from 'react'; + +import { openModal } from '@/mastodon/actions/modal'; +import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_in_picture'; +import { deployPictureInPicture } from '@/mastodon/actions/picture_in_picture'; +import { CollectionPreviewCard } from '@/mastodon/features/collections/components/collection_preview_card'; +import Card from '@/mastodon/features/status/components/card'; +import { displayMedia } from '@/mastodon/initial_state'; +import type { + MediaAttachment, + MediaAttachmentShape, +} from '@/mastodon/models/status'; +import { isMediaAttachmentOfType } from '@/mastodon/models/status'; +import { + selectExpandedStatus, + selectMediaMatchFilters, + selectPictureInPicture, +} from '@/mastodon/selectors/statuses'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import { compareUrls } from '@/mastodon/utils/compare_urls'; + +import { PictureInPicturePlaceholder } from '../picture_in_picture_placeholder'; + +export const StatusAttachments: React.FC<{ + statusId: string; + contextType?: string; +}> = ({ statusId, contextType }) => { + // Selectors + const status = useAppSelector((state) => + selectExpandedStatus(state, statusId), + ); + + if (!status) { + return null; + } + + const attachment = status.media_attachments[0]; + if (attachment) { + return ( + + ); + } + + // Don't display the card or collection if this is a quote. + if (status.quote) { + return null; + } + + const card = status.card; + const collection = card?.url + ? status.tagged_collections.find(({ url }) => compareUrls(url, card.url)) + : status.tagged_collections[0]; + if (card && !collection) { + return ( + + ); + } + + if (collection) { + return ; + } + + return null; +}; + +type TMediaGallery = React.ComponentClass< + { + media: Immutable.List; + height: number; + onOpenMedia: (index: number) => void; + onToggleVisibility?: () => void; + sensitive?: boolean; + lang?: string; + visible?: boolean; + autoplay?: boolean; + matchedFilters?: (string | null | undefined)[]; + cacheWidth?: () => void; + defaultWidth?: number; + }, + { visible: boolean; width?: number } +>; + +const MediaGallery = lazy( + () => import('@/mastodon/components/media_gallery'), +); +const Audio = lazy(() => import('@/mastodon/features/audio')); +const Video = lazy(() => import('@/mastodon/features/video')); + +const MediaAttachments: React.FC<{ + statusId: string; + accountId: string; + contextType?: string; + sensitive: boolean; + language: string; + attachment: MediaAttachmentShape; + restAttachments: MediaAttachmentShape[]; + defaultPosterUrl: string; +}> = ({ + statusId, + accountId, + contextType, + sensitive, + language, + attachment, + defaultPosterUrl, +}) => { + const description = + attachment.translation?.description ?? attachment.description; + + const immutableAttachments = useAppSelector( + (state) => + state.statuses.getIn( + statusId, + 'media_attachments', + ) as Immutable.List, + ); + const mediaFilters = useAppSelector((state) => + selectMediaMatchFilters(state, { statusId, contextType }), + ); + const pictureInPicture = useAppSelector((state) => + selectPictureInPicture(state, statusId), + ); + + const [showMedia, setShowMedia] = useState( + () => + mediaFilters.length === 0 && + ((displayMedia !== 'hide_all' && !sensitive) || + displayMedia === 'show_all'), + ); + + const dispatch = useAppDispatch(); + const handleToggleMediaVisibility = useCallback(() => { + setShowMedia((prev) => !prev); + }, []); + const handleOpenMedia = useCallback( + (index: number) => { + dispatch( + openModal({ + modalType: 'MEDIA', + modalProps: { statusId, media: attachment, index, lang: language }, + }), + ); + }, + [attachment, dispatch, language, statusId], + ); + const handleOpenVideo = useCallback( + (options: { + startTime: number; + autoPlay: boolean; + defaultVolume: number; + }) => { + dispatch( + openModal({ + modalType: 'VIDEO', + modalProps: { + statusId, + options, + media: attachment, + lang: language, + }, + }), + ); + }, + [attachment, dispatch, language, statusId], + ); + const handleDeployPictureInPicture: DeployPictureInPictureCallback = + useCallback( + (type, props) => { + if (!accountId || !pictureInPicture.available) { + return; + } + void dispatch( + deployPictureInPicture({ + statusId, + accountId, + playerType: type, + props, + }), + ); + }, + [dispatch, pictureInPicture.available, accountId, statusId], + ); + + let aspectRatio = '3 / 2'; + if ( + isMediaAttachmentOfType(attachment, 'image') || + isMediaAttachmentOfType(attachment, 'video') || + isMediaAttachmentOfType(attachment, 'gifv') + ) { + aspectRatio = `${attachment.meta.original.width} / ${attachment.meta.original.height}`; + } else if (isMediaAttachmentOfType(attachment, 'audio')) { + aspectRatio = '16 / 9'; + } + + if (pictureInPicture.inUse) { + return ; + } + + if (isMediaAttachmentOfType(attachment, 'audio')) { + const { colors, original } = attachment.meta; + return ( + } + > + + ); + } + + if (isMediaAttachmentOfType(attachment, 'video')) { + const { original } = attachment.meta; + return ( + } + > + + ); + } + + return ( + } + > + + + ); +}; diff --git a/app/javascript/mastodon/features/audio/index.tsx b/app/javascript/mastodon/features/audio/index.tsx index 07633f84d75..676f497a7c5 100644 --- a/app/javascript/mastodon/features/audio/index.tsx +++ b/app/javascript/mastodon/features/audio/index.tsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { useSpring, animated, config } from '@react-spring/web'; +import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_in_picture'; import DownloadIcon from '@/material-icons/400-24px/download.svg?react'; import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react'; import PauseIcon from '@/material-icons/400-24px/pause-fill.svg?react'; @@ -68,19 +69,7 @@ export const Audio: React.FC<{ startPlaying?: boolean; startVolume?: number; startMuted?: boolean; - deployPictureInPicture?: ( - type: string, - mediaProps: { - src: string; - muted: boolean; - volume: number; - currentTime: number; - poster?: string; - backgroundColor: string; - foregroundColor: string; - accentColor: string; - }, - ) => void; + deployPictureInPicture?: DeployPictureInPictureCallback; matchedFilters?: string[]; }> = ({ src, diff --git a/app/javascript/mastodon/features/status/components/card.tsx b/app/javascript/mastodon/features/status/components/card.tsx index 525929625ea..2452ab6494d 100644 --- a/app/javascript/mastodon/features/status/components/card.tsx +++ b/app/javascript/mastodon/features/status/components/card.tsx @@ -1,9 +1,11 @@ -import { useCallback, useId, useState } from 'react'; +import { useCallback, useId, useMemo, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import { isMap } from 'immutable'; + import punycode from 'punycode/'; import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; @@ -14,7 +16,7 @@ import { Icon } from 'mastodon/components/icon'; import { MoreFromAuthor } from 'mastodon/components/more_from_author'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { displayMedia, useBlurhash } from 'mastodon/initial_state'; -import type { Card as CardType } from 'mastodon/models/status'; +import type { CardShape, Card as CardType } from 'mastodon/models/status'; const IDNA_PREFIX = 'xn--'; @@ -66,27 +68,26 @@ const handleIframeUrl = (html: string, url: string, providerName: string) => { const hideAllMedia = displayMedia === 'hide_all'; interface CardProps { - card: CardType | null; + card: CardType | CardShape | null; sensitive?: boolean; } -const CardVideo: React.FC> = ({ card }) => ( +const CardVideo: React.FC<{ card: CardShape }> = ({ card }) => (
); -const Card: React.FC = ({ card, sensitive }) => { +const Card: React.FC = ({ card: rawCard, sensitive }) => { + const card: CardShape | null = useMemo( + () => (isMap(rawCard) ? (rawCard.toJS() as CardShape) : rawCard), + [rawCard], + ); + const [previewLoaded, setPreviewLoaded] = useState(false); const [embedded, setEmbedded] = useState(false); const [revealed, setRevealed] = useState(!sensitive && !hideAllMedia); @@ -116,52 +117,43 @@ const Card: React.FC = ({ card, sensitive }) => { } const provider = - card.get('provider_name').length === 0 - ? decodeIDNA(getHostname(card.get('url'))) - : card.get('provider_name'); - const interactive = card.get('type') === 'video'; - const language = card.get('language') ?? ''; - const hasImage = (card.get('image')?.length ?? 0) > 0; - const largeImage = - (hasImage && card.get('width') > card.get('height')) || interactive; - const showAuthor = !!card.getIn(['authors', 0, 'accountId']); + card.provider_name.length === 0 + ? decodeIDNA(getHostname(card.url)) + : card.provider_name; + const interactive = card.type === 'video'; + const language = card.language ?? ''; + const hasImage = (card.image?.length ?? 0) > 0; + const largeImage = (hasImage && card.width > card.height) || interactive; + const author = card.authors.at(0)?.accountId; const description = (
{provider} - {card.get('published_at') && ( + {card.published_at && ( <> {' '} - ·{' '} - + · )} - - {card.get('title')} + + {card.title} - {!showAuthor && - (card.get('author_name').length > 0 ? ( + {!author && + (card.author_name.length > 0 ? ( {card.get('author_name')} }} + values={{ name: {card.author_name} }} /> ) : ( - {card.get('description')} + {card.description} ))}
@@ -172,7 +164,7 @@ const Card: React.FC = ({ card, sensitive }) => { aspectRatio: '1', }; - if (largeImage && card.get('type') === 'video') { + if (largeImage && card.type === 'video') { thumbnailStyle.aspectRatio = `16 / 9`; } else if (largeImage) { thumbnailStyle.aspectRatio = '1.91 / 1'; @@ -185,15 +177,15 @@ const Card: React.FC = ({ card, sensitive }) => { className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden': revealed && previewLoaded, })} - hash={card.get('blurhash')} + hash={card.blurhash} dummy={!useBlurhash} /> ); - const thumbnailDescription = card.get('image_description'); + const thumbnailDescription = card.image_description; const thumbnail = ( {thumbnailDescription} = ({ card, sensitive }) => { = ({ card, sensitive }) => { ); - } else if (card.get('image')) { + } else if (card.image) { embed = (
{canvas} @@ -300,10 +292,10 @@ const Card: React.FC = ({ card, sensitive }) => { return ( <> = ({ card, sensitive }) => { {description} - {showAuthor && ( - - )} + {author && } ); }; diff --git a/app/javascript/mastodon/features/video/index.tsx b/app/javascript/mastodon/features/video/index.tsx index 7a6406885c1..6c6b34a4e11 100644 --- a/app/javascript/mastodon/features/video/index.tsx +++ b/app/javascript/mastodon/features/video/index.tsx @@ -7,6 +7,7 @@ import classNames from 'classnames'; import { useSpring, animated, config } from '@react-spring/web'; import { throttle } from 'lodash'; +import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_in_picture'; import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react'; import FullscreenIcon from '@/material-icons/400-24px/fullscreen.svg?react'; import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.svg?react'; @@ -174,15 +175,7 @@ export const Video: React.FC<{ alwaysVisible?: boolean; visible?: boolean; onToggleVisibility?: () => void; - deployPictureInPicture?: ( - type: string, - mediaProps: { - src: string; - muted: boolean; - volume: number; - currentTime: number; - }, - ) => void; + deployPictureInPicture?: DeployPictureInPictureCallback; blurhash?: string; startPlaying?: boolean; startTime?: number; @@ -241,7 +234,7 @@ export const Video: React.FC<{ (c: HTMLVideoElement | null) => { if (videoRef.current && !videoRef.current.paused && c === null) { deployPictureInPicture?.('video', { - src: src, + src, currentTime: videoRef.current.currentTime, muted: videoRef.current.muted, volume: videoRef.current.volume, diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index f2523cf3344..ccb6f47621d 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -55,6 +55,14 @@ export interface AccountShape extends Required< moved: string | null; url: string; } +export type AccountShapeFull = Omit< + AccountShape, + 'emojis' | 'fields' | 'roles' +> & { + emojis: CustomEmoji[]; + fields: AccountFieldShape[]; + roles: AccountRoleShape[]; +}; export type Account = RecordOf; diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts index dc4138330fc..666d6442664 100644 --- a/app/javascript/mastodon/models/status.ts +++ b/app/javascript/mastodon/models/status.ts @@ -2,7 +2,15 @@ import type { RecordOf } from 'immutable'; import type { ApiCollectionJSON } from '@/mastodon/api_types/collections'; import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; -import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments'; +import type { + ApiAudioAttachmentJSON, + ApiGifvAttachmentJSON, + ApiImageAttachmentJSON, + ApiMediaAttachmentJSON, + ApiUnknownAttachmentJSON, + ApiVideoAttachmentJSON, + MediaAttachmentType, +} from '@/mastodon/api_types/media_attachments'; import type { ApiQuoteJSON, ApiQuotePolicyJSON, @@ -17,6 +25,8 @@ import type { StatusVisibility, } from '@/mastodon/api_types/statuses'; +import type { AccountShapeFull } from './account'; + export type { StatusVisibility } from '@/mastodon/api_types/statuses'; // Temporary until we type it correctly @@ -75,6 +85,10 @@ export interface StatusShape { replies_count: number; visibility: StatusVisibility; } +export type ExpandedStatusShape = Omit & { + account: AccountShapeFull; + reblog?: Omit; +}; export type CardShape = Omit & { authors: (Omit & { @@ -86,14 +100,42 @@ export type Card = RecordOf; export type MediaAttachment = Immutable.Map; -export type MediaAttachmentShape = Omit< - ApiMediaAttachmentJSON, - 'remote_url' -> & { +export type MediaAttachmentShape< + TAttachmentJSON extends ApiMediaAttachmentJSON = ApiMediaAttachmentJSON, +> = Omit & { remote_url: string | null; - translation?: string; + translation?: { + description: string; + }; }; +export function isMediaAttachmentOfType( + attachment: MediaAttachmentShape, + type: 'image', +): attachment is MediaAttachmentShape; +export function isMediaAttachmentOfType( + attachment: MediaAttachmentShape, + type: 'video', +): attachment is MediaAttachmentShape; +export function isMediaAttachmentOfType( + attachment: MediaAttachmentShape, + type: 'gifv', +): attachment is MediaAttachmentShape; +export function isMediaAttachmentOfType( + attachment: MediaAttachmentShape, + type: 'audio', +): attachment is MediaAttachmentShape; +export function isMediaAttachmentOfType( + attachment: MediaAttachmentShape, + type: 'unknown', +): attachment is MediaAttachmentShape; +export function isMediaAttachmentOfType( + attachment: MediaAttachmentShape, + type: MediaAttachmentType, +) { + return attachment.type === type; +} + export type CollectionAttachment = RecordOf; export type FilterResult = Omit & { diff --git a/app/javascript/mastodon/reducers/picture_in_picture.ts b/app/javascript/mastodon/reducers/picture_in_picture.ts index 10d4f1fae51..b26844c63dc 100644 --- a/app/javascript/mastodon/reducers/picture_in_picture.ts +++ b/app/javascript/mastodon/reducers/picture_in_picture.ts @@ -11,10 +11,10 @@ export interface PIPMediaProps { muted: boolean; volume: number; currentTime: number; - poster: string; - backgroundColor: string; - foregroundColor: string; - accentColor: string; + poster?: string; + backgroundColor?: string; + foregroundColor?: string; + accentColor?: string; } interface PIPStateWithValue extends Partial { @@ -34,7 +34,7 @@ const initialState = { muted: false, volume: 0, currentTime: 0, -}; +} satisfies PIPStateEmpty; export const pictureInPictureReducer: Reducer = ( state = initialState, diff --git a/app/javascript/mastodon/selectors/accounts.ts b/app/javascript/mastodon/selectors/accounts.ts index bf608fec4e4..74538d366ac 100644 --- a/app/javascript/mastodon/selectors/accounts.ts +++ b/app/javascript/mastodon/selectors/accounts.ts @@ -3,7 +3,11 @@ import { Record as ImmutableRecord, List as ImmutableList } from 'immutable'; import { me } from 'mastodon/initial_state'; import { accountDefaultValues } from 'mastodon/models/account'; -import type { Account, AccountShape } from 'mastodon/models/account'; +import type { + Account, + AccountShape, + AccountShapeFull, +} from 'mastodon/models/account'; import type { Relationship } from 'mastodon/models/relationship'; import { createAppSelector } from 'mastodon/store'; import type { RootState } from 'mastodon/store'; @@ -50,6 +54,11 @@ export function makeGetAccount() { ); } +export const selectPlainAccount = createAppSelector( + [(state, accountId: string) => state.accounts.get(accountId)], + (account) => (account ? (account.toJS() as AccountShapeFull) : null), +); + export const getAccountHidden = createAppSelector( [ (state, id: string) => state.accounts.get(id)?.hidden, diff --git a/app/javascript/mastodon/selectors/filters.ts b/app/javascript/mastodon/selectors/filters.ts index f84d01216ad..0520c768c3f 100644 --- a/app/javascript/mastodon/selectors/filters.ts +++ b/app/javascript/mastodon/selectors/filters.ts @@ -3,6 +3,14 @@ import { createSelector } from '@reduxjs/toolkit'; import type { RootState } from 'mastodon/store'; import { toServerSideType } from 'mastodon/utils/filters'; +export interface FilterShape { + id: string; + title: string; + context: string[]; + expires_at: string | null; + filter_action: 'hide' | 'blur' | 'warn'; +} + // TODO: move to `app/javascript/mastodon/models` and use more globally type Filter = Immutable.Map; @@ -12,7 +20,7 @@ type FilterResult = Immutable.Map; export const getFilters = createSelector( [ (state: RootState) => state.filters as Immutable.Map, - (_, { contextType }: { contextType: string }) => contextType, + (_, { contextType }: { contextType?: string }) => contextType, ], (filters, contextType) => { if (!contextType) { diff --git a/app/javascript/mastodon/selectors/statuses.ts b/app/javascript/mastodon/selectors/statuses.ts index dde0b8b2270..4f9962d09f7 100644 --- a/app/javascript/mastodon/selectors/statuses.ts +++ b/app/javascript/mastodon/selectors/statuses.ts @@ -2,7 +2,11 @@ import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import { createAppSelector } from 'mastodon/store'; -import type { StatusShape } from '../models/status'; +import type { ExpandedStatusShape, StatusShape } from '../models/status'; + +import { selectPlainAccount } from './accounts'; +import type { FilterShape } from './filters'; +import { getFilters } from './filters'; export const getStatusList = createAppSelector( [ @@ -21,3 +25,84 @@ export const selectPlainStatus = createAppSelector( return status.toJS() as unknown as StatusShape; }, ); + +export const selectAccountStatus = createAppSelector( + [ + selectPlainStatus, + (state, statusId: string) => { + const accountId = state.statuses.getIn(statusId, 'account'); + if (typeof accountId !== 'string') { + return null; + } + return selectPlainAccount(state, accountId); + }, + ], + (status, account) => { + if (!status || !account) { + return null; + } + return { + ...status, + account, + }; + }, +); + +export const selectExpandedStatus = createAppSelector( + [ + selectAccountStatus, + (state, statusId: string) => { + const reblogId = state.statuses.getIn(statusId, 'reblog'); + if (typeof reblogId !== 'string') { + return null; + } + return selectAccountStatus(state, reblogId); + }, + ], + (status, reblog): ExpandedStatusShape | null => { + if (!status) { + return null; + } + + return { + ...status, + reblog: reblog ?? undefined, + }; + }, +); + +export const selectPictureInPicture = createAppSelector( + [ + (state, statusId: string) => + state.picture_in_picture.type !== null && + state.picture_in_picture.statusId === statusId, + (state) => state.meta.get('layout') !== 'mobile', + ], + (inUse, available) => ({ + inUse: inUse && available, + available, + }), +); + +export const selectMediaMatchFilters = createAppSelector( + [ + (state, { statusId }: { statusId: string }) => + selectPlainStatus(state, statusId), + getFilters, + ], + (status, immutableFilters) => { + const filters = immutableFilters + ? (immutableFilters.toJS() as unknown as Record) + : null; + const mediaFilters: string[] = []; + if (status?.filtered && filters) { + for (const { filter } of status.filtered) { + if (filters[filter]?.filter_action === 'blur') { + mediaFilters.push(filters[filter].title); + } + } + } + + return mediaFilters; + }, +);