import { useCallback, useEffect, useRef, useState } from 'react'; import { Camera, Loader2, Trash2, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { Muted } from '@/components/ui/typography'; import { apiClient } from '@/api/client'; import { useAuthStore } from '@/stores/auth-store'; import { useAvatarUrl } from '@/hooks/use-avatar-url'; import { useFileInput } from '@/hooks/use-file-input'; import { toAvatarBlob } from '@/lib/avatar-image'; import { logError, toUserMessage } from '@/lib/errors'; import { cn } from '@/lib/utils'; // Guard the *source* file before decoding so we never load a huge image into // memory just to throw most of it away — the uploaded blob is always our small // re-encoded square regardless of input size. const MAX_SOURCE_BYTES = 30 * 1024 * 1024; interface AvatarEditDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps) { const user = useAuthStore((s) => s.user); const refreshUser = useAuthStore((s) => s.refreshUser); const currentUrl = useAvatarUrl(user?.avatar_object_id); const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?'; const hasAvatar = !!user?.avatar_object_id; const [tab, setTab] = useState<'upload' | 'camera'>('upload'); const [prepared, setPrepared] = useState<{ blob: Blob; url: string } | null>( null, ); const [busy, setBusy] = useState(false); // Keep the latest prepared blob in a ref so the unmount cleanup can revoke // its object URL without re-running on every change. const preparedRef = useRef(prepared); useEffect(() => { preparedRef.current = prepared; }, [prepared]); useEffect( () => () => { if (preparedRef.current) URL.revokeObjectURL(preparedRef.current.url); }, [], ); const setPreparedFromBlob = useCallback((blob: Blob) => { setPrepared((prev) => { if (prev) URL.revokeObjectURL(prev.url); return { blob, url: URL.createObjectURL(blob) }; }); }, []); // Close and reset to a clean slate so reopening starts fresh. const close = useCallback(() => { setPrepared((prev) => { if (prev) URL.revokeObjectURL(prev.url); return null; }); setTab('upload'); setBusy(false); onOpenChange(false); }, [onOpenChange]); const handleOpenChange = useCallback( (next: boolean) => { if (next) onOpenChange(true); else close(); }, [close, onOpenChange], ); const handleFiles = useCallback( async (files: File[]) => { const file = files[0]; if (!file) return; if (!file.type.startsWith('image/')) { toast.error('Please choose an image file.'); return; } if (file.size > MAX_SOURCE_BYTES) { toast.error('That image is too large — choose one under 30MB.'); return; } try { const bitmap = await createImageBitmap(file); const blob = await toAvatarBlob(bitmap); bitmap.close(); setPreparedFromBlob(blob); } catch (err) { toast.error('Could not process that image.'); logError(err, { scope: 'avatar.processFile' }); } }, [setPreparedFromBlob], ); const { openFilePicker, isDragging, dropZoneProps } = useFileInput({ onFilesSelected: handleFiles, enabled: open && tab === 'upload', }); const handleSave = async () => { if (!prepared) return; setBusy(true); try { await apiClient.updateAvatar(prepared.blob); await refreshUser(); toast.success('Avatar updated'); close(); } catch (err) { toast.error(toUserMessage(err)); logError(err, { scope: 'avatar.save' }); setBusy(false); } }; const handleRemove = async () => { setBusy(true); try { await apiClient.deleteAvatar(); await refreshUser(); toast.success('Avatar removed'); close(); } catch (err) { toast.error(toUserMessage(err)); logError(err, { scope: 'avatar.remove' }); setBusy(false); } }; const previewUrl = prepared?.url ?? currentUrl; return ( Profile picture Upload an image or take one with your camera. {previewUrl ? ( ) : ( {initials} )} setTab(v as 'upload' | 'camera')} className="w-full" > Upload Take photo Drag an image here, or Choose image {hasAvatar && ( Remove )} Cancel {busy && } Save ); } function CameraCapture({ active, onCapture, }: { active: boolean; onCapture: (blob: Blob) => void; }) { const videoRef = useRef(null); const [stream, setStream] = useState(null); const [error, setError] = useState(null); const [capturing, setCapturing] = useState(false); useEffect(() => { if (!active) return; let cancelled = false; let acquired: MediaStream | null = null; navigator.mediaDevices .getUserMedia({ video: { aspectRatio: { ideal: 1 } }, audio: false }) .then((s) => { if (cancelled) { s.getTracks().forEach((t) => t.stop()); return; } acquired = s; setStream(s); setError(null); }) .catch((err: unknown) => { if (cancelled) return; setError( err instanceof Error ? err.message : 'Unable to access camera', ); }); return () => { cancelled = true; acquired?.getTracks().forEach((t) => t.stop()); setStream(null); }; }, [active]); useEffect(() => { if (videoRef.current) videoRef.current.srcObject = stream; }, [stream]); const handleCapture = async () => { const video = videoRef.current; if (!video) return; setCapturing(true); try { const bitmap = await createImageBitmap(video); // Mirror to match the (mirrored) live preview the user is looking at. const blob = await toAvatarBlob(bitmap, { mirror: true }); bitmap.close(); onCapture(blob); } catch (err) { toast.error('Could not capture photo.'); logError(err, { scope: 'avatar.capture' }); } finally { setCapturing(false); } }; if (error) { return ( Couldn't access your camera. {error} ); } return ( Capture ); }