Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/media-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add persistent Cache API for media storage; skip caching failed media requests.
8 changes: 6 additions & 2 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ export const ImageViewer = as<'div', ImageViewerProps>(
useImageGestures(true, 0.2);

const handleDownload = async () => {
const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt);
try {
const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt);
} catch {
// Download failed (e.g. network error or non-2xx response) — silently ignore.
}
};

return (
Expand Down
145 changes: 123 additions & 22 deletions src/app/components/room-avatar/AvatarImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,103 @@
import { useSetting } from '$state/hooks/settings';
import * as css from './RoomAvatar.css';

type AvatarImageProps = {
src: string;
alt?: string;
uniformIcons?: boolean;
onError: () => void;
};
// Module-level cache: maps a Matrix media URL → processed SVG text (or null
// for confirmed non-SVG). Storing text (not blob URLs) means cache eviction
// never revokes a blob URL that a still-mounted component is displaying.
// Blob URLs are created per component instance and revoked in their cleanup,
// so their lifetime is always tied to the component that owns them.
const SVG_TEXT_CACHE_MAX = 1000;
// null = confirmed non-SVG; string = processed SVG markup ready for Blob.
const svgTextCache = new Map<string, string | null>();

export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) {
const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons');
const [image, setImage] = useState<HTMLImageElement | undefined>(undefined);
const [processedSrc, setProcessedSrc] = useState<string>(src);
/** Number of entries currently held in the module-level SVG text cache. */
export function getSvgCacheSize(): number {
return svgTextCache.size;
}

const useUniformIcons = uniformIconsSetting && uniformIcons === true;
const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined;
/** Clear the SVG text cache to free memory.
* Blob URLs are managed per-component and will be revoked when each
* AvatarImage / UserAvatar unmounts — nothing to revoke here.
*/
export function clearSvgBlobCache(): void {
svgTextCache.clear();
}

/**
* Resolves an avatar HTTP URL to a displayable src string.
* - SVG images are fetched, animation-sanitised, converted to a blob URL, and
* the processed text is cached so subsequent mounts skip the network fetch.
* Each component instance owns its own blob URL and revokes it on unmount.
* - Non-SVG images are returned unchanged.
* - `undefined` src returns `undefined`.
*/
export function useProcessedAvatarSrc(src: string | undefined): string | undefined {
const [processedSrc, setProcessedSrc] = useState<string | undefined>(src);

useEffect(() => {
if (!src) {
setProcessedSrc(undefined);
return;
}

let isMounted = true;
let objectUrl: string | null = null;
// Each component instance tracks its own blob URL so cleanup can revoke it
// without affecting any other mounted component showing the same image.
let blobUrl: string | null = null;

const makeBlobUrl = (svgText: string): string => {
const blob = new Blob([svgText], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
blobUrl = url;
return url;
};

// Reset to raw src first so any stale blob URL from a previous src is gone.
setProcessedSrc(src);

// Fast path: text cache hit — create a component-local blob URL immediately.
const cachedText = svgTextCache.get(src);
if (cachedText !== undefined) {
if (cachedText !== null) {
setProcessedSrc(makeBlobUrl(cachedText));
}
// null → confirmed non-SVG; processedSrc already set to src above.
return () => {
isMounted = false;
if (blobUrl) URL.revokeObjectURL(blobUrl);
};

Check warning on line 73 in src/app/components/room-avatar/AvatarImage.tsx

View workflow job for this annotation

GitHub Actions / Lint

typescript-eslint(consistent-return)

Function expected no return value.
}

const processImage = async () => {
// Another concurrent call may have resolved the cache while we were
// waiting for the async queue — check again before fetching.
const alreadyCached = svgTextCache.get(src);
if (alreadyCached !== undefined) {
if (alreadyCached !== null && isMounted) {
setProcessedSrc(makeBlobUrl(alreadyCached));
}
return;
}

try {
// Fast path: if the URL has a non-SVG extension we can skip the fetch
// entirely and let the browser's <img> element load it directly.
const urlPath = src.split('?')[0]?.toLowerCase() ?? '';
const hasSvgExtension = urlPath.endsWith('.svg');
const hasNonSvgExtension = /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/.test(urlPath);

if (hasNonSvgExtension) {
svgTextCache.set(src, null);
// processedSrc is already src — nothing more to do.
return;
}

const res = await fetch(src, { mode: 'cors' });
const contentType = res.headers.get('content-type');
const isSvg =
hasSvgExtension || (contentType ? contentType.includes('image/svg+xml') : false);

if (contentType && contentType.includes('image/svg+xml')) {
if (isSvg) {
const text = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'image/svg+xml');
Expand All @@ -43,12 +115,24 @@
doc.documentElement.appendChild(style);

const serializer = new XMLSerializer();
const newSvgString = serializer.serializeToString(doc);
const blob = new Blob([newSvgString], { type: 'image/svg+xml' });
const svgString = serializer.serializeToString(doc);

objectUrl = URL.createObjectURL(blob);
if (isMounted) setProcessedSrc(objectUrl);
} else if (isMounted) setProcessedSrc(src);
// Evict oldest entry if cache is full. Safe to just delete — there are
// no blob URLs stored here, so no revocation needed.
if (svgTextCache.size >= SVG_TEXT_CACHE_MAX) {
const firstKey = svgTextCache.keys().next().value;
if (firstKey !== undefined) svgTextCache.delete(firstKey);
}
svgTextCache.set(src, svgString);

if (isMounted) {
setProcessedSrc(makeBlobUrl(svgString));
}
// If unmounted: text is cached for the next mount; no blob URL created.
} else {
svgTextCache.set(src, null);
// processedSrc is already src.
}
} catch {
if (isMounted) setProcessedSrc(src);
}
Expand All @@ -56,14 +140,31 @@

processImage();

return () => {
isMounted = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
// Revoke the blob URL owned by this component instance.
if (blobUrl) URL.revokeObjectURL(blobUrl);
};

Check warning on line 147 in src/app/components/room-avatar/AvatarImage.tsx

View workflow job for this annotation

GitHub Actions / Lint

typescript-eslint(consistent-return)

Function expected no return value.
}, [src]);

return processedSrc;
}

type AvatarImageProps = {
src: string;
alt?: string;
uniformIcons?: boolean;
onError: () => void;
};

export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) {
const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons');
const [image, setImage] = useState<HTMLImageElement | undefined>(undefined);
const processedSrc = useProcessedAvatarSrc(src) ?? src;

const useUniformIcons = uniformIconsSetting && uniformIcons === true;
const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined;

const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
setImage(evt.currentTarget);
Expand Down
10 changes: 7 additions & 3 deletions src/app/components/url-preview/UrlPreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ const openMediaInNewTab = async (url: string | undefined) => {
console.warn('Attempted to open an empty url');
return;
}
const blob = await downloadMedia(url);
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl, '_blank');
try {
const blob = await downloadMedia(url);
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl, '_blank');
} catch (err) {
console.error('Failed to open media in new tab', err);
}
};

function ogPositiveDimension(value: unknown): number | undefined {
Expand Down
6 changes: 4 additions & 2 deletions src/app/components/user-avatar/UserAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ReactEventHandler, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import classNames from 'classnames';
import colorMXID from '$utils/colorMXID';
import { useProcessedAvatarSrc } from '$components/room-avatar/AvatarImage';
import * as css from './UserAvatar.css';

type UserAvatarProps = {
Expand All @@ -19,12 +20,13 @@ const handleImageLoad: ReactEventHandler<HTMLImageElement> = (evt) => {

export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
const [error, setError] = useState(false);
const processedSrc = useProcessedAvatarSrc(src);

useEffect(() => {
setError(false);
}, [src]);

if (!src || error) {
if (!processedSrc || error) {
return (
<AvatarFallback
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
Expand All @@ -38,7 +40,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
return (
<AvatarImage
className={classNames(css.UserAvatar, className)}
src={src}
src={processedSrc}
alt={alt}
Comment thread
Just-Insane marked this conversation as resolved.
onError={() => setError(true)}
onLoad={handleImageLoad}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();

const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// TODO: remove cast once account data typing is unified.
Expand Down
Loading
Loading