diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 6138d0d7..a341b433 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -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; + spoiler?: 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, + spoiler: content[MATRIX_SPOILER_PROPERTY_NAME], + spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], })} diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index d4241b64..80e1e6a0 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -51,6 +51,8 @@ export type ImageContentProps = { info?: IImageInfo; encInfo?: EncryptedAttachmentInfo; autoPlay?: boolean; + spoiler?: boolean; + spoilerReason?: string; renderViewer: (props: RenderViewerProps) => ReactNode; renderImage: (props: RenderImageProps) => ReactNode; }; @@ -64,6 +66,8 @@ export const ImageContent = as<'div', ImageContentProps>( info, encInfo, autoPlay, + spoiler, + spoilerReason, renderViewer, renderImage, ...props @@ -77,6 +81,7 @@ export const ImageContent = as<'div', ImageContentProps>( const [load, setLoad] = useState(false); const [error, setError] = useState(false); const [viewer, setViewer] = useState(false); + const [spoiled, setSpoiled] = useState(spoiler ?? false); const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { @@ -160,7 +165,7 @@ export const ImageContent = as<'div', ImageContentProps>( )} {srcState.status === AsyncStatus.Success && ( - + {renderImage({ alt: body, title: body, @@ -172,6 +177,35 @@ export const ImageContent = as<'div', ImageContentProps>( })} )} + {srcState.status === AsyncStatus.Success && spoiled && ( + + + {spoilerReason} + + ) + } + position="Top" + align="Center" + > + {(triggerRef) => ( + + )} + + + )} {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && !load && ( diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts index a2f5b55d..f6cadd3c 100644 --- a/src/app/components/message/content/style.css.ts +++ b/src/app/components/message/content/style.css.ts @@ -30,3 +30,10 @@ export const AbsoluteFooter = style([ right: config.space.S100, }, ]); + +export const Blur = style([ + DefaultReset, + { + filter: 'blur(44px)', + }, +]); diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index 5df68f2a..e814a6f7 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -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, spoiled: !metadata.spoiled }); + }, [setMetadata, metadata]); + const removeUpload = () => { cancelUpload(); onRemove(file); @@ -53,6 +66,30 @@ export function UploadCardRenderer({ Retry )} + {file.type.startsWith('image') && ( + + Spoil Image + + } + position="Top" + align="Center" + > + {(triggerRef) => ( + + + + )} + + )} ( const encryptFiles = fulfilledPromiseSettledResult( await Promise.allSettled(safeFiles.map((f) => encryptFile(f))) ); - encryptFiles.forEach((ef) => fileItems.push(ef)); + encryptFiles.forEach((ef) => + fileItems.push({ + ...ef, + metadata: { + spoiled: false, + }, + }) + ); } else { safeFiles.forEach((f) => - fileItems.push({ file: f, originalFile: f, encInfo: undefined }) + fileItems.push({ + file: f, + originalFile: f, + encInfo: undefined, + metadata: { + spoiled: false, + }, + }) ); } setSelectedFiles({ type: 'PUT', - item: fileItems, + items: fileItems, }); }, [setSelectedFiles, room] @@ -213,7 +227,7 @@ export const RoomInput = forwardRef( const uploads = Array.isArray(upload) ? upload : [upload]; setSelectedFiles({ type: 'DELETE', - item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)), + items: selectedFiles.filter((f) => uploads.find((u) => u === f.file)), }); uploads.forEach((u) => roomUploadAtomFamily.remove(u)); }, @@ -420,7 +434,10 @@ export const RoomInput = forwardRef( // eslint-disable-next-line react/no-array-index-key key={index} isEncrypted={!!fileItem.encInfo} - uploadAtom={roomUploadAtomFamily(fileItem.file)} + fileItem={fileItem} + setMetadata={(metadata) => + setSelectedFiles({ type: 'MODIFY', item: fileItem, metadata }) + } onRemove={handleRemoveUpload} /> ))} diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 60781ef0..dee06807 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -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 => { - 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.spoiled, }; 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 => { const { file, originalFile, encInfo } = item; diff --git a/src/app/state/list.ts b/src/app/state/list.ts deleted file mode 100644 index 670e6db1..00000000 --- a/src/app/state/list.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { atom } from 'jotai'; - -export type ListAction = - | { - type: 'PUT'; - item: T | T[]; - } - | { - type: 'DELETE'; - item: T | T[]; - }; - -export const createListAtom = () => { - const baseListAtom = atom([]); - return atom], undefined>( - (get) => get(baseListAtom), - (get, set, action) => { - const items = get(baseListAtom); - const newItems = Array.isArray(action.item) ? action.item : [action.item]; - if (action.type === 'DELETE') { - set( - baseListAtom, - items.filter((item) => !newItems.includes(item)) - ); - return; - } - if (action.type === 'PUT') { - set(baseListAtom, [...items, ...newItems]); - } - } - ); -}; -export type TListAtom = ReturnType>; diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 33bd0607..5eb00787 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -3,22 +3,66 @@ 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'; -export const roomUploadAtomFamily = createUploadAtomFamily(); +export type TUploadMetadata = { + spoiled: boolean; +}; export type TUploadItem = { file: TUploadContent; originalFile: TUploadContent; + metadata: TUploadMetadata; encInfo: EncryptedAttachmentInfo | undefined; }; -export const roomIdToUploadItemsAtomFamily = atomFamily>( - createListAtom +export type UploadListAction = + | { + type: 'PUT'; + items: TUploadItem[]; + } + | { + type: 'DELETE'; + items: TUploadItem[]; + } + | { + type: 'MODIFY'; + item: TUploadItem; + metadata: TUploadMetadata; + }; + +export const createUploadListAtom = () => { + const baseListAtom = atom([]); + return atom( + (get) => get(baseListAtom), + (get, set, action) => { + const items = get(baseListAtom); + if (action.type === 'DELETE') { + set( + baseListAtom, + items.filter((item) => !action.items.includes(item)) + ); + return; + } + if (action.type === 'PUT') { + set(baseListAtom, [...items, ...action.items]); + return; + } + if (action.type === 'MODIFY') { + set(baseListAtom, items.map((item) => item === action.item ? {...item, metadata: action.metadata} : item)); + } + } + ); +}; +export type TUploadListAtom = ReturnType; + +export const roomIdToUploadItemsAtomFamily = atomFamily( + createUploadListAtom ); +export const roomUploadAtomFamily = createUploadAtomFamily(); + export type RoomIdToMsgAction = | { type: 'PUT'; diff --git a/src/types/matrix/common.ts b/src/types/matrix/common.ts index f2f12a6a..210c711f 100644 --- a/src/types/matrix/common.ts +++ b/src/types/matrix/common.ts @@ -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 = {