Add support for spoilers on images (MSC4193) (#2212)

* Add support for MSC4193: Spoilers on Media

* Clarify variable names and wording

* Restore list atom

* Improve spoilered image UX with autoload off

* Use `aria-pressed` to indicate attachment spoiler state

* Improve spoiler button tooltip wording, keep reveal button from conflicting with load errors
This commit is contained in:
Ginger 2025-02-22 03:55:13 -05:00 committed by GitHub
parent 7c6ab366af
commit dd4c1a94e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 158 additions and 19 deletions

View file

@ -22,6 +22,8 @@ import {
IThumbnailContent,
IVideoContent,
IVideoInfo,
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
@ -177,6 +179,8 @@ type RenderImageContentProps = {
mimeType?: string;
url: string;
encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
type MImageProps = {
content: IImageContent;
@ -204,6 +208,8 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>

View file

@ -3,6 +3,7 @@ import {
Badge,
Box,
Button,
Chip,
Icon,
Icons,
Modal,
@ -51,6 +52,8 @@ export type ImageContentProps = {
info?: IImageInfo;
encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode;
};
@ -64,6 +67,8 @@ export const ImageContent = as<'div', ImageContentProps>(
info,
encInfo,
autoPlay,
markedAsSpoiler,
spoilerReason,
renderViewer,
renderImage,
...props
@ -77,6 +82,7 @@ export const ImageContent = as<'div', ImageContentProps>(
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
@ -145,7 +151,7 @@ export const ImageContent = as<'div', ImageContentProps>(
punch={1}
/>
)}
{!autoPlay && srcState.status === AsyncStatus.Idle && (
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button
variant="Secondary"
@ -160,7 +166,7 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box>
)}
{srcState.status === AsyncStatus.Success && (
<Box className={css.AbsoluteContainer}>
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderImage({
alt: body,
title: body,
@ -172,8 +178,42 @@ export const ImageContent = as<'div', ImageContentProps>(
})}
</Box>
)}
{blurred && !error && srcState.status !== AsyncStatus.Error && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<TooltipProvider
tooltip={
typeof spoilerReason === 'string' && (
<Tooltip variant="Secondary">
<Text>{spoilerReason}</Text>
</Tooltip>
)
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Secondary"
radii="Pill"
size="500"
outlined
onClick={() => {
setBlurred(false);
if (srcState.status === AsyncStatus.Idle) {
loadSrc();
}
}}
>
<Text size="B300">Spoiler</Text>
</Chip>
)}
</TooltipProvider>
</Box>
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && (
!load &&
!markedAsSpoiler && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" />
</Box>

View file

@ -30,3 +30,10 @@ export const AbsoluteFooter = style([
right: config.space.S100,
},
]);
export const Blur = style([
DefaultReset,
{
filter: 'blur(44px)',
},
]);