From 55a9032656965368fe152b84b9527d099aa0da2d Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:03:14 +0000 Subject: [PATCH 1/5] Add gallery support Co-Authored-By: Tomasz Sterna --- src/app/components/RenderMessageContent.tsx | 66 ++++++++- src/app/components/media/media.css.ts | 2 +- src/app/components/message/MGallery.css.ts | 62 ++++++++ src/app/components/message/MGallery.tsx | 134 ++++++++++++++++++ .../components/message/MsgTypeRenderers.tsx | 49 +++++-- src/app/components/message/index.ts | 1 + src/app/features/room/RoomInput.tsx | 48 +++++++ src/app/features/room/msgContent.ts | 52 ++++++- .../settings/experimental/Experimental.tsx | 2 + .../experimental/MSC4274MediaGalleries.tsx | 39 +++++ src/app/features/settings/settingsLink.ts | 7 +- src/app/state/settings.ts | 2 + src/types/matrix/common.ts | 20 +++ 13 files changed, 469 insertions(+), 15 deletions(-) create mode 100644 src/app/components/message/MGallery.css.ts create mode 100644 src/app/components/message/MGallery.tsx create mode 100644 src/app/features/settings/experimental/MSC4274MediaGalleries.tsx diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index e4e6c64301..f1a50ed5f4 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,18 @@ function RenderMessageContentInternal({ style={{ display: 'flex', flexDirection: attachmentDirection, + width: '100%', + height: '100%', }} > -
{attachment}
+
+ {attachment} +
{renderCaption()} ); @@ -272,6 +285,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 +453,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..f673754e04 --- /dev/null +++ b/src/app/components/message/MGallery.css.ts @@ -0,0 +1,62 @@ +import { recipe } from '@vanilla-extract/recipes'; +import { style } from '@vanilla-extract/css'; +import { DefaultReset, color, config, toRem } from 'folds'; + +export const GalleryHolder = style({ + position: 'relative', + marginTop: config.space.S200, +}); + +export const GalleryItem = style({ + width: toRem(300), + height: toRem(200), + flexShrink: 0, + overflow: 'hidden', + borderRadius: config.radii.R300, +}); + +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..381bd77d38 --- /dev/null +++ b/src/app/components/message/MGallery.tsx @@ -0,0 +1,134 @@ +import type { ReactNode } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Icon, IconButton, Icons, Scroll } from 'folds'; +import type { IContent } from 'matrix-js-sdk'; +import type { IGalleryContent, IGalleryItem } from '$types/matrix/common'; +import { + getIntersectionObserverEntry, + useIntersectionObserver, +} from '$hooks/useIntersectionObserver'; +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; +}; + +export function MGallery({ content, renderItem, renderCaption }: MGalleryProps) { + const scrollRef = useRef(null); + const backAnchorRef = useRef(null); + const frontAnchorRef = useRef(null); + const [backVisible, setBackVisible] = useState(true); + const [frontVisible, setFrontVisible] = useState(true); + + const intersectionObserver = useIntersectionObserver( + useCallback((entries) => { + const backAnchor = backAnchorRef.current; + const frontAnchor = frontAnchorRef.current; + const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries); + const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries); + if (backEntry) { + setBackVisible(backEntry.isIntersecting); + } + if (frontEntry) { + setFrontVisible(frontEntry.isIntersecting); + } + }, []), + useCallback( + () => ({ + root: scrollRef.current, + rootMargin: '10px', + }), + [] + ) + ); + + useEffect(() => { + const backAnchor = backAnchorRef.current; + const frontAnchor = frontAnchorRef.current; + if (backAnchor) intersectionObserver?.observe(backAnchor); + if (frontAnchor) intersectionObserver?.observe(frontAnchor); + return () => { + if (backAnchor) intersectionObserver?.unobserve(backAnchor); + if (frontAnchor) intersectionObserver?.unobserve(frontAnchor); + }; + }, [intersectionObserver]); + + const handleScrollBack = () => { + const scroll = scrollRef.current; + if (!scroll) return; + const { offsetWidth, scrollLeft } = scroll; + scroll.scrollTo({ + left: scrollLeft - offsetWidth / 1.3, + behavior: 'smooth', + }); + }; + const handleScrollFront = () => { + const scroll = scrollRef.current; + if (!scroll) return; + const { offsetWidth, scrollLeft } = scroll; + scroll.scrollTo({ + left: scrollLeft + offsetWidth / 1.3, + behavior: 'smooth', + }); + }; + + const items = content.itemtypes; + + return ( + + + + +
+ {!backVisible && ( + <> +
+ + + + + )} + + {items.map((item, index) => ( +
+ {renderItem(galleryItemToContent(item), index)} +
+ ))} + {!frontVisible && ( + <> +
+ + + + + )} +
+ + + + + {renderCaption?.()} + + ); +} diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 7c7d81f686..17fd09db11 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,23 @@ 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 +493,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 +513,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 +523,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: style={{ flexGrow: 1, flexShrink: 0, + width: fitParent ? '100%' : toRem(displayWidth), + height: fitParent ? '100%' : 'auto', }} outlined={outlined} > @@ -530,7 +544,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: {renderVideoContent({ @@ -580,8 +595,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 +620,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 +674,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[]; +}; From 2b0139fc800b85716ee64e07c45c26036c1d480b Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:06:53 +0000 Subject: [PATCH 2/5] Add changeset --- .changeset/feat_add_gallery_support.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/feat_add_gallery_support.md 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 From c9d6cc543abff86262468e736c2cf9938b0f802f Mon Sep 17 00:00:00 2001 From: Shea Date: Wed, 1 Jul 2026 12:15:52 +0000 Subject: [PATCH 3/5] Make items keep aspect ratio --- src/app/components/media/media.css.ts | 2 +- src/app/components/message/MGallery.css.ts | 26 +++++++++++++++++----- src/app/components/message/MGallery.tsx | 7 ++++-- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/app/components/media/media.css.ts b/src/app/components/media/media.css.ts index a176fef027..4253b52d25 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: 'cover', + objectFit: 'contain', width: '100%', height: '100%', }, diff --git a/src/app/components/message/MGallery.css.ts b/src/app/components/message/MGallery.css.ts index f673754e04..74061538ab 100644 --- a/src/app/components/message/MGallery.css.ts +++ b/src/app/components/message/MGallery.css.ts @@ -7,12 +7,26 @@ export const GalleryHolder = style({ marginTop: config.space.S200, }); -export const GalleryItem = style({ - width: toRem(300), - height: toRem(200), - flexShrink: 0, - overflow: 'hidden', - borderRadius: config.radii.R300, +export const GalleryItem = recipe({ + base: [ + DefaultReset, + { + maxWidth: toRem(450), + flexShrink: 0, + overflow: 'hidden', + borderRadius: config.radii.R300, + }, + ], + variants: { + isImage: { + true: { + height: toRem(300), + }, + false: { + maxHeight: toRem(300), + }, + }, + }, }); export const GalleryHolderGradient = recipe({ diff --git a/src/app/components/message/MGallery.tsx b/src/app/components/message/MGallery.tsx index 381bd77d38..2a917a108a 100644 --- a/src/app/components/message/MGallery.tsx +++ b/src/app/components/message/MGallery.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Icon, IconButton, Icons, Scroll } from 'folds'; -import type { IContent } from 'matrix-js-sdk'; +import { MsgType, type IContent } from 'matrix-js-sdk'; import type { IGalleryContent, IGalleryItem } from '$types/matrix/common'; import { getIntersectionObserverEntry, @@ -104,7 +104,10 @@ export function MGallery({ content, renderItem, renderCaption }: MGalleryProps) )} {items.map((item, index) => ( -
+
{renderItem(galleryItemToContent(item), index)}
))} From ea9a392982a4fdbb7d3b174f51a8da141de181c5 Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:13:23 +0000 Subject: [PATCH 4/5] WIP css changes --- src/app/components/RenderMessageContent.tsx | 8 +++----- src/app/components/message/MGallery.css.ts | 15 ++++----------- src/app/components/message/MGallery.tsx | 5 +---- src/app/components/message/MsgTypeRenderers.tsx | 9 +++++---- 4 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index f1a50ed5f4..f6af3da09f 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -10,7 +10,7 @@ import { useSetting } from '$state/hooks/settings'; import { settingsAtom, CaptionPosition } from '$state/settings'; import type { HTMLReactParserOptions } from 'html-react-parser'; import type { Opts } from 'linkifyjs'; -import { Box, config } from 'folds'; +import { Box, config, toRem } from 'folds'; import { AudioContent, DownloadFile, @@ -264,14 +264,12 @@ function RenderMessageContentInternal({ style={{ display: 'flex', flexDirection: attachmentDirection, - width: '100%', - height: '100%', }} >
{attachment} diff --git a/src/app/components/message/MGallery.css.ts b/src/app/components/message/MGallery.css.ts index 74061538ab..40318150b3 100644 --- a/src/app/components/message/MGallery.css.ts +++ b/src/app/components/message/MGallery.css.ts @@ -3,7 +3,6 @@ import { style } from '@vanilla-extract/css'; import { DefaultReset, color, config, toRem } from 'folds'; export const GalleryHolder = style({ - position: 'relative', marginTop: config.space.S200, }); @@ -11,22 +10,16 @@ export const GalleryItem = recipe({ base: [ DefaultReset, { + display: 'flex', maxWidth: toRem(450), flexShrink: 0, + flexGrow: 1, overflow: 'hidden', borderRadius: config.radii.R300, + alignSelf: 'stretch', + backgroundColor: '#AA00AA', }, ], - variants: { - isImage: { - true: { - height: toRem(300), - }, - false: { - maxHeight: toRem(300), - }, - }, - }, }); export const GalleryHolderGradient = recipe({ diff --git a/src/app/components/message/MGallery.tsx b/src/app/components/message/MGallery.tsx index 2a917a108a..5686f04ad2 100644 --- a/src/app/components/message/MGallery.tsx +++ b/src/app/components/message/MGallery.tsx @@ -104,10 +104,7 @@ export function MGallery({ content, renderItem, renderCaption }: MGalleryProps) )} {items.map((item, index) => ( -
+
{renderItem(galleryItemToContent(item), index)}
))} diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 17fd09db11..71a3add1fc 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -447,13 +447,14 @@ export function MImage({ content, renderImageContent, outlined, fitParent }: MIm // 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 ( @@ -461,7 +462,7 @@ export function MImage({ content, renderImageContent, outlined, fitParent }: MIm style={{ flexGrow: 1, aspectRatio, - width: fitParent ? '100%' : toRem(displayWidth), + width: fitParent ? 'auto' : toRem(displayWidth), height: fitParent ? '100%' : toRem(height < 48 ? 48 : height), }} > @@ -524,7 +525,7 @@ export function MVideo({ flexGrow: 1, flexShrink: 0, width: fitParent ? '100%' : toRem(displayWidth), - height: fitParent ? '100%' : 'auto', + height: fitParent ? 400 : 'auto', }} outlined={outlined} > @@ -544,7 +545,7 @@ export function MVideo({ From 2dd1dc2ba3dc0f48802dd0d82aab61aeec645c5e Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:30:24 +0000 Subject: [PATCH 5/5] Change gallery to be a grid --- src/app/components/RenderMessageContent.tsx | 15 +- src/app/components/media/media.css.ts | 2 +- src/app/components/message/MGallery.css.ts | 49 ++++- src/app/components/message/MGallery.tsx | 206 +++++++++--------- .../components/message/MsgTypeRenderers.tsx | 7 +- 5 files changed, 154 insertions(+), 125 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index f6af3da09f..acb16fa128 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -10,7 +10,7 @@ import { useSetting } from '$state/hooks/settings'; import { settingsAtom, CaptionPosition } from '$state/settings'; import type { HTMLReactParserOptions } from 'html-react-parser'; import type { Opts } from 'linkifyjs'; -import { Box, config, toRem } from 'folds'; +import { Box, config } from 'folds'; import { AudioContent, DownloadFile, @@ -264,16 +264,11 @@ function RenderMessageContentInternal({ style={{ display: 'flex', flexDirection: attachmentDirection, + height: '100%', + width: '100%', }} > -
- {attachment} -
+ {attachment} {renderCaption()}
); @@ -283,7 +278,7 @@ function RenderMessageContentInternal({ renderCaptionedAttachment( & { msgtype: MsgType.File }} - fitParent={isGallery} + //fitParent={isGallery} renderFileContent={({ body, mimeType, info, encInfo, url }) => ( ReactNode; }; +type PartitionedMediaItem = { + items: (IGalleryImageItem | IGalleryVideoItem)[]; + type: 'ThreeByThree' | 'TwoByTwo' | 'OneByOne' | 'ThreeItems'; +}; + export function MGallery({ content, renderItem, renderCaption }: MGalleryProps) { - const scrollRef = useRef(null); - const backAnchorRef = useRef(null); - const frontAnchorRef = useRef(null); - const [backVisible, setBackVisible] = useState(true); - const [frontVisible, setFrontVisible] = useState(true); + const items = content.itemtypes; - const intersectionObserver = useIntersectionObserver( - useCallback((entries) => { - const backAnchor = backAnchorRef.current; - const frontAnchor = frontAnchorRef.current; - const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries); - const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries); - if (backEntry) { - setBackVisible(backEntry.isIntersecting); - } - if (frontEntry) { - setFrontVisible(frontEntry.isIntersecting); - } - }, []), - useCallback( - () => ({ - root: scrollRef.current, - rootMargin: '10px', - }), - [] - ) + 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 ); - useEffect(() => { - const backAnchor = backAnchorRef.current; - const frontAnchor = frontAnchorRef.current; - if (backAnchor) intersectionObserver?.observe(backAnchor); - if (frontAnchor) intersectionObserver?.observe(frontAnchor); - return () => { - if (backAnchor) intersectionObserver?.unobserve(backAnchor); - if (frontAnchor) intersectionObserver?.unobserve(frontAnchor); - }; - }, [intersectionObserver]); - - const handleScrollBack = () => { - const scroll = scrollRef.current; - if (!scroll) return; - const { offsetWidth, scrollLeft } = scroll; - scroll.scrollTo({ - left: scrollLeft - offsetWidth / 1.3, - behavior: 'smooth', - }); - }; - const handleScrollFront = () => { - const scroll = scrollRef.current; - if (!scroll) return; - const { offsetWidth, scrollLeft } = scroll; - scroll.scrollTo({ - left: scrollLeft + offsetWidth / 1.3, - behavior: 'smooth', - }); - }; + let partitionedMediaItems: PartitionedMediaItem[] = []; - const items = content.itemtypes; + 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 ( - - - - -
- {!backVisible && ( - <> -
- + + + {partitionedMediaItems.map((item) => ( +
+ {item.items.map((mediaItem, index) => ( +
- - - - )} - - {items.map((item, index) => ( -
- {renderItem(galleryItemToContent(item), index)} + {renderItem(galleryItemToContent(mediaItem), index)}
))} - {!frontVisible && ( - <> -
- - - - - )} -
- - - +
+ ))} + + + {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 71a3add1fc..e703aa2523 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -454,15 +454,15 @@ export function MImage({ content, renderImageContent, outlined, fitParent }: MIm flexGrow: 1, flexShrink: 0, width: fitParent ? '100%' : toRem(displayWidth), - height: fitParent ? MAX_SIZE : 'auto', + height: fitParent ? '100%' : 'auto', }} outlined={outlined} > @@ -545,6 +545,7 @@ export function MVideo({