Files
root-org/src/lib/cache/mediaCache.ts
AlacrisDevs d1ce5d0951 feat: integrate Matrix chat (Option 2 - credentials stored in Supabase)
- Add matrix-js-sdk, marked, highlight.js, twemoji, @tanstack/svelte-virtual deps
- Copy Matrix core layer: /matrix/, /stores/matrix.ts, /cache/, /services/
- Copy Matrix components: matrix/, message/, chat-layout/, chat-settings/
- Copy UI components: EmojiPicker, Twemoji, ImagePreviewModal, VirtualList
- Copy utils: emojiData, twemoji, twemojiGlobal
- Replace lucide-svelte with Material Symbols in SyncRecoveryBanner
- Extend Avatar with xs size and status indicator prop
- Fix ui.ts store conflict: re-export toasts from toast.svelte.ts
- Add migration 020_matrix_credentials for storing Matrix tokens per user/org
- Add /api/matrix-credentials endpoint (GET/POST/DELETE)
- Create [orgSlug]/chat page with Matrix login form + full chat UI
- Add Chat to sidebar navigation
2026-02-07 01:44:06 +02:00

196 lines
5.2 KiB
TypeScript

/**
* Media caching utilities
* Provides cached access to avatars and media with blob storage
*/
import { getCachedMedia, cacheMedia, getCachedAvatar, cacheAvatar, isCacheAvailable } from './index';
// In-memory cache for blob URLs to avoid creating duplicates
const blobUrlCache = new Map<string, string>();
/**
* Fetch media with caching support
* Returns a blob URL that can be used directly in img/video/audio elements
*/
export async function fetchMediaCached(url: string): Promise<string> {
// Check in-memory cache first
const memCached = blobUrlCache.get(url);
if (memCached) return memCached;
// Check IndexedDB cache
if (isCacheAvailable()) {
const cachedBlob = await getCachedMedia(url);
if (cachedBlob) {
const blobUrl = URL.createObjectURL(cachedBlob);
blobUrlCache.set(url, blobUrl);
return blobUrl;
}
}
// Fetch from network
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
// Cache in IndexedDB
if (isCacheAvailable()) {
cacheMedia(url, blob).catch(() => { });
}
// Create and cache blob URL
const blobUrl = URL.createObjectURL(blob);
blobUrlCache.set(url, blobUrl);
return blobUrl;
} catch {
// Return original URL as fallback
return url;
}
}
/**
* Fetch avatar with caching support
* Handles mxc:// URLs with authenticated requests
*/
export async function fetchAvatarCached(
mxcUrl: string | null,
homeserverUrl: string,
size = 40
): Promise<string | null> {
if (!mxcUrl) return null;
// Check in-memory cache first (fastest)
const memCached = blobUrlCache.get(mxcUrl);
if (memCached) return memCached;
// Check IndexedDB cache
if (isCacheAvailable()) {
const cached = await getCachedAvatar(mxcUrl);
if (cached) {
blobUrlCache.set(mxcUrl, cached.blobUrl);
return cached.blobUrl;
}
}
// Get auth token for authenticated fetch
let accessToken: string | null = null;
try {
const creds = localStorage.getItem('matrix_credentials');
if (creds) {
accessToken = JSON.parse(creds).accessToken;
}
} catch { }
// Convert mxc:// to authenticated HTTP URL
const httpUrl = mxcToHttpAuth(mxcUrl, homeserverUrl, size);
if (!httpUrl) return null;
// Fetch from network with auth
try {
const headers: HeadersInit = {};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const response = await fetch(httpUrl, { headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
// Cache in IndexedDB
if (isCacheAvailable()) {
cacheAvatar(mxcUrl, httpUrl, blob).catch(() => { });
}
// Create and cache blob URL
const blobUrl = URL.createObjectURL(blob);
blobUrlCache.set(mxcUrl, blobUrl);
return blobUrl;
} catch {
return null;
}
}
/**
* Convert mxc:// URL to authenticated HTTP thumbnail URL
* Uses /_matrix/client/v1/media/ which requires auth but is the modern standard
*/
function mxcToHttpAuth(mxcUrl: string, homeserverUrl: string, size: number): string | null {
if (!mxcUrl.startsWith('mxc://')) return null;
const parts = mxcUrl.slice(6).split('/');
if (parts.length !== 2) return null;
const [serverName, mediaId] = parts;
// Use authenticated thumbnail endpoint
if (size <= 96) {
return `${homeserverUrl}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=${size}&height=${size}&method=crop`;
}
return `${homeserverUrl}/_matrix/client/v1/media/download/${serverName}/${mediaId}`;
}
/**
* Convert mxc:// URL to HTTP thumbnail URL (legacy, unauthenticated)
*/
function mxcToHttp(mxcUrl: string, homeserverUrl: string, size: number): string | null {
if (!mxcUrl.startsWith('mxc://')) return null;
const parts = mxcUrl.slice(6).split('/');
if (parts.length !== 2) return null;
const [serverName, mediaId] = parts;
// Use thumbnail endpoint for smaller sizes
if (size <= 96) {
return `${homeserverUrl}/_matrix/media/v3/thumbnail/${serverName}/${mediaId}?width=${size}&height=${size}&method=crop`;
}
// Use download endpoint for larger sizes
return `${homeserverUrl}/_matrix/media/v3/download/${serverName}/${mediaId}`;
}
/**
* Preload avatars for a list of users
* Call this when loading a room to cache avatars in advance
*/
export async function preloadAvatars(
avatarUrls: (string | null)[],
homeserverUrl: string
): Promise<void> {
const uniqueUrls = [...new Set(avatarUrls.filter(Boolean))] as string[];
// Preload in parallel, but limit concurrency
const batchSize = 5;
for (let i = 0; i < uniqueUrls.length; i += batchSize) {
const batch = uniqueUrls.slice(i, i + batchSize);
await Promise.all(
batch.map(url => fetchAvatarCached(url, homeserverUrl).catch(() => null))
);
}
}
/**
* Clear blob URL cache (call on logout)
*/
export function clearBlobUrlCache(): void {
for (const blobUrl of blobUrlCache.values()) {
URL.revokeObjectURL(blobUrl);
}
blobUrlCache.clear();
}
/**
* Get cache statistics
*/
export function getBlobCacheStats(): { count: number; urls: string[] } {
return {
count: blobUrlCache.size,
urls: [...blobUrlCache.keys()],
};
}