From c286501be8adb564aaaaa85b0511ea9d86738961 Mon Sep 17 00:00:00 2001 From: litruv Date: Wed, 22 Jul 2026 20:00:14 +1000 Subject: [PATCH] Improve timeline scroll, media layout, avatars, and emoji coverage. Remember room scroll position across switches, reserve image/video heights from Matrix dimensions, cache authenticated media blobs for avatars, restore jumbo emoji-only messages, and extend emoji font unicode-range for Unicode 15 glyphs. --- package.json | 2 +- .../components/message/MsgTypeRenderers.tsx | 25 ++- .../message/content/ImageContent.tsx | 75 ++++++-- .../message/content/VideoContent.tsx | 75 ++++++-- .../components/message/content/style.css.ts | 75 +++++++- .../components/message/layout/layout.css.ts | 37 +++- src/app/components/room-avatar/RoomAvatar.tsx | 91 ++++++++-- src/app/components/user-avatar/UserAvatar.tsx | 110 ++++++++---- src/app/features/room/RoomTimeline.tsx | 139 ++++++++++++++- src/app/features/room/msgContent.ts | 6 +- src/app/hooks/useAuthenticatedMediaUrl.ts | 123 ++++--------- src/app/state/mediaDimensionCache.ts | 168 ++++++++++++++++++ src/app/state/roomScrollCache.ts | 32 ++++ src/app/styles/CustomHtml.css.ts | 27 ++- src/app/utils/authenticatedMediaCache.ts | 87 +++++++++ src/app/utils/common.ts | 17 ++ src/index.css | 9 +- 17 files changed, 885 insertions(+), 213 deletions(-) create mode 100644 src/app/state/mediaDimensionCache.ts create mode 100644 src/app/state/roomScrollCache.ts create mode 100644 src/app/utils/authenticatedMediaCache.ts diff --git a/package.json b/package.json index a7580d4..547bcbb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": ">=16.0.0" }, "scripts": { - "start": "vite --open false", + "start": "vite", "build": "vite build", "lint": "yarn check:eslint && yarn check:prettier", "check:eslint": "eslint src/*", diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index f86323e..31d7033 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -28,6 +28,7 @@ import { } from '../../../types/matrix/common'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { parseGeoUri, scaleYDimension } from '../../utils/common'; +import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { FileHeader, FileDownloadButton } from './FileHeader'; @@ -199,6 +200,13 @@ type MImageProps = { renderImageContent: (props: RenderImageContentProps) => ReactNode; outlined?: boolean; }; + +const resolveMediaBoxSize = ( + mxcUrl: string, + w?: number, + h?: number +): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h); + export function MImage({ content, renderImageContent, outlined }: MImageProps) { const imgInfo = content?.info; const mxcUrl = content.file?.url ?? content.url; @@ -206,9 +214,14 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) { return ; } + const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h); + return ( - + {renderImageContent({ body: content.body || 'Image', info: imgInfo, @@ -251,6 +264,11 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: } const filename = content.filename ?? content.body ?? 'Video'; + const { width, height } = resolveMediaBoxSize( + mxcUrl, + videoInfo.w ?? videoInfo.thumbnail_info?.w, + videoInfo.h ?? videoInfo.thumbnail_info?.h + ); return ( @@ -268,7 +286,10 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: } /> - + {renderVideoContent({ body: content.body || 'Video', info: videoInfo, diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 14abf3a..0f3520f 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -2,7 +2,7 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Icon, Icons } from '../../icons'; import classNames from 'classnames'; -import { BlurhashCanvas } from 'react-blurhash'; +import { Blurhash } from 'react-blurhash'; import FocusTrap from 'focus-trap-react'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common'; @@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ModalWide } from '../../../styles/Modal.css'; import { validBlurHash } from '../../../utils/blurHash'; import { getCurrentAccessToken } from '../../../utils/auth'; +import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache'; /** * Fetches media with authentication headers and returns a blob URL. @@ -68,7 +69,7 @@ type RenderImageProps = { alt: string; title: string; src: string; - onLoad: () => void; + onLoad: (event?: React.SyntheticEvent) => void; onError: () => void; onClick: () => void; tabIndex: number; @@ -105,12 +106,23 @@ export const ImageContent = as<'div', ImageContentProps>( ) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); + const eventBlurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); + const cachedBlurHash = getMediaBlurHash(url); + const blurHash = eventBlurHash ?? cachedBlurHash; + const cachedDims = getMediaDimensions(url); + const blurResolutionX = 32; + const ratioW = info?.w || cachedDims?.w; + const ratioH = info?.h || cachedDims?.h; + const blurResolutionY = + ratioW && ratioH && ratioW > 0 + ? Math.max(1, Math.round(blurResolutionX * (ratioH / ratioW))) + : 32; const [load, setLoad] = useState(false); const [error, setError] = useState(false); const [viewer, setViewer] = useState(false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); + const [showPlaceholder, setShowPlaceholder] = useState(true); const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { @@ -139,6 +151,7 @@ export const ImageContent = as<'div', ImageContentProps>( const handleError = () => { setLoad(false); setError(true); + setShowPlaceholder(true); }; const handleRetry = () => { @@ -150,8 +163,34 @@ export const ImageContent = as<'div', ImageContentProps>( if (autoPlay) loadSrc(); }, [autoPlay, loadSrc]); + // Keep blurhash under the fade, then remove it once the image is fully opaque. + useEffect(() => { + if (!load) { + setShowPlaceholder(true); + return undefined; + } + const timer = window.setTimeout(() => setShowPlaceholder(false), 540); + return () => window.clearTimeout(timer); + }, [load]); + return ( + {showPlaceholder && + (typeof blurHash === 'string' ? ( +
+ +
+ ) : ( +
+ ))} {srcState.status === AsyncStatus.Success && ( }> @@ -177,15 +216,6 @@ export const ImageContent = as<'div', ImageContentProps>( )} - {typeof blurHash === 'string' && !load && ( - - )} {!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (