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.
-
-
-
-
-
-
- {isUploading ? : }
- {isUploading ? "Uploading..." : "Upload photo"}
-
- {hasAvatarDraftChanges ? (
-
- Undo
-
- ) : (
-
- You can always add one later.
-
- )}
-
-
-
-
-
-
- Avatar URL
-
-
-
- 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.
+
+
+
+
+
+
+ {isUploading ? : }
+ {isUploading ? "Uploading..." : "Upload photo"}
+
+ {showClear && onClear ? (
+
+ Undo
+
+ ) : (
+
{idleHint}
+ )}
+
{
+ void handleFileChange(event);
+ }}
+ ref={inputRef}
+ type="file"
+ />
+
+
+
+
+
+ Avatar URL
+
+
+
+ {
+ 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({
-
-
- Avatar URL
-
-
-
- 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"
+ />