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