diff --git a/.changeset/feat_add_gallery_support.md b/.changeset/feat_add_gallery_support.md new file mode 100644 index 0000000000..49d3520911 --- /dev/null +++ b/.changeset/feat_add_gallery_support.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Added a gallery support as per msc4274 diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index e4e6c64301..ab93f25241 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -25,6 +25,7 @@ import { MNotice, MText, MVideo, + MGallery, ReadPdfFile, ReadTextFile, RenderBody, @@ -49,7 +50,8 @@ import { ClientSideHoverFreeze } from './ClientSideHoverFreeze'; import { CuteEventType, MCuteEvent } from './message/MCuteEvent'; import { PollEvent } from './message/PollEvent'; import { M_TEXT } from 'matrix-js-sdk'; -import type { IImageInfo } from '$types/matrix/common'; +import type { IImageInfo, IGalleryContent } from '$types/matrix/common'; +import { GALLERY_MSGTYPE } from '$types/matrix/common'; type RenderMessageContentProps = { displayName: string; @@ -61,6 +63,7 @@ type RenderMessageContentProps = { bundledPreview?: boolean; urlPreview?: boolean; clientUrlPreview?: boolean; + isGallery?: boolean; showMaps?: boolean; highlightRegex?: RegExp; htmlReactParserOptions: HTMLReactParserOptions; @@ -94,6 +97,7 @@ function RenderMessageContentInternal({ edited, getContent, mediaAutoLoad, + isGallery, bundledPreview, urlPreview, clientUrlPreview, @@ -213,8 +217,9 @@ function RenderMessageContentInternal({ if (captionPosition === CaptionPosition.Hidden || hideCaption) return null; if ( hasCaption && - (content as { filename?: string }).filename && - (content as { filename?: string }).filename !== content.body + (((content as { filename?: string }).filename && + (content as { filename?: string }).filename !== content.body) || + msgType === GALLERY_MSGTYPE) ) { if (captionPosition !== CaptionPosition.Inline) return ( @@ -254,16 +259,19 @@ function RenderMessageContentInternal({ return null; }; - function renderCaptionedAttachment(attachment: JSX.Element): JSX.Element { + function renderCaptionedAttachment(attachment: JSX.Element, isInGallery?: boolean): JSX.Element { return (
-
{attachment}
- {renderCaption()} + {attachment} + {!isInGallery && renderCaption()}
); } @@ -368,6 +376,7 @@ function RenderMessageContentInternal({ return renderCaptionedAttachment( & { msgtype: MsgType.Image }} + fitParent={isGallery} renderImageContent={(imageProps) => ( )} outlined={outlineAttachment} - /> + />, + isGallery ); } @@ -416,7 +426,8 @@ function RenderMessageContentInternal({ /> )} outlined={outlineAttachment} - /> + />, + isGallery ); } @@ -429,6 +440,7 @@ function RenderMessageContentInternal({ } /> )} outlined={outlineAttachment} + fitParent={isGallery} /> ); } @@ -436,6 +448,30 @@ function RenderMessageContentInternal({ if (msgType === (MsgType.File as string)) return renderFile(); if (msgType === (MsgType.Location as string)) return ; + + if (msgType === GALLERY_MSGTYPE) { + return renderCaptionedAttachment( + ( + itemContent} + mediaAutoLoad={mediaAutoLoad} + urlPreview={urlPreview} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} + outlineAttachment={outlineAttachment} + isGallery={true} + /> + )} + /> + ); + } + if (msgType === 'm.bad.encrypted') return ; // cute events diff --git a/src/app/components/media/media.css.ts b/src/app/components/media/media.css.ts index 4253b52d25..a176fef027 100644 --- a/src/app/components/media/media.css.ts +++ b/src/app/components/media/media.css.ts @@ -4,7 +4,7 @@ import { DefaultReset } from 'folds'; export const Image = style([ DefaultReset, { - objectFit: 'contain', + objectFit: 'cover', width: '100%', height: '100%', }, diff --git a/src/app/components/message/MGallery.css.ts b/src/app/components/message/MGallery.css.ts new file mode 100644 index 0000000000..c0dc173f13 --- /dev/null +++ b/src/app/components/message/MGallery.css.ts @@ -0,0 +1,57 @@ +import { recipe } from '@vanilla-extract/recipes'; +import { style } from '@vanilla-extract/css'; +import { DefaultReset, config, toRem } from 'folds'; + +export const GalleryHolder = style({ + marginTop: config.space.S200, +}); + +export const GalleryImageGrid = recipe({ + base: [ + DefaultReset, + { + display: 'grid', + gap: '0.5rem', + maxWidth: toRem(600), + height: '100%', + width: '100%', + gridAutoColumns: toRem(100), + }, + ], + variants: { + type: { + ThreeItems: { + gridTemplateColumns: '1fr 1fr', + gridTemplateRows: `repeat(2, 1fr)`, + }, + ThreeByThree: { + gridTemplateColumns: 'repeat(3,minmax(0,1fr))', + }, + TwoByTwo: { + gridTemplateColumns: 'repeat(2,minmax(0,1fr))', + }, + OneByOne: { + maxHeight: toRem(300), + gridTemplateColumns: '1fr', + }, + }, + }, +}); + +export const GalleryItem = recipe({ + base: [ + DefaultReset, + { + borderRadius: config.radii.R300, + overflow: 'hidden', + width: '100%', + height: '100%', + aspectRatio: '1/1', + selectors: { + [`${GalleryImageGrid.classNames.variants.type.ThreeItems} &:nth-child(1)`]: { + gridRow: 'span 2', + }, + }, + }, + ], +}); diff --git a/src/app/components/message/MGallery.tsx b/src/app/components/message/MGallery.tsx new file mode 100644 index 0000000000..92605fa334 --- /dev/null +++ b/src/app/components/message/MGallery.tsx @@ -0,0 +1,127 @@ +import { type ReactNode } from 'react'; +import { Box } from 'folds'; +import { MsgType, type IContent } from 'matrix-js-sdk'; +import type { + IGalleryContent, + IGalleryImageItem, + IGalleryItem, + IGalleryVideoItem, +} from '$types/matrix/common'; +import * as css from './MGallery.css'; + +function galleryItemToContent(item: IGalleryItem): IContent { + const { itemtype, ...rest } = item; + return { ...rest, msgtype: itemtype } as IContent; +} + +type MGalleryProps = { + content: IGalleryContent; + renderItem: (content: IContent, index: number) => ReactNode; +}; + +type PartitionedMediaItem = { + items: (IGalleryImageItem | IGalleryVideoItem)[]; + type: 'ThreeByThree' | 'TwoByTwo' | 'OneByOne' | 'ThreeItems'; +}; + +export function MGallery({ content, renderItem }: MGalleryProps) { + const items = content.itemtypes; + + let mediaItems = items.filter( + (item) => item.itemtype == MsgType.Video || item.itemtype == MsgType.Image + ); + const columnItems = items.filter( + (item) => item.itemtype == MsgType.File || item.itemtype == MsgType.Audio + ); + + let partitionedMediaItems: PartitionedMediaItem[] = []; + while (mediaItems.length > 0) { + if (mediaItems.length >= 9) { + partitionedMediaItems.unshift({ + items: mediaItems.slice(-9), + type: 'ThreeByThree', + }); + mediaItems = mediaItems.slice(0, mediaItems.length - 9); + continue; + } + if (mediaItems.length >= 6) { + partitionedMediaItems.unshift({ + items: mediaItems.slice(-6), + type: 'ThreeByThree', + }); + mediaItems = mediaItems.slice(0, mediaItems.length - 6); + continue; + } + if (mediaItems.length >= 4) { + partitionedMediaItems.unshift({ + items: mediaItems.slice(-4), + type: 'TwoByTwo', + }); + mediaItems = mediaItems.slice(0, mediaItems.length - 4); + continue; + } + if (mediaItems.length > 3) { + partitionedMediaItems.unshift({ + items: mediaItems.slice(-3), + type: 'ThreeByThree', + }); + mediaItems = mediaItems.slice(0, mediaItems.length - 3); + continue; + } + if (mediaItems.length == 3) { + partitionedMediaItems.unshift({ + items: mediaItems.slice(-3), + type: 'ThreeItems', + }); + mediaItems = mediaItems.slice(0, mediaItems.length - 3); + continue; + } + if (mediaItems.length >= 2) { + partitionedMediaItems.unshift({ + items: mediaItems.slice(-2), + type: 'TwoByTwo', + }); + mediaItems = mediaItems.slice(0, mediaItems.length - 2); + continue; + } + if (mediaItems.length >= 1) { + partitionedMediaItems.unshift({ + items: mediaItems.slice(-1), + type: 'OneByOne', + }); + mediaItems = mediaItems.slice(0, mediaItems.length - 1); + continue; + } + } + return ( + + {partitionedMediaItems.length > 0 && ( + + {partitionedMediaItems.map((item) => ( +
+ {item.items.map((mediaItem, index) => ( +
+ {renderItem(galleryItemToContent(mediaItem), index)} +
+ ))} +
+ ))} +
+ )} + {columnItems.length > 0 && ( + + {columnItems.map((item, index) => ( +
+ {renderItem(galleryItemToContent(item), index)} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 7c7d81f686..eb686aa075 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -432,8 +432,9 @@ type MImageProps = { content: IImageContent; renderImageContent: (props: RenderImageContentProps) => ReactNode; outlined?: boolean; + fitParent?: boolean; }; -export function MImage({ content, renderImageContent, outlined }: MImageProps) { +export function MImage({ content, renderImageContent, outlined, fitParent }: MImageProps) { const imgInfo = content?.info; const mxcUrl = content.file?.url ?? content.url; if (typeof mxcUrl !== 'string') { @@ -445,24 +446,28 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) { const aspectRatio = imgInfo?.w && imgInfo?.h ? `${imgW} / ${imgH}` : undefined; // this garbage is for portrait images, we cap the width so the card doesn't exceed the bounds of the image const displayWidth = imgH > imgW ? Math.round(MAX_SIZE * (imgW / imgH)) : MAX_SIZE; + const height = scaleYDimension(imgInfo?.w || 400, displayWidth, imgInfo?.h || 400); return ( {renderImageContent({ - body: content.filename || content.body || 'Image', + body: content.body || content.filename || 'Image', info: imgInfo, mimeType: imgInfo?.mimetype, url: mxcUrl, @@ -489,6 +494,7 @@ type MVideoProps = { renderAsFile: () => ReactNode; renderVideoContent: (props: RenderVideoContentProps) => ReactNode; outlined?: boolean; + fitParent?: boolean; }; export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) { const videoInfo = content?.info; @@ -502,6 +508,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: return ; } + const displayWidth = Math.min(videoInfo.w || 400, 400); const height = Math.min(scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400), 400); const filename = content.filename ?? content.body ?? 'Video'; @@ -511,6 +518,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: style={{ flexGrow: 1, flexShrink: 0, + width: toRem(displayWidth), + height: 'auto', }} outlined={outlined} > @@ -530,6 +539,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: @@ -580,8 +591,15 @@ type MAudioProps = { renderAsFile: () => ReactNode; renderAudioContent: (props: RenderAudioContentProps) => ReactNode; outlined?: boolean; + fitParent?: boolean; }; -export function MAudio({ content, renderAsFile, renderAudioContent, outlined }: MAudioProps) { +export function MAudio({ + content, + renderAsFile, + renderAudioContent, + outlined, + fitParent, +}: MAudioProps) { const audioInfo = content?.info; const mxcUrl = content.file?.url ?? content.url; const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? ''); @@ -598,7 +616,10 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }: const resolvedInfo = durationMs !== undefined ? { ...audioInfo, duration: durationMs } : audioInfo; return ( - + ReactNode; outlined?: boolean; + fitParent?: boolean; }; export function MFile({ content, renderFileContent, outlined }: MFileProps) { const fileInfo = content?.info; @@ -648,7 +670,7 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) { } return ( - + {renderFileContent({ - body: content.filename ?? content.body ?? 'File', + body: content.body ?? content.filename ?? 'File', info: fileInfo ?? {}, mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE, url: mxcUrl, diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index e514f40526..666684da51 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -241,7 +241,6 @@ export const VideoContent = as<'div', VideoContentProps>( ( setBlurred(false); } else setBlurred(!blurred); }} - /> + > + {menuIcon(blurred ? Eye : EyeSlash)} + )} diff --git a/src/app/components/message/index.ts b/src/app/components/message/index.ts index 4d6326b554..df8a1773f8 100644 --- a/src/app/components/message/index.ts +++ b/src/app/components/message/index.ts @@ -10,3 +10,4 @@ export * from './Time'; export * from './MsgTypeRenderers'; export * from './FileHeader'; export * from './RenderBody'; +export * from './MGallery'; diff --git a/src/app/components/upload-board/UploadBoard.css.ts b/src/app/components/upload-board/UploadBoard.css.ts index 80c1b264d5..8b694f40c4 100644 --- a/src/app/components/upload-board/UploadBoard.css.ts +++ b/src/app/components/upload-board/UploadBoard.css.ts @@ -1,37 +1,23 @@ import { style } from '@vanilla-extract/css'; -import { DefaultReset, color, config, toRem } from 'folds'; +import { DefaultReset, color, config } from 'folds'; export const UploadBoardBase = style([ DefaultReset, { - position: 'relative', - pointerEvents: 'none', + borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, }, ]); export const UploadBoardContainer = style([ DefaultReset, { - position: 'absolute', - bottom: config.space.S200, - left: 0, - right: 0, - zIndex: config.zIndex.Max, + width: '100%', }, ]); export const UploadBoard = style({ - maxWidth: toRem(400), width: '100%', - maxHeight: toRem(450), - height: '100%', - backgroundColor: color.Surface.Container, - color: color.Surface.OnContainer, - borderRadius: config.radii.R400, - boxShadow: config.shadow.E200, - border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, overflow: 'hidden', - pointerEvents: 'all', }); export const UploadBoardHeaderContent = style({ @@ -42,5 +28,4 @@ export const UploadBoardHeaderContent = style({ export const UploadBoardContent = style({ padding: config.space.S200, paddingBottom: 0, - paddingRight: 0, }); diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx index 232d65feca..6875a50900 100644 --- a/src/app/components/upload-board/UploadBoard.tsx +++ b/src/app/components/upload-board/UploadBoard.tsx @@ -1,7 +1,7 @@ import type { MutableRefObject, ReactNode } from 'react'; import { useImperativeHandle, useRef } from 'react'; import { Badge, Box, Chip, Header, Spinner, Text, as, percent } from 'folds'; -import { CaretRight, CaretUp, PaperPlaneTilt, X, sizedIcon } from '$components/icons/phosphor'; +import { CaretRight, CaretUp, X, sizedIcon } from '$components/icons/phosphor'; import classNames from 'classnames'; import { useAtomValue } from 'jotai'; @@ -92,18 +92,6 @@ export function UploadBoardHeader({ Files - {isSuccess && ( - - Send - - )} {isError && !open && ( Upload Failed @@ -117,7 +105,7 @@ export function UploadBoardHeader({ )} - {!isSuccess && open && ( + {open && ( (({ className, children, ...props }, ref) => ( ( - ({ before, after, children, bottom, radii, outlined, compact }, ref) => ( - - - {before} - - {children} - - {after} +export const UploadCard = forwardRef< + HTMLDivElement, + UploadCardProps & css.UploadCardVariant & { style?: React.CSSProperties } +>(({ before, after, children, bottom, radii, outlined, compact, style }, ref) => ( + + + {before} + + {children} - {bottom} + {after} - ) -); + {bottom} + +)); type UploadCardProgressProps = { sentBytes: number; diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index cccab59af5..c00058fb5f 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -1,17 +1,6 @@ import type { ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { - Box, - Chip, - IconButton, - Scroll, - Text, - Tooltip, - TooltipProvider, - color, - config, - toRem, -} from 'folds'; +import { Box, Chip, IconButton, Text, Tooltip, TooltipProvider, color, config, toRem } from 'folds'; import { Check, EyeSlash, @@ -68,7 +57,7 @@ function PreviewImage({ fileItem }: Readonly) { style={{ objectFit: 'contain', width: '100%', - height: toRem(152), + height: toRem(128), filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined, }} alt={originalFile.name} @@ -90,7 +79,7 @@ function PreviewVideo({ fileItem }: Readonly) { style={{ objectFit: 'contain', width: '100%', - height: toRem(152), + height: toRem(128), filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined, }} src={fileUrl} @@ -377,6 +366,7 @@ type UploadCardRendererProps = { onRemove: (file: TUploadContent) => void; onComplete?: (upload: UploadSuccess) => void; roomId: string; + hideCaption?: boolean; }; export function UploadCardRenderer({ isEncrypted, @@ -386,6 +376,7 @@ export function UploadCardRenderer({ onRemove, onComplete, roomId, + hideCaption, }: Readonly) { const mx = useMatrixClient(); const mediaConfig = useMediaConfig(); @@ -452,6 +443,8 @@ export function UploadCardRenderer({ return ( @@ -467,7 +460,7 @@ export function UploadCardRenderer({ Retry )} - {!isDescribed && ( + {!isDescribed && !hideCaption && ( { setIsDescribed(true); @@ -480,7 +473,7 @@ export function UploadCardRenderer({ {sizedIcon(PencilSimple, '50')} )} - {isDescribed && ( + {isDescribed && !hideCaption && ( )} - {isDescribed && ( + {isDescribed && !hideCaption && ( { @@ -552,31 +545,17 @@ export function UploadCardRenderer({ onCancel={() => setIsDescribed(false)} /> )} - {!isDescribed && fileItem.body && fileItem.body.length > 0 && ( - - - - - - - + {!isDescribed && !hideCaption && fileItem.body && fileItem.body.length > 0 && ( + + + + + )} } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 4dff58cf71..f4d8e9e532 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -170,6 +170,8 @@ import { getImageMsgContent, getVideoMsgContent, getGifMsgContent, + buildGalleryContent, + getGalleryItemContent, } from './msgContent'; import { outgoingMessageTransforms } from './outgoingMessageTransforms'; import { getKlipyMxcUrl } from '$utils/klipy'; @@ -437,6 +439,11 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); + const [enableMediaGalleries] = useSetting(settingsAtom, 'enableMediaGalleries'); + const [sendIndividualAttachmentAsCaption] = useSetting( + settingsAtom, + 'sendIndividualAttachmentAsCaption' + ); useElementSizeObserver( useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), @@ -676,22 +683,68 @@ export const RoomInput = forwardRef( } }; + const uploadToContent = async (upload: UploadSuccess) => { + const fileItem = selectedFiles.find((f) => f.file === upload.file); + if (!fileItem) throw new Error('Broken upload'); + + if (fileItem.file.type.startsWith('image')) { + return getImageMsgContent(mx, fileItem, upload.mxc); + } + if (fileItem.file.type.startsWith('video')) { + return getVideoMsgContent(mx, fileItem, upload.mxc); + } + if (fileItem.file.type.startsWith('audio')) { + return getAudioMsgContent(fileItem, upload.mxc); + } + return getFileMsgContent(fileItem, upload.mxc); + }; + const handleSendUpload = async (uploads: UploadSuccess[]) => { - const contentsPromises = uploads.map(async (upload) => { - const fileItem = selectedFiles.find((f) => f.file === upload.file); - if (!fileItem) throw new Error('Broken upload'); + const plainText = toPlainText(editor.children).trim(); + const caption = plainText.length > 0 ? plainText : undefined; + let customHtml = trimCustomHtml( + toMatrixCustomHTML(editor.children, { + stripNickname: true, + room, + }) + ); + const formattedCaption = + caption && !customHtmlEqualsPlainText(customHtml, plainText) ? customHtml : undefined; - if (fileItem.file.type.startsWith('image')) { - return getImageMsgContent(mx, fileItem, upload.mxc); - } - if (fileItem.file.type.startsWith('video')) { - return getVideoMsgContent(mx, fileItem, upload.mxc); - } - if (fileItem.file.type.startsWith('audio')) { - return getAudioMsgContent(fileItem, upload.mxc); + if (uploads.length == 1 && sendIndividualAttachmentAsCaption) { + const upload = uploads[0]; + if (!upload) throw new Error('Broken upload'); + let content = await uploadToContent(upload); + handleCancelUpload(uploads); + + content.body = caption; + content.formatted_body = undefined; + + if (formattedCaption) { + content.format = 'org.matrix.custom.html'; + content.formatted_body = formattedCaption; } - return getFileMsgContent(fileItem, upload.mxc); - }); + + await handleSendContents([content]); + return; + } + if (uploads.length >= 2 && enableMediaGalleries) { + const itemsPromises = uploads.map(async (upload) => { + const fileItem = selectedFiles.find((f) => f.file === upload.file); + if (!fileItem) throw new Error('Broken upload'); + return getGalleryItemContent(mx, fileItem, upload.mxc); + }); + handleCancelUpload(uploads); + const items = fulfilledPromiseSettledResult(await Promise.allSettled(itemsPromises)); + + if (items.length === 0) return; + + const galleryContent = buildGalleryContent(items, caption, formattedCaption); + + await handleSendContents([galleryContent]); + return; + } + const contentsPromises = uploads.map(uploadToContent); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); @@ -735,6 +788,15 @@ export const RoomInput = forwardRef( const submit = useCallback(async () => { uploadBoardHandlers.current?.handleSend(); + if ( + (selectedFiles.length >= 2 && enableMediaGalleries) || + (selectedFiles.length == 1 && sendIndividualAttachmentAsCaption) + ) { + resetEditor(editor); + resetEditorHistory(editor); + sendTypingStatus(false); + return; + } const commandName = getBeginCommand(editor); /** @@ -1124,6 +1186,9 @@ export const RoomInput = forwardRef( setScheduledTime, setServerMaxDelayMs, replyDraftBase, + selectedFiles, + enableMediaGalleries, + sendIndividualAttachmentAsCaption, ]); const handleKeyDown: KeyboardEventHandler = useCallback( @@ -1323,40 +1388,6 @@ export const RoomInput = forwardRef( return (
- {selectedFiles.length > 0 && ( - setUploadBoard(!uploadBoard)} - uploadFamilyObserverAtom={uploadFamilyObserverAtom} - onSend={handleSendUpload} - imperativeHandlerRef={uploadBoardHandlers} - onCancel={handleCancelUpload} - /> - } - > - {uploadBoard && ( - - - {Array.from(selectedFiles) - .toReversed() - .map((fileItem) => ( - - ))} - - - )} - - )} } @@ -1448,6 +1479,43 @@ export const RoomInput = forwardRef( forceMultilineLayout={showAudioRecorder} top={ <> + {selectedFiles.length > 0 && ( + setUploadBoard(!uploadBoard)} + uploadFamilyObserverAtom={uploadFamilyObserverAtom} + onSend={handleSendUpload} + imperativeHandlerRef={uploadBoardHandlers} + onCancel={handleCancelUpload} + /> + } + > + {uploadBoard && ( + + + {Array.from(selectedFiles) + .toReversed() + .map((fileItem) => ( + + ))} + + + )} + + )} {scheduledTime && (
{ + const result = { ...content, itemtype }; + delete result.msgtype; + return result as IGalleryItem; +}; + +export const getGalleryItemContent = async ( + mx: MatrixClient, + item: TUploadItem, + mxc: string +): Promise => { + if (item.file.type.startsWith('image')) { + return swapMsgTypeToItemType(await getImageMsgContent(mx, item, mxc), MsgType.Image); + } + if (item.file.type.startsWith('video')) { + return swapMsgTypeToItemType(await getVideoMsgContent(mx, item, mxc), MsgType.Video); + } + if (item.file.type.startsWith('audio')) { + return swapMsgTypeToItemType(getAudioMsgContent(item, mxc), MsgType.Audio); + } + return swapMsgTypeToItemType(getFileMsgContent(item, mxc), MsgType.File); +}; + +export const buildGalleryContent = ( + items: IGalleryItem[], + caption?: string, + formattedCaption?: string +): IContent => { + const body = + caption || + items.map((item) => `[${item.filename ?? item.itemtype}: ${item.url ?? 'file'}]`).join('\n'); + + const content: IContent = { + msgtype: GALLERY_MSGTYPE, + body, + itemtypes: items, + }; + + if (formattedCaption) { + content.format = 'org.matrix.custom.html'; + content.formatted_body = formattedCaption; + } + + return content; +}; diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index fe4b039c7a..085355f40f 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -11,6 +11,7 @@ import { Sync } from '../general'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; +import { MSC4274MediaGalleries } from './MSC4274MediaGalleries'; function PersonaToggle() { const [showPersonaSetting, setShowPersonaSetting] = useSetting( @@ -63,6 +64,7 @@ export function Experimental({ requestBack, requestClose }: Readonly + diff --git a/src/app/features/settings/experimental/MSC4274MediaGalleries.tsx b/src/app/features/settings/experimental/MSC4274MediaGalleries.tsx new file mode 100644 index 0000000000..397f9306c4 --- /dev/null +++ b/src/app/features/settings/experimental/MSC4274MediaGalleries.tsx @@ -0,0 +1,39 @@ +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { Box, Switch, Text } from 'folds'; +import { SequenceCardStyle } from '../styles.css'; + +export function MSC4274MediaGalleries() { + const [enabledMediaGalleries, setEnabledMediaGalleries] = useSetting( + settingsAtom, + 'enableMediaGalleries' + ); + + return ( + + Enable Media Galleries Support + + + } + /> + + + ); +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 89359f913c..a3fef67ac7 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -428,6 +428,10 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); + const [sendIndividualAttachmentAsCaption, setSendIndividualAttachmentAsCaption] = useSetting( + settingsAtom, + 'sendIndividualAttachmentAsCaption' + ); return ( @@ -504,6 +508,20 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { } /> + + + } + /> + ); } diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index eb258b1c33..e39fc9b33b 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -210,7 +210,12 @@ const settingsLinkFocusIdsBySection: Record & { itemtype: MsgType.Image }; +export type IGalleryVideoItem = Omit & { itemtype: MsgType.Video }; +export type IGalleryAudioItem = Omit & { itemtype: MsgType.Audio }; +export type IGalleryFileItem = Omit & { itemtype: MsgType.File }; +export type IGalleryItem = + | IGalleryImageItem + | IGalleryVideoItem + | IGalleryAudioItem + | IGalleryFileItem; + +export const GALLERY_MSGTYPE = 'dm.filament.gallery'; + +export type IGalleryContent = { + msgtype: typeof GALLERY_MSGTYPE; + body: string; + format?: string; + formatted_body?: string; + itemtypes: IGalleryItem[]; +};