Add support to mark videos as spoilers (#2255)
authorGinger <ginger@gingershaped.computer>
Thu, 25 Sep 2025 03:41:35 +0000 (23:41 -0400)
committerGitHub <noreply@github.com>
Thu, 25 Sep 2025 03:41:35 +0000 (13:41 +1000)
* 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

* Make it possible to mark videos as spoilers

* Allow videos to be marked as spoilers when uploaded

* Apply requested changes

* Show a loading spinner on spoiled media when unblurred

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
src/app/components/RenderMessageContent.tsx
src/app/components/message/MsgTypeRenderers.tsx
src/app/components/message/content/ImageContent.tsx
src/app/components/message/content/VideoContent.tsx
src/app/components/upload-card/UploadCardRenderer.tsx
src/app/features/room/msgContent.ts

index 2457e5e356c9120284cf3e8ef8c02174ca86b0e5..4cfcb7dc10be0a7fb73ce537f9c9410bbab51e32 100644 (file)
@@ -209,13 +209,11 @@ export function RenderMessageContent({
         <MVideo
           content={getContent()}
           renderAsFile={renderFile}
-          renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
+          renderVideoContent={({ body, info, ...props }) => (
             <VideoContent
               body={body}
               info={info}
-              mimeType={mimeType}
-              url={url}
-              encInfo={encInfo}
+              {...props}
               renderThumbnail={
                 mediaAutoLoad
                   ? () => (
index 15c95ddfa8708e0309a464d30520fc8b91d2dd02..a40ecae1e70c3644d57bf099c0a6c9a633783ae7 100644 (file)
@@ -224,6 +224,8 @@ type RenderVideoContentProps = {
   mimeType: string;
   url: string;
   encInfo?: IEncryptedFile;
+  markedAsSpoiler?: boolean;
+  spoilerReason?: string;
 };
 type MVideoProps = {
   content: IVideoContent;
@@ -274,6 +276,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
           mimeType: safeMimeType,
           url: mxcUrl,
           encInfo: content.file,
+          markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
+          spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
         })}
       </AttachmentBox>
     </Attachment>
index cc0c0c914e951ae226df6b2da2dd2d41e9cf127b..84e3709eb56ab825d22b76b0415e7af3ab0f1913 100644 (file)
@@ -214,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
         )}
         {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
           !load &&
-          !markedAsSpoiler && (
+          !blurred && (
             <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
               <Spinner variant="Secondary" />
             </Box>
index 3b6138033f0927c83bab42236158bb8e1182e649..52073ac1f4deb4cbe8b053e94cb9e960e3441524 100644 (file)
@@ -3,6 +3,7 @@ import {
   Badge,
   Box,
   Button,
+  Chip,
   Icon,
   Icons,
   Spinner,
@@ -47,6 +48,8 @@ type VideoContentProps = {
   info: IVideoInfo & IThumbnailContent;
   encInfo?: EncryptedAttachmentInfo;
   autoPlay?: boolean;
+  markedAsSpoiler?: boolean;
+  spoilerReason?: string;
   renderThumbnail?: () => ReactNode;
   renderVideo: (props: RenderVideoProps) => ReactNode;
 };
@@ -60,6 +63,8 @@ export const VideoContent = as<'div', VideoContentProps>(
       info,
       encInfo,
       autoPlay,
+      markedAsSpoiler,
+      spoilerReason,
       renderThumbnail,
       renderVideo,
       ...props
@@ -72,6 +77,7 @@ export const VideoContent = as<'div', VideoContentProps>(
 
     const [load, setLoad] = useState(false);
     const [error, setError] = useState(false);
+    const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
 
     const [srcState, loadSrc] = useAsyncCallback(
       useCallback(async () => {
@@ -114,11 +120,15 @@ export const VideoContent = as<'div', VideoContentProps>(
           />
         )}
         {renderThumbnail && !load && (
-          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+          <Box
+            className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
+            alignItems="Center"
+            justifyContent="Center"
+          >
             {renderThumbnail()}
           </Box>
         )}
-        {!autoPlay && srcState.status === AsyncStatus.Idle && (
+        {!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
           <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
             <Button
               variant="Secondary"
@@ -133,7 +143,7 @@ export const VideoContent = as<'div', VideoContentProps>(
           </Box>
         )}
         {srcState.status === AsyncStatus.Success && (
-          <Box className={css.AbsoluteContainer}>
+          <Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
             {renderVideo({
               title: body,
               src: srcState.data,
@@ -144,8 +154,39 @@ export const VideoContent = as<'div', VideoContentProps>(
             })}
           </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);
+                  }}
+                >
+                  <Text size="B300">Spoiler</Text>
+                </Chip>
+              )}
+            </TooltipProvider>
+          </Box>
+        )}
         {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
-          !load && (
+          !load &&
+          !blurred && (
             <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
               <Spinner variant="Secondary" />
             </Box>
index 0a127a087fbccab956a10b581e8fa9809ced9d4e..f5bc68e0df7e3996d7a77ceaa98f7fbc671a5668 100644 (file)
@@ -1,4 +1,4 @@
-import React, { useEffect } from 'react';
+import React, { ReactNode, useEffect } from 'react';
 import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
 import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
 import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
@@ -13,8 +13,54 @@ import {
 import { useObjectURL } from '../../hooks/useObjectURL';
 import { useMediaConfig } from '../../hooks/useMediaConfig';
 
-type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
-function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
+type PreviewImageProps = {
+  fileItem: TUploadItem;
+};
+function PreviewImage({ fileItem }: PreviewImageProps) {
+  const { originalFile, metadata } = fileItem;
+  const fileUrl = useObjectURL(originalFile);
+
+  return (
+    <img
+      style={{
+        objectFit: 'contain',
+        width: '100%',
+        height: toRem(152),
+        filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
+      }}
+      alt={originalFile.name}
+      src={fileUrl}
+    />
+  );
+}
+
+type PreviewVideoProps = {
+  fileItem: TUploadItem;
+};
+function PreviewVideo({ fileItem }: PreviewVideoProps) {
+  const { originalFile, metadata } = fileItem;
+  const fileUrl = useObjectURL(originalFile);
+
+  return (
+    // eslint-disable-next-line jsx-a11y/media-has-caption
+    <video
+      style={{
+        objectFit: 'contain',
+        width: '100%',
+        height: toRem(152),
+        filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
+      }}
+      src={fileUrl}
+    />
+  );
+}
+
+type MediaPreviewProps = {
+  fileItem: TUploadItem;
+  onSpoiler: (marked: boolean) => void;
+  children: ReactNode;
+};
+function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
   const { originalFile, metadata } = fileItem;
   const fileUrl = useObjectURL(originalFile);
 
@@ -27,16 +73,7 @@ function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
         position: 'relative',
       }}
     >
-      <img
-        style={{
-          objectFit: 'contain',
-          width: '100%',
-          height: toRem(152),
-          filter: fileItem.metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
-        }}
-        src={fileUrl}
-        alt={originalFile.name}
-      />
+      {children}
       <Box
         justifyContent="End"
         style={{
@@ -136,7 +173,14 @@ export function UploadCardRenderer({
       bottom={
         <>
           {fileItem.originalFile.type.startsWith('image') && (
-            <ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} />
+            <MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
+              <PreviewImage fileItem={fileItem} />
+            </MediaPreview>
+          )}
+          {fileItem.originalFile.type.startsWith('video') && (
+            <MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
+              <PreviewVideo fileItem={fileItem} />
+            </MediaPreview>
           )}
           {upload.status === UploadStatus.Idle && !fileSizeExceeded && (
             <UploadCardProgress sentBytes={0} totalBytes={file.size} />
index bc660f2c342c88a89e641bcf674b37aaed48bf75..5b7cd14527264e06fb91d01512d80b7532cbc13c 100644 (file)
@@ -82,7 +82,7 @@ export const getVideoMsgContent = async (
   item: TUploadItem,
   mxc: string
 ): Promise<IContent> => {
-  const { file, originalFile, encInfo } = item;
+  const { file, originalFile, encInfo, metadata } = item;
 
   const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
   if (videoError) console.warn(videoError);
@@ -91,6 +91,7 @@ export const getVideoMsgContent = async (
     msgtype: MsgType.Video,
     filename: file.name,
     body: file.name,
+    [MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
   };
   if (videoEl) {
     const [thumbError, thumbContent] = await to(