This commit is contained in:
Arjun Patel
2026-06-11 15:03:33 -07:00
parent c4e6cfa29b
commit fa9d54aab5
3 changed files with 32 additions and 22 deletions
+4 -2
View File
@@ -2,8 +2,10 @@ 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'> {
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. */
@@ -14,6 +14,7 @@ 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';
@@ -30,7 +31,10 @@ interface AvatarEditDialogProps {
onOpenChange: (open: boolean) => void;
}
export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps) {
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);
@@ -156,11 +160,7 @@ export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps)
<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"
/>
<img src={previewUrl} alt="" className="size-full object-cover" />
) : (
<span className="text-muted-foreground text-2xl font-medium">
{initials}
@@ -221,12 +221,7 @@ export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps)
Remove
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={close}
disabled={busy}
>
<Button variant="outline" size="sm" onClick={close} disabled={busy}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
@@ -250,6 +245,10 @@ function CameraCapture({
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;
@@ -257,8 +256,11 @@ function CameraCapture({
let cancelled = false;
let acquired: MediaStream | null = null;
const video: MediaTrackConstraints = { aspectRatio: { ideal: 1 } };
if (savedCameraId) video.deviceId = { ideal: savedCameraId };
navigator.mediaDevices
.getUserMedia({ video: { aspectRatio: { ideal: 1 } }, audio: false })
.getUserMedia({ video, audio: false })
.then((s) => {
if (cancelled) {
s.getTracks().forEach((t) => t.stop());
@@ -280,7 +282,7 @@ function CameraCapture({
acquired?.getTracks().forEach((t) => t.stop());
setStream(null);
};
}, [active]);
}, [active, savedCameraId]);
useEffect(() => {
if (videoRef.current) videoRef.current.srcObject = stream;
@@ -288,7 +290,12 @@ function CameraCapture({
const handleCapture = async () => {
const video = videoRef.current;
if (!video) return;
// 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);
@@ -324,11 +331,7 @@ function CameraCapture({
className="size-full -scale-x-100 object-cover"
/>
</div>
<Button
size="sm"
onClick={handleCapture}
disabled={!stream || capturing}
>
<Button size="sm" onClick={handleCapture} disabled={!stream || capturing}>
<Camera className="mr-1 size-3.5" />
Capture
</Button>
+5
View File
@@ -11,6 +11,11 @@ export async function toAvatarBlob(
{ size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
): Promise<Blob> {
const side = Math.min(source.width, source.height);
if (side === 0) {
// A not-yet-decoded <video> or a corrupt image yields a zero-size source;
// cropping it would silently produce a blank avatar, so fail instead.
throw new Error('Image source has zero dimensions');
}
const sx = (source.width - side) / 2;
const sy = (source.height - side) / 2;