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..acb16fa128 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, @@ -260,9 +264,11 @@ function RenderMessageContentInternal({ style={{ display: 'flex', flexDirection: attachmentDirection, + height: '100%', + width: '100%', }} > -
{attachment}
+ {attachment} {renderCaption()} ); @@ -272,6 +278,7 @@ function RenderMessageContentInternal({ renderCaptionedAttachment( & { msgtype: MsgType.File }} + //fitParent={isGallery} renderFileContent={({ body, mimeType, info, encInfo, url }) => ( & { msgtype: MsgType.Image }} + fitParent={isGallery} renderImageContent={(imageProps) => ( & { msgtype: MsgType.Video }} renderAsFile={renderFile} + fitParent={isGallery} renderVideoContent={({ body, info, ...videoProps }) => ( } /> )} outlined={outlineAttachment} + fitParent={isGallery} /> ); } @@ -436,6 +446,51 @@ function RenderMessageContentInternal({ if (msgType === (MsgType.File as string)) return renderFile(); if (msgType === (MsgType.Location as string)) return ; + + if (msgType === GALLERY_MSGTYPE) { + const galleryContent = getContent() as IGalleryContent; + return ( + ( + itemContent} + mediaAutoLoad={mediaAutoLoad} + urlPreview={urlPreview} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} + outlineAttachment={outlineAttachment} + isGallery={true} + /> + )} + renderCaption={ + galleryContent.body + ? () => ( + ( + + )} + renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + /> + ) + : undefined + } + /> + ); + } + 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..d85a8de7fe --- /dev/null +++ b/src/app/components/message/MGallery.css.ts @@ -0,0 +1,104 @@ +import { recipe } from '@vanilla-extract/recipes'; +import { style } from '@vanilla-extract/css'; +import { DefaultReset, color, 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, ${toRem(200)})`, + }, + 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, + minHeight: toRem(175), + minWidth: toRem(175), + width: '100%', + height: '100%', + aspectRatio: '1/1', + selectors: { + [`${GalleryImageGrid.classNames.variants.type.ThreeItems} &:nth-child(1)`]: { + gridRow: 'span 2', + }, + }, + }, + ], +}); + +export const GalleryHolderGradient = recipe({ + base: [ + DefaultReset, + { + position: 'absolute', + height: '100%', + width: toRem(10), + zIndex: 1, + }, + ], + variants: { + position: { + Left: { + left: 0, + background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`, + }, + Right: { + right: 0, + background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`, + }, + }, + }, +}); + +export const GalleryHolderBtn = recipe({ + base: [ + DefaultReset, + { + position: 'absolute', + zIndex: 1, + }, + ], + variants: { + position: { + Left: { + left: 0, + transform: 'translateX(-25%)', + }, + Right: { + right: 0, + transform: 'translateX(25%)', + }, + }, + }, +}); diff --git a/src/app/components/message/MGallery.tsx b/src/app/components/message/MGallery.tsx new file mode 100644 index 0000000000..c28501e9bf --- /dev/null +++ b/src/app/components/message/MGallery.tsx @@ -0,0 +1,132 @@ +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; + renderCaption?: () => ReactNode; +}; + +type PartitionedMediaItem = { + items: (IGalleryImageItem | IGalleryVideoItem)[]; + type: 'ThreeByThree' | 'TwoByTwo' | 'OneByOne' | 'ThreeItems'; +}; + +export function MGallery({ content, renderItem, renderCaption }: 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.map((item) => ( +
+ {item.items.map((mediaItem, index) => ( +
+ {renderItem(galleryItemToContent(mediaItem), index)} +
+ ))} +
+ ))} +
+ + {columnItems.map((item, index) => ( +
+ {renderItem(galleryItemToContent(item), index)} +
+ ))} +
+
+ {renderCaption?.()} +
+ ); +} diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 7c7d81f686..e703aa2523 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,20 +446,24 @@ 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({ @@ -489,8 +494,15 @@ type MVideoProps = { renderAsFile: () => ReactNode; renderVideoContent: (props: RenderVideoContentProps) => ReactNode; outlined?: boolean; + fitParent?: boolean; }; -export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) { +export function MVideo({ + content, + renderAsFile, + renderVideoContent, + outlined, + fitParent, +}: MVideoProps) { const videoInfo = content?.info; const mxcUrl = content.file?.url ?? content.url; const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? ''); @@ -502,6 +514,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 +524,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: style={{ flexGrow: 1, flexShrink: 0, + width: fitParent ? '100%' : toRem(displayWidth), + height: fitParent ? 400 : 'auto', }} outlined={outlined} > @@ -530,7 +545,9 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: {renderVideoContent({ @@ -580,8 +597,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 +622,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) { +export function MFile({ content, renderFileContent, outlined, fitParent }: MFileProps) { const fileInfo = content?.info; const mxcUrl = content.file?.url ?? content.url; @@ -648,7 +676,10 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) { } return ( - + ( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); + const [enableMediaGalleries] = useSetting(settingsAtom, 'enableMediaGalleries'); useElementSizeObserver( useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), @@ -677,6 +680,44 @@ export const RoomInput = forwardRef( }; const handleSendUpload = async (uploads: UploadSuccess[]) => { + if (uploads.length >= 2 && enableMediaGalleries) { + 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; + + 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); + + const mentionData = getMentions(mx, roomId, editor); + if (replyDraft && replyDraft.userId !== mx.getUserId()) { + mentionData.users.add(replyDraft.userId); + } + const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room); + galleryContent['m.mentions'] = mMentions; + + if (replyDraft) { + galleryContent['m.relates_to'] = getReplyContent(replyDraft); + } + + await handleSendContents([galleryContent]); + return; + } const contentsPromises = uploads.map(async (upload) => { const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); @@ -735,6 +776,12 @@ export const RoomInput = forwardRef( const submit = useCallback(async () => { uploadBoardHandlers.current?.handleSend(); + if (selectedFiles.length >= 2) { + resetEditor(editor); + resetEditorHistory(editor); + sendTypingStatus(false); + return; + } const commandName = getBeginCommand(editor); /** @@ -1124,6 +1171,7 @@ export const RoomInput = forwardRef( setScheduledTime, setServerMaxDelayMs, replyDraftBase, + selectedFiles, ]); const handleKeyDown: KeyboardEventHandler = useCallback( diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 2d3f109016..d8da4262fb 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -1,7 +1,8 @@ import type { IContent, MatrixClient } from '$types/matrix-sdk'; import { MsgType } from '$types/matrix-sdk'; import to from 'await-to-js'; -import type { IThumbnailContent } from '$types/matrix/common'; +import type { IGalleryItem } from '$types/matrix/common'; +import { GALLERY_MSGTYPE, type IThumbnailContent } from '$types/matrix/common'; import { getImageFileUrl, getThumbnail, @@ -288,3 +289,52 @@ export const getGifMsgContent = async ( return content; }; + +const swapMsgTypeToItemType = ( + content: IContent, + itemtype: IGalleryItem['itemtype'] +): IGalleryItem => { + 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/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 0310e8f402..cafa7f1248 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -201,7 +201,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[]; +};