feat: avatars for humans (#273)

* implement avatar backend functionality

* add avatar endpoints

* typo

* implement client side avatar upload and handling

* fixes

* Update go/internal/handler/handler.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update js/desktop/src/lib/avatar-image.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* bug in order

* remove unused component

* fix invalid migration

* fix syntax errors

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit was merged in pull request #273.
This commit is contained in:
Arjun Patel
2026-06-11 15:30:21 -07:00
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
parent ca3bbf204e
commit 4facc6b371
25 changed files with 739 additions and 256 deletions
+6 -5
View File
@@ -11,6 +11,7 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -45,11 +46,11 @@ function MemberRow({
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<HumanAvatar
avatarObjectId={human.avatar_object_id}
initials={initials}
fallbackClassName="bg-primary/10 text-primary font-medium"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{human.email_prefix}</p>
<Muted className="text-xs">{human.email}</Muted>
@@ -26,7 +26,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { particlePath } from '@/lib/particle-path';
import { resolveHumanDisplay } from '@/lib/humans';
import { RelativeTimestamp } from '@/components/relative-timestamp';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import { Separator } from '@/components/ui/separator';
import { Progress } from '@/components/ui/progress';
import { Small } from '@/components/ui/typography';
@@ -150,7 +150,7 @@ const StreamRow = memo(function StreamRow({
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => {
const avatar = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`,
@@ -158,7 +158,12 @@ const StreamRow = memo(function StreamRow({
if (otherEntry) {
const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
if (otherHuman) {
return {
initials: getInitials(otherHuman.email),
avatarObjectId: otherHuman.avatar_object_id ?? null,
};
}
}
}
@@ -166,10 +171,18 @@ const StreamRow = memo(function StreamRow({
const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id,
);
if (creator) return getInitials(creator.email);
if (creator) {
return {
initials: getInitials(creator.email),
avatarObjectId: creator.avatar_object_id ?? null,
};
}
}
return particle.properties.name.slice(0, 2).toUpperCase();
return {
initials: particle.properties.name.slice(0, 2).toUpperCase(),
avatarObjectId: null,
};
}, [
isDM,
particle.visible_to,
@@ -242,11 +255,12 @@ const StreamRow = memo(function StreamRow({
{videoThumbObjectId ? (
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
) : (
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<HumanAvatar
className={cn(isUnseen && 'ring-2 ring-primary')}
avatarObjectId={avatar.avatarObjectId}
initials={avatar.initials}
fallbackClassName="bg-primary/10 text-primary font-medium"
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
@@ -1,4 +1,4 @@
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import {
Tooltip,
TooltipContent,
@@ -161,18 +161,16 @@ function SegmentPresenceAvatars({
{visible.map((human) => (
<Tooltip key={human.humanId}>
<TooltipTrigger asChild>
<Avatar
<HumanAvatar
size="xs"
className={
onlineHumanIds?.has(human.humanId)
? 'ring-2 ring-green-500'
: 'ring-1 ring-black/50'
}
>
<AvatarFallback>
{human.emailPrefix.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
avatarObjectId={human.avatarObjectId}
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{human.email}
@@ -5,7 +5,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
import { cn } from '@/lib/utils';
import { resolveHumanDisplay } from '@/lib/humans';
@@ -113,11 +113,13 @@ export function ReactionBar({
: 'bg-black/40 hover:bg-black/50',
)}
>
<Avatar size="xs" className="shrink-0">
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white">
{firstReactor.initials}
</AvatarFallback>
</Avatar>
<HumanAvatar
size="xs"
className="shrink-0"
avatarObjectId={firstReactor.avatarObjectId}
initials={firstReactor.initials}
fallbackClassName="bg-white/15 text-[9px] font-medium text-white"
/>
<span className="truncate text-white/90">{text}</span>
{reactors.length > 1 && (
<span className="shrink-0 text-white/60">
@@ -1,179 +0,0 @@
import { forwardRef, useMemo } from 'react';
import { Headphones } from 'lucide-react';
import { cn, getInitials } from '@/lib/utils';
import { useLiveLatestChild } from '@/hooks/use-particle';
import { useAuthStore } from '@/stores/auth-store';
import { particlePath } from '@/lib/particle-path';
import type { Particle, StreamProperties } from '@/api/types';
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
import { ParticlePreview } from '@/features/particles/particle-preview';
import { useNetwork } from '@/hooks/use-networks';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { RelativeTimestamp } from '@/components/relative-timestamp';
import { Small } from '@/components/ui/typography';
interface StreamCardProps {
particle: Particle & { type: 'stream'; properties: StreamProperties };
networkId: string;
onClick: () => void;
isSelected?: boolean;
shortcutKey?: number;
}
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
function StreamCard(
{ particle, networkId, onClick, isSelected, shortcutKey },
ref,
) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const userId = useAuthStore((s) => s.user?.id) ?? '';
const network = useNetwork(networkId);
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const hasActiveHuddle =
particle.huddle_active_participants &&
particle.huddle_active_participants.length > 0;
const huddleCount = particle.huddle_active_participants?.length ?? 0;
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
}
if (latestChild) {
const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id,
);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [
isDM,
particle.visible_to,
particle.properties.name,
userId,
latestChild,
network,
]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]);
// For media particles with a transcript, show it as an overlay on the preview
const transcript =
latestChild?.type === 'media'
? latestChild.properties.transcript?.transcript
: undefined;
return (
<div
ref={ref}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick();
}}
className={cn(
'cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20',
isUnseen && 'ring-2 ring-primary',
isSelected && 'ring-2 ring-ring',
hasActiveHuddle && 'ring-2 ring-red-500/70',
)}
>
{/* Preview area */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
{hasActiveHuddle && (
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
)}
{shortcutKey && (
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
{shortcutKey}
</kbd>
)}
{latestChild ? (
<ParticlePreview particle={latestChild} />
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-xs italic">
No messages yet
</p>
</div>
)}
{/* Transcript overlay for media with transcripts */}
{transcript && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
<p className="line-clamp-2 text-md leading-snug text-white/90">
{transcript}
</p>
</div>
)}
</div>
{/* Info bar */}
<div className="flex items-center gap-2 px-2.5 py-2">
<Avatar
className={cn('size-6 shrink-0', isUnseen && 'ring-2 ring-primary')}
>
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
{initials}
</AvatarFallback>
</Avatar>
<Small
className={cn(
'min-w-0 truncate',
isUnseen
? 'font-semibold text-foreground'
: 'font-medium text-muted-foreground',
)}
>
{particle.properties.name}
</Small>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones className="size-3 text-red-400" />
<span className="text-[10px] font-medium text-red-400">
{huddleCount}
</span>
</span>
)}
{latestChild && (
<Small
className={cn(
'text-[10px]',
isUnseen ? 'text-primary' : 'text-muted-foreground',
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
)}
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</div>
</div>
</div>
);
},
);
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { KeyHint } from '@/components/key-hint';
import {
@@ -170,11 +170,12 @@ export function StreamMembersOverlay({
key={id}
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{display.initials}
</AvatarFallback>
</Avatar>
<HumanAvatar
size="sm"
avatarObjectId={display.avatarObjectId}
initials={display.initials}
fallbackClassName="text-[10px]"
/>
<span
className={cn(
'flex-1 truncate',
@@ -225,11 +226,12 @@ export function StreamMembersOverlay({
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5',
)}
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{getInitials(human.email)}
</AvatarFallback>
</Avatar>
<HumanAvatar
size="sm"
avatarObjectId={human.avatar_object_id}
initials={getInitials(human.email)}
fallbackClassName="text-[10px]"
/>
<span className="flex-1 truncate">
{human.email_prefix}
</span>
@@ -4,7 +4,8 @@ import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/api/client';
import { isParticleDeleted, type Particle } from '@/api/types';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
import { AvatarGroup } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import {
Tooltip,
TooltipContent,
@@ -147,11 +148,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
return (
<Tooltip key={humanId}>
<TooltipTrigger asChild>
<Avatar size="sm">
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
{display.initials}
</AvatarFallback>
</Avatar>
<HumanAvatar
size="sm"
avatarObjectId={display.avatarObjectId}
initials={display.initials}
fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
/>
</TooltipTrigger>
<TooltipContent>{display.email}</TooltipContent>
</Tooltip>
@@ -313,11 +315,13 @@ function MembersIndicator({
<>
<AvatarGroup>
{shownMembers.map((human) => (
<Avatar key={human.id} size="sm">
<AvatarFallback className="text-[8px]">
{resolveHumanDisplay(human.id, humans).initials}
</AvatarFallback>
</Avatar>
<HumanAvatar
key={human.id}
size="sm"
avatarObjectId={human.avatar_object_id}
initials={resolveHumanDisplay(human.id, humans).initials}
fallbackClassName="text-[8px]"
/>
))}
</AvatarGroup>
{overflow > 0 && (
@@ -355,9 +359,12 @@ function ParticleBreadcrumbContent({
return (
<span className="flex items-center gap-1.5">
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
<AvatarFallback>{display.initials}</AvatarFallback>
</Avatar>
<HumanAvatar
size="sm"
className={isOnline ? 'ring-2 ring-green-500' : ''}
avatarObjectId={display.avatarObjectId}
initials={display.initials}
/>
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
</span>
);
+22 -6
View File
@@ -11,8 +11,10 @@ import {
FileText,
Volume2,
ArrowLeft,
Camera,
} from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { WindowControls } from '@/components/window-controls';
@@ -88,6 +90,7 @@ export default function SettingsPage() {
const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled);
const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled);
const [version, setVersion] = useState<string>();
const [avatarOpen, setAvatarOpen] = useState(false);
useEffect(() => {
platform.app.getVersion().then(setVersion);
@@ -135,17 +138,30 @@ export default function SettingsPage() {
<ScrollArea className="flex-1">
{/* Profile header */}
<div className="flex items-center gap-3 px-4 py-5">
<Avatar size="lg">
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<button
type="button"
onClick={() => setAvatarOpen(true)}
className="group relative rounded-full"
aria-label="Change profile picture"
>
<HumanAvatar
size="lg"
avatarObjectId={user?.avatar_object_id}
initials={initials}
fallbackClassName="bg-primary/10 text-primary font-medium"
/>
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<Camera className="size-4 text-white" />
</span>
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user?.email_prefix}</p>
<Muted className="text-xs">{user?.email}</Muted>
</div>
</div>
<AvatarEditDialog open={avatarOpen} onOpenChange={setAvatarOpen} />
<Separator />
<SettingsGroup title="Notifications">
@@ -0,0 +1,340 @@
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 { useMediaDevicesStore } from '@/stores/media-devices-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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Profile picture</DialogTitle>
<DialogDescription>
Upload an image or take one with your camera.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-1">
<div className="bg-muted flex size-24 items-center justify-center overflow-hidden rounded-full border">
{previewUrl ? (
<img src={previewUrl} alt="" className="size-full object-cover" />
) : (
<span className="text-muted-foreground text-2xl font-medium">
{initials}
</span>
)}
</div>
<Tabs
value={tab}
onValueChange={(v) => setTab(v as 'upload' | 'camera')}
className="w-full"
>
<TabsList className="w-full">
<TabsTrigger value="upload">
<Upload />
Upload
</TabsTrigger>
<TabsTrigger value="camera">
<Camera />
Take photo
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="pt-3">
<div
{...dropZoneProps}
className={cn(
'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-5 text-center transition-colors',
isDragging && 'border-primary bg-primary/5',
)}
>
<Muted className="text-xs">Drag an image here, or</Muted>
<Button variant="outline" size="sm" onClick={openFilePicker}>
Choose image
</Button>
</div>
</TabsContent>
<TabsContent value="camera" className="pt-3">
<CameraCapture
active={open && tab === 'camera'}
onCapture={setPreparedFromBlob}
/>
</TabsContent>
</Tabs>
</div>
<DialogFooter>
{hasAvatar && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive sm:mr-auto"
onClick={handleRemove}
disabled={busy}
>
<Trash2 className="mr-1 size-3.5" />
Remove
</Button>
)}
<Button variant="outline" size="sm" onClick={close} disabled={busy}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
{busy && <Loader2 className="mr-1 size-3.5 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CameraCapture({
active,
onCapture,
}: {
active: boolean;
onCapture: (blob: Blob) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null);
const [capturing, setCapturing] = useState(false);
// Honor the camera the user picked in Audio & Video settings. `ideal` rather
// than `exact` so a since-unplugged device falls back to the default instead
// of throwing OverconstrainedError.
const savedCameraId = useMediaDevicesStore((s) => s.camera?.deviceId);
useEffect(() => {
if (!active) return;
let cancelled = false;
let acquired: MediaStream | null = null;
const video: MediaTrackConstraints = { aspectRatio: { ideal: 1 } };
if (savedCameraId) video.deviceId = { ideal: savedCameraId };
navigator.mediaDevices
.getUserMedia({ video, 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, savedCameraId]);
useEffect(() => {
if (videoRef.current) videoRef.current.srcObject = stream;
}, [stream]);
const handleCapture = async () => {
const video = videoRef.current;
// readyState < HAVE_CURRENT_DATA (or zero dimensions) means no frame has
// decoded yet — capturing now would grab a blank image.
if (!video || video.readyState < 2 || !video.videoWidth) {
toast.error('Camera is still starting — try again in a moment.');
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 (
<div className="flex flex-col items-center gap-1 rounded-lg border border-dashed px-6 py-5 text-center">
<Muted className="text-xs">Couldn't access your camera.</Muted>
<Muted className="text-[11px]">{error}</Muted>
</div>
);
}
return (
<div className="flex flex-col items-center gap-3">
<div className="bg-muted size-36 overflow-hidden rounded-full border">
<video
ref={videoRef}
muted
autoPlay
playsInline
className="size-full -scale-x-100 object-cover"
/>
</div>
<Button size="sm" onClick={handleCapture} disabled={!stream || capturing}>
<Camera className="mr-1 size-3.5" />
Capture
</Button>
</div>
);
}