From 63e2ba2465be3aa303c047bf62de94156526afc6 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 7 May 2026 17:54:59 -0700 Subject: [PATCH 1/2] feat(desktop): extract reusable AvatarUpload component for onboarding and settings Extract avatar upload logic from onboarding into a shared useAvatarUpload hook and AvatarUpload component. Wire the new component into both the onboarding ProfileStep and the Settings ProfileSettingsCard, replacing the URL-only input in Settings with full file upload support. Co-Authored-By: Claude Opus 4.6 --- .../features/onboarding/ui/OnboardingFlow.tsx | 77 +-------- .../features/onboarding/ui/ProfileStep.tsx | 153 ++---------------- desktop/src/features/onboarding/ui/types.ts | 7 - .../src/features/profile/ui/AvatarUpload.tsx | 140 ++++++++++++++++ .../src/features/profile/useAvatarUpload.ts | 82 ++++++++++ .../settings/ui/ProfileSettingsCard.tsx | 58 ++----- 6 files changed, 254 insertions(+), 263 deletions(-) create mode 100644 desktop/src/features/profile/ui/AvatarUpload.tsx create mode 100644 desktop/src/features/profile/useAvatarUpload.ts diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 210b3a6a1..57784f27f 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -9,7 +9,6 @@ import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { getIdentity, importIdentity as tauriImportIdentity, - uploadMediaBytes, } from "@/shared/api/tauri"; import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -129,13 +128,6 @@ function resolveProfileSaveRecovery( }; } -const AVATAR_IMAGE_TYPES = [ - "image/gif", - "image/jpeg", - "image/png", - "image/webp", -]; - export function OnboardingFlow({ actions, initialProfile, @@ -144,7 +136,6 @@ export function OnboardingFlow({ const { complete, skipForNow } = actions; const { setDesktopEnabled } = notifications; const savedProfile = resolveSavedProfile(initialProfile); - const avatarInputRef = React.useRef(null); const profileUpdateMutation = useUpdateProfileMutation(); const { error: profileSaveError, isPending: isSavingProfile } = profileUpdateMutation; @@ -152,10 +143,6 @@ export function OnboardingFlow({ React.useState("profile"); const [profileDraft, setProfileDraft] = React.useState(savedProfile); - const [avatarErrorMessage, setAvatarErrorMessage] = React.useState< - string | null - >(null); - const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false); const [deniedPubkey, setDeniedPubkey] = React.useState(""); // For displaying the current identity at the top of the profile step and @@ -183,23 +170,13 @@ export function OnboardingFlow({ // labels and similar UI reflect the active identity. const { activeWorkspace, updateWorkspace } = useWorkspaces(); - const openAvatarPicker = React.useCallback(() => { - avatarInputRef.current?.click(); - }, []); - const resetProfileSaveError = React.useCallback(() => { profileUpdateMutation.reset(); }, [profileUpdateMutation]); const updateProfileDraft = React.useCallback( - ( - patch: Partial, - options?: { clearAvatarError?: boolean }, - ) => { + (patch: Partial) => { resetProfileSaveError(); - if (options?.clearAvatarError) { - setAvatarErrorMessage(null); - } setProfileDraft((current) => ({ ...current, ...patch, @@ -208,44 +185,6 @@ export function OnboardingFlow({ [resetProfileSaveError], ); - const handleAvatarFileChange = React.useCallback( - async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - event.target.value = ""; - - if (!file) { - return; - } - - if (!AVATAR_IMAGE_TYPES.includes(file.type)) { - setAvatarErrorMessage("Choose a PNG, JPG, GIF, or WebP image."); - return; - } - - resetProfileSaveError(); - setIsUploadingAvatar(true); - setAvatarErrorMessage(null); - - try { - const buffer = await file.arrayBuffer(); - const uploaded = await uploadMediaBytes([...new Uint8Array(buffer)]); - updateProfileDraft( - { avatarUrl: uploaded.url }, - { clearAvatarError: true }, - ); - } catch (error) { - setAvatarErrorMessage( - error instanceof Error - ? error.message - : "Could not upload that avatar.", - ); - } finally { - setIsUploadingAvatar(false); - } - }, - [resetProfileSaveError, updateProfileDraft], - ); - const showSetupPage = React.useCallback(() => { setCurrentPage("setup"); }, []); @@ -310,16 +249,13 @@ export function OnboardingFlow({ const updateAvatarUrlDraft = React.useCallback( (value: string) => { - updateProfileDraft({ avatarUrl: value }, { clearAvatarError: true }); + updateProfileDraft({ avatarUrl: value }); }, [updateProfileDraft], ); const resetAvatarDraft = React.useCallback(() => { - updateProfileDraft( - { avatarUrl: savedProfile.avatarUrl }, - { clearAvatarError: true }, - ); + updateProfileDraft({ avatarUrl: savedProfile.avatarUrl }); }, [savedProfile.avatarUrl, updateProfileDraft]); const handleEnableDesktopNotifications = React.useCallback(() => { @@ -330,9 +266,6 @@ export function OnboardingFlow({ const profileStepState: ProfileStepState = { avatar: { draftUrl: profileDraft.avatarUrl, - errorMessage: avatarErrorMessage, - inputRef: avatarInputRef, - isUploading: isUploadingAvatar, savedUrl: savedProfile.avatarUrl, }, currentNpub, @@ -410,16 +343,12 @@ export function OnboardingFlow({ advanceWithoutSaving: showSetupPage, clearAvatarDraft: resetAvatarDraft, importIdentity: handleImportIdentity, - openAvatarPicker, skipForNow, submit: () => { void saveProfileAndContinue(); }, updateAvatarUrl: updateAvatarUrlDraft, updateDisplayName: updateDisplayNameDraft, - uploadAvatarFile: (event) => { - void handleAvatarFileChange(event); - }, }} state={profileStepState} /> diff --git a/desktop/src/features/onboarding/ui/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx index 2c7f3fadb..bb5499bec 100644 --- a/desktop/src/features/onboarding/ui/ProfileStep.tsx +++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx @@ -1,15 +1,7 @@ import * as React from "react"; -import { - Camera, - Check, - KeyRound, - Link2, - Loader2, - Upload, - UserRound, -} from "lucide-react"; +import { Check, KeyRound, Loader2, Upload, UserRound } from "lucide-react"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { AvatarUpload } from "@/features/profile/ui/AvatarUpload"; import { nsecToNpub, shortenNpub } from "@/shared/lib/nostrUtils"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; @@ -33,120 +25,6 @@ function ErrorBanner({ message }: { message: string | null }) { ); } -type AvatarSectionProps = { - actions: Pick< - ProfileStepActions, - | "clearAvatarDraft" - | "openAvatarPicker" - | "updateAvatarUrl" - | "uploadAvatarFile" - >; - avatar: ProfileStepState["avatar"]; - isSaving: boolean; - previewName: string; -}; - -function AvatarSection({ - actions, - avatar, - isSaving, - previewName, -}: AvatarSectionProps) { - const { - clearAvatarDraft, - openAvatarPicker, - updateAvatarUrl, - uploadAvatarFile, - } = actions; - const { draftUrl, inputRef, isUploading, savedUrl } = avatar; - const hasAvatarDraftChanges = draftUrl.length > 0 && draftUrl !== savedUrl; - const isAvatarInputDisabled = isSaving || isUploading; - - return ( -
-
-
-
- -
- -
-
-
-

Add a profile photo

-

- Optional, but it makes conversations easier to scan. -

-
-
- -
- - {hasAvatarDraftChanges ? ( - - ) : ( -

- You can always add one later. -

- )} - -
-
- -
- -
- - updateAvatarUrl(event.target.value)} - placeholder="https://example.com/avatar.png" - value={draftUrl} - /> -
-

- Prefer a link instead? Paste it here and we'll save that instead. -

-
-
- ); -} - /** * Import-key flow. * @@ -366,18 +244,14 @@ export function ProfileStep({ actions, state }: ProfileStepProps) { advanceWithoutSaving, clearAvatarDraft, importIdentity, - openAvatarPicker, skipForNow, submit, updateAvatarUrl, updateDisplayName, - uploadAvatarFile, } = actions; const { avatar, currentNpub, isSaving, name, saveRecovery } = state; - const { errorMessage: avatarErrorMessage } = avatar; const { draftValue: displayNameDraft, savedValue: savedDisplayName } = name; - const isSubmittingDisabled = isSaving || avatar.isUploading; - const canSubmit = displayNameDraft.trim().length > 0 && !isSubmittingDisabled; + const canSubmit = displayNameDraft.trim().length > 0 && !isSaving; const avatarPreviewLabel = displayNameDraft.trim() || savedDisplayName || "You"; @@ -424,7 +298,7 @@ export function ProfileStep({ actions, state }: ProfileStepProps) { autoFocus className="pl-9" data-testid="onboarding-display-name" - disabled={isSubmittingDisabled} + disabled={isSaving} id="onboarding-display-name" onChange={(event) => updateDisplayName(event.target.value)} onKeyDown={(event) => { @@ -442,21 +316,20 @@ export function ProfileStep({ actions, state }: ProfileStepProps) {

- 0 && avatar.draftUrl !== avatar.savedUrl + } + disabled={isSaving} + testIdPrefix="onboarding-avatar" /> -
diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts index 51c49e8f4..d1baf21ad 100644 --- a/desktop/src/features/onboarding/ui/types.ts +++ b/desktop/src/features/onboarding/ui/types.ts @@ -1,5 +1,3 @@ -import type * as React from "react"; - import type { DesktopNotificationPermissionState, NotificationSettings, @@ -43,9 +41,6 @@ export type ProfileStepNameState = { export type ProfileStepAvatarState = { draftUrl: string; - errorMessage: string | null; - inputRef: React.RefObject; - isUploading: boolean; savedUrl: string; }; @@ -63,12 +58,10 @@ export type ProfileStepActions = { advanceWithoutSaving: () => void; clearAvatarDraft: () => void; importIdentity: (nsec: string) => Promise; - openAvatarPicker: () => void; skipForNow: () => void; submit: () => void; updateAvatarUrl: (value: string) => void; updateDisplayName: (value: string) => void; - uploadAvatarFile: (event: React.ChangeEvent) => void; }; export type SetupStepActions = { diff --git a/desktop/src/features/profile/ui/AvatarUpload.tsx b/desktop/src/features/profile/ui/AvatarUpload.tsx new file mode 100644 index 000000000..a638bf688 --- /dev/null +++ b/desktop/src/features/profile/ui/AvatarUpload.tsx @@ -0,0 +1,140 @@ +import * as React from "react"; +import { Camera, Link2, Loader2 } from "lucide-react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; + +type AvatarUploadProps = { + avatarUrl: string; + previewName: string; + onUrlChange: (url: string) => void; + onClear?: () => void; + showClear?: boolean; + disabled?: boolean; + testIdPrefix?: string; +}; + +export function AvatarUpload({ + avatarUrl, + previewName, + onUrlChange, + onClear, + showClear, + disabled, + testIdPrefix = "avatar", +}: AvatarUploadProps) { + const onUploadSuccess = React.useCallback( + (url: string) => { + onUrlChange(url); + }, + [onUrlChange], + ); + + const { + inputRef, + isUploading, + errorMessage, + clearError, + openPicker, + handleFileChange, + } = useAvatarUpload({ onUploadSuccess }); + + const isInputDisabled = disabled || isUploading; + + return ( +
+
+
+
+ +
+ +
+
+
+

Add a profile photo

+

+ Optional, but it makes conversations easier to scan. +

+
+
+ +
+ + {showClear && onClear ? ( + + ) : ( +

+ You can always add one later. +

+ )} + { + void handleFileChange(event); + }} + ref={inputRef} + type="file" + /> +
+
+ +
+ +
+ + { + clearError(); + onUrlChange(event.target.value); + }} + placeholder="https://example.com/avatar.png" + value={avatarUrl} + /> +
+

+ Prefer a link instead? Paste it here and we'll save that instead. +

+
+ + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/useAvatarUpload.ts b/desktop/src/features/profile/useAvatarUpload.ts new file mode 100644 index 000000000..f391269a5 --- /dev/null +++ b/desktop/src/features/profile/useAvatarUpload.ts @@ -0,0 +1,82 @@ +import * as React from "react"; + +import { uploadMediaBytes } from "@/shared/api/tauri"; + +const AVATAR_IMAGE_TYPES = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]; + +type UseAvatarUploadOptions = { + onUploadSuccess: (url: string) => void; +}; + +type UseAvatarUploadReturn = { + inputRef: React.RefObject; + isUploading: boolean; + errorMessage: string | null; + clearError: () => void; + openPicker: () => void; + handleFileChange: (event: React.ChangeEvent) => void; +}; + +export function useAvatarUpload({ + onUploadSuccess, +}: UseAvatarUploadOptions): UseAvatarUploadReturn { + const inputRef = React.useRef(null); + const [isUploading, setIsUploading] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + + const clearError = React.useCallback(() => { + setErrorMessage(null); + }, []); + + const openPicker = React.useCallback(() => { + inputRef.current?.click(); + }, []); + + const handleFileChange = React.useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + + if (!file) { + return; + } + + if (!AVATAR_IMAGE_TYPES.includes(file.type)) { + setErrorMessage("Choose a PNG, JPG, GIF, or WebP image."); + return; + } + + setIsUploading(true); + setErrorMessage(null); + + try { + const buffer = await file.arrayBuffer(); + const uploaded = await uploadMediaBytes([...new Uint8Array(buffer)]); + onUploadSuccess(uploaded.url); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Could not upload that avatar.", + ); + } finally { + setIsUploading(false); + } + }, + [onUploadSuccess], + ); + + return { + inputRef, + isUploading, + errorMessage, + clearError, + openPicker, + handleFileChange, + }; +} diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 2b2a97a9e..4922910e0 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -1,11 +1,11 @@ -import { AtSign, Check, Link2, UserRound } from "lucide-react"; +import { AtSign, Check, UserRound } from "lucide-react"; import * as React from "react"; import { useProfileQuery, useUpdateProfileMutation, } from "@/features/profile/hooks"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { AvatarUpload } from "@/features/profile/ui/AvatarUpload"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Separator } from "@/shared/ui/separator"; @@ -112,30 +112,17 @@ export function ProfileSettingsCard({ fallbackDisplayName || "Your profile"; const resolvedPubkey = profile?.pubkey ?? currentPubkey ?? "Unavailable"; - const resolvedAvatarUrl = - nextAvatarUrl.length > 0 ? nextAvatarUrl : (profile?.avatarUrl ?? null); const nip05Handle = profile?.nip05Handle ?? "Not set"; return (
-
- -
-
-

- {resolvedName} -

-

- Manage how your identity appears across Sprout. -

-
-
+
+

+ {resolvedName} +

+

+ Manage how your identity appears across Sprout. +

@@ -194,26 +181,13 @@ export function ProfileSettingsCard({
-
- -
- - setAvatarUrlDraft(event.target.value)} - placeholder="https://example.com/avatar.png" - value={avatarUrlDraft} - /> -
-
+ setAvatarUrlDraft(url)} + disabled={updateProfileMutation.isPending} + testIdPrefix="profile-avatar" + />