From 2b0e8b47fa7909637c822e90ce386491a711ee89 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 23 Sep 2026 15:29:00 +0000 Subject: [PATCH] Status redesign: Media attachments (#40673) --- .../mastodon/actions/compose_typed.ts | 11 +- .../mastodon/api_types/media_attachments.ts | 2 +- .../components/alt_text_badge/index.tsx | 18 +- .../alt_text_badge/styles.module.scss | 25 +++ .../column_header/styles.module.scss | 2 +- .../mastodon/components/media_gallery.jsx | 14 +- .../components/status/attachments.module.scss | 45 +++++ .../components/status/attachments.tsx | 161 ++++++++++++++---- .../mastodon/components/status/content.tsx | 50 +++--- .../components/status/styles.module.scss | 3 + .../mastodon/features/video/index.tsx | 10 +- .../mastodon/hooks/useAudioContext.ts | 3 + app/javascript/mastodon/locales/en.json | 2 + app/javascript/testing/factories.ts | 16 +- 14 files changed, 288 insertions(+), 74 deletions(-) diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 3f830926e4d..a9ad31f2959 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -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'), diff --git a/app/javascript/mastodon/api_types/media_attachments.ts b/app/javascript/mastodon/api_types/media_attachments.ts index 7385125487e..be871fd743c 100644 --- a/app/javascript/mastodon/api_types/media_attachments.ts +++ b/app/javascript/mastodon/api_types/media_attachments.ts @@ -58,7 +58,7 @@ export interface ApiGifvAttachmentJSON extends BaseApiMediaAttachmentJSON { export interface ApiUnknownAttachmentJSON extends BaseApiMediaAttachmentJSON { type: 'unknown'; - meta: unknown; + meta: Record; } export type ApiMediaAttachmentJSON = diff --git a/app/javascript/mastodon/components/alt_text_badge/index.tsx b/app/javascript/mastodon/components/alt_text_badge/index.tsx index 64e120ea4fe..3ff989751b6 100644 --- a/app/javascript/mastodon/components/alt_text_badge/index.tsx +++ b/app/javascript/mastodon/components/alt_text_badge/index.tsx @@ -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 ( <> - + (
); } + const ButtonComp = isRedesignStatusEnabled() ? Button : 'button'; + return (
{children} @@ -328,7 +332,15 @@ class MediaGallery extends PureComponent { {(visible && !uncached) && (
- + + +
)}
diff --git a/app/javascript/mastodon/components/status/attachments.module.scss b/app/javascript/mastodon/components/status/attachments.module.scss index cdd69265949..d494b0e74b8 100644 --- a/app/javascript/mastodon/components/status/attachments.module.scss +++ b/app/javascript/mastodon/components/status/attachments.module.scss @@ -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; diff --git a/app/javascript/mastodon/components/status/attachments.tsx b/app/javascript/mastodon/components/status/attachments.tsx index 7c8fcb19443..bf324456423 100644 --- a/app/javascript/mastodon/components/status/attachments.tsx +++ b/app/javascript/mastodon/components/status/attachments.tsx @@ -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<{ , + index: number, + lang?: string, +) => void; + type TMediaGallery = React.ComponentClass< { media: Immutable.List; 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(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('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 ; } + const wrapperProps = { + sensitive, + visible: showMedia, + onToggle: handleToggleMediaVisibility, + aspectRatio, + mediaFilters, + wrapperRef, + } satisfies Omit< + React.ComponentProps, + 'children' + >; + if (isMediaAttachmentOfType(attachment, 'audio')) { const { colors, original } = attachment.meta; return ( - } - > + + ); } if (isMediaAttachmentOfType(attachment, 'video')) { const { original } = attachment.meta; return ( - } - > + + ); } return ( - } - > + - + + ); +}; + +const MediaAttachmentWrapper: React.FC<{ + sensitive: boolean; + visible: boolean; + onToggle: () => void; + type?: 'media' | 'video' | 'audio'; + children: React.ReactNode; + aspectRatio: string; + mediaFilters: string[]; + wrapperRef: React.RefObject; +}> = ({ + sensitive, + visible, + type = 'media', + onToggle, + children, + aspectRatio, + mediaFilters, + wrapperRef, +}) => { + let message = ( + + ); + if (sensitive) { + message = ( + + ); + } else if (mediaFilters.length > 0) { + message = ( + {chunks}, + }} + /> + ); + } + + const showSpoiler = sensitive || mediaFilters.length > 0 || !visible; + + return ( +
+ {showSpoiler && ( +
+ {message} + +
+ )} +
+ } + > + {children} + +
+
); }; diff --git a/app/javascript/mastodon/components/status/content.tsx b/app/javascript/mastodon/components/status/content.tsx index 4181302d419..20f8d48dc54 100644 --- a/app/javascript/mastodon/components/status/content.tsx +++ b/app/javascript/mastodon/components/status/content.tsx @@ -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} > - + {text.trim().length > 0 && ( + + )} {children}
@@ -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; +} diff --git a/app/javascript/mastodon/components/status/styles.module.scss b/app/javascript/mastodon/components/status/styles.module.scss index fc511617720..f61169ce1d7 100644 --- a/app/javascript/mastodon/components/status/styles.module.scss +++ b/app/javascript/mastodon/components/status/styles.module.scss @@ -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; diff --git a/app/javascript/mastodon/features/video/index.tsx b/app/javascript/mastodon/features/video/index.tsx index 3ac5ffcc557..0ccf6cf1940 100644 --- a/app/javascript/mastodon/features/video/index.tsx +++ b/app/javascript/mastodon/features/video/index.tsx @@ -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 (
@@ -855,7 +859,9 @@ export const Video: React.FC<{ active: paused || hovered, })} > - +
)} diff --git a/app/javascript/mastodon/hooks/useAudioContext.ts b/app/javascript/mastodon/hooks/useAudioContext.ts index 65b20ff1012..bb8bb4a129d 100644 --- a/app/javascript/mastodon/hooks/useAudioContext.ts +++ b/app/javascript/mastodon/hooks/useAudioContext.ts @@ -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]); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 682574ca57c..458aa1058c0 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index c9be2d8a0eb..71e592e8e0a 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -263,21 +263,13 @@ export function mediaAttachmentFactoryAPI( ): ApiMediaAttachmentJSON { switch (data.type ?? 'image') { case 'image': - return imageAttachmentFactoryAPI( - data as PartialDeep, - ); + return imageAttachmentFactoryAPI(data); case 'video': - return videoAttachmentFactoryAPI( - data as PartialDeep, - ); + return videoAttachmentFactoryAPI(data); case 'audio': - return audioAttachmentFactoryAPI( - data as PartialDeep, - ); + return audioAttachmentFactoryAPI(data); case 'gifv': - return gifvAttachmentFactoryAPI( - data as PartialDeep, - ); + return gifvAttachmentFactoryAPI(data); default: { return { ...baseAttachment,