diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 210b3a6a1..96d66c306 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,11 +143,8 @@ 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(""); + const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false); // For displaying the current identity at the top of the profile step and // for refreshing the UI in place after `import_identity` completes — the @@ -183,23 +171,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 +186,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 +250,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,12 +267,10 @@ export function OnboardingFlow({ const profileStepState: ProfileStepState = { avatar: { draftUrl: profileDraft.avatarUrl, - errorMessage: avatarErrorMessage, - inputRef: avatarInputRef, - isUploading: isUploadingAvatar, savedUrl: savedProfile.avatarUrl, }, currentNpub, + isUploadingAvatar, isSaving: isSavingProfile, name: { draftValue: profileDraft.displayName, @@ -410,16 +345,13 @@ export function OnboardingFlow({ advanceWithoutSaving: showSetupPage, clearAvatarDraft: resetAvatarDraft, importIdentity: handleImportIdentity, - openAvatarPicker, + onUploadingChange: setIsUploadingAvatar, 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..67a205e8f 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,23 @@ export function ProfileStep({ actions, state }: ProfileStepProps) { advanceWithoutSaving, clearAvatarDraft, importIdentity, - openAvatarPicker, + onUploadingChange, skipForNow, submit, updateAvatarUrl, updateDisplayName, - uploadAvatarFile, } = actions; - const { avatar, currentNpub, isSaving, name, saveRecovery } = state; - const { errorMessage: avatarErrorMessage } = avatar; + const { + avatar, + currentNpub, + isUploadingAvatar, + isSaving, + name, + saveRecovery, + } = state; 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 && !isUploadingAvatar; const avatarPreviewLabel = displayNameDraft.trim() || savedDisplayName || "You"; @@ -424,7 +307,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 +325,21 @@ 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..cae551fd5 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; }; @@ -54,6 +49,7 @@ export type ProfileStepState = { /** Bech32-encoded current pubkey (npub1…), shown so the user can confirm * which identity they're saving the profile for. */ currentNpub: string | null; + isUploadingAvatar: boolean; isSaving: boolean; name: ProfileStepNameState; saveRecovery: ProfileStepSaveRecovery; @@ -63,12 +59,11 @@ export type ProfileStepActions = { advanceWithoutSaving: () => void; clearAvatarDraft: () => void; importIdentity: (nsec: string) => Promise; - openAvatarPicker: () => void; + onUploadingChange: (isUploading: boolean) => 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..990e239a1 --- /dev/null +++ b/desktop/src/features/profile/ui/AvatarUpload.tsx @@ -0,0 +1,146 @@ +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; + onUploadingChange?: (isUploading: boolean) => void; + showClear?: boolean; + disabled?: boolean; + idleHint?: string; + testIdPrefix?: string; +}; + +export function AvatarUpload({ + avatarUrl, + previewName, + onUrlChange, + onClear, + onUploadingChange, + showClear, + disabled, + idleHint = "You can always add one later.", + testIdPrefix = "avatar", +}: AvatarUploadProps) { + const onUploadSuccess = React.useCallback( + (url: string) => { + onUrlChange(url); + }, + [onUrlChange], + ); + + const { + inputRef, + isUploading, + errorMessage, + clearError, + openPicker, + handleFileChange, + } = useAvatarUpload({ onUploadSuccess }); + + React.useEffect(() => { + onUploadingChange?.(isUploading); + }, [isUploading, onUploadingChange]); + + const isInputDisabled = disabled || isUploading; + + return ( +
+
+
+
+ +
+ +
+
+
+

Add a profile photo

+

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

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

{idleHint}

+ )} + { + 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..f9405a4e1 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -1,10 +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 { AvatarUpload } from "@/features/profile/ui/AvatarUpload"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; @@ -112,18 +113,15 @@ 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 (
@@ -194,26 +192,14 @@ export function ProfileSettingsCard({
-
- -
- - setAvatarUrlDraft(event.target.value)} - placeholder="https://example.com/avatar.png" - value={avatarUrlDraft} - /> -
-
+ setAvatarUrlDraft(url)} + disabled={updateProfileMutation.isPending} + idleHint="Upload or paste a URL to change your avatar." + testIdPrefix="profile-avatar" + />