implement client side avatar upload and handling
This commit is contained in:
@@ -42,16 +42,12 @@ class ApiClient {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
private async send(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
init: { headers?: Record<string, string>; body?: BodyInit } = {},
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const headers: Record<string, string> = { ...init.headers };
|
||||
|
||||
const token = this.config.getToken();
|
||||
if (token) {
|
||||
@@ -61,7 +57,7 @@ class ApiClient {
|
||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: init.body,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
@@ -77,6 +73,17 @@ class ApiClient {
|
||||
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>(
|
||||
schema: z.ZodType<T>,
|
||||
method: string,
|
||||
@@ -137,6 +144,25 @@ class ApiClient {
|
||||
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 ---
|
||||
|
||||
async prepareUpload(data: PrepareUploadRequest) {
|
||||
|
||||
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
|
||||
email: z.string().email(),
|
||||
email_prefix: z.string(),
|
||||
email_notifications_enabled: z.boolean(),
|
||||
avatar_object_id: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { HumanAvatar } from '@/components/human-avatar';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { Small } from '@/components/ui/typography';
|
||||
|
||||
@@ -41,7 +41,7 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
|
||||
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}`,
|
||||
@@ -49,7 +49,12 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,10 +62,18 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
|
||||
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,
|
||||
@@ -132,13 +145,12 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
|
||||
|
||||
{/* Info bar */}
|
||||
<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')}
|
||||
>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
avatarObjectId={avatar.avatarObjectId}
|
||||
initials={avatar.initials}
|
||||
fallbackClassName="bg-primary/10 text-primary text-[10px] font-medium"
|
||||
/>
|
||||
<Small
|
||||
className={cn(
|
||||
'min-w-0 truncate',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
email: string;
|
||||
emailPrefix: string;
|
||||
avatarObjectId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,7 @@ export function usePresencePositions(
|
||||
humanId: human.id,
|
||||
email: human.email,
|
||||
emailPrefix: human.email_prefix,
|
||||
avatarObjectId: human.avatar_object_id ?? null,
|
||||
};
|
||||
if (existing) {
|
||||
existing.push(presence);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export interface HumanDisplay {
|
||||
email: string;
|
||||
/** Initials for avatar fallback. */
|
||||
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,
|
||||
email: REMOVED_MEMBER_LABEL,
|
||||
initials: REMOVED_MEMBER_INITIALS,
|
||||
avatarObjectId: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -39,5 +42,6 @@ export function resolveHumanDisplay(
|
||||
displayName: human.email_prefix,
|
||||
email: human.email,
|
||||
initials: getInitials(human.email),
|
||||
avatarObjectId: human.avatar_object_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AuthState {
|
||||
isSigningOut: boolean;
|
||||
error: string | null;
|
||||
restoreSession: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
requestCode: (email: string) => Promise<void>;
|
||||
signIn: (email: string, code: string) => 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) => {
|
||||
set({ isRequestingCode: true, error: null });
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user