Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 6 additions & 74 deletions desktop/src/features/onboarding/ui/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -129,13 +128,6 @@ function resolveProfileSaveRecovery(
};
}

const AVATAR_IMAGE_TYPES = [
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
];

export function OnboardingFlow({
actions,
initialProfile,
Expand All @@ -144,19 +136,15 @@ export function OnboardingFlow({
const { complete, skipForNow } = actions;
const { setDesktopEnabled } = notifications;
const savedProfile = resolveSavedProfile(initialProfile);
const avatarInputRef = React.useRef<HTMLInputElement | null>(null);
const profileUpdateMutation = useUpdateProfileMutation();
const { error: profileSaveError, isPending: isSavingProfile } =
profileUpdateMutation;
const [currentPage, setCurrentPage] =
React.useState<OnboardingPage>("profile");
const [profileDraft, setProfileDraft] =
React.useState<OnboardingProfileValues>(savedProfile);
const [avatarErrorMessage, setAvatarErrorMessage] = React.useState<
string | null
>(null);
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
const [deniedPubkey, setDeniedPubkey] = React.useState<string>("");
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
Expand All @@ -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<OnboardingProfileValues>,
options?: { clearAvatarError?: boolean },
) => {
(patch: Partial<OnboardingProfileValues>) => {
resetProfileSaveError();
if (options?.clearAvatarError) {
setAvatarErrorMessage(null);
}
setProfileDraft((current) => ({
...current,
...patch,
Expand All @@ -208,44 +186,6 @@ export function OnboardingFlow({
[resetProfileSaveError],
);

const handleAvatarFileChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
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");
}, []);
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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,
Expand Down Expand Up @@ -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}
/>
Expand Down
165 changes: 24 additions & 141 deletions desktop/src/features/onboarding/ui/ProfileStep.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div className="rounded-[28px] border border-border/70 bg-muted/20 p-5">
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="relative h-20 w-20 shrink-0">
<ProfileAvatar
avatarUrl={draftUrl || null}
className="h-full w-full rounded-3xl text-xl"
iconClassName="h-6 w-6"
label={previewName}
testId="onboarding-avatar-preview"
/>
<div className="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground shadow-sm">
<Camera className="h-4 w-4" />
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Add a profile photo</p>
<p className="max-w-sm text-sm text-muted-foreground">
Optional, but it makes conversations easier to scan.
</p>
</div>
</div>

<div className="flex flex-col items-stretch gap-2 sm:min-w-[220px]">
<Button
className="w-full justify-center"
data-testid="onboarding-avatar-upload"
disabled={isAvatarInputDisabled}
onClick={openAvatarPicker}
size="lg"
type="button"
>
{isUploading ? <Loader2 className="animate-spin" /> : <Camera />}
{isUploading ? "Uploading..." : "Upload photo"}
</Button>
{hasAvatarDraftChanges ? (
<Button
data-testid="onboarding-avatar-clear"
onClick={clearAvatarDraft}
size="sm"
type="button"
variant="ghost"
>
Undo
</Button>
) : (
<p className="text-xs text-muted-foreground">
You can always add one later.
</p>
)}
<input
accept="image/gif,image/jpeg,image/png,image/webp"
className="hidden"
onChange={uploadAvatarFile}
ref={inputRef}
type="file"
/>
</div>
</div>

<div className="mt-5 space-y-1.5">
<label className="text-sm font-medium" htmlFor="onboarding-avatar-url">
Avatar URL
</label>
<div className="relative min-w-0">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="onboarding-avatar-url"
disabled={isAvatarInputDisabled}
id="onboarding-avatar-url"
onChange={(event) => updateAvatarUrl(event.target.value)}
placeholder="https://example.com/avatar.png"
value={draftUrl}
/>
</div>
<p className="text-xs text-muted-foreground">
Prefer a link instead? Paste it here and we&apos;ll save that instead.
</p>
</div>
</div>
);
}

/**
* Import-key flow.
*
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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) => {
Expand All @@ -442,21 +325,21 @@ export function ProfileStep({ actions, state }: ProfileStepProps) {
</p>
</div>

<AvatarSection
actions={{
clearAvatarDraft,
openAvatarPicker,
updateAvatarUrl,
uploadAvatarFile,
}}
avatar={avatar}
isSaving={isSaving}
<AvatarUpload
avatarUrl={avatar.draftUrl}
previewName={avatarPreviewLabel}
onUrlChange={updateAvatarUrl}
onClear={clearAvatarDraft}
onUploadingChange={onUploadingChange}
showClear={
avatar.draftUrl.length > 0 && avatar.draftUrl !== avatar.savedUrl
}
disabled={isSaving}
testIdPrefix="onboarding-avatar"
/>

<ImportKeySection onImport={importIdentity} />

<ErrorBanner message={avatarErrorMessage} />
<ErrorBanner message={saveRecovery.errorMessage} />

<div className="flex flex-wrap items-center justify-end gap-2">
Expand Down
Loading
Loading