diff --git a/package.json b/package.json
index a7580d4..97ba576 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"node": ">=16.0.0"
},
"scripts": {
- "start": "vite --open false",
+ "start": "vite --no-open",
"build": "vite build",
"lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*",
diff --git a/src/app/components/media/media.css.ts b/src/app/components/media/media.css.ts
index 406294a..99bd685 100644
--- a/src/app/components/media/media.css.ts
+++ b/src/app/components/media/media.css.ts
@@ -5,8 +5,11 @@ export const Image = style([
DefaultReset,
{
display: 'block',
+ width: '100%',
+ height: '100%',
maxWidth: '100%',
maxHeight: '100%',
+ objectFit: 'cover',
borderRadius: 'inherit',
imageRendering: 'auto',
backfaceVisibility: 'hidden',
@@ -18,7 +21,10 @@ export const Video = style([
DefaultReset,
{
display: 'block',
+ width: '100%',
+ height: '100%',
maxWidth: '100%',
maxHeight: '100%',
+ objectFit: 'cover',
},
]);
diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx
index 4ad3a7d..7f29d36 100644
--- a/src/app/components/message/MsgTypeRenderers.tsx
+++ b/src/app/components/message/MsgTypeRenderers.tsx
@@ -27,7 +27,8 @@ import {
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
-import { parseGeoUri, scaleYDimension } from '../../utils/common';
+import { parseGeoUri, scaleYDimension, fitMediaSize } from '../../utils/common';
+import { getMediaDimensions } from '../../state/mediaDimensionCache';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
@@ -204,6 +205,20 @@ type MImageProps = {
renderImageContent: (props: RenderImageContentProps) => ReactNode;
outlined?: boolean;
};
+
+const ATTACHMENT_MAX = 400;
+
+const resolveMediaBoxSize = (
+ mxcUrl: string,
+ w?: number,
+ h?: number
+): { width: number; height: number } => {
+ const cached = (!w || !h) ? getMediaDimensions(mxcUrl) : undefined;
+ const naturalW = w && w > 0 ? w : cached?.w;
+ const naturalH = h && h > 0 ? h : cached?.h;
+ return fitMediaSize(naturalW ?? 0, naturalH ?? 0, ATTACHMENT_MAX, ATTACHMENT_MAX);
+};
+
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
@@ -211,9 +226,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,
@@ -256,6 +276,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 (
@@ -273,7 +298,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..1891867 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), 300);
+ 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 && (
)}
{srcState.status === AsyncStatus.Success && (
-
+
{renderImage({
alt: body,
title: body,
src: srcState.data,
- onLoad: handleLoad,
+ onLoad: (e?: React.SyntheticEvent) => {
+ const img = e?.currentTarget;
+ if (img?.naturalWidth && img?.naturalHeight) {
+ setMediaDimensions(url, img.naturalWidth, img.naturalHeight);
+ }
+ if (img) rememberMediaBlurHash(url, img);
+ handleLoad();
+ },
onError: handleError,
onClick: () => setViewer(true),
tabIndex: 0,
@@ -248,7 +292,8 @@ export const ImageContent = as<'div', ImageContentProps>(
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load &&
- !blurred && (
+ !blurred &&
+ typeof blurHash !== 'string' && (
diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx
index 566ac54..9fcf31a 100644
--- a/src/app/components/message/content/VideoContent.tsx
+++ b/src/app/components/message/content/VideoContent.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 {
@@ -25,6 +25,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
import { ModalWide } from '../../../styles/Modal.css';
import { stopPropagation } from '../../../utils/keyboard';
+import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
type RenderViewerProps = {
src: string;
@@ -34,7 +35,7 @@ type RenderViewerProps = {
type RenderVideoProps = {
title: string;
src: string;
- onLoadedMetadata: () => void;
+ onLoadedMetadata: (event?: React.SyntheticEvent) => void;
onError: () => void;
autoPlay: boolean;
controls: boolean;
@@ -74,12 +75,23 @@ export const VideoContent = as<'div', VideoContentProps>(
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
- const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
+ const eventBlurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
+ const cachedBlurHash = getMediaBlurHash(url);
+ const blurHash = eventBlurHash ?? cachedBlurHash;
+ const cachedDims = getMediaDimensions(url);
+ const blurResolutionX = 32;
+ const blurW = info.w ?? info.thumbnail_info?.w ?? cachedDims?.w;
+ const blurH = info.h ?? info.thumbnail_info?.h ?? cachedDims?.h;
+ const blurResolutionY =
+ blurW && blurH && blurW > 0
+ ? Math.max(1, Math.round(blurResolutionX * (blurH / blurW)))
+ : 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 () => {
@@ -95,12 +107,17 @@ export const VideoContent = as<'div', VideoContentProps>(
}, [mx, url, useAuthentication, mimeType, encInfo])
);
- const handleLoad = () => {
+ const handleLoad = (video?: HTMLVideoElement) => {
+ if (video?.videoWidth && video?.videoHeight) {
+ setMediaDimensions(url, video.videoWidth, video.videoHeight);
+ }
+ if (video) rememberMediaBlurHash(url, video);
setLoad(true);
};
const handleError = () => {
setLoad(false);
setError(true);
+ setShowPlaceholder(true);
};
const handleRetry = () => {
@@ -112,8 +129,33 @@ export const VideoContent = as<'div', VideoContentProps>(
if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]);
+ useEffect(() => {
+ if (!load) {
+ setShowPlaceholder(true);
+ return undefined;
+ }
+ const timer = window.setTimeout(() => setShowPlaceholder(false), 300);
+ return () => window.clearTimeout(timer);
+ }, [load]);
+
return (
+ {showPlaceholder &&
+ (typeof blurHash === 'string' ? (
+
+
+
+ ) : (
+
+ ))}
{renderViewer && srcState.status === AsyncStatus.Success && (
}>
@@ -136,15 +178,6 @@ export const VideoContent = as<'div', VideoContentProps>(
)}
- {typeof blurHash === 'string' && !load && (
-
- )}
{renderThumbnail && !load && (
(
)}
{srcState.status === AsyncStatus.Success && (
-
+
{renderVideo({
title: body,
src: srcState.data,
- onLoadedMetadata: handleLoad,
+ onLoadedMetadata: (e?: React.SyntheticEvent) => {
+ handleLoad(e?.currentTarget);
+ },
onError: handleError,
autoPlay: true,
controls: true,
@@ -213,7 +255,8 @@ export const VideoContent = as<'div', VideoContentProps>(
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load &&
- !blurred && (
+ !blurred &&
+ typeof blurHash !== 'string' && (
diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts
index 7912f83..f777f9b 100644
--- a/src/app/components/message/content/style.css.ts
+++ b/src/app/components/message/content/style.css.ts
@@ -1,11 +1,13 @@
-import { style } from '@vanilla-extract/css';
-import { DefaultReset, config } from 'folds';
+import { keyframes, style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
export const RelativeBase = style([
DefaultReset,
{
position: 'relative',
display: 'flex',
+ width: '100%',
+ height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: '7px',
@@ -16,9 +18,13 @@ export const RelativeBase = style([
export const AbsoluteContainer = style([
DefaultReset,
{
+ position: 'absolute',
+ inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
+ width: '100%',
+ height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 'inherit',
@@ -34,6 +40,7 @@ export const AbsoluteFooter = style([
bottom: config.space.S100,
left: config.space.S100,
right: config.space.S100,
+ zIndex: 1,
},
]);
@@ -43,3 +50,56 @@ export const Blur = style([
filter: 'blur(44px)',
},
]);
+
+const shimmer = keyframes({
+ '0%': {
+ backgroundPosition: '200% 0',
+ },
+ '100%': {
+ backgroundPosition: '-200% 0',
+ },
+});
+
+/** Soft fallback when no blurhash is available. */
+export const MediaSkeleton = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ inset: 0,
+ borderRadius: 'inherit',
+ backgroundImage: `linear-gradient(
+ 90deg,
+ ${color.SurfaceVariant.Container} 0%,
+ ${color.SurfaceVariant.ContainerHover} 40%,
+ ${color.SurfaceVariant.Container} 80%
+ )`,
+ backgroundSize: '200% 100%',
+ animation: `${shimmer} 1.4s ease-in-out infinite`,
+ },
+]);
+
+/** Soft blurhash layer that fills the reserved media box. */
+export const BlurhashPlaceholder = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ inset: 0,
+ overflow: 'hidden',
+ borderRadius: 'inherit',
+ zIndex: 0,
+ // Soften the decode so it reads as color blobs; slight scale hides blur edges.
+ filter: 'blur(14px)',
+ transform: 'scale(1.06)',
+ },
+]);
+
+/** Media starts invisible and fades in once loaded over the placeholder. */
+export const MediaFadeIn = style({
+ zIndex: 1,
+ opacity: 0,
+ transition: 'opacity 280ms ease-out',
+});
+
+export const MediaFadeInLoaded = style({
+ opacity: 1,
+});
diff --git a/src/app/components/message/layout/Base.tsx b/src/app/components/message/layout/Base.tsx
index 19d4d7f..3bae631 100644
--- a/src/app/components/message/layout/Base.tsx
+++ b/src/app/components/message/layout/Base.tsx
@@ -29,7 +29,7 @@ export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...p
));
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
- ({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => {
+ ({ as: AsComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => {
const bodyClass = classNames(
css.MessageTextBody({ preWrap, jumboEmoji, emote }),
className
@@ -50,7 +50,7 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
return (
;
-/** Jumbo emoji-only messages: 100×100px (system unicode + custom mx-emoticons). */
+/** Jumbo emoji-only messages: 70×70px cells, glyph inset so fonts aren't cropped. */
const jumboEmojiSelector = '[data-jumbo-emoji="true"]';
+const jumboCell = toRem(70);
+const jumboGlyph = toRem(56);
globalStyle(jumboEmojiSelector, {
- fontSize: `${toRem(100)} !important`,
- lineHeight: `${toRem(100)} !important`,
+ fontSize: `${jumboGlyph} !important`,
+ lineHeight: '1 !important',
+});
+
+/**
+ * Linkify / parser wrappers are often a single child; flatten them so each
+ * emoticon participates in the flex row and wrapped rows get gap spacing.
+ */
+globalStyle(`${jumboEmojiSelector} > *`, {
+ display: 'contents',
+});
+
+/** Force
to wrap onto a new flex row in jumbo messages. */
+globalStyle(`${jumboEmojiSelector} br`, {
+ display: 'block !important',
+ flexBasis: '100%',
+ width: '100%',
+ height: '0 !important',
});
globalStyle(`${jumboEmojiSelector} .${htmlCss.EmoticonBase}`, {
- height: `${toRem(100)} !important`,
+ display: 'inline-flex !important',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: `${jumboCell} !important`,
+ height: `${jumboCell} !important`,
padding: '0 !important',
+ overflow: 'hidden',
verticalAlign: 'bottom',
+ flex: '0 0 auto',
});
globalStyle(`${jumboEmojiSelector} .${htmlCss.EmoticonBaseJumbo}`, {
- height: `${toRem(100)} !important`,
+ display: 'inline-flex !important',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: `${jumboCell} !important`,
+ height: `${jumboCell} !important`,
padding: '0 !important',
+ overflow: 'hidden',
verticalAlign: 'bottom',
+ flex: '0 0 auto',
});
globalStyle(`${jumboEmojiSelector} .${htmlCss.Emoticon.classNames.base}`, {
- fontSize: `${toRem(100)} !important`,
- height: `${toRem(100)} !important`,
- minWidth: `${toRem(100)} !important`,
- lineHeight: `${toRem(100)} !important`,
+ display: 'inline-flex !important',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: `${jumboGlyph} !important`,
+ width: `${jumboGlyph} !important`,
+ height: `${jumboGlyph} !important`,
+ minWidth: `${jumboGlyph} !important`,
+ lineHeight: '1 !important',
top: '0 !important',
+ overflow: 'visible',
});
globalStyle(`${jumboEmojiSelector} .${htmlCss.Emoticon.classNames.variants.jumbo.true}`, {
- fontSize: `${toRem(100)} !important`,
- height: `${toRem(100)} !important`,
- minWidth: `${toRem(100)} !important`,
- lineHeight: `${toRem(100)} !important`,
+ display: 'inline-flex !important',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: `${jumboGlyph} !important`,
+ width: `${jumboGlyph} !important`,
+ height: `${jumboGlyph} !important`,
+ minWidth: `${jumboGlyph} !important`,
+ lineHeight: '1 !important',
top: '0 !important',
+ overflow: 'visible',
});
globalStyle(`${jumboEmojiSelector} .${htmlCss.EmoticonImg.classNames.base}`, {
- height: `${toRem(100)} !important`,
- width: `${toRem(100)} !important`,
- maxHeight: `${toRem(100)} !important`,
- maxWidth: `${toRem(100)} !important`,
+ height: `${jumboGlyph} !important`,
+ width: `${jumboGlyph} !important`,
+ maxHeight: `${jumboGlyph} !important`,
+ maxWidth: `${jumboGlyph} !important`,
objectFit: 'contain',
});
globalStyle(`${jumboEmojiSelector} .${htmlCss.EmoticonImg.classNames.variants.jumbo.true}`, {
- height: `${toRem(100)} !important`,
- width: `${toRem(100)} !important`,
- maxHeight: `${toRem(100)} !important`,
- maxWidth: `${toRem(100)} !important`,
+ height: `${jumboGlyph} !important`,
+ width: `${jumboGlyph} !important`,
+ maxHeight: `${jumboGlyph} !important`,
+ maxWidth: `${jumboGlyph} !important`,
objectFit: 'contain',
});
diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx
index a02c82f..5306057 100644
--- a/src/app/components/room-avatar/RoomAvatar.tsx
+++ b/src/app/components/room-avatar/RoomAvatar.tsx
@@ -1,13 +1,23 @@
import { JoinRule } from 'matrix-js-sdk';
import { AvatarFallback, AvatarImage, color } from 'folds';
import { Icon, Icons } from '../icons';
-import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
+import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useEffect, useState } from 'react';
+import { Blurhash } from 'react-blurhash';
import * as css from './RoomAvatar.css';
import { joinRuleToIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
+import {
+ getCachedAuthenticatedMediaUrl,
+ isAuthenticatedMediaUrl,
+} from '../../utils/authenticatedMediaCache';
+import {
+ getMediaBlurHash,
+ getMediaDimensions,
+ rememberMediaBlurHash,
+} from '../../state/mediaDimensionCache';
type RoomAvatarProps = {
roomId: string;
@@ -17,19 +27,36 @@ type RoomAvatarProps = {
};
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
const [error, setError] = useState(false);
- const [loaded, setLoaded] = useState(() => isAvatarCached(src));
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
+ const blurHash = getMediaBlurHash(src ?? '');
+ const dims = getMediaDimensions(src ?? '');
+ const blobCached =
+ !!src &&
+ (!useAuthentication ||
+ !isAuthenticatedMediaUrl(src) ||
+ !!getCachedAuthenticatedMediaUrl(src));
+ const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
+
+ useEffect(() => {
+ if (
+ authenticatedSrc &&
+ (isAvatarCached(src) ||
+ (src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
+ ) {
+ setLoaded(true);
+ }
+ }, [authenticatedSrc, src, useAuthentication]);
const handleLoad: ReactEventHandler = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
setLoaded(true);
cacheAvatar(src);
pruneAvatarCache();
+ rememberMediaBlurHash(src ?? '', evt.currentTarget);
};
- // No src or error - show fallback only
- if (!authenticatedSrc || error) {
+ if (!src || error) {
return (
0
+ ? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
+ : 32;
+
return (
{renderFallback()}
-
setError(true)}
- onLoad={handleLoad}
- draggable={false}
- />
+ {blurHash && (
+
+
+
+ )}
+ {authenticatedSrc && (
+ setError(true)}
+ onLoad={handleLoad}
+ draggable={false}
+ />
+ )}
);
}
diff --git a/src/app/components/title-bar/TitleBar.tsx b/src/app/components/title-bar/TitleBar.tsx
index 75bab3a..1d367b9 100644
--- a/src/app/components/title-bar/TitleBar.tsx
+++ b/src/app/components/title-bar/TitleBar.tsx
@@ -1,7 +1,7 @@
import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { Box, Text, IconButton } from 'folds';
import { invoke } from '@tauri-apps/api/core';
-import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules, SyncState } from 'matrix-js-sdk';
+import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules, SyncState, IRoomTimelineData } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { WindowControls } from './WindowControls';
@@ -300,13 +300,30 @@ export function TitleBar({ mx, children }: TitleBarProps) {
useEffect(() => {
if (!mx) return;
- const update = () => setUpdateCounter(c => c + 1);
+ let unreadRaf: number | null = null;
+ const scheduleUnreadUpdate = () => {
+ if (unreadRaf !== null) return;
+ unreadRaf = requestAnimationFrame(() => {
+ unreadRaf = null;
+ setUpdateCounter((c) => c + 1);
+ });
+ };
- const handleTimelineEvent = (event: MatrixEvent, room: Room | undefined) => {
- update();
+ const handleTimelineEvent = (
+ event: MatrixEvent,
+ room: Room | undefined,
+ toStartOfTimeline: boolean | undefined,
+ _removed: boolean,
+ data: IRoomTimelineData
+ ) => {
+ // Historical sync/pagination floods this; only refresh chrome for live events.
+ if (data.liveEvent && !toStartOfTimeline) {
+ scheduleUnreadUpdate();
+ }
// Check if this is a new message we should notify about
if (!room || !event) return;
+ if (!data.liveEvent || toStartOfTimeline) return;
// Ignore if it's the currently active room
if (room.roomId === roomId) {
@@ -315,13 +332,11 @@ export function TitleBar({ mx, children }: TitleBarProps) {
// Ignore if it's our own message
if (event.getSender() === mx.getUserId()) {
- console.log('[TitleBar] Skipping: own message');
return;
}
// Only notify for actual messages
if (event.getType() !== 'm.room.message') {
- console.log('[TitleBar] Skipping: not a message event, type:', event.getType());
return;
}
@@ -330,7 +345,6 @@ export function TitleBar({ mx, children }: TitleBarProps) {
const eventTime = event.getTs();
const now = Date.now();
if (now - eventTime > 15000) {
- console.log('[TitleBar] Skipping: message too old', (now - eventTime) / 1000, 'seconds');
return; // More than 15 seconds old
}
@@ -343,7 +357,6 @@ export function TitleBar({ mx, children }: TitleBarProps) {
// If we found both events and the new event is not after the read marker, skip it
if (readUpToIndex !== -1 && eventIndex !== -1 && eventIndex <= readUpToIndex) {
- console.log('[TitleBar] Skipping: already read (eventIndex:', eventIndex, ', readUpToIndex:', readUpToIndex, ')');
return;
}
}
@@ -437,15 +450,16 @@ export function TitleBar({ mx, children }: TitleBarProps) {
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
- mx.on(RoomEvent.Receipt, update);
- mx.on(ClientEvent.Room, update);
+ mx.on(RoomEvent.Receipt, scheduleUnreadUpdate);
+ mx.on(ClientEvent.Room, scheduleUnreadUpdate);
return () => {
+ if (unreadRaf !== null) cancelAnimationFrame(unreadRaf);
mx.off(RoomEvent.Timeline, handleTimelineEvent);
- mx.off(RoomEvent.Receipt, update);
- mx.off(ClientEvent.Room, update);
+ mx.off(RoomEvent.Receipt, scheduleUnreadUpdate);
+ mx.off(ClientEvent.Room, scheduleUnreadUpdate);
};
- }, [mx, roomId, scheduleDismiss, notificationData, shouldNotifySpecialMessage]);
+ }, [mx, roomId, scheduleDismiss, notificationData, shouldNotifySpecialMessage, mDirects]);
// Clear notifications when user reads messages in a room
useEffect(() => {
diff --git a/src/app/components/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx
index 5eb90bd..f5b25d4 100644
--- a/src/app/components/user-avatar/UserAvatar.tsx
+++ b/src/app/components/user-avatar/UserAvatar.tsx
@@ -1,18 +1,21 @@
import { AvatarFallback, AvatarImage, color } from 'folds';
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
+import { Blurhash } from 'react-blurhash';
import * as css from './UserAvatar.css';
import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
-
-// Check if image is already in browser cache
-function isImageInBrowserCache(url: string): boolean {
- const img = new Image();
- img.src = url;
- return img.complete && img.naturalWidth > 0;
-}
+import {
+ getCachedAuthenticatedMediaUrl,
+ isAuthenticatedMediaUrl,
+} from '../../utils/authenticatedMediaCache';
+import {
+ getMediaBlurHash,
+ getMediaDimensions,
+ rememberMediaBlurHash,
+} from '../../state/mediaDimensionCache';
type UserAvatarProps = {
className?: string;
@@ -25,20 +28,29 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
const [error, setError] = useState(false);
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
-
- // Check both our cache and browser's native cache
- const [loaded, setLoaded] = useState(() => {
- if (isAvatarCached(src)) return true;
- if (authenticatedSrc && isImageInBrowserCache(authenticatedSrc)) {
- cacheAvatar(src);
- return true;
+ const blurHash = getMediaBlurHash(src ?? '');
+ const dims = getMediaDimensions(src ?? '');
+ const blobCached =
+ !!src &&
+ (!useAuthentication ||
+ !isAuthenticatedMediaUrl(src) ||
+ !!getCachedAuthenticatedMediaUrl(src));
+
+ const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
+
+ // When a cached blob URL appears, treat as ready (no refetch flash).
+ useEffect(() => {
+ if (
+ authenticatedSrc &&
+ (isAvatarCached(src) ||
+ (src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
+ ) {
+ setLoaded(true);
}
- return false;
- });
-
+ }, [authenticatedSrc, src, useAuthentication]);
+
const imgRef = useRef(null);
- // Also check on mount if image is already complete (browser cached)
useEffect(() => {
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setLoaded(true);
@@ -51,10 +63,10 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
setLoaded(true);
cacheAvatar(src);
pruneAvatarCache();
+ rememberMediaBlurHash(src ?? '', evt.currentTarget);
};
- // No src or error - show fallback only
- if (!authenticatedSrc || error) {
+ if (!src || error) {
return (
0
+ ? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
+ : 32;
+
return (
{renderFallback()}
-
setError(true)}
- onLoad={handleLoad}
- draggable={false}
- />
+ {blurHash && (
+
+
+
+ )}
+ {authenticatedSrc && (
+ setError(true)}
+ onLoad={handleLoad}
+ draggable={false}
+ />
+ )}
);
}
diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts
index 5b7cd14..dd44df3 100644
--- a/src/app/features/room/msgContent.ts
+++ b/src/app/features/room/msgContent.ts
@@ -59,10 +59,14 @@ export const getImageMsgContent = async (
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
if (imgEl) {
- const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
+ const naturalW = imgEl.naturalWidth || imgEl.width;
+ const naturalH = imgEl.naturalHeight || imgEl.height;
+ const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(naturalW, 512, naturalH));
content.info = {
...getImageInfo(imgEl, file),
+ w: naturalW,
+ h: naturalH,
[MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
};
}
diff --git a/src/app/hooks/useAuthenticatedMediaUrl.ts b/src/app/hooks/useAuthenticatedMediaUrl.ts
index 0570d00..be6260f 100644
--- a/src/app/hooks/useAuthenticatedMediaUrl.ts
+++ b/src/app/hooks/useAuthenticatedMediaUrl.ts
@@ -1,142 +1,60 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
-import { getCurrentAccessToken } from '../utils/auth';
+import {
+ getCachedAuthenticatedMediaUrl,
+ isAuthenticatedMediaUrl,
+ resolveAuthenticatedMediaUrl,
+} from '../utils/authenticatedMediaCache';
/**
- * Fetches media with authentication and returns a blob URL.
- * This is needed because service workers may not work reliably in Tauri/WebKit environments,
- * and cross-origin image requests cannot include Authorization headers.
+ * Resolves media URLs for Matrix authenticated media.
+ * Uses a shared blob-URL cache so avatars/thumbs are not re-fetched on every remount.
*/
export const useAuthenticatedMediaUrl = (
src: string | undefined,
useAuthentication: boolean
): string | undefined => {
const mx = useMatrixClient();
- const [blobUrl, setBlobUrl] = useState(undefined);
+ const [blobUrl, setBlobUrl] = useState(() => {
+ if (!src) return undefined;
+ if (!useAuthentication || !isAuthenticatedMediaUrl(src)) return src;
+ return getCachedAuthenticatedMediaUrl(src);
+ });
useEffect(() => {
if (!src) {
setBlobUrl(undefined);
- return;
+ return undefined;
}
- // If not using authentication, just return the original URL
- if (!useAuthentication) {
+ if (!useAuthentication || !isAuthenticatedMediaUrl(src)) {
setBlobUrl(src);
- return;
+ return undefined;
}
- // Check if this is an authenticated media URL
- const isAuthenticatedMediaUrl =
- src.includes('/_matrix/client/v1/media/download') ||
- src.includes('/_matrix/client/v1/media/thumbnail') ||
- (src.includes('/_matrix/media/') &&
- (src.includes('/download/') || src.includes('/thumbnail/')));
-
- if (!isAuthenticatedMediaUrl) {
- setBlobUrl(src);
- return;
+ const cached = getCachedAuthenticatedMediaUrl(src);
+ if (cached) {
+ setBlobUrl(cached);
+ return undefined;
}
let cancelled = false;
- let objectUrl: string | undefined;
- const fetchMedia = async () => {
- try {
- // Always use current session's token to avoid stale tokens during account switches
- const accessToken = getCurrentAccessToken();
- let response = await fetch(src, {
- method: 'GET',
- headers: accessToken
- ? { Authorization: `Bearer ${accessToken}` }
- : undefined,
- });
-
- // If we got a 401 and we tried with auth, fallback to unauthenticated request
- if (!response.ok && response.status === 401 && accessToken) {
- console.warn('[useAuthenticatedMediaUrl] Auth failed (401), attempting unauthenticated fallback for:', src);
- response = await fetch(src, { method: 'GET' });
- }
-
- if (!response.ok) {
- console.warn(`Failed to fetch authenticated media: ${response.status}`);
- // Fall back to original URL in case server doesn't require auth
- if (!cancelled) setBlobUrl(src);
- return;
- }
-
- const blob = await response.blob();
- if (!cancelled) {
- objectUrl = URL.createObjectURL(blob);
- setBlobUrl(objectUrl);
- }
- } catch (error) {
- console.warn('Error fetching authenticated media:', error);
- // Fall back to original URL
+ resolveAuthenticatedMediaUrl(src, useAuthentication)
+ .then((url) => {
+ if (!cancelled) setBlobUrl(url);
+ })
+ .catch((error) => {
+ console.warn('[useAuthenticatedMediaUrl] Error fetching authenticated media:', error);
+ // Fall back to original URL in case the server doesn't require auth.
if (!cancelled) setBlobUrl(src);
- }
- };
-
- fetchMedia();
+ });
return () => {
cancelled = true;
- if (objectUrl) {
- URL.revokeObjectURL(objectUrl);
- }
+ // Shared cache owns blob URLs — do not revoke on unmount.
};
}, [src, useAuthentication, mx]);
return blobUrl;
};
-
-/**
- * Creates an authenticated fetch function for media URLs.
- * Useful for components that need to load multiple images.
- */
-export const useAuthenticatedMediaFetch = () => {
- const mx = useMatrixClient();
-
- return useCallback(
- async (src: string): Promise => {
- const isAuthenticatedMediaUrl =
- src.includes('/_matrix/client/v1/media/download') ||
- src.includes('/_matrix/client/v1/media/thumbnail') ||
- (src.includes('/_matrix/media/') &&
- (src.includes('/download/') || src.includes('/thumbnail/')));
-
- if (!isAuthenticatedMediaUrl) {
- return src;
- }
-
- try {
- // Always use current session's token to avoid stale tokens during account switches
- const accessToken = getCurrentAccessToken();
- let response = await fetch(src, {
- method: 'GET',
- headers: accessToken
- ? { Authorization: `Bearer ${accessToken}` }
- : undefined,
- });
-
- // If we got a 401 and we tried with auth, fallback to unauthenticated request
- if (!response.ok && response.status === 401 && accessToken) {
- console.warn('[useAuthenticatedMediaFetch] Auth failed (401), attempting unauthenticated fallback');
- response = await fetch(src, { method: 'GET' });
- }
-
- if (!response.ok) {
- console.warn(`Failed to fetch authenticated media: ${response.status}`);
- return src;
- }
-
- const blob = await response.blob();
- return URL.createObjectURL(blob);
- } catch (error) {
- console.warn('Error fetching authenticated media:', error);
- return src;
- }
- },
- [mx]
- );
-};
diff --git a/src/app/hooks/useRoomListReorder.ts b/src/app/hooks/useRoomListReorder.ts
index 815a840..2789455 100644
--- a/src/app/hooks/useRoomListReorder.ts
+++ b/src/app/hooks/useRoomListReorder.ts
@@ -12,6 +12,7 @@ import { MatrixClient, MatrixEvent, Room, RoomEvent, IRoomTimelineData } from 'm
*/
export const useRoomListReorder = (mx: MatrixClient, roomIds?: string[]): number => {
const [updateCounter, setUpdateCounter] = useState(0);
+ const rafRef = useRef(null);
useEffect(() => {
const handleTimelineEvent = (
@@ -25,13 +26,19 @@ export const useRoomListReorder = (mx: MatrixClient, roomIds?: string[]): number
if (toStartOfTimeline) return;
if (roomIds && !roomIds.includes(room.roomId)) return;
-
- setUpdateCounter((prev) => prev + 1);
+
+ // Coalesce rapid live events so DM list sort doesn't thrash the main thread.
+ if (rafRef.current !== null) return;
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ setUpdateCounter((prev) => prev + 1);
+ });
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [mx, roomIds]);
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 1387ffa..dd77974 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -61,26 +61,41 @@ import {
/**
* Applies the selected emoji style font to the document.
- * - System: Uses the native OS emoji font
- * - Apple: Uses Apple Color Emoji (bundled font)
- * - Twemoji: Uses Twitter's Twemoji font
+ * - System: native OS emoji (Apple/Segoe) when available
+ * - Apple: Apple Color Emoji / Segoe UI Emoji when available
+ * - Twemoji: bundled Mozilla Twemoji
+ *
+ * On Linux, fontconfig often aliases every local('…Emoji') to system twemoji.ttf,
+ * which has different metrics than our bundled Mozilla file — so Apple/System would
+ * show the same glyphs at a different size than Twemoji. Use the bundled face there.
*/
function EmojiStyleFeature() {
const [emojiStyle] = useSetting(settingsAtom, 'emojiStyle');
+ const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
+ const platform = typeof navigator !== 'undefined' ? navigator.platform : '';
+ const isAppleOS =
+ /Mac|iPhone|iPad|iPod/.test(platform) || /Mac OS|iPhone|iPad/.test(ua);
+ const isWindows = /Win/.test(platform) || /Windows/.test(ua);
+ // Real Apple/Segoe locals exist on Apple OS / Windows; elsewhere prefer bundled.
+ const nativeEmojiAvailable = isAppleOS || isWindows;
+ let fontFamily = 'Twemoji';
switch (emojiStyle) {
case EmojiStyle.Apple:
- document.documentElement.style.setProperty('--font-emoji', 'AppleColorEmoji');
+ fontFamily = nativeEmojiAvailable ? 'AppleColorEmoji' : 'Twemoji';
break;
case EmojiStyle.Twemoji:
- document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
+ fontFamily = 'Twemoji';
break;
case EmojiStyle.System:
default:
- document.documentElement.style.setProperty('--font-emoji', 'SystemEmoji');
+ fontFamily = nativeEmojiAvailable ? 'SystemEmoji' : 'Twemoji';
break;
}
+ document.documentElement.style.setProperty('--font-emoji', fontFamily);
+ document.documentElement.dataset.emojiStyle = emojiStyle;
+
return null;
}
diff --git a/src/app/state/mediaDimensionCache.ts b/src/app/state/mediaDimensionCache.ts
new file mode 100644
index 0000000..2b894a6
--- /dev/null
+++ b/src/app/state/mediaDimensionCache.ts
@@ -0,0 +1,151 @@
+import { encodeBlurHash, validBlurHash } from '../utils/blurHash';
+
+const STORAGE_KEY = 'paarrot.media.meta';
+const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions';
+const MAX_ENTRIES = 500;
+
+export type MediaMeta = {
+ w: number;
+ h: number;
+ blurHash?: string;
+};
+
+export type MediaDimensions = Pick;
+
+type CacheMap = Record;
+
+let memoryCache: CacheMap | null = null;
+
+const loadCache = (): CacheMap => {
+ if (memoryCache) return memoryCache;
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ memoryCache = JSON.parse(raw) as CacheMap;
+ return memoryCache;
+ }
+ // Migrate older dimensions-only cache once.
+ const legacy = localStorage.getItem(LEGACY_DIMENSIONS_KEY);
+ if (legacy) {
+ memoryCache = JSON.parse(legacy) as CacheMap;
+ localStorage.setItem(STORAGE_KEY, legacy);
+ localStorage.removeItem(LEGACY_DIMENSIONS_KEY);
+ return memoryCache;
+ }
+ } catch {
+ // ignore
+ }
+ memoryCache = {};
+ return memoryCache;
+};
+
+const persistCache = (cache: CacheMap) => {
+ memoryCache = cache;
+ try {
+ const keys = Object.keys(cache);
+ if (keys.length > MAX_ENTRIES) {
+ const trimmed: CacheMap = {};
+ keys.slice(keys.length - MAX_ENTRIES).forEach((key) => {
+ trimmed[key] = cache[key];
+ });
+ memoryCache = trimmed;
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
+ return;
+ }
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
+ } catch {
+ // Quota / private mode — keep in-memory only.
+ }
+};
+
+const touchEntry = (mxcUrl: string, patch: Partial): void => {
+ if (!mxcUrl) return;
+ const cache = loadCache();
+ const prev = cache[mxcUrl];
+ const next: MediaMeta = {
+ w: patch.w ?? prev?.w ?? 0,
+ h: patch.h ?? prev?.h ?? 0,
+ blurHash: patch.blurHash ?? prev?.blurHash,
+ };
+ if (next.w <= 0 || next.h <= 0) return;
+ if (
+ prev &&
+ prev.w === next.w &&
+ prev.h === next.h &&
+ prev.blurHash === next.blurHash
+ ) {
+ return;
+ }
+ if (prev) delete cache[mxcUrl];
+ cache[mxcUrl] = next;
+ persistCache(cache);
+};
+
+export const getMediaDimensions = (mxcUrl: string): MediaDimensions | undefined => {
+ if (!mxcUrl) return undefined;
+ const entry = loadCache()[mxcUrl];
+ if (!entry || entry.w <= 0 || entry.h <= 0) return undefined;
+ return { w: entry.w, h: entry.h };
+};
+
+export const setMediaDimensions = (mxcUrl: string, w: number, h: number): void => {
+ if (!mxcUrl || w <= 0 || h <= 0) return;
+ touchEntry(mxcUrl, { w, h });
+};
+
+export const getMediaBlurHash = (mxcUrl: string): string | undefined => {
+ if (!mxcUrl) return undefined;
+ return validBlurHash(loadCache()[mxcUrl]?.blurHash);
+};
+
+export const setMediaBlurHash = (mxcUrl: string, blurHash: string, w?: number, h?: number): void => {
+ const hash = validBlurHash(blurHash);
+ if (!mxcUrl || !hash) return;
+ const cache = loadCache();
+ const prev = cache[mxcUrl];
+ const nextW = w && w > 0 ? w : prev?.w;
+ const nextH = h && h > 0 ? h : prev?.h;
+ if (!nextW || !nextH) {
+ // Dimensions required for the shared meta entry; skip until we know size.
+ return;
+ }
+ touchEntry(mxcUrl, { w: nextW, h: nextH, blurHash: hash });
+};
+
+/** Encode + cache a blurhash from a loaded image/video (idle, non-blocking). */
+export const rememberMediaBlurHash = (
+ mxcUrl: string,
+ media: HTMLImageElement | HTMLVideoElement
+): void => {
+ if (!mxcUrl || typeof window === 'undefined') return;
+
+ const run = () => {
+ try {
+ const w =
+ media instanceof HTMLVideoElement
+ ? media.videoWidth
+ : media.naturalWidth || media.width;
+ const h =
+ media instanceof HTMLVideoElement
+ ? media.videoHeight
+ : media.naturalHeight || media.height;
+ if (w <= 0 || h <= 0) return;
+
+ setMediaDimensions(mxcUrl, w, h);
+ if (getMediaBlurHash(mxcUrl)) return;
+
+ const encW = 32;
+ const encH = Math.max(1, Math.round(encW * (h / w)));
+ const hash = encodeBlurHash(media, encW, encH);
+ if (hash) setMediaBlurHash(mxcUrl, hash, w, h);
+ } catch {
+ // Encoding can fail for tainted canvases; ignore.
+ }
+ };
+
+ if (typeof window.requestIdleCallback === 'function') {
+ window.requestIdleCallback(() => run(), { timeout: 1500 });
+ } else {
+ window.setTimeout(run, 0);
+ }
+};
diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts
index 80403e6..835e1e3 100644
--- a/src/app/styles/CustomHtml.css.ts
+++ b/src/app/styles/CustomHtml.css.ts
@@ -264,8 +264,13 @@ export const EmoticonBase = style([
export const EmoticonBaseJumbo = style([
EmoticonBase,
{
- height: toRem(100),
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: toRem(70),
+ height: toRem(70),
padding: 0,
+ overflow: 'hidden',
verticalAlign: 'bottom',
},
]);
@@ -278,6 +283,8 @@ export const Emoticon = recipe({
justifyContent: 'center',
alignItems: 'center',
+ // Pin the selected emoji face so metrics don't shift via the body font stack.
+ fontFamily: 'var(--font-emoji)',
height: '1em',
minWidth: '1em',
fontSize: '1.33em',
@@ -296,11 +303,13 @@ export const Emoticon = recipe({
},
jumbo: {
true: {
- fontSize: toRem(100),
- height: toRem(100),
- minWidth: toRem(100),
- lineHeight: toRem(100),
+ fontSize: toRem(56),
+ width: toRem(56),
+ height: toRem(56),
+ minWidth: toRem(56),
+ lineHeight: 1,
top: 0,
+ overflow: 'visible',
},
},
},
@@ -317,10 +326,10 @@ export const EmoticonImg = recipe({
variants: {
jumbo: {
true: {
- height: toRem(100),
- width: toRem(100),
- maxHeight: toRem(100),
- maxWidth: toRem(100),
+ height: toRem(56),
+ width: toRem(56),
+ maxHeight: toRem(56),
+ maxWidth: toRem(56),
objectFit: 'contain',
},
},
diff --git a/src/app/utils/authenticatedMediaCache.ts b/src/app/utils/authenticatedMediaCache.ts
new file mode 100644
index 0000000..ada3251
--- /dev/null
+++ b/src/app/utils/authenticatedMediaCache.ts
@@ -0,0 +1,87 @@
+import { getCurrentAccessToken } from './auth';
+
+const MAX_ENTRIES = 250;
+
+/** Shared blob URLs for authenticated Matrix media (avatars, thumbs, etc.). */
+const blobCache = new Map();
+const inflight = new Map>();
+
+export const isAuthenticatedMediaUrl = (url: string): boolean =>
+ url.includes('/_matrix/client/v1/media/download') ||
+ url.includes('/_matrix/client/v1/media/thumbnail') ||
+ (url.includes('/_matrix/media/') &&
+ (url.includes('/download/') || url.includes('/thumbnail/')));
+
+export const getCachedAuthenticatedMediaUrl = (src: string | undefined): string | undefined => {
+ if (!src) return undefined;
+ return blobCache.get(src);
+};
+
+const touch = (src: string, blobUrl: string) => {
+ // Re-insert for LRU ordering (Map preserves insertion order).
+ if (blobCache.has(src)) blobCache.delete(src);
+ blobCache.set(src, blobUrl);
+
+ while (blobCache.size > MAX_ENTRIES) {
+ const oldest = blobCache.keys().next().value as string | undefined;
+ if (!oldest) break;
+ const oldUrl = blobCache.get(oldest);
+ blobCache.delete(oldest);
+ if (oldUrl) URL.revokeObjectURL(oldUrl);
+ }
+};
+
+const fetchBlobUrl = async (src: string): Promise => {
+ const accessToken = getCurrentAccessToken();
+ let response = await fetch(src, {
+ method: 'GET',
+ headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
+ });
+
+ if (!response.ok && response.status === 401 && accessToken) {
+ response = await fetch(src, { method: 'GET' });
+ }
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch authenticated media: ${response.status}`);
+ }
+
+ const blob = await response.blob();
+ return URL.createObjectURL(blob);
+};
+
+/**
+ * Returns a stable blob: URL for authenticated media, reusing cache across mounts.
+ * Non-auth URLs are returned as-is.
+ */
+export const resolveAuthenticatedMediaUrl = async (
+ src: string,
+ useAuthentication: boolean
+): Promise => {
+ if (!useAuthentication || !isAuthenticatedMediaUrl(src)) {
+ return src;
+ }
+
+ const cached = blobCache.get(src);
+ if (cached) {
+ touch(src, cached);
+ return cached;
+ }
+
+ let pending = inflight.get(src);
+ if (!pending) {
+ pending = fetchBlobUrl(src)
+ .then((blobUrl) => {
+ touch(src, blobUrl);
+ inflight.delete(src);
+ return blobUrl;
+ })
+ .catch((err) => {
+ inflight.delete(src);
+ throw err;
+ });
+ inflight.set(src, pending);
+ }
+
+ return pending;
+};
diff --git a/src/app/utils/blurHash.ts b/src/app/utils/blurHash.ts
index 566f6d1..360f844 100644
--- a/src/app/utils/blurHash.ts
+++ b/src/app/utils/blurHash.ts
@@ -5,8 +5,10 @@ export const encodeBlurHash = (
width?: number,
height?: number
): string | undefined => {
- const imgWidth = img instanceof HTMLVideoElement ? img.videoWidth : img.width;
- const imgHeight = img instanceof HTMLVideoElement ? img.videoHeight : img.height;
+ const imgWidth =
+ img instanceof HTMLVideoElement ? img.videoWidth : img.naturalWidth || img.width;
+ const imgHeight =
+ img instanceof HTMLVideoElement ? img.videoHeight : img.naturalHeight || img.height;
const canvas = document.createElement('canvas');
canvas.width = width || imgWidth;
canvas.height = height || imgHeight;
diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts
index aa6570b..a5573e5 100644
--- a/src/app/utils/common.ts
+++ b/src/app/utils/common.ts
@@ -87,6 +87,23 @@ export const scaleYDimension = (x: number, scaledX: number, y: number): number =
return scaleFactor * y;
};
+/** Fit natural media size into a max box without upscaling. */
+export const fitMediaSize = (
+ w: number,
+ h: number,
+ maxW: number,
+ maxH: number
+): { width: number; height: number } => {
+ if (w <= 0 || h <= 0) {
+ return { width: maxW, height: Math.round(maxW * 0.75) };
+ }
+ const scale = Math.min(maxW / w, maxH / h, 1);
+ return {
+ width: Math.max(1, Math.round(w * scale)),
+ height: Math.max(1, Math.round(h * scale)),
+ };
+};
+
export const parseGeoUri = (location: string) => {
const [, data] = location.split(':');
const [cords] = data.split(';');
diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts
index 3de6911..36185d5 100644
--- a/src/app/utils/matrix.ts
+++ b/src/app/utils/matrix.ts
@@ -59,8 +59,8 @@ export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): str
export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
const info: IImageInfo = {};
- info.w = img.width;
- info.h = img.height;
+ info.w = img.naturalWidth || img.width;
+ info.h = img.naturalHeight || img.height;
info.mimetype = fileOrBlob.type;
info.size = fileOrBlob.size;
return info;
diff --git a/src/index.css b/src/index.css
index b2f6197..47eb2ab 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,32 +1,35 @@
-/* Twemoji font (Twitter emoji) — unicode-range keeps spaces/Latin out of emoji metrics */
+/* Twemoji (bundled Mozilla COLR). unicode-range keeps spaces/Latin out of emoji metrics. */
@font-face {
font-family: Twemoji;
- src: url('../public/font/Twemoji.Mozilla.v15.1.0.woff2'),
- url('../public/font/Twemoji.Mozilla.v15.1.0.ttf');
+ src: url('../public/font/Twemoji.Mozilla.v15.1.0.woff2') format('woff2'),
+ url('../public/font/Twemoji.Mozilla.v15.1.0.ttf') format('truetype');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
-/* Apple Color Emoji - uses local Apple Color Emoji if available (macOS/iOS),
- falls back to Segoe UI Emoji (Windows) or Noto (Linux/Android) */
+/*
+ * Apple / System faces prefer native emoji fonts.
+ * Always end with the same bundled Twemoji URLs as the Twemoji face so that when
+ * locals are missing — or Linux fontconfig falsely aliases them to system
+ * twemoji.ttf — JS can point all styles at "Twemoji" for identical metrics.
+ * (Do not rely on local('Noto Color Emoji') alone on Linux: it often resolves to
+ * system Twemoji with different metrics than our bundled file.)
+ */
@font-face {
font-family: AppleColorEmoji;
- src: local('Apple Color Emoji'),
- local('Segoe UI Emoji'),
- local('Noto Color Emoji');
+ src: local('Apple Color Emoji'), local('Segoe UI Emoji'),
+ url('../public/font/Twemoji.Mozilla.v15.1.0.woff2') format('woff2'),
+ url('../public/font/Twemoji.Mozilla.v15.1.0.ttf') format('truetype');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
-/* System emoji - uses native OS emoji */
@font-face {
font-family: SystemEmoji;
- src: local('Apple Color Emoji'),
- local('Segoe UI Emoji'),
- local('Segoe UI Symbol'),
- local('Noto Color Emoji'),
- local('Android Emoji'),
- local('EmojiOne Color');
+ src: local('Apple Color Emoji'), local('Segoe UI Emoji'), local('Segoe UI Symbol'),
+ local('Noto Color Emoji'), local('Android Emoji'),
+ url('../public/font/Twemoji.Mozilla.v15.1.0.woff2') format('woff2'),
+ url('../public/font/Twemoji.Mozilla.v15.1.0.ttf') format('truetype');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}