Status media attachments refactor (#39601)

This commit is contained in:
Echo
2026-06-25 18:34:11 +02:00
committed by GitHub
parent e4e19ba264
commit 3f98bee57f
11 changed files with 494 additions and 88 deletions

View File

@@ -29,3 +29,8 @@ export const deployPictureInPicture = createAppAsyncThunk(
}
},
);
export type DeployPictureInPictureCallback = (
type: 'audio' | 'video',
props: PIPMediaProps,
) => void;

View File

@@ -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 (
<MediaAttachments
statusId={statusId}
accountId={status.account.id}
contextType={contextType}
sensitive={status.sensitive}
language={status.translation?.language ?? status.language}
attachment={attachment}
restAttachments={status.media_attachments.slice(1)}
defaultPosterUrl={status.account.avatar_static}
/>
);
}
// 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 (
<Card
key={`${status.id}-${status.edited_at}`}
card={card}
sensitive={status.sensitive}
/>
);
}
if (collection) {
return <CollectionPreviewCard collection={collection} headingLevel='h2' />;
}
return null;
};
type TMediaGallery = React.ComponentClass<
{
media: Immutable.List<MediaAttachment>;
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<TMediaGallery>(
() => 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<MediaAttachment>,
);
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 <PictureInPicturePlaceholder aspectRatio={aspectRatio} />;
}
if (isMediaAttachmentOfType(attachment, 'audio')) {
const { colors, original } = attachment.meta;
return (
<Suspense
fallback={<div className='audio-player' style={{ aspectRatio }} />}
>
<Audio
src={attachment.url}
alt={description}
lang={language}
poster={attachment.preview_url || defaultPosterUrl}
backgroundColor={colors.background}
foregroundColor={colors.foreground}
accentColor={colors.accent}
duration={original.duration}
deployPictureInPicture={handleDeployPictureInPicture}
sensitive={sensitive}
blurhash={attachment.blurhash}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
matchedFilters={mediaFilters}
/>
</Suspense>
);
}
if (isMediaAttachmentOfType(attachment, 'video')) {
const { original } = attachment.meta;
return (
<Suspense
fallback={<div className='video-player' style={{ aspectRatio }} />}
>
<Video
src={attachment.url}
alt={description}
lang={language}
preview={attachment.preview_url}
frameRate={original.frame_rate}
aspectRatio={aspectRatio}
blurhash={attachment.blurhash}
sensitive={sensitive}
onOpenVideo={handleOpenVideo}
deployPictureInPicture={handleDeployPictureInPicture}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
matchedFilters={mediaFilters}
/>
</Suspense>
);
}
return (
<Suspense
fallback={<div className='media-player' style={{ aspectRatio }} />}
>
<MediaGallery
media={immutableAttachments}
lang={language}
sensitive={sensitive}
height={110}
onOpenMedia={handleOpenMedia}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
matchedFilters={mediaFilters}
/>
</Suspense>
);
};

View File

@@ -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,

View File

@@ -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<Pick<CardProps, 'card'>> = ({ card }) => (
const CardVideo: React.FC<{ card: CardShape }> = ({ card }) => (
<div
className='status-card__image status-card-video'
dangerouslySetInnerHTML={{
__html: card
? handleIframeUrl(
card.get('html'),
card.get('url'),
card.get('provider_name'),
)
: '',
__html: handleIframeUrl(card.html, card.url, card.provider_name),
}}
style={{ aspectRatio: '16 / 9' }}
/>
);
const Card: React.FC<CardProps> = ({ card, sensitive }) => {
const Card: React.FC<CardProps> = ({ 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<CardProps> = ({ 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 = (
<div className='status-card__content' dir='auto'>
<span className='status-card__host'>
<span lang={language}>{provider}</span>
{card.get('published_at') && (
{card.published_at && (
<>
{' '}
·{' '}
<RelativeTimestamp
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
timestamp={card.get('published_at')!}
/>
· <RelativeTimestamp timestamp={card.published_at} />
</>
)}
</span>
<strong
className='status-card__title'
title={card.get('title')}
lang={language}
>
{card.get('title')}
<strong className='status-card__title' title={card.title} lang={language}>
{card.title}
</strong>
{!showAuthor &&
(card.get('author_name').length > 0 ? (
{!author &&
(card.author_name.length > 0 ? (
<span className='status-card__author'>
<FormattedMessage
id='link_preview.author'
defaultMessage='By {name}'
values={{ name: <strong>{card.get('author_name')}</strong> }}
values={{ name: <strong>{card.author_name}</strong> }}
/>
</span>
) : (
<span className='status-card__description' lang={language}>
{card.get('description')}
{card.description}
</span>
))}
</div>
@@ -172,7 +164,7 @@ const Card: React.FC<CardProps> = ({ 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<CardProps> = ({ 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 = (
<img
src={card.get('image') ?? undefined}
src={card.image ?? undefined}
alt={thumbnailDescription}
title={thumbnailDescription}
lang={language}
@@ -251,7 +243,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
<Icon id='play' icon={PlayArrowIcon} />
</button>
<a
href={card.get('url')}
href={card.url}
onClick={handleExternalLinkClick}
target='_blank'
rel='noopener'
@@ -271,7 +263,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
<div className={classNames('status-card', { expanded: largeImage })}>
{embed}
<a
href={card.get('url')}
href={card.url}
target='_blank'
rel='noopener'
onClick={revealed ? undefined : handleReveal}
@@ -281,7 +273,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
</a>
</div>
);
} else if (card.get('image')) {
} else if (card.image) {
embed = (
<div className='status-card__image'>
{canvas}
@@ -300,10 +292,10 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
return (
<>
<a
href={card.get('url')}
href={card.url}
className={classNames('status-card', {
expanded: largeImage,
bottomless: showAuthor,
bottomless: !!author,
})}
target='_blank'
rel='noopener'
@@ -312,11 +304,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
{description}
</a>
{showAuthor && (
<MoreFromAuthor
accountId={card.getIn(['authors', 0, 'accountId']) as string}
/>
)}
{author && <MoreFromAuthor accountId={author} />}
</>
);
};

View File

@@ -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,

View File

@@ -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<AccountShape>;

View File

@@ -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<StatusShape, 'account' | 'reblog'> & {
account: AccountShapeFull;
reblog?: Omit<ExpandedStatusShape, 'reblog'>;
};
export type CardShape = Omit<ApiPreviewCardJSON, 'authors'> & {
authors: (Omit<ApiPreviewCardAuthorJSON, 'author'> & {
@@ -86,14 +100,42 @@ export type Card = RecordOf<CardShape>;
export type MediaAttachment = Immutable.Map<string, unknown>;
export type MediaAttachmentShape = Omit<
ApiMediaAttachmentJSON,
'remote_url'
> & {
export type MediaAttachmentShape<
TAttachmentJSON extends ApiMediaAttachmentJSON = ApiMediaAttachmentJSON,
> = Omit<TAttachmentJSON, 'remote_url'> & {
remote_url: string | null;
translation?: string;
translation?: {
description: string;
};
};
export function isMediaAttachmentOfType(
attachment: MediaAttachmentShape,
type: 'image',
): attachment is MediaAttachmentShape<ApiImageAttachmentJSON>;
export function isMediaAttachmentOfType(
attachment: MediaAttachmentShape,
type: 'video',
): attachment is MediaAttachmentShape<ApiVideoAttachmentJSON>;
export function isMediaAttachmentOfType(
attachment: MediaAttachmentShape,
type: 'gifv',
): attachment is MediaAttachmentShape<ApiGifvAttachmentJSON>;
export function isMediaAttachmentOfType(
attachment: MediaAttachmentShape,
type: 'audio',
): attachment is MediaAttachmentShape<ApiAudioAttachmentJSON>;
export function isMediaAttachmentOfType(
attachment: MediaAttachmentShape,
type: 'unknown',
): attachment is MediaAttachmentShape<ApiUnknownAttachmentJSON>;
export function isMediaAttachmentOfType(
attachment: MediaAttachmentShape,
type: MediaAttachmentType,
) {
return attachment.type === type;
}
export type CollectionAttachment = RecordOf<ApiCollectionJSON>;
export type FilterResult = Omit<ApiFilterResultJSON, 'filter'> & {

View File

@@ -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<PIPMediaProps> {
@@ -34,7 +34,7 @@ const initialState = {
muted: false,
volume: 0,
currentTime: 0,
};
} satisfies PIPStateEmpty;
export const pictureInPictureReducer: Reducer<PIPState> = (
state = initialState,

View File

@@ -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,

View File

@@ -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<string, unknown>;
@@ -12,7 +20,7 @@ type FilterResult = Immutable.Map<string, unknown>;
export const getFilters = createSelector(
[
(state: RootState) => state.filters as Immutable.Map<string, Filter>,
(_, { contextType }: { contextType: string }) => contextType,
(_, { contextType }: { contextType?: string }) => contextType,
],
(filters, contextType) => {
if (!contextType) {

View File

@@ -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<string, FilterShape>)
: 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;
},
);