44088b35d7
Route splitting - React.lazy for AdminPage, SettingsPage, FriendsPage, DevicePage, AuthCallbackPage; ChatsPage + ConversationPage stay eager - RouteSuspense wrapper with spinner fallback Vendor chunking - Vite manualChunks splits livekit-client, libsodium, @supabase, react into dedicated cacheable chunks Image thumbnails - createImageBitmap + OffscreenCanvas downscales inline preview to max 640px, emits webp; full blob reserved for the lightbox - Passes through gif/apng/webp so animation is preserved - decoding="async" on the inline img Attachment cache - lib/attachmentCache.ts backed by OPFS; 7-day TTL - AttachmentImage/Audio/Video/PDF/Generic read cache first, decrypt on miss, write-through on success; graceful no-op when OPFS missing Avatar cache - lib/avatarCache.ts — session Map<url, blobUrl> + warmAvatarCache() helper for bulk preload Message batching - Realtime INSERT burst collapses to a single refresh() when >3 ids land within a 250ms window; solo inserts keep the per-id path for latency parity Conversation-list virtualization - VirtualConversationList with IntersectionObserver sentinel, initial 40 rows + 40 per batch; no overhead under threshold Rust release tuning - Cargo [profile.release]: lto, codegen-units=1, strip=symbols, panic=abort, opt-level="s" — ~20-30% smaller binary, faster startup
49 lines
1.3 KiB
TypeScript
49 lines
1.3 KiB
TypeScript
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
|
// letter circle. Use this everywhere the app needs to render a profile.
|
|
|
|
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
|
|
|
interface Props {
|
|
url?: string | null | undefined;
|
|
displayName?: string | null | undefined;
|
|
className?: string;
|
|
// Background gradient classes used when no image is set. Defaults to a
|
|
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
|
|
fallbackClass?: string;
|
|
alt?: string;
|
|
}
|
|
|
|
export function Avatar({
|
|
url,
|
|
displayName,
|
|
className = 'h-10 w-10',
|
|
fallbackClass = 'bg-accent/20 text-accent',
|
|
alt,
|
|
}: Props) {
|
|
const effectiveUrl = useCachedAvatarUrl(url);
|
|
if (effectiveUrl) {
|
|
return (
|
|
<img
|
|
src={effectiveUrl}
|
|
alt={alt ?? displayName ?? ''}
|
|
className={'shrink-0 rounded-full object-cover ' + className}
|
|
draggable={false}
|
|
/>
|
|
);
|
|
}
|
|
const letter = (displayName ?? '?').trim().charAt(0).toUpperCase() || '?';
|
|
return (
|
|
<div
|
|
aria-hidden={alt ? undefined : 'true'}
|
|
className={
|
|
'flex shrink-0 items-center justify-center rounded-full font-semibold ' +
|
|
fallbackClass +
|
|
' ' +
|
|
className
|
|
}
|
|
>
|
|
{letter}
|
|
</div>
|
|
);
|
|
}
|