import { Button } from '@luke-ui/react/button'; import { Cluster } from '@luke-ui/react/cluster'; import { Icon } from '@luke-ui/react/icon'; import { Text } from '@luke-ui/react/text'; import { useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { Button as RacButton } from 'react-aria-components/Button'; import { Menu, MenuItem, MenuTrigger } from 'react-aria-components/Menu'; import { Popover } from 'react-aria-components/Popover'; import type { Settings } from '../api/schemas.js'; import { profileSchema } from '../api/schemas.js'; import { settingsApi } from '../api/settings-api.js'; import { settingsQueryKey } from '../api/settings-query.js'; import * as styles from '../styles/settings.css.js'; import { avatarUploadReducer, initialAvatarUploadState } from '../workflows/avatar-upload.js'; import { SettingsRow, SettingsSection, SettingsStatus } from './settings-section.js'; const SUPPORTED_AVATAR_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); const MAX_AVATAR_BYTES = 1024 * 1024; const NAME_PARTS_PATTERN = /\s+/; const AVATAR_MUTATION_KEY = [...settingsQueryKey, 'avatar'] as const; export function ProfileAvatarSection({ children, profile, }: { children: ReactNode; profile: Settings['profile']; }) { const queryClient = useQueryClient(); const fileInputRef = useRef(null); const [avatar, dispatch] = useReducer(avatarUploadReducer, initialAvatarUploadState); const [status, setStatus] = useState(); const { error: avatarMutationError, mutateAsync: mutateAvatar, reset: resetAvatarMutation, variables: avatarMutationVariables, } = useMutation({ mutationFn: (avatarDataUrl: string | null) => settingsApi.updateProfile({ avatarDataUrl }), mutationKey: AVATAR_MUTATION_KEY, onSuccess: (settings) => queryClient.setQueryData(settingsQueryKey, settings), }); const isAvatarSaving = useIsMutating({ exact: true, mutationKey: AVATAR_MUTATION_KEY }) > 0; const isAvatarPending = avatar.status === 'reading' || isAvatarSaving; const avatarError: string | undefined = (() => { if (avatar.status === 'readFailed') return avatar.error; if (avatarMutationError instanceof Error) return avatarMutationError.message; return; })(); const saveAvatar = useCallback( async (dataUrl: string | null) => { if (isAvatarSaving) return; dispatch({ type: 'reset' }); setStatus(undefined); resetAvatarMutation(); try { await mutateAvatar(dataUrl); setStatus('Profile picture updated.'); } catch { // The mutation owns retryable save errors and keeps its variables. } }, [isAvatarSaving, mutateAvatar, resetAvatarMutation], ); useEffect(() => { if (avatar.status !== 'reading') return; let isCancelled = false; void readImage(avatar.file).then( (dataUrl) => { if (isCancelled) return; const result = profileSchema.shape.avatarDataUrl.safeParse(dataUrl); if (!result.success || !result.data) { dispatch({ error: 'Choose a PNG, JPG or WebP image smaller than 1 MB.', type: 'readFailed', }); return; } void saveAvatar(result.data); }, () => { if (isCancelled) return; dispatch({ error: 'Could not read this image. Choose another file.', type: 'readFailed' }); }, ); return () => { isCancelled = true; }; }, [avatar, saveAvatar]); function readAvatar(file: File) { if (isAvatarPending) return; setStatus(undefined); resetAvatarMutation(); if (!SUPPORTED_AVATAR_TYPES.has(file.type)) { dispatch({ error: 'Choose a PNG, JPG or WebP image.', type: 'rejected' }); return; } if (file.size > MAX_AVATAR_BYTES) { dispatch({ error: 'Choose an image smaller than 1 MB.', type: 'rejected' }); return; } dispatch({ file, type: 'selected' }); } return ( <> { const file = event.target.files?.[0]; if (file) readAvatar(file); event.target.value = ''; }} ref={fileInputRef} type="file" /> {profile.avatarDataUrl ? ( ) : ( )} { if (isAvatarPending) return; if (key === 'change') { fileInputRef.current?.click(); return; } if (key === 'remove') void saveAvatar(null); }} > Change picture Remove picture {children} {avatarError ? ( {avatarError} {avatarMutationError && avatarMutationVariables !== undefined ? ( ) : null} ) : null} {isAvatarPending ? 'Updating profile picture…' : status} ); } function getInitials(name: string): string { const parts = name.trim().split(NAME_PARTS_PATTERN); const first = parts[0]?.[0] ?? ''; const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? '') : ''; return `${first}${last}`.toUpperCase(); } async function readImage(file: File): Promise { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result !== 'string') { reject(new Error('Could not read image')); return; } resolve(reader.result); }; reader.onerror = () => reject(new Error('Could not read image')); reader.onabort = () => reject(new Error('Image read cancelled')); reader.readAsDataURL(file); }); await new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { if (image.naturalWidth === 0 || image.naturalHeight === 0) { reject(new Error('Invalid image')); return; } resolve(); }; image.onerror = () => reject(new Error('Invalid image')); image.src = dataUrl; }); return dataUrl; }