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 && (
)}
{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..ca615a6 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), 540);
+ 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..481ef65 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,67 @@ 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`,
+ },
+]);
+
+const blurhashFadeIn = keyframes({
+ from: {
+ opacity: 0,
+ },
+ to: {
+ opacity: 1,
+ },
+});
+
+/** 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)',
+ opacity: 0,
+ animation: `${blurhashFadeIn} 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards`,
+ },
+]);
+
+/** Media starts invisible and fades in once loaded over the placeholder. */
+export const MediaFadeIn = style({
+ zIndex: 1,
+ opacity: 0,
+ transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)',
+});
+
+export const MediaFadeInLoaded = style({
+ opacity: 1,
+});
diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts
index 017943e..8eb9a7d 100644
--- a/src/app/components/message/layout/layout.css.ts
+++ b/src/app/components/message/layout/layout.css.ts
@@ -1,4 +1,5 @@
-import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css';
+import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
+import * as htmlCss from '../../../styles/CustomHtml.css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
@@ -117,6 +118,7 @@ export const MessageBase = recipe({
collapse: {
true: {
marginTop: 0,
+ paddingTop: config.space.S0,
},
},
autoCollapse: {
@@ -209,6 +211,8 @@ export const UsernameBold = style({
fontWeight: 550,
});
+const jumboEmojiSize = toRem(70);
+
export const MessageTextBody = recipe({
base: {
wordBreak: 'break-word',
@@ -226,8 +230,9 @@ export const MessageTextBody = recipe({
},
jumboEmoji: {
true: {
- fontSize: '1.504em',
- lineHeight: '1.4962em',
+ lineHeight: 1,
+ overflow: 'visible',
+ overflowY: 'visible',
},
},
emote: {
@@ -240,3 +245,29 @@ export const MessageTextBody = recipe({
});
export type MessageTextBodyVariants = RecipeVariants;
+
+const jumboEmojiClass = MessageTextBody.classNames.variants.jumboEmoji.true;
+
+globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
+ height: jumboEmojiSize,
+ padding: 0,
+ overflow: 'visible',
+ verticalAlign: 'middle',
+});
+
+globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
+ fontSize: jumboEmojiSize,
+ height: jumboEmojiSize,
+ minWidth: jumboEmojiSize,
+ lineHeight: 1,
+ position: 'static',
+ top: 0,
+ overflow: 'visible',
+});
+
+globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonImg}`, {
+ height: jumboEmojiSize,
+ width: jumboEmojiSize,
+ maxHeight: 'none',
+ 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/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx
index 5eb90bd..7260561 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,28 @@ 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);
+
+ 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 +62,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/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index dd75ffe..76b58fc 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -103,6 +103,11 @@ import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalSc
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
+import {
+ clearRoomScrollState,
+ getRoomScrollState,
+ saveRoomScrollState,
+} from '../../state/roomScrollCache';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@@ -620,6 +625,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const location = useLocation();
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
+ const savedScrollRef = useRef(
+ eventId || scrollToLatest ? undefined : getRoomScrollState(room.roomId)
+ );
+
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [unreadInfo, setUnreadInfo] = useState(() =>
@@ -631,7 +640,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
const atBottomAnchorRef = useRef(null);
- const [atBottom, setAtBottom] = useState(true);
+ const [atBottom, setAtBottom] = useState(
+ () => savedScrollRef.current?.atBottom ?? true
+ );
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
@@ -643,6 +654,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const scrollRef = useRef(null);
const timelineContentRef = useRef(null);
const timelineContentHeightRef = useRef(0);
+ const restoringScrollRef = useRef(false);
const scrollToBottomRef = useRef({
count: 0,
smooth: true,
@@ -680,9 +692,40 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
const parseMemberEvent = useMemberEventParser();
- const [timeline, setTimeline] = useState(() =>
- eventId ? getEmptyTimeline() : getInitialTimeline(room)
+ const [timeline, setTimeline] = useState(() => {
+ if (eventId) return getEmptyTimeline();
+ const initial = getInitialTimeline(room);
+ const saved = savedScrollRef.current;
+ if (!saved?.range) return initial;
+
+ const evLength = getTimelinesEventsCount(initial.linkedTimelines);
+ if (evLength === 0) return initial;
+
+ const start = Math.min(saved.range.start, Math.max(0, evLength - 1));
+ const end = Math.min(Math.max(start + 1, saved.range.end), evLength);
+
+ return { ...initial, range: { start, end } };
+ });
+ const timelineRef = useRef(timeline);
+ timelineRef.current = timeline;
+
+ const persistTimelineScroll = useCallback(
+ (roomId: string) => {
+ if (eventId) return;
+ const scrollEl = scrollRef.current;
+ if (!scrollEl) return;
+
+ const distanceFromBottom =
+ scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
+ saveRoomScrollState(roomId, {
+ top: scrollEl.scrollTop,
+ atBottom: distanceFromBottom <= 50,
+ range: timelineRef.current.range,
+ });
+ },
+ [eventId]
);
+
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
const liveTimelineLinked =
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
@@ -861,6 +904,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const scrollElement = getScrollElement();
if (!editorBaseEntry || !scrollElement) return;
+ if (restoringScrollRef.current) return;
+
if (isScrolledToBottom(scrollElement)) {
autoScrollToBottom(scrollElement, false, true);
}
@@ -882,6 +927,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight;
+ if (restoringScrollRef.current) {
+ const saved = savedScrollRef.current;
+ if (saved) {
+ if (saved.atBottom) {
+ scrollElement.scrollTop = scrollElement.scrollHeight;
+ } else {
+ scrollElement.scrollTop = saved.top;
+ }
+ }
+ return;
+ }
+
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
return;
}
@@ -994,6 +1051,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Scroll to latest when clicking on already-selected room
useEffect(() => {
if (scrollToLatest) {
+ clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null);
setUnreadInfo(undefined);
setTimeline(getInitialTimeline(room));
@@ -1003,12 +1061,78 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, [scrollToLatest, room]);
- // Scroll to bottom on initial timeline load
+ // Remember scroll position when leaving a room (switch DM, nav away, etc.).
+ useLayoutEffect(() => {
+ const roomId = room.roomId;
+ return () => {
+ persistTimelineScroll(roomId);
+ };
+ }, [room.roomId, persistTimelineScroll]);
+
+ // Keep scroll cache updated while reading (useEffect cleanup runs after DOM teardown).
+ useEffect(() => {
+ const scrollEl = scrollRef.current;
+ if (!scrollEl || eventId) return undefined;
+
+ const roomId = room.roomId;
+ let raf = 0;
+ const onScroll = () => {
+ if (restoringScrollRef.current) return;
+ window.cancelAnimationFrame(raf);
+ raf = window.requestAnimationFrame(() => persistTimelineScroll(roomId));
+ };
+
+ scrollEl.addEventListener('scroll', onScroll, { passive: true });
+ return () => {
+ window.cancelAnimationFrame(raf);
+ scrollEl.removeEventListener('scroll', onScroll);
+ };
+ }, [room.roomId, eventId, persistTimelineScroll]);
+
+ // Restore saved scroll or default to bottom on first open.
useLayoutEffect(() => {
const scrollEl = scrollRef.current;
- if (scrollEl) {
+ if (!scrollEl) return undefined;
+
+ if (scrollToLatest) {
autoScrollToBottom(scrollEl, false, true);
+ return undefined;
}
+
+ const saved = savedScrollRef.current;
+ if (!saved) {
+ autoScrollToBottom(scrollEl, false, true);
+ return undefined;
+ }
+
+ restoringScrollRef.current = true;
+ setAtBottom(saved.atBottom);
+
+ const restore = () => {
+ if (saved.atBottom) {
+ scrollEl.scrollTop = scrollEl.scrollHeight;
+ } else {
+ scrollEl.scrollTop = saved.top;
+ }
+ };
+
+ restore();
+ const raf1 = window.requestAnimationFrame(() => {
+ restore();
+ window.requestAnimationFrame(() => {
+ restore();
+ window.setTimeout(() => {
+ restore();
+ window.setTimeout(() => {
+ restoringScrollRef.current = false;
+ }, 150);
+ }, 50);
+ });
+ });
+ return () => {
+ window.cancelAnimationFrame(raf1);
+ restoringScrollRef.current = false;
+ };
}, []);
// if live timeline is linked and unreadInfo change
@@ -1052,7 +1176,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// scroll to bottom of timeline
const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => {
- if (scrollToBottomCount > 0) {
+ if (scrollToBottomCount > 0 && !restoringScrollRef.current) {
const scrollEl = scrollRef.current;
if (scrollEl) {
autoScrollToBottom(
@@ -1113,6 +1237,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
+ clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null);
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
@@ -2291,7 +2416,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)}
-
+
{
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);
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]);
@@ -98,42 +66,11 @@ 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;
- }
-
+ async (src: string, useAuthentication = true): Promise => {
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);
+ return await resolveAuthenticatedMediaUrl(src, useAuthentication);
} catch (error) {
- console.warn('Error fetching authenticated media:', error);
+ console.warn('[useAuthenticatedMediaFetch] Error fetching authenticated media:', error);
return src;
}
},
diff --git a/src/app/state/mediaDimensionCache.ts b/src/app/state/mediaDimensionCache.ts
new file mode 100644
index 0000000..73c216a
--- /dev/null
+++ b/src/app/state/mediaDimensionCache.ts
@@ -0,0 +1,168 @@
+import { encodeBlurHash, validBlurHash } from '../utils/blurHash';
+import { fitMediaSize } from '../utils/common';
+
+const STORAGE_KEY = 'paarrot.media.meta';
+const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions';
+const MAX_ENTRIES = 500;
+
+/** Same max box as MImage / MVideo attachment rendering. */
+export const ATTACHMENT_MAX_SIZE = 400;
+
+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 };
+};
+
+/** Fit attachment box size using event info, then local dimension cache. */
+export const resolveAttachmentBoxSize = (
+ mxcUrl: string | undefined,
+ w?: number,
+ h?: number,
+ maxSize: number = ATTACHMENT_MAX_SIZE
+): { width: number; height: number } => {
+ const cached = mxcUrl && (!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, maxSize, maxSize);
+};
+
+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/state/roomScrollCache.ts b/src/app/state/roomScrollCache.ts
new file mode 100644
index 0000000..3937396
--- /dev/null
+++ b/src/app/state/roomScrollCache.ts
@@ -0,0 +1,32 @@
+export type RoomScrollRange = {
+ start: number;
+ end: number;
+};
+
+export type RoomScrollState = {
+ top: number;
+ atBottom: boolean;
+ range: RoomScrollRange;
+};
+
+const cache = new Map();
+
+const MAX_ENTRIES = 20;
+
+export const getRoomScrollState = (roomId: string): RoomScrollState | undefined =>
+ cache.get(roomId);
+
+export const saveRoomScrollState = (roomId: string, state: RoomScrollState): void => {
+ if (cache.has(roomId)) cache.delete(roomId);
+ cache.set(roomId, state);
+
+ while (cache.size > MAX_ENTRIES) {
+ const oldest = cache.keys().next().value as string | undefined;
+ if (!oldest) break;
+ cache.delete(oldest);
+ }
+};
+
+export const clearRoomScrollState = (roomId: string): void => {
+ cache.delete(roomId);
+};
diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts
index 1fb68d1..a8c3512 100644
--- a/src/app/styles/CustomHtml.css.ts
+++ b/src/app/styles/CustomHtml.css.ts
@@ -262,9 +262,10 @@ export const EmoticonBase = style([
DefaultReset,
{
display: 'inline-block',
- padding: '0.05rem',
- height: '1em',
- verticalAlign: 'middle',
+ verticalAlign: '-0.1em',
+ lineHeight: 1,
+ height: 'auto',
+ padding: 0,
},
]);
@@ -272,18 +273,10 @@ export const Emoticon = recipe({
base: [
DefaultReset,
{
- display: 'inline-flex',
- justifyContent: 'center',
- alignItems: 'center',
-
- height: '1em',
- minWidth: '1em',
- fontSize: '1.33em',
- lineHeight: '1em',
- verticalAlign: 'middle',
- position: 'relative',
- top: '-0.35em',
- borderRadius: config.radii.R300,
+ display: 'inline',
+ fontSize: '1.2em',
+ lineHeight: 1,
+ verticalAlign: 'inherit',
},
],
variants: {
@@ -298,7 +291,9 @@ export const Emoticon = recipe({
export const EmoticonImg = style([
DefaultReset,
{
- height: '1em',
+ display: 'block',
+ height: '1.2em',
+ width: '1.2em',
cursor: 'default',
},
]);
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/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/index.css b/src/index.css
index a69627f..d906aa8 100644
--- a/src/index.css
+++ b/src/index.css
@@ -4,7 +4,8 @@
src: url('../public/font/Twemoji.Mozilla.v15.1.0.woff2'),
url('../public/font/Twemoji.Mozilla.v15.1.0.ttf');
font-display: swap;
- unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
+ unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF,
+ U+1FA70-1FAFF, U+1FAB0-1FABF, U+1FAC0-1FAFF;
}
/* Apple Color Emoji - uses local Apple Color Emoji if available (macOS/iOS),
@@ -15,7 +16,8 @@
local('Segoe UI Emoji'),
local('Noto Color Emoji');
font-display: swap;
- unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
+ unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF,
+ U+1FA70-1FAFF, U+1FAB0-1FABF, U+1FAC0-1FAFF;
}
/* System emoji - uses native OS emoji */
@@ -28,7 +30,8 @@
local('Android Emoji'),
local('EmojiOne Color');
font-display: swap;
- unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
+ unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF,
+ U+1FA70-1FAFF, U+1FAB0-1FABF, U+1FAC0-1FAFF;
}
:root {