feat: avatars for humans #273

Merged
talksik merged 11 commits from worktree-refactored-strolling-treasure into main 2026-06-11 22:30:21 +00:00
3 changed files with 32 additions and 22 deletions
Showing only changes of commit fa9d54aab5 - Show all commits
+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> {
coderabbitai[bot] commented 2026-06-11 22:11:53 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟡 Minor | Quick win

Validate size as a positive integer before creating the canvas.

Line 22 depends on size; invalid values can yield broken avatar output or throw at runtime.

Suggested fix
 export async function toAvatarBlob(
   source: ImageBitmap,
   { size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
 ): Promise<Blob> {
+  if (!Number.isInteger(size) || size <= 0) {
+    throw new Error('Avatar size must be a positive integer');
+  }
   const side = Math.min(source.width, source.height);
   if (side === 0) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  { size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
): Promise<Blob> {
  if (!Number.isInteger(size) || size <= 0) {
    throw new Error('Avatar size must be a positive integer');
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/desktop/src/lib/avatar-image.ts` around lines 11 - 12, Validate and
normalize the incoming size parameter at the start of the avatar generation
function (the function with signature "{ size = 512, mirror = false }: { size?:
number; mirror?: boolean } = {}, ): Promise<Blob>"). Ensure size is a positive
integer before using it to create the canvas: check Number.isInteger(size) &&
size > 0 (or coerce via Math.floor and then verify >0), and if invalid either
throw a clear error or fall back to a safe default (e.g., 512); then use that
validated/normalized value for the canvas creation to prevent runtime errors or
broken avatars.

Addressed in commits ffac812 to 6bf2c99

_⚠️ Potential issue_ | _🟡 Minor_ | _⚡ Quick win_ **Validate `size` as a positive integer before creating the canvas.** Line 22 depends on `size`; invalid values can yield broken avatar output or throw at runtime. <details> <summary>Suggested fix</summary> ```diff export async function toAvatarBlob( source: ImageBitmap, { size = 512, mirror = false }: { size?: number; mirror?: boolean } = {}, ): Promise<Blob> { + if (!Number.isInteger(size) || size <= 0) { + throw new Error('Avatar size must be a positive integer'); + } const side = Math.min(source.width, source.height); if (side === 0) { ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion { size = 512, mirror = false }: { size?: number; mirror?: boolean } = {}, ): Promise<Blob> { if (!Number.isInteger(size) || size <= 0) { throw new Error('Avatar size must be a positive integer'); } ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@js/desktop/src/lib/avatar-image.ts` around lines 11 - 12, Validate and normalize the incoming size parameter at the start of the avatar generation function (the function with signature "{ size = 512, mirror = false }: { size?: number; mirror?: boolean } = {}, ): Promise<Blob>"). Ensure size is a positive integer before using it to create the canvas: check Number.isInteger(size) && size > 0 (or coerce via Math.floor and then verify >0), and if invalid either throw a clear error or fall back to a safe default (e.g., 512); then use that validated/normalized value for the canvas creation to prevent runtime errors or broken avatars. ``` </details> <!-- fingerprinting:phantom:poseidon:hawk --> <!-- cr-comment:v1:3412b6e39458ff9b8c5d8ef9 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commits ffac812 to 6bf2c99
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;