fixes
This commit is contained in:
@@ -2,8 +2,10 @@ import * as React from 'react';
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
|
|
||||||
interface HumanAvatarProps
|
interface HumanAvatarProps extends Omit<
|
||||||
extends Omit<React.ComponentProps<typeof Avatar>, 'children'> {
|
React.ComponentProps<typeof Avatar>,
|
||||||
|
'children'
|
||||||
|
> {
|
||||||
/** Object id of the human's profile picture, if any. */
|
/** Object id of the human's profile picture, if any. */
|
||||||
avatarObjectId?: string | null;
|
avatarObjectId?: string | null;
|
||||||
/** Initials rendered while loading or when no picture is set. */
|
/** 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 { Muted } from '@/components/ui/typography';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
||||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
import { useFileInput } from '@/hooks/use-file-input';
|
import { useFileInput } from '@/hooks/use-file-input';
|
||||||
import { toAvatarBlob } from '@/lib/avatar-image';
|
import { toAvatarBlob } from '@/lib/avatar-image';
|
||||||
@@ -30,7 +31,10 @@ interface AvatarEditDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps) {
|
export function AvatarEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: AvatarEditDialogProps) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const refreshUser = useAuthStore((s) => s.refreshUser);
|
const refreshUser = useAuthStore((s) => s.refreshUser);
|
||||||
const currentUrl = useAvatarUrl(user?.avatar_object_id);
|
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="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">
|
<div className="bg-muted flex size-24 items-center justify-center overflow-hidden rounded-full border">
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
<img
|
<img src={previewUrl} alt="" className="size-full object-cover" />
|
||||||
src={previewUrl}
|
|
||||||
alt=""
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-2xl font-medium">
|
<span className="text-muted-foreground text-2xl font-medium">
|
||||||
{initials}
|
{initials}
|
||||||
@@ -221,12 +221,7 @@ export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps)
|
|||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={close} disabled={busy}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={close}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
|
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
|
||||||
@@ -250,6 +245,10 @@ function CameraCapture({
|
|||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [capturing, setCapturing] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -257,8 +256,11 @@ function CameraCapture({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let acquired: MediaStream | null = null;
|
let acquired: MediaStream | null = null;
|
||||||
|
|
||||||
|
const video: MediaTrackConstraints = { aspectRatio: { ideal: 1 } };
|
||||||
|
if (savedCameraId) video.deviceId = { ideal: savedCameraId };
|
||||||
|
|
||||||
navigator.mediaDevices
|
navigator.mediaDevices
|
||||||
.getUserMedia({ video: { aspectRatio: { ideal: 1 } }, audio: false })
|
.getUserMedia({ video, audio: false })
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
s.getTracks().forEach((t) => t.stop());
|
s.getTracks().forEach((t) => t.stop());
|
||||||
@@ -280,7 +282,7 @@ function CameraCapture({
|
|||||||
acquired?.getTracks().forEach((t) => t.stop());
|
acquired?.getTracks().forEach((t) => t.stop());
|
||||||
setStream(null);
|
setStream(null);
|
||||||
};
|
};
|
||||||
}, [active]);
|
}, [active, savedCameraId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoRef.current) videoRef.current.srcObject = stream;
|
if (videoRef.current) videoRef.current.srcObject = stream;
|
||||||
@@ -288,7 +290,12 @@ function CameraCapture({
|
|||||||
|
|
||||||
const handleCapture = async () => {
|
const handleCapture = async () => {
|
||||||
const video = videoRef.current;
|
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);
|
setCapturing(true);
|
||||||
try {
|
try {
|
||||||
const bitmap = await createImageBitmap(video);
|
const bitmap = await createImageBitmap(video);
|
||||||
@@ -324,11 +331,7 @@ function CameraCapture({
|
|||||||
className="size-full -scale-x-100 object-cover"
|
className="size-full -scale-x-100 object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button size="sm" onClick={handleCapture} disabled={!stream || capturing}>
|
||||||
size="sm"
|
|
||||||
onClick={handleCapture}
|
|
||||||
disabled={!stream || capturing}
|
|
||||||
>
|
|
||||||
<Camera className="mr-1 size-3.5" />
|
<Camera className="mr-1 size-3.5" />
|
||||||
Capture
|
Capture
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export async function toAvatarBlob(
|
|||||||
{ size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
|
{ size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const side = Math.min(source.width, source.height);
|
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 sx = (source.width - side) / 2;
|
||||||
const sy = (source.height - side) / 2;
|
const sy = (source.height - side) / 2;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user