Add support for spoilers on images (MSC4193) (#2212)
authorGinger <75683114+gingershaped@users.noreply.github.com>
Sat, 22 Feb 2025 08:55:13 +0000 (03:55 -0500)
committerGitHub <noreply@github.com>
Sat, 22 Feb 2025 08:55:13 +0000 (14:25 +0530)
* 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

src/app/components/message/MsgTypeRenderers.tsx
src/app/components/message/content/ImageContent.tsx
src/app/components/message/content/style.css.ts
src/app/components/upload-card/UploadCardRenderer.tsx
src/app/features/room/RoomInput.tsx
src/app/features/room/msgContent.ts
src/app/state/list.ts
src/app/state/room/roomInputDrafts.ts
src/types/matrix/common.ts

index 6138d0d735148b4582cc02cdacea1be178aae1c4..287a5ca475cecc377618d41191714841b9cf4fd4 100644 (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>
index d4241b64c48f99c99c17590ec10c1c4ae4e516fd..69c7ade8e51a6f00c20d070e74b8d1266f3c3966 100644 (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>
index a2f5b55d8f4a5189128a30693603529f556d7832..f6cadd3cba2a39c8285ea5902313701e79bd3f72 100644 (file)
@@ -30,3 +30,10 @@ export const AbsoluteFooter = style([
     right: config.space.S100,
   },
 ]);
+
+export const Blur = style([
+  DefaultReset,
+  {
+    filter: 'blur(44px)',
+  },
+]);
index 5df68f2a28e771adae864fdbc4d9f49a39be2604..4cc8a00ccf6827e304e7a53f4a2fbdc479b8d113 100644 (file)
@@ -1,29 +1,42 @@
-import React, { useEffect } from 'react';
-import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
+import React, { useCallback, useEffect } from 'react';
+import { Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, color } from 'folds';
 import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
-import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
+import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { TUploadContent } from '../../utils/matrix';
 import { getFileTypeIcon } from '../../utils/common';
+import {
+  roomUploadAtomFamily,
+  TUploadItem,
+  TUploadMetadata,
+} from '../../state/room/roomInputDrafts';
 
 type UploadCardRendererProps = {
   isEncrypted?: boolean;
-  uploadAtom: TUploadAtom;
+  fileItem: TUploadItem;
+  setMetadata: (metadata: TUploadMetadata) => void;
   onRemove: (file: TUploadContent) => void;
   onComplete?: (upload: UploadSuccess) => void;
 };
 export function UploadCardRenderer({
   isEncrypted,
-  uploadAtom,
+  fileItem,
+  setMetadata,
   onRemove,
   onComplete,
 }: UploadCardRendererProps) {
   const mx = useMatrixClient();
+  const uploadAtom = roomUploadAtomFamily(fileItem.file);
+  const { metadata } = fileItem;
   const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
   const { file } = upload;
 
   if (upload.status === UploadStatus.Idle) startUpload();
 
+  const toggleSpoiler = useCallback(() => {
+    setMetadata({ ...metadata, markedAsSpoiler: !metadata.markedAsSpoiler });
+  }, [setMetadata, metadata]);
+
   const removeUpload = () => {
     cancelUpload();
     onRemove(file);
@@ -53,6 +66,31 @@ export function UploadCardRenderer({
               <Text size="B300">Retry</Text>
             </Chip>
           )}
+          {file.type.startsWith('image') && (
+            <TooltipProvider
+              tooltip={
+                <Tooltip variant="SurfaceVariant">
+                  <Text>Mark as Spoiler</Text>
+                </Tooltip>
+              }
+              position="Top"
+              align="Center"
+            >
+              {(triggerRef) => (
+                <IconButton
+                  ref={triggerRef}
+                  onClick={toggleSpoiler}
+                  aria-label="Mark as Spoiler"
+                  variant="SurfaceVariant"
+                  radii="Pill"
+                  size="300"
+                  aria-pressed={metadata.markedAsSpoiler}
+                >
+                  <Icon src={Icons.EyeBlind} size="200" />
+                </IconButton>
+              )}
+            </TooltipProvider>
+          )}
           <IconButton
             onClick={removeUpload}
             aria-label="Cancel Upload"
index c4befef6b51209e90fe454652ae960428317a460..df7310e5d76c21f8c9ecafc40a06ab19fe668112 100644 (file)
@@ -167,10 +167,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
           const encryptFiles = fulfilledPromiseSettledResult(
             await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
           );
-          encryptFiles.forEach((ef) => fileItems.push(ef));
+          encryptFiles.forEach((ef) =>
+            fileItems.push({
+              ...ef,
+              metadata: {
+                markedAsSpoiler: false,
+              },
+            })
+          );
         } else {
           safeFiles.forEach((f) =>
-            fileItems.push({ file: f, originalFile: f, encInfo: undefined })
+            fileItems.push({
+              file: f,
+              originalFile: f,
+              encInfo: undefined,
+              metadata: {
+                markedAsSpoiler: false,
+              },
+            })
           );
         }
         setSelectedFiles({
@@ -420,7 +434,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
                         // eslint-disable-next-line react/no-array-index-key
                         key={index}
                         isEncrypted={!!fileItem.encInfo}
-                        uploadAtom={roomUploadAtomFamily(fileItem.file)}
+                        fileItem={fileItem}
+                        setMetadata={(metadata) =>
+                          setSelectedFiles({
+                            type: 'REPLACE',
+                            item: fileItem,
+                            replacement: { ...fileItem, metadata },
+                          })
+                        }
                         onRemove={handleRemoveUpload}
                       />
                     ))}
index 60781ef0abaea72fc379830cbb54213e5b1df3b7..bc660f2c342c88a89e641bcf674b37aaed48bf75 100644 (file)
@@ -1,6 +1,10 @@
 import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
 import to from 'await-to-js';
-import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common';
+import {
+  IThumbnailContent,
+  MATRIX_BLUR_HASH_PROPERTY_NAME,
+  MATRIX_SPOILER_PROPERTY_NAME,
+} from '../../../types/matrix/common';
 import {
   getImageFileUrl,
   getThumbnail,
@@ -42,9 +46,9 @@ const generateThumbnailContent = async (
 export const getImageMsgContent = async (
   mx: MatrixClient,
   item: TUploadItem,
-  mxc: string,
+  mxc: string
 ): Promise<IContent> => {
-  const { file, originalFile, encInfo } = item;
+  const { file, originalFile, encInfo, metadata } = item;
   const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
   if (imgError) console.warn(imgError);
 
@@ -52,6 +56,7 @@ export const getImageMsgContent = async (
     msgtype: MsgType.Image,
     filename: file.name,
     body: file.name,
+    [MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
   };
   if (imgEl) {
     const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
@@ -75,7 +80,7 @@ export const getImageMsgContent = async (
 export const getVideoMsgContent = async (
   mx: MatrixClient,
   item: TUploadItem,
-  mxc: string,
+  mxc: string
 ): Promise<IContent> => {
   const { file, originalFile, encInfo } = item;
 
index 670e6db18b54468a9b73c6c9db88388b71585748..4377e53255aa09d0472c41001e9340c111a8951c 100644 (file)
@@ -5,6 +5,11 @@ export type ListAction<T> =
       type: 'PUT';
       item: T | T[];
     }
+  | {
+    type: 'REPLACE';
+    item: T;
+    replacement: T;
+    }
   | {
       type: 'DELETE';
       item: T | T[];
@@ -26,8 +31,12 @@ export const createListAtom = <T>() => {
       }
       if (action.type === 'PUT') {
         set(baseListAtom, [...items, ...newItems]);
+        return;
+      }
+      if (action.type === 'REPLACE') {
+        set(baseListAtom, items.map((item) => item === action.item ? action.replacement : item));
       }
     }
   );
 };
-export type TListAtom<T> = ReturnType<typeof createListAtom<T>>;
+export type TListAtom<T> = ReturnType<typeof createListAtom<T>>;
\ No newline at end of file
index 33bd06076bdb094821608df1824fa370e04a6a1b..5d8ec8c76e8c7dcde15ecfd15560e7a642a23cfc 100644 (file)
@@ -3,22 +3,29 @@ import { atomFamily } from 'jotai/utils';
 import { Descendant } from 'slate';
 import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
 import { IEventRelation } from 'matrix-js-sdk';
-import { TListAtom, createListAtom } from '../list';
 import { createUploadAtomFamily } from '../upload';
 import { TUploadContent } from '../../utils/matrix';
+import { createListAtom } from '../list';
 
-export const roomUploadAtomFamily = createUploadAtomFamily();
+export type TUploadMetadata = {
+  markedAsSpoiler: boolean;
+};
 
 export type TUploadItem = {
   file: TUploadContent;
   originalFile: TUploadContent;
+  metadata: TUploadMetadata;
   encInfo: EncryptedAttachmentInfo | undefined;
 };
 
-export const roomIdToUploadItemsAtomFamily = atomFamily<string, TListAtom<TUploadItem>>(
+export type TUploadListAtom = ReturnType<typeof createListAtom<TUploadItem>>;
+
+export const roomIdToUploadItemsAtomFamily = atomFamily<string, TUploadListAtom>(
   createListAtom
 );
 
+export const roomUploadAtomFamily = createUploadAtomFamily();
+
 export type RoomIdToMsgAction =
   | {
       type: 'PUT';
index f2f12a6acdca7fe64d772102034e67edfe13c4cd..210c711f4b7249dadd02d38691358e9ea57d1925 100644 (file)
@@ -2,6 +2,8 @@ import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
 import { MsgType } from 'matrix-js-sdk';
 
 export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
+export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
+export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason';
 
 export type IImageInfo = {
   w?: number;
@@ -47,6 +49,8 @@ export type IImageContent = {
   url?: string;
   info?: IImageInfo & IThumbnailContent;
   file?: IEncryptedFile;
+  [MATRIX_SPOILER_PROPERTY_NAME]?: boolean;
+  [MATRIX_SPOILER_REASON_PROPERTY_NAME]?: string;
 };
 
 export type IVideoContent = {
@@ -56,6 +60,8 @@ export type IVideoContent = {
   url?: string;
   info?: IVideoInfo & IThumbnailContent;
   file?: IEncryptedFile;
+  [MATRIX_SPOILER_PROPERTY_NAME]?: boolean;
+  [MATRIX_SPOILER_REASON_PROPERTY_NAME]?: string;
 };
 
 export type IAudioContent = {