Status redesign: Media attachments (#40673)

This commit is contained in:
Echo
2026-09-23 15:29:00 +00:00
committed by GitHub
parent 99509ca17c
commit 2b0e8b47fa
14 changed files with 288 additions and 74 deletions

View File

@@ -8,7 +8,11 @@ import { apiGetSearch } from '@/mastodon/api/search';
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { MediaAttachment } from '@/mastodon/models/media_attachment';
import type { Status, StatusVisibility } from '@/mastodon/models/status';
import type {
MediaAttachmentShape,
Status,
StatusVisibility,
} from '@/mastodon/models/status';
import type { RootState } from '@/mastodon/store';
import {
createDataLoadingThunk,
@@ -61,10 +65,13 @@ const simulateModifiedApiResponse = (
): SimulatedMediaAttachmentJSON => {
const [x, y] = (params.focus ?? '').split(',');
const jsMedia = media.toJS() as MediaAttachmentShape;
const data = {
...media.toJS(),
...jsMedia,
...params,
meta: {
...jsMedia.meta,
focus: {
x: parseFloat(x ?? '0'),
y: parseFloat(y ?? '0'),

View File

@@ -58,7 +58,7 @@ export interface ApiGifvAttachmentJSON extends BaseApiMediaAttachmentJSON {
export interface ApiUnknownAttachmentJSON extends BaseApiMediaAttachmentJSON {
type: 'unknown';
meta: unknown;
meta: Record<string, unknown>;
}
export type ApiMediaAttachmentJSON =

View File

@@ -4,9 +4,11 @@ import { FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { useSelectableClick } from '@/mastodon/hooks/useSelectableClick';
import { isRedesignStatusEnabled } from '@/mastodon/utils/environment';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
import { Button } from '../button/redesign';
import { IconButton } from '../icon_button';
import { Popover } from '../popover';
@@ -40,10 +42,14 @@ export const AltTextBadge: React.FC<{
const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClose);
const ButtonComp = isRedesignStatusEnabled() ? Button : 'button';
return (
<>
<button
<ButtonComp
size='xs'
type='button'
variant='solid'
ref={setButtonElement}
className={classNames('media-gallery__alt__label', className)}
onClick={handleClick}
@@ -52,7 +58,7 @@ export const AltTextBadge: React.FC<{
aria-haspopup='dialog'
>
ALT
</button>
</ButtonComp>
<Popover
isOpen={open}
@@ -64,7 +70,11 @@ export const AltTextBadge: React.FC<{
{({ props }) => (
<div {...props} className='hover-card-controller'>
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
className='info-tooltip dropdown-animation'
className={classNames(
'info-tooltip',
'dropdown-animation',
isRedesignStatusEnabled() && classes.redesignPopover,
)}
role='dialog'
aria-labelledby={titleId}
ref={popoverRef}

View File

@@ -1,3 +1,5 @@
@use '@/styles/mastodon/mixins';
.closeButton {
position: absolute;
top: 5px;
@@ -15,3 +17,26 @@
height: 20px;
}
}
.redesignPopover {
@include mixins.elevation-1;
padding: var(--space-4);
border-radius: var(--radius-4);
background: var(--color-bg-primary);
color: var(--color-text-primary);
box-shadow: none;
backdrop-filter: none;
word-break: break-all;
h4 {
@include mixins.type-body-strong;
}
.closeButton {
top: 0;
inset-inline-end: 0;
border-end-start-radius: var(--radius-4);
border-start-end-radius: var(--radius-4);
}
}

View File

@@ -4,7 +4,7 @@
.root {
position: sticky;
top: 0;
z-index: 2;
z-index: 10;
border-top: 0;
// Make bottom half of column header transparent to make the

View File

@@ -16,6 +16,8 @@ import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
import { isRedesignStatusEnabled } from '../utils/environment';
import { Button } from './button/redesign';
class Item extends PureComponent {
@@ -320,6 +322,8 @@ class MediaGallery extends PureComponent {
children = media.map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
}
const ButtonComp = isRedesignStatusEnabled() ? Button : 'button';
return (
<div className={`media-gallery media-gallery--layout-${size}`} style={style} ref={this.handleRef}>
{children}
@@ -328,7 +332,15 @@ class MediaGallery extends PureComponent {
{(visible && !uncached) && (
<div className='media-gallery__actions'>
<button type='button' className='media-gallery__actions__pill' onClick={this.handleOpen}><FormattedMessage id='media_gallery.hide' defaultMessage='Hide' /></button>
<ButtonComp
size='xs'
type='button'
variant='solid'
className='media-gallery__actions__pill'
onClick={this.handleOpen}
>
<FormattedMessage id='media_gallery.hide' defaultMessage='Hide' />
</ButtonComp>
</div>
)}
</div>

View File

@@ -1,5 +1,50 @@
@use '@/styles/mastodon/mixins';
.galleryWrapper {
:global(.media-gallery) {
margin-top: 0;
border-radius: 0;
}
// Overrides .media-gallery--layout-2 > .media-gallery__item:nth-child(1)
:global(.media-gallery__item):global(.media-gallery__item),
:global(.video-player),
:global(.audio-player) {
border-radius: var(--radius-4);
outline: none;
}
:global(.media-gallery__alt__label--non-interactive) {
@include mixins.type-meta;
color: var(--color-text-inverted);
background-color: var(--color-bg-inverted);
border-radius: var(--radius-round);
padding-inline: var(--space-2);
}
}
.gallerySpoilerWrapper {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.gallerySpoilerContent {
@include mixins.type-body-strong;
font-style: italic;
flex-grow: 1;
}
.galleryHideButtons {
:global(.media-gallery__item__badges),
:global(.media-gallery__actions) {
display: none;
}
}
.cardMedia {
> :global(.status-card) {
margin-top: 0;

View File

@@ -1,7 +1,9 @@
import { lazy, Suspense, useCallback } from 'react';
import { lazy, Suspense, useCallback, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { openModal } from '@/mastodon/actions/modal';
import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_in_picture';
import { deployPictureInPicture } from '@/mastodon/actions/picture_in_picture';
@@ -9,7 +11,6 @@ import { CollectionPreviewCard } from '@/mastodon/features/collections/component
import MediaCard from '@/mastodon/features/status/components/card';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { displayMedia } from '@/mastodon/initial_state';
import type {
CardShape,
@@ -34,6 +35,7 @@ import classes from './attachments.module.scss';
import { useStatusContext } from './hooks';
import { PictureInPicturePlaceholder } from './legacy/picture_in_picture_placeholder';
import { StatusQuote } from './quote';
import mainClasses from './styles.module.scss';
export const StatusAttachments: React.FC<{
statusId: string;
@@ -50,7 +52,7 @@ export const StatusAttachments: React.FC<{
<MediaAttachments
statusId={statusId}
accountId={status.account.id}
sensitive={status.sensitive}
sensitive={status.sensitive && !status.spoiler_text}
language={status.translation?.language ?? status.language}
attachment={attachment}
restAttachments={status.media_attachments.slice(1)}
@@ -79,11 +81,17 @@ export const StatusAttachments: React.FC<{
return null;
};
type OnOpenMediaCallback = (
media: Immutable.List<MediaAttachment>,
index: number,
lang?: string,
) => void;
type TMediaGallery = React.ComponentClass<
{
media: Immutable.List<MediaAttachment>;
height: number;
onOpenMedia: (index: number) => void;
onOpenMedia: OnOpenMediaCallback;
onToggleVisibility?: () => void;
sensitive?: boolean;
lang?: string;
@@ -135,29 +143,41 @@ const MediaAttachments: React.FC<{
selectPictureInPicture(state, statusId),
);
const [showMedia, { onToggle: handleToggleMediaVisibility }] = useToggle(
const wrapperRef = useRef<HTMLDivElement>(null);
const [showMedia, setShowMedia] = useState(
() =>
mediaFilters.length === 0 &&
((displayMedia !== 'hide_all' && !sensitive) ||
displayMedia === 'show_all'),
);
const handleToggleMediaVisibility = useCallback(() => {
setShowMedia((prev) => {
// Pause the video or audio if hiding the media
if (prev && wrapperRef.current) {
wrapperRef.current
.querySelector<HTMLVideoElement | HTMLAudioElement>('video, audio')
?.pause();
}
return !prev;
});
}, []);
const dispatch = useAppDispatch();
const handleOpenMedia = useCallback(
(index: number) => {
const handleOpenMedia: OnOpenMediaCallback = useCallback(
(media, index, lang) => {
dispatch(
openModal({
modalType: 'MEDIA',
modalProps: {
statusId,
media: immutableAttachments,
media,
index,
lang: language,
lang,
},
}),
);
},
[immutableAttachments, dispatch, language, statusId],
[dispatch, statusId],
);
const handleOpenVideo = useCallback(
(options: {
@@ -212,12 +232,22 @@ const MediaAttachments: React.FC<{
return <PictureInPicturePlaceholder aspectRatio={aspectRatio} />;
}
const wrapperProps = {
sensitive,
visible: showMedia,
onToggle: handleToggleMediaVisibility,
aspectRatio,
mediaFilters,
wrapperRef,
} satisfies Omit<
React.ComponentProps<typeof MediaAttachmentWrapper>,
'children'
>;
if (isMediaAttachmentOfType(attachment, 'audio')) {
const { colors, original } = attachment.meta;
return (
<Suspense
fallback={<div className='audio-player' style={{ aspectRatio }} />}
>
<MediaAttachmentWrapper {...wrapperProps} type='audio'>
<Audio
src={attachment.url}
alt={description}
@@ -228,22 +258,17 @@ const MediaAttachments: React.FC<{
accentColor={colors?.accent}
duration={original.duration}
deployPictureInPicture={handleDeployPictureInPicture}
sensitive={sensitive}
blurhash={attachment.blurhash}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
matchedFilters={mediaFilters}
/>
</Suspense>
</MediaAttachmentWrapper>
);
}
if (isMediaAttachmentOfType(attachment, 'video')) {
const { original } = attachment.meta;
return (
<Suspense
fallback={<div className='video-player' style={{ aspectRatio }} />}
>
<MediaAttachmentWrapper {...wrapperProps} type='video'>
<Video
src={attachment.url}
alt={description}
@@ -252,32 +277,106 @@ const MediaAttachments: React.FC<{
frameRate={original.frame_rate}
aspectRatio={aspectRatio}
blurhash={attachment.blurhash}
sensitive={sensitive}
onOpenVideo={handleOpenVideo}
deployPictureInPicture={handleDeployPictureInPicture}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
matchedFilters={mediaFilters}
/>
</Suspense>
</MediaAttachmentWrapper>
);
}
return (
<Suspense
fallback={<div className='media-player' style={{ aspectRatio }} />}
>
<MediaAttachmentWrapper {...wrapperProps} type='media'>
<MediaGallery
media={immutableAttachments}
lang={language}
sensitive={sensitive}
height={110}
onOpenMedia={handleOpenMedia}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
matchedFilters={mediaFilters}
/>
</Suspense>
</MediaAttachmentWrapper>
);
};
const MediaAttachmentWrapper: React.FC<{
sensitive: boolean;
visible: boolean;
onToggle: () => void;
type?: 'media' | 'video' | 'audio';
children: React.ReactNode;
aspectRatio: string;
mediaFilters: string[];
wrapperRef: React.RefObject<HTMLDivElement | null>;
}> = ({
sensitive,
visible,
type = 'media',
onToggle,
children,
aspectRatio,
mediaFilters,
wrapperRef,
}) => {
let message = (
<FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />
);
if (sensitive) {
message = (
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
);
} else if (mediaFilters.length > 0) {
message = (
<FormattedMessage
id='filter_warning.matches_filter'
defaultMessage='Matches filter “<span>{title}</span>”'
values={{
title: mediaFilters.join(', '),
span: (chunks) => <span className='filter-name'>{chunks}</span>,
}}
/>
);
}
const showSpoiler = sensitive || mediaFilters.length > 0 || !visible;
return (
<div className={classes.galleryWrapper} ref={wrapperRef}>
{showSpoiler && (
<div className={classes.gallerySpoilerWrapper}>
<span className={classes.gallerySpoilerContent}>{message}</span>
<Button variant='solid' size='sm' onClick={onToggle}>
{visible ? (
<FormattedMessage
id='content_warning.media.hide_short'
defaultMessage='Hide media'
/>
) : (
<FormattedMessage
id='content_warning.media.show_short'
defaultMessage='Show media'
/>
)}
</Button>
</div>
)}
<div
data-color-scheme='dark'
className={classNames(
mainClasses.contentWrapper,
!visible && mainClasses.isFiltered,
!visible && classes.galleryHideButtons,
)}
>
<Suspense
fallback={<div className={`media-${type}`} style={{ aspectRatio }} />}
>
{children}
</Suspense>
</div>
</div>
);
};

View File

@@ -6,6 +6,7 @@ import classNames from 'classnames';
import { CaretRightIcon } from '@phosphor-icons/react';
import { useResizeObserver } from '@/mastodon/hooks/useObserver';
import type {
ExpandedStatusShape,
StatusShape,
@@ -39,25 +40,28 @@ export const StatusContent: React.FC<
}) => {
// Determines if a long post should show the read more button.
const [collapsed, setCollapsed] = useState(false);
const onResize: ResizeObserverCallback = useCallback((entries) => {
for (const { target } of entries) {
setCollapsed(isElementOverflowing(target));
}
}, []);
const observer = useResizeObserver(onResize);
const onRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || collapsed) {
return;
}
const { lineHeight } = getComputedStyle(node);
const lineHeightPx = parseFloat(lineHeight);
const maxHeight = lineHeightPx * MAX_LINES;
setCollapsed(
node.clientHeight > maxHeight || node.scrollWidth > node.clientWidth,
);
observer.observe(node);
setCollapsed(isElementOverflowing(node));
},
[collapsed],
[collapsed, observer],
);
const htmlHandlers = useHandlersForStatus(status);
const text =
statusContent ?? status.translation?.contentHtml ?? status.contentHtml;
const language = status.translation?.language ?? status.language;
const isCollapsed = !!onReadMore && collapsible && collapsed;
@@ -79,18 +83,16 @@ export const StatusContent: React.FC<
style={style}
ref={onRef}
>
<EmojiHTML
className={classes.contentText}
ref={onRef}
lang={language}
htmlString={
statusContent ??
status.translation?.contentHtml ??
status.contentHtml
}
extraEmojis={status.emojis}
{...htmlHandlers}
/>
{text.trim().length > 0 && (
<EmojiHTML
className={classes.contentText}
ref={onRef}
lang={language}
htmlString={text}
extraEmojis={status.emojis}
{...htmlHandlers}
/>
)}
{children}
</div>
@@ -111,3 +113,11 @@ export const StatusContent: React.FC<
</>
);
};
function isElementOverflowing(node: Element) {
const { lineHeight } = getComputedStyle(node);
const lineHeightPx = parseFloat(lineHeight);
const maxHeight = lineHeightPx * MAX_LINES;
return node.clientHeight > maxHeight || node.scrollWidth > node.clientWidth;
}

View File

@@ -133,6 +133,9 @@
background-size: var(--space-2) var(--space-2);
background-repeat: repeat;
// Override .media-gallery__item
z-index: 2;
// See: https://css-tricks.com/snippets/css/toggle-visibility-when-hiding-elements/
visibility: hidden;
opacity: 0;

View File

@@ -8,7 +8,9 @@ import { useSpring, animated, config } from '@react-spring/web';
import { throttle } from 'lodash';
import type { DeployPictureInPictureCallback } from '@/mastodon/actions/picture_in_picture';
import { Button } from '@/mastodon/components/button/redesign';
import { useRevealedMedia } from '@/mastodon/hooks/useRevealedMedia';
import { isRedesignStatusEnabled } from '@/mastodon/utils/environment';
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';
@@ -780,6 +782,8 @@ export const Video: React.FC<{
preload = 'none';
}
const ButtonComp = isRedesignStatusEnabled() ? Button : 'button';
// The outer wrapper is necessary to avoid reflowing the layout when going into full screen
return (
<div>
@@ -855,7 +859,9 @@ export const Video: React.FC<{
active: paused || hovered,
})}
>
<button
<ButtonComp
size='xs'
variant='solid'
className='media-gallery__actions__pill'
onClick={toggleReveal}
type='button'
@@ -864,7 +870,7 @@ export const Video: React.FC<{
id='media_gallery.hide'
defaultMessage='Hide'
/>
</button>
</ButtonComp>
</div>
)}

View File

@@ -39,6 +39,9 @@ export const useAudioContext = ({ audioElementRef }: AudioContextOptions) => {
if (context.state !== 'closed') {
void context.close();
}
audioContextRef.current = null;
gainNodeRef.current = null;
sourceRef.current = null;
};
}, [audioElementRef]);

View File

@@ -682,6 +682,8 @@
"confirmations.withdraw_request.title": "Withdraw request to follow {name}?",
"content_warning.hide": "Hide post",
"content_warning.hide_short": "Hide",
"content_warning.media.hide_short": "Hide media",
"content_warning.media.show_short": "Show media",
"content_warning.show": "Show anyway",
"content_warning.show_more": "Show more",
"content_warning.show_short": "Show",

View File

@@ -263,21 +263,13 @@ export function mediaAttachmentFactoryAPI(
): ApiMediaAttachmentJSON {
switch (data.type ?? 'image') {
case 'image':
return imageAttachmentFactoryAPI(
data as PartialDeep<ApiImageAttachmentJSON>,
);
return imageAttachmentFactoryAPI(data);
case 'video':
return videoAttachmentFactoryAPI(
data as PartialDeep<ApiVideoAttachmentJSON>,
);
return videoAttachmentFactoryAPI(data);
case 'audio':
return audioAttachmentFactoryAPI(
data as PartialDeep<ApiAudioAttachmentJSON>,
);
return audioAttachmentFactoryAPI(data);
case 'gifv':
return gifvAttachmentFactoryAPI(
data as PartialDeep<ApiGifvAttachmentJSON>,
);
return gifvAttachmentFactoryAPI(data);
default: {
return {
...baseAttachment,