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>(
)}
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[];
+};