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
This commit is contained in:
195
src/lib/cache/mediaCache.ts
vendored
Normal file
195
src/lib/cache/mediaCache.ts
vendored
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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()],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user