fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user