From fcf27657d8bea1e6793905d9291dc3df5132b172 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 01/10] 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 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[]; +}; From 23926d944963690e6aeb7f15afad2fef4b536218 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 02/10] 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 4db5a4203371c1094b169c86c2b4ed2d89cfffe2 Mon Sep 17 00:00:00 2001 From: Shea Date: Wed, 1 Jul 2026 12:15:52 +0000 Subject: [PATCH 03/10] 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 2d3b5af26f2014a07a663f8f41e76c1ac4686609 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 04/10] 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 29489638bcec0da204acbc2c6150ca730ddf9d11 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 05/10] 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({ Date: Sat, 4 Jul 2026 09:50:42 +0000 Subject: [PATCH 06/10] Fix re-rendering --- src/app/components/RenderMessageContent.tsx | 22 +++---- src/app/components/message/MGallery.css.ts | 48 +--------------- src/app/components/message/MGallery.tsx | 57 +++++++++---------- .../components/message/MsgTypeRenderers.tsx | 23 +++----- .../message/content/VideoContent.tsx | 5 +- 5 files changed, 50 insertions(+), 105 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index acb16fa128..bd9573660d 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -258,7 +258,7 @@ function RenderMessageContentInternal({ return null; }; - function renderCaptionedAttachment(attachment: JSX.Element): JSX.Element { + function renderCaptionedAttachment(attachment: JSX.Element, isInGallery?: boolean): JSX.Element { return (
{attachment} - {renderCaption()} + {!isInGallery && renderCaption()}
); } @@ -278,7 +279,6 @@ function RenderMessageContentInternal({ renderCaptionedAttachment( & { msgtype: MsgType.File }} - //fitParent={isGallery} renderFileContent={({ body, mimeType, info, encInfo, url }) => ( )} outlined={outlineAttachment} - /> + />, + isGallery ); } @@ -403,7 +404,6 @@ function RenderMessageContentInternal({ & { msgtype: MsgType.Video }} renderAsFile={renderFile} - fitParent={isGallery} renderVideoContent={({ body, info, ...videoProps }) => ( )} outlined={outlineAttachment} - /> + />, + isGallery ); } @@ -448,12 +449,11 @@ function RenderMessageContentInternal({ return ; if (msgType === GALLERY_MSGTYPE) { - const galleryContent = getContent() as IGalleryContent; return ( ( - )} renderCaption={ - galleryContent.body + content.body ? () => ( ( 0) { if (mediaItems.length >= 9) { partitionedMediaItems.unshift({ @@ -95,36 +94,36 @@ export function MGallery({ content, renderItem, renderCaption }: MGalleryProps) continue; } } - return ( - - {partitionedMediaItems.map((item) => ( -
- {item.items.map((mediaItem, index) => ( -
- {renderItem(galleryItemToContent(mediaItem), index)} -
- ))} -
- ))} -
- - {columnItems.map((item, index) => ( -
- {renderItem(galleryItemToContent(item), index)} -
- ))} -
+ {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)} +
+ ))} +
+ )}
{renderCaption?.()}
diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index e703aa2523..68eb37e2d0 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -496,13 +496,7 @@ type MVideoProps = { outlined?: boolean; fitParent?: boolean; }; -export function MVideo({ - content, - renderAsFile, - renderVideoContent, - outlined, - fitParent, -}: MVideoProps) { +export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) { const videoInfo = content?.info; const mxcUrl = content.file?.url ?? content.url; const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? ''); @@ -524,8 +518,8 @@ export function MVideo({ style={{ flexGrow: 1, flexShrink: 0, - width: fitParent ? '100%' : toRem(displayWidth), - height: fitParent ? 400 : 'auto', + width: toRem(displayWidth), + height: 'auto', }} outlined={outlined} > @@ -546,8 +540,8 @@ export function MVideo({ {renderVideoContent({ @@ -667,7 +661,7 @@ type MFileProps = { outlined?: boolean; fitParent?: boolean; }; -export function MFile({ content, renderFileContent, outlined, fitParent }: MFileProps) { +export function MFile({ content, renderFileContent, outlined }: MFileProps) { const fileInfo = content?.info; const mxcUrl = content.file?.url ?? content.url; @@ -676,10 +670,7 @@ export function MFile({ content, renderFileContent, outlined, fitParent }: MFile } return ( - + ( ( setBlurred(false); } else setBlurred(!blurred); }} - /> + > + {menuIcon(blurred ? Eye : EyeSlash)} + )} From 7ebf12fc75687b41373a6fa8914daa33d859a2bf Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:44:22 +0000 Subject: [PATCH 07/10] Rendering changes --- src/app/components/RenderMessageContent.tsx | 29 ++-------- src/app/components/message/MGallery.css.ts | 5 +- src/app/components/message/MGallery.tsx | 62 ++++++++++----------- 3 files changed, 36 insertions(+), 60 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index bd9573660d..d3399a9f9f 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, Tooltip, TooltipProvider } from 'folds'; import { AudioContent, DownloadFile, @@ -217,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 ( @@ -449,7 +450,7 @@ function RenderMessageContentInternal({ return ; if (msgType === GALLERY_MSGTYPE) { - return ( + return renderCaptionedAttachment( ( @@ -467,26 +468,6 @@ function RenderMessageContentInternal({ isGallery={true} /> )} - renderCaption={ - content.body - ? () => ( - ( - - )} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} - /> - ) - : undefined - } /> ); } diff --git a/src/app/components/message/MGallery.css.ts b/src/app/components/message/MGallery.css.ts index 2575cdb198..c0dc173f13 100644 --- a/src/app/components/message/MGallery.css.ts +++ b/src/app/components/message/MGallery.css.ts @@ -22,7 +22,7 @@ export const GalleryImageGrid = recipe({ type: { ThreeItems: { gridTemplateColumns: '1fr 1fr', - gridTemplateRows: `repeat(2, ${toRem(200)})`, + gridTemplateRows: `repeat(2, 1fr)`, }, ThreeByThree: { gridTemplateColumns: 'repeat(3,minmax(0,1fr))', @@ -43,8 +43,7 @@ export const GalleryItem = recipe({ DefaultReset, { borderRadius: config.radii.R300, - minHeight: toRem(175), - minWidth: toRem(175), + overflow: 'hidden', width: '100%', height: '100%', aspectRatio: '1/1', diff --git a/src/app/components/message/MGallery.tsx b/src/app/components/message/MGallery.tsx index 23c26caf49..92605fa334 100644 --- a/src/app/components/message/MGallery.tsx +++ b/src/app/components/message/MGallery.tsx @@ -17,7 +17,6 @@ function galleryItemToContent(item: IGalleryItem): IContent { type MGalleryProps = { content: IGalleryContent; renderItem: (content: IContent, index: number) => ReactNode; - renderCaption?: () => ReactNode; }; type PartitionedMediaItem = { @@ -25,7 +24,7 @@ type PartitionedMediaItem = { type: 'ThreeByThree' | 'TwoByTwo' | 'OneByOne' | 'ThreeItems'; }; -export function MGallery({ content, renderItem, renderCaption }: MGalleryProps) { +export function MGallery({ content, renderItem }: MGalleryProps) { const items = content.itemtypes; let mediaItems = items.filter( @@ -95,37 +94,34 @@ export function MGallery({ content, renderItem, renderCaption }: MGalleryProps) } } 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)} -
- ))} -
- )} -
- {renderCaption?.()} + + {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)} +
+ ))} +
+ )}
); } From d800ae3fac650be706613e5f1dd7c719a6cb5e07 Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:48:06 +0000 Subject: [PATCH 08/10] Prefer body over filename in tooltip --- src/app/components/RenderMessageContent.tsx | 2 +- src/app/components/message/MsgTypeRenderers.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index d3399a9f9f..ab93f25241 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, Tooltip, TooltipProvider } from 'folds'; +import { Box, config } from 'folds'; import { AudioContent, DownloadFile, diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 68eb37e2d0..eb686aa075 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -467,7 +467,7 @@ export function MImage({ content, renderImageContent, outlined, fitParent }: MIm }} > {renderImageContent({ - body: content.filename || content.body || 'Image', + body: content.body || content.filename || 'Image', info: imgInfo, mimeType: imgInfo?.mimetype, url: mxcUrl, @@ -680,7 +680,7 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) { {renderFileContent({ - body: content.filename ?? content.body ?? 'File', + body: content.body ?? content.filename ?? 'File', info: fileInfo ?? {}, mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE, url: mxcUrl, From f2683fec71f3137541932a16d302e3d18d47ef63 Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:32:00 +0000 Subject: [PATCH 09/10] Redesign uploads menu --- .../upload-board/UploadBoard.css.ts | 21 +----- .../components/upload-board/UploadBoard.tsx | 18 +---- src/app/components/upload-card/UploadCard.tsx | 36 +++++----- .../upload-card/UploadCardRenderer.tsx | 49 ++++--------- src/app/features/room/RoomInput.tsx | 68 +++++++++---------- 5 files changed, 72 insertions(+), 120 deletions(-) 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..4caa03f90f 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, @@ -452,6 +441,8 @@ export function UploadCardRenderer({ return ( @@ -553,30 +544,16 @@ export function UploadCardRenderer({ /> )} {!isDescribed && fileItem.body && fileItem.body.length > 0 && ( - - - - - - - + + + + + )} } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index d3955dfba2..6b0215a49f 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1371,40 +1371,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) => ( - - ))} - - - )} - - )} } @@ -1496,6 +1462,40 @@ 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 && (
Date: Sun, 5 Jul 2026 11:03:40 +0000 Subject: [PATCH 10/10] Add the ability to send individual attachment text as caption --- .../upload-card/UploadCardRenderer.tsx | 14 +-- src/app/features/room/RoomInput.tsx | 96 +++++++++++-------- src/app/features/settings/general/General.tsx | 18 ++++ src/app/state/settings.ts | 2 + 4 files changed, 86 insertions(+), 44 deletions(-) diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index 4caa03f90f..c00058fb5f 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -57,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} @@ -79,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} @@ -366,6 +366,7 @@ type UploadCardRendererProps = { onRemove: (file: TUploadContent) => void; onComplete?: (upload: UploadSuccess) => void; roomId: string; + hideCaption?: boolean; }; export function UploadCardRenderer({ isEncrypted, @@ -375,6 +376,7 @@ export function UploadCardRenderer({ onRemove, onComplete, roomId, + hideCaption, }: Readonly) { const mx = useMatrixClient(); const mediaConfig = useMediaConfig(); @@ -458,7 +460,7 @@ export function UploadCardRenderer({ Retry )} - {!isDescribed && ( + {!isDescribed && !hideCaption && ( { setIsDescribed(true); @@ -471,7 +473,7 @@ export function UploadCardRenderer({ {sizedIcon(PencilSimple, '50')} )} - {isDescribed && ( + {isDescribed && !hideCaption && ( )} - {isDescribed && ( + {isDescribed && !hideCaption && ( { @@ -543,7 +545,7 @@ export function UploadCardRenderer({ onCancel={() => setIsDescribed(false)} /> )} - {!isDescribed && fileItem.body && fileItem.body.length > 0 && ( + {!isDescribed && !hideCaption && fileItem.body && fileItem.body.length > 0 && ( ( const isEncrypted = room.hasEncryptionStateEvent(); const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); const [enableMediaGalleries] = useSetting(settingsAtom, 'enableMediaGalleries'); + const [sendIndividualAttachmentAsCaption] = useSetting( + settingsAtom, + 'sendIndividualAttachmentAsCaption' + ); useElementSizeObserver( useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), @@ -679,19 +683,52 @@ 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[]) => { - 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 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 (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; + } + + 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'); @@ -704,35 +741,10 @@ export const RoomInput = forwardRef( 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'); - - 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 contentsPromises = uploads.map(uploadToContent); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); @@ -776,7 +788,10 @@ export const RoomInput = forwardRef( const submit = useCallback(async () => { uploadBoardHandlers.current?.handleSend(); - if (selectedFiles.length >= 2) { + if ( + (selectedFiles.length >= 2 && enableMediaGalleries) || + (selectedFiles.length == 1 && sendIndividualAttachmentAsCaption) + ) { resetEditor(editor); resetEditorHistory(editor); sendTypingStatus(false); @@ -1172,6 +1187,8 @@ export const RoomInput = forwardRef( setServerMaxDelayMs, replyDraftBase, selectedFiles, + enableMediaGalleries, + sendIndividualAttachmentAsCaption, ]); const handleKeyDown: KeyboardEventHandler = useCallback( @@ -1489,6 +1506,9 @@ export const RoomInput = forwardRef( onRemove={handleRemoveUpload} setDesc={setDesc} roomId={roomId} + hideCaption={ + selectedFiles.length == 1 && sendIndividualAttachmentAsCaption + } /> ))} 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/state/settings.ts b/src/app/state/settings.ts index c813c9a9c5..26ddd14767 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -226,6 +226,7 @@ export interface Settings { vcmsgSidebarWidth: number; widgetSidebarWidth: number; isShowingAllRoomsInHome: boolean; + sendIndividualAttachmentAsCaption: boolean; // furry stuff renderAnimals: boolean; @@ -388,6 +389,7 @@ export const defaultSettings: Settings = { vcmsgSidebarWidth: 399, widgetSidebarWidth: 420, isShowingAllRoomsInHome: false, + sendIndividualAttachmentAsCaption: true, // furry stuff renderAnimals: true,