Remember room scroll position across switches, reserve image/video heights from Matrix dimensions, cache authenticated media blobs for avatars, restore jumbo emoji-only messages, and extend emoji font unicode-range for Unicode 15 glyphs.
169 lines
4.9 KiB
TypeScript
169 lines
4.9 KiB
TypeScript
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<MediaMeta, 'w' | 'h'>;
|
|
|
|
type CacheMap = Record<string, MediaMeta>;
|
|
|
|
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<MediaMeta>): 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);
|
|
}
|
|
};
|