implement client side avatar upload and handling

This commit is contained in:
Arjun Patel
2026-06-11 14:49:48 -07:00
parent 71fe83bd0f
commit c4e6cfa29b
17 changed files with 588 additions and 78 deletions
+34 -8
View File
@@ -42,16 +42,12 @@ class ApiClient {
this.config = config; this.config = config;
} }
private async fetch( private async send(
method: string, method: string,
path: string, path: string,
body?: unknown, init: { headers?: Record<string, string>; body?: BodyInit } = {},
): Promise<Response> { ): Promise<Response> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = { ...init.headers };
if (body) {
headers['Content-Type'] = 'application/json';
}
const token = this.config.getToken(); const token = this.config.getToken();
if (token) { if (token) {
@@ -61,7 +57,7 @@ class ApiClient {
const response = await fetch(`${this.config.baseUrl}${path}`, { const response = await fetch(`${this.config.baseUrl}${path}`, {
method, method,
headers, headers,
body: body ? JSON.stringify(body) : undefined, body: init.body,
}); });
if (response.status === 401) { if (response.status === 401) {
@@ -77,6 +73,17 @@ class ApiClient {
return response; return response;
} }
private async fetch(
method: string,
path: string,
body?: unknown,
): Promise<Response> {
return this.send(method, path, {
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
}
private async request<T>( private async request<T>(
schema: z.ZodType<T>, schema: z.ZodType<T>,
method: string, method: string,
@@ -137,6 +144,25 @@ class ApiClient {
await this.requestVoid('PATCH', '/humans/me/settings', data); await this.requestVoid('PATCH', '/humans/me/settings', data);
} }
// --- Avatar ---
async updateAvatar(blob: Blob): Promise<void> {
await this.send('PUT', '/humans/me/avatar', {
headers: { 'Content-Type': blob.type || 'image/jpeg' },
body: blob,
});
}
async deleteAvatar(): Promise<void> {
await this.requestVoid('DELETE', '/humans/me/avatar');
}
async getAvatarDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
const data = await response.json();
return data.url;
}
// --- Depot --- // --- Depot ---
async prepareUpload(data: PrepareUploadRequest) { async prepareUpload(data: PrepareUploadRequest) {
+1
View File
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
email: z.string().email(), email: z.string().email(),
email_prefix: z.string(), email_prefix: z.string(),
email_notifications_enabled: z.boolean(), email_notifications_enabled: z.boolean(),
avatar_object_id: z.string().nullable().optional(),
}); });
export type Human = z.infer<typeof HumanSchema>; export type Human = z.infer<typeof HumanSchema>;
@@ -0,0 +1,34 @@
import * as React from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
interface HumanAvatarProps
extends Omit<React.ComponentProps<typeof Avatar>, 'children'> {
/** Object id of the human's profile picture, if any. */
avatarObjectId?: string | null;
/** Initials rendered while loading or when no picture is set. */
initials: string;
/** Extra classes for the initials fallback. */
fallbackClassName?: string;
}
/**
* Renders a human's avatar: their profile picture when set (resolved to a
* signed URL), otherwise their initials. The fallback also shows while the
* image loads or if it fails, so this is a drop-in for the initials-only
* <Avatar> usages throughout the app.
*/
export function HumanAvatar({
avatarObjectId,
initials,
fallbackClassName,
...props
}: HumanAvatarProps) {
const url = useAvatarUrl(avatarObjectId);
return (
<Avatar {...props}>
{url && <AvatarImage src={url} alt={initials} />}
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
</Avatar>
);
}
+6 -5
View File
@@ -11,6 +11,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -45,11 +46,11 @@ function MemberRow({
return ( return (
<div className="flex w-full items-center gap-3 px-4 py-3"> <div className="flex w-full items-center gap-3 px-4 py-3">
<Avatar> <HumanAvatar
<AvatarFallback className="bg-primary/10 text-primary font-medium"> avatarObjectId={human.avatar_object_id}
{initials} initials={initials}
</AvatarFallback> fallbackClassName="bg-primary/10 text-primary font-medium"
</Avatar> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{human.email_prefix}</p> <p className="truncate text-sm font-medium">{human.email_prefix}</p>
<Muted className="text-xs">{human.email}</Muted> <Muted className="text-xs">{human.email}</Muted>
@@ -26,7 +26,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { particlePath } from '@/lib/particle-path'; import { particlePath } from '@/lib/particle-path';
import { resolveHumanDisplay } from '@/lib/humans'; import { resolveHumanDisplay } from '@/lib/humans';
import { RelativeTimestamp } from '@/components/relative-timestamp'; 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 { Separator } from '@/components/ui/separator';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Small } from '@/components/ui/typography'; import { Small } from '@/components/ui/typography';
@@ -150,7 +150,7 @@ const StreamRow = memo(function StreamRow({
particle.visible_to.length === 2 && particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith('human:')); particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => { const avatar = useMemo(() => {
if (isDM) { if (isDM) {
const otherEntry = particle.visible_to.find( const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`, (v) => v !== `human:${userId}`,
@@ -158,7 +158,12 @@ const StreamRow = memo(function StreamRow({
if (otherEntry) { if (otherEntry) {
const otherId = otherEntry.replace('human:', ''); const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId); 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( const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id, (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, isDM,
particle.visible_to, particle.visible_to,
@@ -242,11 +255,12 @@ const StreamRow = memo(function StreamRow({
{videoThumbObjectId ? ( {videoThumbObjectId ? (
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} /> <VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
) : ( ) : (
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}> <HumanAvatar
<AvatarFallback className="bg-primary/10 text-primary font-medium"> className={cn(isUnseen && 'ring-2 ring-primary')}
{initials} avatarObjectId={avatar.avatarObjectId}
</AvatarFallback> initials={avatar.initials}
</Avatar> fallbackClassName="bg-primary/10 text-primary font-medium"
/>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <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 { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -161,18 +161,16 @@ function SegmentPresenceAvatars({
{visible.map((human) => ( {visible.map((human) => (
<Tooltip key={human.humanId}> <Tooltip key={human.humanId}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Avatar <HumanAvatar
size="xs" size="xs"
className={ className={
onlineHumanIds?.has(human.humanId) onlineHumanIds?.has(human.humanId)
? 'ring-2 ring-green-500' ? 'ring-2 ring-green-500'
: 'ring-1 ring-black/50' : 'ring-1 ring-black/50'
} }
> avatarObjectId={human.avatarObjectId}
<AvatarFallback> initials={human.emailPrefix.slice(0, 2).toUpperCase()}
{human.emailPrefix.slice(0, 2).toUpperCase()} />
</AvatarFallback>
</Avatar>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="text-xs"> <TooltipContent side="top" className="text-xs">
{human.email} {human.email}
@@ -5,7 +5,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } 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 { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { resolveHumanDisplay } from '@/lib/humans'; import { resolveHumanDisplay } from '@/lib/humans';
@@ -113,11 +113,13 @@ export function ReactionBar({
: 'bg-black/40 hover:bg-black/50', : 'bg-black/40 hover:bg-black/50',
)} )}
> >
<Avatar size="xs" className="shrink-0"> <HumanAvatar
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white"> size="xs"
{firstReactor.initials} className="shrink-0"
</AvatarFallback> avatarObjectId={firstReactor.avatarObjectId}
</Avatar> initials={firstReactor.initials}
fallbackClassName="bg-white/15 text-[9px] font-medium text-white"
/>
<span className="truncate text-white/90">{text}</span> <span className="truncate text-white/90">{text}</span>
{reactors.length > 1 && ( {reactors.length > 1 && (
<span className="shrink-0 text-white/60"> <span className="shrink-0 text-white/60">
@@ -8,7 +8,7 @@ import type { Particle, StreamProperties } from '@/api/types';
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay'; import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
import { ParticlePreview } from '@/features/particles/particle-preview'; import { ParticlePreview } from '@/features/particles/particle-preview';
import { useNetwork } from '@/hooks/use-networks'; import { useNetwork } from '@/hooks/use-networks';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { HumanAvatar } from '@/components/human-avatar';
import { RelativeTimestamp } from '@/components/relative-timestamp'; import { RelativeTimestamp } from '@/components/relative-timestamp';
import { Small } from '@/components/ui/typography'; import { Small } from '@/components/ui/typography';
@@ -41,7 +41,7 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
particle.visible_to.length === 2 && particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith('human:')); particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => { const avatar = useMemo(() => {
if (isDM) { if (isDM) {
const otherEntry = particle.visible_to.find( const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`, (v) => v !== `human:${userId}`,
@@ -49,7 +49,12 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
if (otherEntry) { if (otherEntry) {
const otherId = otherEntry.replace('human:', ''); const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId); 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,
};
}
} }
} }
@@ -57,10 +62,18 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
const creator = network?.humans?.find( const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id, (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, isDM,
particle.visible_to, particle.visible_to,
@@ -132,13 +145,12 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
{/* Info bar */} {/* Info bar */}
<div className="flex items-center gap-2 px-2.5 py-2"> <div className="flex items-center gap-2 px-2.5 py-2">
<Avatar <HumanAvatar
className={cn('size-6 shrink-0', isUnseen && 'ring-2 ring-primary')} className={cn('size-6 shrink-0', isUnseen && 'ring-2 ring-primary')}
> avatarObjectId={avatar.avatarObjectId}
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium"> initials={avatar.initials}
{initials} fallbackClassName="bg-primary/10 text-primary text-[10px] font-medium"
</AvatarFallback> />
</Avatar>
<Small <Small
className={cn( className={cn(
'min-w-0 truncate', 'min-w-0 truncate',
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react'; 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 { ScrollArea } from '@/components/ui/scroll-area';
import { KeyHint } from '@/components/key-hint'; import { KeyHint } from '@/components/key-hint';
import { import {
@@ -170,11 +170,12 @@ export function StreamMembersOverlay({
key={id} key={id}
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70" className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
> >
<Avatar size="sm"> <HumanAvatar
<AvatarFallback className="text-[10px]"> size="sm"
{display.initials} avatarObjectId={display.avatarObjectId}
</AvatarFallback> initials={display.initials}
</Avatar> fallbackClassName="text-[10px]"
/>
<span <span
className={cn( className={cn(
'flex-1 truncate', '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', '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"> <HumanAvatar
<AvatarFallback className="text-[10px]"> size="sm"
{getInitials(human.email)} avatarObjectId={human.avatar_object_id}
</AvatarFallback> initials={getInitials(human.email)}
</Avatar> fallbackClassName="text-[10px]"
/>
<span className="flex-1 truncate"> <span className="flex-1 truncate">
{human.email_prefix} {human.email_prefix}
</span> </span>
@@ -4,7 +4,8 @@ import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { isParticleDeleted, type Particle } from '@/api/types'; import { isParticleDeleted, type Particle } from '@/api/types';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; 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 { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -147,11 +148,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
return ( return (
<Tooltip key={humanId}> <Tooltip key={humanId}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Avatar size="sm"> <HumanAvatar
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200"> size="sm"
{display.initials} avatarObjectId={display.avatarObjectId}
</AvatarFallback> initials={display.initials}
</Avatar> fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{display.email}</TooltipContent> <TooltipContent>{display.email}</TooltipContent>
</Tooltip> </Tooltip>
@@ -313,11 +315,13 @@ function MembersIndicator({
<> <>
<AvatarGroup> <AvatarGroup>
{shownMembers.map((human) => ( {shownMembers.map((human) => (
<Avatar key={human.id} size="sm"> <HumanAvatar
<AvatarFallback className="text-[8px]"> key={human.id}
{resolveHumanDisplay(human.id, humans).initials} size="sm"
</AvatarFallback> avatarObjectId={human.avatar_object_id}
</Avatar> initials={resolveHumanDisplay(human.id, humans).initials}
fallbackClassName="text-[8px]"
/>
))} ))}
</AvatarGroup> </AvatarGroup>
{overflow > 0 && ( {overflow > 0 && (
@@ -355,9 +359,12 @@ function ParticleBreadcrumbContent({
return ( return (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}> <HumanAvatar
<AvatarFallback>{display.initials}</AvatarFallback> size="sm"
</Avatar> className={isOnline ? 'ring-2 ring-green-500' : ''}
avatarObjectId={display.avatarObjectId}
initials={display.initials}
/>
{display.displayName} - <RelativeTimestamp date={particle.created_at} /> {display.displayName} - <RelativeTimestamp date={particle.created_at} />
</span> </span>
); );
+22 -6
View File
@@ -11,8 +11,10 @@ import {
FileText, FileText,
Volume2, Volume2,
ArrowLeft, ArrowLeft,
Camera,
} from 'lucide-react'; } 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 { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { WindowControls } from '@/components/window-controls'; import { WindowControls } from '@/components/window-controls';
@@ -88,6 +90,7 @@ export default function SettingsPage() {
const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled); const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled);
const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled); const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled);
const [version, setVersion] = useState<string>(); const [version, setVersion] = useState<string>();
const [avatarOpen, setAvatarOpen] = useState(false);
useEffect(() => { useEffect(() => {
platform.app.getVersion().then(setVersion); platform.app.getVersion().then(setVersion);
@@ -135,17 +138,30 @@ export default function SettingsPage() {
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{/* Profile header */} {/* Profile header */}
<div className="flex items-center gap-3 px-4 py-5"> <div className="flex items-center gap-3 px-4 py-5">
<Avatar size="lg"> <button
<AvatarFallback className="bg-primary/10 text-primary font-medium"> type="button"
{initials} onClick={() => setAvatarOpen(true)}
</AvatarFallback> className="group relative rounded-full"
</Avatar> 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"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user?.email_prefix}</p> <p className="truncate text-sm font-medium">{user?.email_prefix}</p>
<Muted className="text-xs">{user?.email}</Muted> <Muted className="text-xs">{user?.email}</Muted>
</div> </div>
</div> </div>
<AvatarEditDialog open={avatarOpen} onOpenChange={setAvatarOpen} />
<Separator /> <Separator />
<SettingsGroup title="Notifications"> <SettingsGroup title="Notifications">
@@ -0,0 +1,337 @@
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 (
<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);
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 (
<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>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useQuery, skipToken } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
/**
* Resolves an avatar object id to a signed download URL. Mirrors
* {@link import('./use-download-url').useDownloadUrl} — React Query handles
* caching and de-duping, so many avatars sharing an id make a single request.
*/
export function useAvatarUrl(
objectId: string | null | undefined,
): string | undefined {
const { data } = useQuery({
queryKey: ['avatar-url', objectId],
queryFn: objectId
? () => apiClient.getAvatarDownloadUrl(objectId)
: skipToken,
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
});
return data;
}
@@ -5,6 +5,7 @@ export interface HumanPresence {
humanId: string; humanId: string;
email: string; email: string;
emailPrefix: string; emailPrefix: string;
avatarObjectId: string | null;
} }
/** /**
@@ -42,6 +43,7 @@ export function usePresencePositions(
humanId: human.id, humanId: human.id,
email: human.email, email: human.email,
emailPrefix: human.email_prefix, emailPrefix: human.email_prefix,
avatarObjectId: human.avatar_object_id ?? null,
}; };
if (existing) { if (existing) {
existing.push(presence); existing.push(presence);
+28
View File
@@ -0,0 +1,28 @@
/**
* Center-crop an image source to a square and downscale it to a JPEG suitable
* for an avatar. Avatars never render larger than ~80px, so 512px is generous
* headroom while keeping the upload to a few tens of KB regardless of input.
*
* Pass `mirror` when capturing from a (mirrored) webcam preview so the saved
* image matches what the user saw.
*/
export async function toAvatarBlob(
source: ImageBitmap,
{ size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
): Promise<Blob> {
const side = Math.min(source.width, source.height);
const sx = (source.width - side) / 2;
const sy = (source.height - side) / 2;
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to acquire 2D canvas context');
if (mirror) {
ctx.translate(size, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(source, sx, sy, side, side, 0, 0, size, size);
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.82 });
}
+4
View File
@@ -13,6 +13,8 @@ export interface HumanDisplay {
email: string; email: string;
/** Initials for avatar fallback. */ /** Initials for avatar fallback. */
initials: string; initials: string;
/** Avatar object id, when the human has a profile picture set. */
avatarObjectId: string | null;
} }
/** /**
@@ -32,6 +34,7 @@ export function resolveHumanDisplay(
displayName: REMOVED_MEMBER_LABEL, displayName: REMOVED_MEMBER_LABEL,
email: REMOVED_MEMBER_LABEL, email: REMOVED_MEMBER_LABEL,
initials: REMOVED_MEMBER_INITIALS, initials: REMOVED_MEMBER_INITIALS,
avatarObjectId: null,
}; };
} }
return { return {
@@ -39,5 +42,6 @@ export function resolveHumanDisplay(
displayName: human.email_prefix, displayName: human.email_prefix,
email: human.email, email: human.email,
initials: getInitials(human.email), initials: getInitials(human.email),
avatarObjectId: human.avatar_object_id ?? null,
}; };
} }
+6
View File
@@ -30,6 +30,7 @@ interface AuthState {
isSigningOut: boolean; isSigningOut: boolean;
error: string | null; error: string | null;
restoreSession: () => Promise<void>; restoreSession: () => Promise<void>;
refreshUser: () => Promise<void>;
requestCode: (email: string) => Promise<void>; requestCode: (email: string) => Promise<void>;
signIn: (email: string, code: string) => Promise<void>; signIn: (email: string, code: string) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
@@ -64,6 +65,11 @@ export const useAuthStore = create<AuthState>((set) => ({
} }
}, },
refreshUser: async () => {
const user = await apiClient.me();
set({ user });
},
requestCode: async (email: string) => { requestCode: async (email: string) => {
set({ isRequestingCode: true, error: null }); set({ isRequestingCode: true, error: null });
try { try {