mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	Add support to mark videos as spoilers (#2255)
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled
				
			* 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>
This commit is contained in:
		
							parent
							
								
									867a47218a
								
							
						
					
					
						commit
						b78f6f23b5
					
				
					 6 changed files with 112 additions and 24 deletions
				
			
		| 
						 | 
					@ -209,13 +209,11 @@ export function RenderMessageContent({
 | 
				
			||||||
        <MVideo
 | 
					        <MVideo
 | 
				
			||||||
          content={getContent()}
 | 
					          content={getContent()}
 | 
				
			||||||
          renderAsFile={renderFile}
 | 
					          renderAsFile={renderFile}
 | 
				
			||||||
          renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
 | 
					          renderVideoContent={({ body, info, ...props }) => (
 | 
				
			||||||
            <VideoContent
 | 
					            <VideoContent
 | 
				
			||||||
              body={body}
 | 
					              body={body}
 | 
				
			||||||
              info={info}
 | 
					              info={info}
 | 
				
			||||||
              mimeType={mimeType}
 | 
					              {...props}
 | 
				
			||||||
              url={url}
 | 
					 | 
				
			||||||
              encInfo={encInfo}
 | 
					 | 
				
			||||||
              renderThumbnail={
 | 
					              renderThumbnail={
 | 
				
			||||||
                mediaAutoLoad
 | 
					                mediaAutoLoad
 | 
				
			||||||
                  ? () => (
 | 
					                  ? () => (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -224,6 +224,8 @@ type RenderVideoContentProps = {
 | 
				
			||||||
  mimeType: string;
 | 
					  mimeType: string;
 | 
				
			||||||
  url: string;
 | 
					  url: string;
 | 
				
			||||||
  encInfo?: IEncryptedFile;
 | 
					  encInfo?: IEncryptedFile;
 | 
				
			||||||
 | 
					  markedAsSpoiler?: boolean;
 | 
				
			||||||
 | 
					  spoilerReason?: string;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
type MVideoProps = {
 | 
					type MVideoProps = {
 | 
				
			||||||
  content: IVideoContent;
 | 
					  content: IVideoContent;
 | 
				
			||||||
| 
						 | 
					@ -274,6 +276,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
 | 
				
			||||||
          mimeType: safeMimeType,
 | 
					          mimeType: safeMimeType,
 | 
				
			||||||
          url: mxcUrl,
 | 
					          url: mxcUrl,
 | 
				
			||||||
          encInfo: content.file,
 | 
					          encInfo: content.file,
 | 
				
			||||||
 | 
					          markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
 | 
				
			||||||
 | 
					          spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
 | 
				
			||||||
        })}
 | 
					        })}
 | 
				
			||||||
      </AttachmentBox>
 | 
					      </AttachmentBox>
 | 
				
			||||||
    </Attachment>
 | 
					    </Attachment>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -214,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
 | 
					        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
 | 
				
			||||||
          !load &&
 | 
					          !load &&
 | 
				
			||||||
          !markedAsSpoiler && (
 | 
					          !blurred && (
 | 
				
			||||||
            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
					            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
				
			||||||
              <Spinner variant="Secondary" />
 | 
					              <Spinner variant="Secondary" />
 | 
				
			||||||
            </Box>
 | 
					            </Box>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,6 +3,7 @@ import {
 | 
				
			||||||
  Badge,
 | 
					  Badge,
 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
  Button,
 | 
					  Button,
 | 
				
			||||||
 | 
					  Chip,
 | 
				
			||||||
  Icon,
 | 
					  Icon,
 | 
				
			||||||
  Icons,
 | 
					  Icons,
 | 
				
			||||||
  Spinner,
 | 
					  Spinner,
 | 
				
			||||||
| 
						 | 
					@ -47,6 +48,8 @@ type VideoContentProps = {
 | 
				
			||||||
  info: IVideoInfo & IThumbnailContent;
 | 
					  info: IVideoInfo & IThumbnailContent;
 | 
				
			||||||
  encInfo?: EncryptedAttachmentInfo;
 | 
					  encInfo?: EncryptedAttachmentInfo;
 | 
				
			||||||
  autoPlay?: boolean;
 | 
					  autoPlay?: boolean;
 | 
				
			||||||
 | 
					  markedAsSpoiler?: boolean;
 | 
				
			||||||
 | 
					  spoilerReason?: string;
 | 
				
			||||||
  renderThumbnail?: () => ReactNode;
 | 
					  renderThumbnail?: () => ReactNode;
 | 
				
			||||||
  renderVideo: (props: RenderVideoProps) => ReactNode;
 | 
					  renderVideo: (props: RenderVideoProps) => ReactNode;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -60,6 +63,8 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
				
			||||||
      info,
 | 
					      info,
 | 
				
			||||||
      encInfo,
 | 
					      encInfo,
 | 
				
			||||||
      autoPlay,
 | 
					      autoPlay,
 | 
				
			||||||
 | 
					      markedAsSpoiler,
 | 
				
			||||||
 | 
					      spoilerReason,
 | 
				
			||||||
      renderThumbnail,
 | 
					      renderThumbnail,
 | 
				
			||||||
      renderVideo,
 | 
					      renderVideo,
 | 
				
			||||||
      ...props
 | 
					      ...props
 | 
				
			||||||
| 
						 | 
					@ -72,6 +77,7 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const [load, setLoad] = useState(false);
 | 
					    const [load, setLoad] = useState(false);
 | 
				
			||||||
    const [error, setError] = useState(false);
 | 
					    const [error, setError] = useState(false);
 | 
				
			||||||
 | 
					    const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const [srcState, loadSrc] = useAsyncCallback(
 | 
					    const [srcState, loadSrc] = useAsyncCallback(
 | 
				
			||||||
      useCallback(async () => {
 | 
					      useCallback(async () => {
 | 
				
			||||||
| 
						 | 
					@ -114,11 +120,15 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {renderThumbnail && !load && (
 | 
					        {renderThumbnail && !load && (
 | 
				
			||||||
          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
					          <Box
 | 
				
			||||||
 | 
					            className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
 | 
				
			||||||
 | 
					            alignItems="Center"
 | 
				
			||||||
 | 
					            justifyContent="Center"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
            {renderThumbnail()}
 | 
					            {renderThumbnail()}
 | 
				
			||||||
          </Box>
 | 
					          </Box>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {!autoPlay && srcState.status === AsyncStatus.Idle && (
 | 
					        {!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
 | 
				
			||||||
          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
					          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
				
			||||||
            <Button
 | 
					            <Button
 | 
				
			||||||
              variant="Secondary"
 | 
					              variant="Secondary"
 | 
				
			||||||
| 
						 | 
					@ -133,7 +143,7 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
				
			||||||
          </Box>
 | 
					          </Box>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {srcState.status === AsyncStatus.Success && (
 | 
					        {srcState.status === AsyncStatus.Success && (
 | 
				
			||||||
          <Box className={css.AbsoluteContainer}>
 | 
					          <Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
 | 
				
			||||||
            {renderVideo({
 | 
					            {renderVideo({
 | 
				
			||||||
              title: body,
 | 
					              title: body,
 | 
				
			||||||
              src: srcState.data,
 | 
					              src: srcState.data,
 | 
				
			||||||
| 
						 | 
					@ -144,8 +154,39 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
				
			||||||
            })}
 | 
					            })}
 | 
				
			||||||
          </Box>
 | 
					          </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) &&
 | 
					        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
 | 
				
			||||||
          !load && (
 | 
					          !load &&
 | 
				
			||||||
 | 
					          !blurred && (
 | 
				
			||||||
            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
					            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
 | 
				
			||||||
              <Spinner variant="Secondary" />
 | 
					              <Spinner variant="Secondary" />
 | 
				
			||||||
            </Box>
 | 
					            </Box>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
 | 
				
			||||||
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
 | 
					import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
 | 
				
			||||||
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
 | 
					import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
 | 
				
			||||||
| 
						 | 
					@ -13,8 +13,54 @@ import {
 | 
				
			||||||
import { useObjectURL } from '../../hooks/useObjectURL';
 | 
					import { useObjectURL } from '../../hooks/useObjectURL';
 | 
				
			||||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
 | 
					import { useMediaConfig } from '../../hooks/useMediaConfig';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
 | 
					type PreviewImageProps = {
 | 
				
			||||||
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
 | 
					  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 { originalFile, metadata } = fileItem;
 | 
				
			||||||
  const fileUrl = useObjectURL(originalFile);
 | 
					  const fileUrl = useObjectURL(originalFile);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -27,16 +73,7 @@ function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
 | 
				
			||||||
        position: 'relative',
 | 
					        position: 'relative',
 | 
				
			||||||
      }}
 | 
					      }}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <img
 | 
					      {children}
 | 
				
			||||||
        style={{
 | 
					 | 
				
			||||||
          objectFit: 'contain',
 | 
					 | 
				
			||||||
          width: '100%',
 | 
					 | 
				
			||||||
          height: toRem(152),
 | 
					 | 
				
			||||||
          filter: fileItem.metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
 | 
					 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
        src={fileUrl}
 | 
					 | 
				
			||||||
        alt={originalFile.name}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
      <Box
 | 
					      <Box
 | 
				
			||||||
        justifyContent="End"
 | 
					        justifyContent="End"
 | 
				
			||||||
        style={{
 | 
					        style={{
 | 
				
			||||||
| 
						 | 
					@ -136,7 +173,14 @@ export function UploadCardRenderer({
 | 
				
			||||||
      bottom={
 | 
					      bottom={
 | 
				
			||||||
        <>
 | 
					        <>
 | 
				
			||||||
          {fileItem.originalFile.type.startsWith('image') && (
 | 
					          {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 && (
 | 
					          {upload.status === UploadStatus.Idle && !fileSizeExceeded && (
 | 
				
			||||||
            <UploadCardProgress sentBytes={0} totalBytes={file.size} />
 | 
					            <UploadCardProgress sentBytes={0} totalBytes={file.size} />
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -82,7 +82,7 @@ export const getVideoMsgContent = async (
 | 
				
			||||||
  item: TUploadItem,
 | 
					  item: TUploadItem,
 | 
				
			||||||
  mxc: string
 | 
					  mxc: string
 | 
				
			||||||
): Promise<IContent> => {
 | 
					): Promise<IContent> => {
 | 
				
			||||||
  const { file, originalFile, encInfo } = item;
 | 
					  const { file, originalFile, encInfo, metadata } = item;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
 | 
					  const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
 | 
				
			||||||
  if (videoError) console.warn(videoError);
 | 
					  if (videoError) console.warn(videoError);
 | 
				
			||||||
| 
						 | 
					@ -91,6 +91,7 @@ export const getVideoMsgContent = async (
 | 
				
			||||||
    msgtype: MsgType.Video,
 | 
					    msgtype: MsgType.Video,
 | 
				
			||||||
    filename: file.name,
 | 
					    filename: file.name,
 | 
				
			||||||
    body: file.name,
 | 
					    body: file.name,
 | 
				
			||||||
 | 
					    [MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
  if (videoEl) {
 | 
					  if (videoEl) {
 | 
				
			||||||
    const [thumbError, thumbContent] = await to(
 | 
					    const [thumbError, thumbContent] = await to(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue