From 2634afebafd73f6edc1c7a6cd43c3747efd51a72 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Tue, 14 Jul 2026 20:14:33 +0300 Subject: [PATCH 1/4] feat: prompt for Unicity ID when deriving a new address (#413) Both derive entry points (AddressSelector "New", Address Manager "Derive New Address") now open a shared NewAddressModal instead of deriving silently: debounced availability check, Register / explicit Skip, and the dead inline nametag modal in AddressSelector is gone. Flow safety (useNewAddressFlow): - The switch is only trusted once getCurrentAddressIndex() reports the new index (identity:changed + polling, 30s budget) - the SDK assigns sphere.identity AFTER awaited network work, so the old 5s race-and-proceed pattern could read the PREVIOUS address identity and even register the ID onto the wrong key. - Recovered-nametag completion additionally requires a chainPubkey match; background nametag:recovered while the prompt is open completes the flow; ALREADY_INITIALIZED is treated as a lost recovery race. - register() refuses to run if the wallet moved off the new address. - Retry after a derivation failure reuses the same index (the SDK has already tracked it) instead of leaking one address per attempt. - UI validates with the SDK isValidNametag gate (no more green availability followed by VALIDATION_ERROR); phone numbers are canonicalized to E.164 for availability, registration and display. - Availability "unknown" (transport missing/disconnected/error) shows "can't verify" instead of falsely claiming the ID is free. - beforeunload guard while the Nostr binding is being published; Escape/backdrop close mirrors Skip and is blocked mid-work. Next index is computed over getAllTrackedAddresses() so a hidden address index is never re-derived. New files added to the no-direct-in-ui guard test; unit tests cover availability states, validation, skip, both recovery races, the slow-switch paths and the entry-point wiring (36 tests). --- .../wallet/L3/modals/AddressManagerModal.tsx | 39 +- .../shared/components/AddressSelector.tsx | 233 +--------- .../wallet/shared/hooks/useNewAddressFlow.ts | 379 +++++++++++++++ .../wallet/shared/modals/NewAddressModal.tsx | 338 ++++++++++++++ src/components/wallet/shared/modals/index.ts | 1 + .../unit/components/NewAddressModal.test.tsx | 140 ++++++ .../components/addressEntryPoints.test.tsx | 84 ++++ tests/unit/hooks/useNewAddressFlow.test.tsx | 430 ++++++++++++++++++ tests/unit/no-direct-in-ui.test.ts | 7 +- 9 files changed, 1415 insertions(+), 236 deletions(-) create mode 100644 src/components/wallet/shared/hooks/useNewAddressFlow.ts create mode 100644 src/components/wallet/shared/modals/NewAddressModal.tsx create mode 100644 tests/unit/components/NewAddressModal.test.tsx create mode 100644 tests/unit/components/addressEntryPoints.test.tsx create mode 100644 tests/unit/hooks/useNewAddressFlow.test.tsx diff --git a/src/components/wallet/L3/modals/AddressManagerModal.tsx b/src/components/wallet/L3/modals/AddressManagerModal.tsx index 1df5449a..661aac9a 100644 --- a/src/components/wallet/L3/modals/AddressManagerModal.tsx +++ b/src/components/wallet/L3/modals/AddressManagerModal.tsx @@ -5,6 +5,7 @@ import { ModalHeader } from '../../ui'; import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; import type { TrackedAddress } from '@unicitylabs/sphere-sdk'; import { truncateId } from '../../../../utils/identifiers'; +import { NewAddressModal } from '../../shared/modals/NewAddressModal'; interface AddressManagerModalProps { isOpen: boolean; @@ -15,7 +16,8 @@ export function AddressManagerModal({ isOpen, onClose }: AddressManagerModalProp const { sphere } = useSphereContext(); const [addresses, setAddresses] = useState([]); const [togglingIndex, setTogglingIndex] = useState(null); - const [isDeriving, setIsDeriving] = useState(false); + // #413: derivation + Unicity ID prompt live in the shared NewAddressModal + const [showNewAddress, setShowNewAddress] = useState(false); const currentIndex = sphere?.getCurrentAddressIndex() ?? 0; @@ -46,21 +48,17 @@ export function AddressManagerModal({ isOpen, onClose }: AddressManagerModalProp } }, [sphere, refreshAddresses]); - const handleDeriveNew = useCallback(async () => { - if (!sphere || isDeriving) return; - setIsDeriving(true); - try { - const nextIndex = addresses.length > 0 - ? Math.max(...addresses.map(a => a.index)) + 1 - : 1; - await sphere.switchToAddress(nextIndex); - refreshAddresses(); - } catch (e) { - console.error('[AddressManager] Failed to derive new address:', e); - } finally { - setIsDeriving(false); - } - }, [sphere, addresses, isDeriving, refreshAddresses]); + // #413: open the shared derive-address flow — it derives, prompts for a + // Unicity ID (with an explicit Skip) and handles nametag recovery itself. + const handleDeriveNew = useCallback(() => { + if (!sphere) return; + setShowNewAddress(true); + }, [sphere]); + + const handleNewAddressClose = useCallback(() => { + setShowNewAddress(false); + refreshAddresses(); + }, [refreshAddresses]); return ( @@ -151,17 +149,14 @@ export function AddressManagerModal({ isOpen, onClose }: AddressManagerModalProp {/* Derive new address button */} + + ); } diff --git a/src/components/wallet/shared/components/AddressSelector.tsx b/src/components/wallet/shared/components/AddressSelector.tsx index def66cba..1be6189b 100644 --- a/src/components/wallet/shared/components/AddressSelector.tsx +++ b/src/components/wallet/shared/components/AddressSelector.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { ChevronDown, Plus, Loader2, Check, Copy, X, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { ChevronDown, Plus, Loader2, Check, Copy } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useIdentity } from '../../../../sdk'; import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; @@ -7,6 +7,7 @@ import { SPHERE_KEYS } from '../../../../sdk/queryKeys'; import { useQueryClient } from '@tanstack/react-query'; import type { TrackedAddress } from '@unicitylabs/sphere-sdk'; import { truncateId } from '../../../../utils/identifiers'; +import { NewAddressModal } from '../modals/NewAddressModal'; /** Truncate long nametags: show first 6 chars + ... + last 3 chars */ function truncateNametag(nametag: string, maxLength: number = 20): string { @@ -24,14 +25,10 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { const [copied, setCopied] = useState<'nametag' | 'address' | false>(false); const [isSwitching, setIsSwitching] = useState(false); - // Nametag modal state - const [showNametagModal, setShowNametagModal] = useState(false); - const [newNametag, setNewNametag] = useState(''); - const [nametagError, setNametagError] = useState(null); - const [nametagAvailability, setNametagAvailability] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle'); - const nametagInputRef = useRef(null); + // #413: derivation + Unicity ID prompt live in the shared NewAddressModal + const [showNewAddressModal, setShowNewAddressModal] = useState(false); - const { sphere, resolveNametag, isDiscoveringAddresses } = useSphereContext(); + const { sphere, isDiscoveringAddresses } = useSphereContext(); const { nametag, chainPubkey } = useIdentity(); const queryClient = useQueryClient(); @@ -55,34 +52,6 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { return () => { unsub1(); unsub2(); }; }, [sphere, currentAddressIndex, nametag, isDiscoveringAddresses]); - // Focus nametag input when modal opens - useEffect(() => { - if (showNametagModal) { - setTimeout(() => nametagInputRef.current?.focus(), 100); - } - }, [showNametagModal]); - - // Debounced nametag availability check - useEffect(() => { - const cleanTag = newNametag.trim().replace(/^@/, ''); - if (!cleanTag || cleanTag.length < 2) { - setNametagAvailability('idle'); - return; - } - - setNametagAvailability('checking'); - const timer = setTimeout(async () => { - try { - const existing = await resolveNametag(cleanTag); - setNametagAvailability(existing ? 'taken' : 'available'); - } catch { - setNametagAvailability('idle'); - } - }, 500); - - return () => clearTimeout(timer); - }, [newNametag, resolveNametag]); - const displayNametag = nametag; const refreshAfterSwitch = useCallback(() => { @@ -148,76 +117,18 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { } }, [sphere, isSwitching, currentAddressIndex, refreshAfterSwitch]); - // Step 1: Create new address, then check if nametag already exists (local + network) - const handleNewClick = useCallback(async () => { - if (!sphere || isSwitching) return; - - // Keep dropdown open to show "Switching..." indicator - setIsSwitching(true); - - try { - const nextIndex = addresses.length > 0 - ? Math.max(...addresses.map(a => a.index)) + 1 - : 1; - - // Create and switch to the new address - // Timeout guards against SDK hanging on Nostr publish when relay is not connected - try { - await Promise.race([ - sphere.switchToAddress(nextIndex), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)), - ]); - } catch (e) { - if (!(e instanceof Error && e.message === 'timeout')) throw e; - } - refreshAfterSwitch(); - setShowDropdown(false); - // Nametag recovery happens async in the SDK background. - // If recovered, 'nametag:recovered' event updates UI via useSphereEvents. - // If not, user can click "Register ID" in the header. - } catch (e) { - console.error('[AddressSelector] Failed to create new address:', e); - setShowDropdown(false); - } finally { - setIsSwitching(false); - } - }, [sphere, isSwitching, addresses, refreshAfterSwitch]); - - // Step 2a: Register nametag on the current address (already created in handleNewClick) - const handleCreateWithNametag = useCallback(async () => { + // #413: open the shared derive-address flow — it derives, prompts for a + // Unicity ID (with an explicit Skip) and handles nametag recovery itself. + const handleNewClick = useCallback(() => { if (!sphere || isSwitching) return; - const cleanTag = newNametag.trim().replace(/^@/, ''); - if (!cleanTag) return; - - setIsSwitching(true); - setNametagError(null); - - try { - // Check availability via Nostr transport - const existing = await resolveNametag(cleanTag); - if (existing) { - setNametagError(`@${cleanTag} is already taken`); - setIsSwitching(false); - return; - } - - // Register nametag on the current address - await sphere.registerNametag(cleanTag); - setShowNametagModal(false); - refreshAfterSwitch(); - } catch (e) { - const msg = e instanceof Error ? e.message : 'Failed to register nametag'; - console.error('[AddressSelector] Failed to register nametag:', e); - setNametagError(msg); - } finally { - setIsSwitching(false); - } - }, [sphere, isSwitching, newNametag, resolveNametag, refreshAfterSwitch]); + setShowDropdown(false); + setShowNewAddressModal(true); + }, [sphere, isSwitching]); - // Step 2b: Skip nametag (address already created in handleNewClick) - const handleCreateWithoutNametag = useCallback(() => { - setShowNametagModal(false); - }, []); + const handleNewAddressModalClose = useCallback(() => { + setShowNewAddressModal(false); + refreshAfterSwitch(); + }, [refreshAfterSwitch]); const sortedAddresses = useMemo(() => { return [...addresses].sort((a, b) => a.index - b.index); @@ -228,111 +139,9 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { return addr.chainPubkey; }, []); - // ========================================================================= - // Nametag Modal (shared between compact and full modes) - // ========================================================================= - const nametagModal = ( - - {showNametagModal && ( - <> - !isSwitching && setShowNametagModal(false)} - /> - -
- {/* Header */} -
-

- New Address -

- -
- - {/* Description */} -

- Choose a Unicity ID for this address, or skip for now. -

- - {/* Nametag input */} -
- @ - { - setNewNametag(e.target.value.toLowerCase()); - setNametagError(null); - }} - onKeyDown={e => { - if (e.key === 'Enter' && newNametag.trim() && nametagAvailability === 'available') handleCreateWithNametag(); - }} - placeholder="nametag" - disabled={isSwitching} - className="w-full pl-7 pr-8 py-2.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:border-orange-500 disabled:opacity-50 transition-colors" - /> - {/* Availability indicator */} - - {nametagAvailability === 'checking' && } - {nametagAvailability === 'available' && } - {nametagAvailability === 'taken' && } - -
- - {/* Availability / Error — fixed height to prevent layout shift */} -
- {nametagAvailability === 'taken' && !nametagError && ( -

@{newNametag.trim().replace(/^@/, '')} is already taken

- )} - {nametagError && ( -

{nametagError}

- )} - {nametagAvailability === 'available' && ( -

@{newNametag.trim().replace(/^@/, '')} is available

- )} -
- - {/* Buttons */} -
- - -
-
-
- - )} -
+ // New-address flow (#413) — shared between compact and full modes + const newAddressModal = ( + ); // No sphere — show minimal nametag if available @@ -358,7 +167,7 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { if (compact) { return (
- {nametagModal} + {newAddressModal}
+
+ + {state.step === 'deriving' && ( +
+ +

+ Generating new address... +

+ {state.slow && ( +

+ Network is slow — still working... +

+ )} +
+ )} + + {(state.step === 'nametag_input' || state.step === 'registering') && ( + <> + {state.newAddress && ( +
+ + Address #{state.newAddress.index} + + + {truncateId(state.newAddress.chainPubkey)} + +
+ )} + +

+ Choose a Unicity ID for this address, or skip for now. +

+ + {state.step === 'registering' ? ( +
+ +

+ Registering @{cleanTag}... +

+

+ + Don't close this window +

+
+ ) : ( + <> + {/* Nametag input */} +
+ + @ + + { + if (e.key === 'Enter') handleRegister(); + }} + placeholder="nametag" + maxLength={32} + className="w-full pl-7 pr-8 py-2.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:border-orange-500 transition-colors" + /> + {/* Availability indicator */} + + {availability === 'checking' && ( + + )} + {availability === 'available' && ( + + )} + {availability === 'taken' && ( + + )} + {availability === 'unknown' && ( + + )} + +
+ + {/* Availability / validation / error — fixed height, no layout shift */} +
+ {state.registerError && ( +

{state.registerError}

+ )} + {!state.registerError && showFormatHint && ( +

{NAMETAG_FORMAT_HINT}

+ )} + {!state.registerError && availability === 'taken' && ( +

@{cleanTag} is already taken

+ )} + {!state.registerError && availability === 'available' && ( +

@{cleanTag} is available

+ )} + {!state.registerError && availability === 'unknown' && ( +

+ Can't verify availability right now — registration will fail if the + ID is taken. +

+ )} +
+ + {/* Buttons */} +
+ + +
+ + )} + + )} + + {state.step === 'complete' && state.outcome !== 'skipped' && ( +
+ +

+ {state.outcome === 'recovered' ? ( + <> + @{state.completedNametag} was + recovered for this address + + ) : ( + <> + @{state.completedNametag} is ready + + )} +

+
+ )} + + {state.step === 'error' && ( +
+ +

{state.error}

+
+ + +
+
+ )} +
+ + + )} + + ); + + return createPortal(modal, document.body); +} diff --git a/src/components/wallet/shared/modals/index.ts b/src/components/wallet/shared/modals/index.ts index 30fb1d9a..3285007e 100644 --- a/src/components/wallet/shared/modals/index.ts +++ b/src/components/wallet/shared/modals/index.ts @@ -1,2 +1,3 @@ export { BackupWalletModal } from "./BackupWalletModal"; export { LogoutConfirmModal } from "./LogoutConfirmModal"; +export { NewAddressModal } from "./NewAddressModal"; diff --git a/tests/unit/components/NewAddressModal.test.tsx b/tests/unit/components/NewAddressModal.test.tsx new file mode 100644 index 00000000..4fcb0ab1 --- /dev/null +++ b/tests/unit/components/NewAddressModal.test.tsx @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { NewAddressModal } from '../../../src/components/wallet/shared/modals/NewAddressModal'; +import type { + NewAddressState, + UseNewAddressFlowReturn, +} from '../../../src/components/wallet/shared/hooks/useNewAddressFlow'; + +const NEW_PUBKEY = '02' + 'cd'.repeat(32); + +let flow: UseNewAddressFlowReturn; + +// Keep the real cleanNametagInput/NAMETAG_FORMAT_HINT exports; fake only the hook. +vi.mock( + '../../../src/components/wallet/shared/hooks/useNewAddressFlow', + async importOriginal => { + const actual = await importOriginal>(); + return { ...actual, useNewAddressFlow: () => flow }; + }, +); + +function makeState(overrides: Partial = {}): NewAddressState { + return { + step: 'nametag_input', + error: null, + registerError: null, + newAddress: { chainPubkey: NEW_PUBKEY, index: 4 }, + completedNametag: null, + outcome: null, + slow: false, + ...overrides, + }; +} + +beforeEach(() => { + flow = { + state: makeState(), + start: vi.fn(async () => {}), + register: vi.fn(async () => {}), + skip: vi.fn(), + checkAvailability: vi.fn(async () => 'available' as const), + clearRegisterError: vi.fn(), + reset: vi.fn(), + }; +}); + +function renderModal() { + return render(); +} + +describe('NewAddressModal — nametag prompt', () => { + it('shows the new address pubkey-first and starts the flow on open', () => { + renderModal(); + expect(flow.start).toHaveBeenCalledTimes(1); + expect(screen.getByText('Address #4')).toBeDefined(); + // MetaMask-style 6+4 truncation of the chain pubkey + expect( + screen.getByText(`${NEW_PUBKEY.slice(0, 6)}...${NEW_PUBKEY.slice(-4)}`), + ).toBeDefined(); + }); + + it('disables Register and shows the SDK format hint for an invalid ID', () => { + renderModal(); + fireEvent.change(screen.getByPlaceholderText('nametag'), { target: { value: 'ab' } }); + + expect(screen.getByText(/use a-z, 0-9/i)).toBeDefined(); + const register = screen.getByRole('button', { name: 'Register' }) as HTMLButtonElement; + expect(register.disabled).toBe(true); + }); + + it('treats an unverifiable availability as "cannot verify", not as free', async () => { + flow.checkAvailability = vi.fn(async () => 'unknown' as const); + renderModal(); + fireEvent.change(screen.getByPlaceholderText('nametag'), { + target: { value: 'validname' }, + }); + + await waitFor( + () => expect(screen.getByText(/can't verify availability/i)).toBeDefined(), + { timeout: 2000 }, + ); + expect(screen.queryByText(/is available/i)).toBeNull(); + // Registration stays possible — the relay is the final uniqueness gate. + const register = screen.getByRole('button', { name: 'Register' }) as HTMLButtonElement; + expect(register.disabled).toBe(false); + fireEvent.click(register); + expect(flow.register).toHaveBeenCalledWith('validname'); + }); + + it('blocks Register when the ID is taken', async () => { + flow.checkAvailability = vi.fn(async () => 'taken' as const); + renderModal(); + fireEvent.change(screen.getByPlaceholderText('nametag'), { + target: { value: 'takenname' }, + }); + + await waitFor( + () => expect(screen.getByText('@takenname is already taken')).toBeDefined(), + { timeout: 2000 }, + ); + const register = screen.getByRole('button', { name: 'Register' }) as HTMLButtonElement; + expect(register.disabled).toBe(true); + }); + + it('Skip hands off to the flow', () => { + renderModal(); + fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + expect(flow.skip).toHaveBeenCalledTimes(1); + }); + + it('cannot be closed while registering — backdrop, X and Escape are all inert', () => { + flow.state = makeState({ step: 'registering' }); + const onClose = vi.fn(); + render(); + + expect(screen.getByText(/don't close this window/i)).toBeDefined(); + + const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement; + fireEvent.click(backdrop); + const xButton = document.querySelector('button:disabled') as HTMLButtonElement; + expect(xButton).not.toBeNull(); + fireEvent.click(xButton); + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('Escape closes the prompt while input is possible', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('never renders a DIRECT:// value', () => { + const { container } = renderModal(); + expect(container.innerHTML).not.toContain('DIRECT://'); + expect(document.body.innerHTML).not.toContain('DIRECT://'); + }); +}); diff --git a/tests/unit/components/addressEntryPoints.test.tsx b/tests/unit/components/addressEntryPoints.test.tsx new file mode 100644 index 00000000..3b8d730c --- /dev/null +++ b/tests/unit/components/addressEntryPoints.test.tsx @@ -0,0 +1,84 @@ +/** + * #413 wiring: both derive-address entry points must open the shared + * NewAddressModal (and refresh their data when it closes) instead of + * deriving silently. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useState, type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AddressSelector } from '../../../src/components/wallet/shared/components/AddressSelector'; +import { AddressManagerModal } from '../../../src/components/wallet/L3/modals/AddressManagerModal'; + +const PUBKEY = '02' + 'ab'.repeat(32); + +// The shared flow itself is covered by its own tests — stub it here. +vi.mock('../../../src/components/wallet/shared/modals/NewAddressModal', () => ({ + NewAddressModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ? ( +
+ +
+ ) : null, +})); + +let fakeSphere: Record; +vi.mock('../../../src/sdk/hooks/core/useSphere', () => ({ + useSphereContext: () => ({ sphere: fakeSphere, isDiscoveringAddresses: false }), +})); + +vi.mock('../../../src/sdk', () => ({ + useIdentity: () => ({ nametag: null, chainPubkey: PUBKEY }), +})); + +function Wrapper({ children }: { children: ReactNode }) { + const [qc] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + return {children}; +} + +beforeEach(() => { + fakeSphere = { + identity: { chainPubkey: PUBKEY }, + getCurrentAddressIndex: vi.fn(() => 0), + getActiveAddresses: vi.fn(() => [{ index: 0, chainPubkey: PUBKEY, hidden: false }]), + getAllTrackedAddresses: vi.fn(() => [{ index: 0, chainPubkey: PUBKEY, hidden: false }]), + setAddressHidden: vi.fn(async () => {}), + on: vi.fn(() => () => {}), + }; +}); + +describe('AddressSelector — "New" opens the Unicity ID flow', () => { + it('opens NewAddressModal instead of deriving silently', () => { + render(, { wrapper: Wrapper }); + + // Open the dropdown (trigger shows the truncated pubkey), then hit "New". + fireEvent.click(screen.getByText(`${PUBKEY.slice(0, 6)}...${PUBKEY.slice(-4)}`)); + expect(screen.queryByTestId('new-address-modal')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + + expect(screen.getByTestId('new-address-modal')).toBeDefined(); + // No direct derivation from the selector anymore. + expect(fakeSphere.switchToAddress).toBeUndefined(); + }); +}); + +describe('AddressManagerModal — "Derive New Address" opens the Unicity ID flow', () => { + it('opens NewAddressModal and refreshes the list when the flow closes', () => { + render(, { wrapper: Wrapper }); + + const callsBefore = (fakeSphere.getAllTrackedAddresses as ReturnType).mock + .calls.length; + + fireEvent.click(screen.getByRole('button', { name: /derive new address/i })); + expect(screen.getByTestId('new-address-modal')).toBeDefined(); + + fireEvent.click(screen.getByRole('button', { name: 'close-flow' })); + expect(screen.queryByTestId('new-address-modal')).toBeNull(); + + const callsAfter = (fakeSphere.getAllTrackedAddresses as ReturnType).mock + .calls.length; + expect(callsAfter).toBeGreaterThan(callsBefore); + }); +}); diff --git a/tests/unit/hooks/useNewAddressFlow.test.tsx b/tests/unit/hooks/useNewAddressFlow.test.tsx new file mode 100644 index 00000000..f9ef3f49 --- /dev/null +++ b/tests/unit/hooks/useNewAddressFlow.test.tsx @@ -0,0 +1,430 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { createElement, useState, type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + useNewAddressFlow, + cleanNametagInput, + canonicalNametag, +} from '../../../src/components/wallet/shared/hooks/useNewAddressFlow'; + +const OLD_PUBKEY = '02' + 'ab'.repeat(32); +const NEW_PUBKEY = '02' + 'cd'.repeat(32); + +type EventHandler = (data: Record) => void; + +interface FakeSphere extends Record { + currentIndex: number; + identity: Record | null; + _handlers: Record; +} + +// The hook reads sphere/providers/resolveNametag via useSphereContext — swap in fakes. +let fakeContext: { + sphere: FakeSphere | null; + providers: Record | null; + resolveNametag: (nametag: string) => Promise; +}; +vi.mock('../../../src/sdk/hooks/core/useSphere', () => ({ + useSphereContext: () => fakeContext, +})); + +function makeSphere(overrides: Record = {}): FakeSphere { + const handlers: Record = {}; + const sphere: FakeSphere = { + currentIndex: 0, + identity: null, + _handlers: handlers, + getCurrentAddressIndex: vi.fn(() => sphere.currentIndex), + getAllTrackedAddresses: vi.fn(() => [{ index: 0 }, { index: 3, hidden: true }]), + switchToAddress: vi.fn(async (index: number) => { + sphere.currentIndex = index; + sphere.identity = { chainPubkey: NEW_PUBKEY }; + }), + deriveAddress: vi.fn((index: number) => ({ + publicKey: NEW_PUBKEY, + privateKey: 'priv', + path: `m/44'/1'/0'/0/${index}`, + index, + })), + registerNametag: vi.fn(async () => {}), + on: vi.fn((event: string, cb: EventHandler) => { + handlers[event] = cb; + return () => { + delete handlers[event]; + }; + }), + }; + return Object.assign(sphere, overrides); +} + +function Wrapper({ children }: { children: ReactNode }) { + const [qc] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + return createElement(QueryClientProvider, { client: qc }, children); +} + +function setup() { + return renderHook(() => useNewAddressFlow(), { wrapper: Wrapper }); +} + +beforeEach(() => { + fakeContext = { + sphere: makeSphere(), + providers: { + transport: { resolveNametagInfo: vi.fn(), isConnected: vi.fn(() => true) }, + }, + resolveNametag: vi.fn(async () => null), + }; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('nametag input normalization', () => { + it('cleanNametagInput trims, strips the leading @ and lowercases', () => { + expect(cleanNametagInput(' @Alice_1 ')).toBe('alice_1'); + expect(cleanNametagInput('BOB')).toBe('bob'); + }); + + it('canonicalNametag converts phone numbers to E.164 like the SDK does', () => { + expect(canonicalNametag('415.555.2671')).toBe('+14155552671'); + expect(canonicalNametag('@Bob_1')).toBe('bob_1'); + }); +}); + +describe('useNewAddressFlow — derivation', () => { + it('derives the next index across ALL tracked addresses (hidden included)', async () => { + const { result } = setup(); + await act(() => result.current.start()); + + const sphere = fakeContext.sphere!; + // max(0, 3-hidden) + 1 = 4 — a hidden index must never be re-derived + expect(sphere.switchToAddress).toHaveBeenCalledWith(4); + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.newAddress).toEqual({ chainPubkey: NEW_PUBKEY, index: 4 }); + }); + + it('starts from index 1 when nothing is tracked', async () => { + fakeContext.sphere = makeSphere({ getAllTrackedAddresses: vi.fn(() => []) }); + const { result } = setup(); + await act(() => result.current.start()); + expect(fakeContext.sphere.switchToAddress).toHaveBeenCalledWith(1); + }); + + it('completes as "recovered" without a prompt when the SDK restored a nametag during the switch', async () => { + const sphere = makeSphere({ + switchToAddress: vi.fn(async (index: number) => { + sphere.currentIndex = index; + sphere.identity = { chainPubkey: NEW_PUBKEY, nametag: 'alice' }; + }), + }); + fakeContext.sphere = sphere; + const { result } = setup(); + await act(() => result.current.start()); + + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('recovered'); + expect(result.current.state.completedNametag).toBe('alice'); + }); + + it('never attributes a nametag from an identity with a different pubkey', async () => { + // Identity claims a nametag but belongs to another key — must prompt, not "recover". + const sphere = makeSphere({ + switchToAddress: vi.fn(async (index: number) => { + sphere.currentIndex = index; + sphere.identity = { chainPubkey: OLD_PUBKEY, nametag: 'not_mine' }; + }), + }); + fakeContext.sphere = sphere; + const { result } = setup(); + await act(() => result.current.start()); + + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.completedNametag).toBeNull(); + }); + + it('lands on the error step when derivation fails', async () => { + fakeContext.sphere = makeSphere({ + switchToAddress: vi.fn(async () => { + throw new Error('boom'); + }), + }); + const { result } = setup(); + await act(() => result.current.start()); + expect(result.current.state.step).toBe('error'); + expect(result.current.state.error).toBe('boom'); + }); +}); + +describe('useNewAddressFlow — slow switch (timeout) handling', () => { + it('keeps waiting after the quick timeout instead of trusting the OLD identity', async () => { + vi.useFakeTimers(); + // Old address has a nametag — the buggy pattern would flash a false "recovered". + const sphere = makeSphere({ + identity: { chainPubkey: OLD_PUBKEY, nametag: 'old_tag' }, + switchToAddress: vi.fn(() => new Promise(() => {})), // hangs + }); + fakeContext.sphere = sphere; + const { result } = setup(); + + let startPromise: Promise | undefined; + act(() => { + startPromise = result.current.start(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + + // Still deriving — no prompt, no false recovered, slow hint on. + expect(result.current.state.step).toBe('deriving'); + expect(result.current.state.slow).toBe(true); + expect(result.current.state.outcome).toBeNull(); + + // The SDK finishes the switch in the background. + sphere.currentIndex = 4; + sphere.identity = { chainPubkey: NEW_PUBKEY }; + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + await startPromise; + }); + + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.completedNametag).toBeNull(); + }); + + it('errors out when the switch never completes within the extended window', async () => { + vi.useFakeTimers(); + const sphere = makeSphere({ + switchToAddress: vi.fn(() => new Promise(() => {})), // hangs forever + }); + fakeContext.sphere = sphere; + const { result } = setup(); + + let startPromise: Promise | undefined; + act(() => { + startPromise = result.current.start(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + await startPromise; + }); + + expect(result.current.state.step).toBe('error'); + expect(result.current.state.error).toMatch(/taking too long/i); + }); + + it('reuses the same index on retry after a failure — no address leak', async () => { + const sphere = makeSphere({ + switchToAddress: vi + .fn() + .mockRejectedValueOnce(new Error('provider init failed')) + .mockImplementation(async (index: number) => { + sphere.currentIndex = index; + sphere.identity = { chainPubkey: NEW_PUBKEY }; + }), + // After the failed attempt the SDK has already tracked index 4. + getAllTrackedAddresses: vi + .fn() + .mockReturnValueOnce([{ index: 0 }, { index: 3, hidden: true }]) + .mockReturnValue([{ index: 0 }, { index: 3, hidden: true }, { index: 4 }]), + }); + fakeContext.sphere = sphere; + const { result } = setup(); + + await act(() => result.current.start()); + expect(result.current.state.step).toBe('error'); + + await act(() => result.current.start()); + expect(result.current.state.step).toBe('nametag_input'); + // Both attempts used index 4 — a naive max+1 would have leaked to 5. + expect(sphere.switchToAddress).toHaveBeenNthCalledWith(1, 4); + expect(sphere.switchToAddress).toHaveBeenNthCalledWith(2, 4); + }); +}); + +describe('useNewAddressFlow — availability', () => { + it('reports taken / available from the resolver (canonical form)', async () => { + fakeContext.resolveNametag = vi.fn(async (tag: string) => + tag === 'taken_tag' ? { nametag: tag } : null, + ); + const { result } = setup(); + expect(await result.current.checkAvailability('taken_tag')).toBe('taken'); + expect(await result.current.checkAvailability('free_tag')).toBe('available'); + // Phone input is resolved in its E.164 form — the key the SDK registers. + expect(await result.current.checkAvailability('415.555.2671')).toBe('available'); + expect(fakeContext.resolveNametag).toHaveBeenCalledWith('+14155552671'); + }); + + it('reports "unknown" (cannot verify) when the resolver throws', async () => { + fakeContext.resolveNametag = vi.fn(async () => { + throw new Error('relay down'); + }); + const { result } = setup(); + expect(await result.current.checkAvailability('any_tag')).toBe('unknown'); + }); + + it('does not trust a null result while the transport is disconnected', async () => { + (fakeContext.providers as { transport: { isConnected: () => boolean } }).transport.isConnected = + () => false; + const { result } = setup(); + expect(await result.current.checkAvailability('any_tag')).toBe('unknown'); + }); + + it('reports "unknown" when the transport cannot resolve nametags at all', async () => { + fakeContext.providers = { transport: {} }; + const { result } = setup(); + expect(await result.current.checkAvailability('any_tag')).toBe('unknown'); + }); +}); + +describe('useNewAddressFlow — registration', () => { + async function startToPrompt() { + const rendered = setup(); + await act(() => rendered.result.current.start()); + expect(rendered.result.current.state.step).toBe('nametag_input'); + return rendered; + } + + it('registers a valid, available ID', async () => { + const { result } = await startToPrompt(); + await act(() => result.current.register('@Bob_1')); + + expect(fakeContext.sphere!.registerNametag).toHaveBeenCalledWith('bob_1'); + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('registered'); + expect(result.current.state.completedNametag).toBe('bob_1'); + }); + + it('registers phone numbers in the E.164 form the SDK uses', async () => { + const { result } = await startToPrompt(); + await act(() => result.current.register('415.555.2671')); + expect(fakeContext.sphere!.registerNametag).toHaveBeenCalledWith('+14155552671'); + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.completedNametag).toBe('+14155552671'); + }); + + it('shows the SDK-stored nametag on completion when the SDK normalized it', async () => { + const sphere = fakeContext.sphere!; + sphere.registerNametag = vi.fn(async () => { + sphere.identity = { ...(sphere.identity ?? {}), nametag: 'sdk_normalized' }; + }); + const { result } = await startToPrompt(); + await act(() => result.current.register('bob_1')); + expect(result.current.state.completedNametag).toBe('sdk_normalized'); + }); + + it('rejects IDs the SDK would reject — before any network call', async () => { + const { result } = await startToPrompt(); + + for (const bad of ['ab', 'my.name', 'a'.repeat(21)]) { + await act(() => result.current.register(bad)); + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.registerError).toContain('Invalid Unicity ID format'); + } + expect(fakeContext.resolveNametag).not.toHaveBeenCalled(); + expect(fakeContext.sphere!.registerNametag).not.toHaveBeenCalled(); + }); + + it('refuses to register when the wallet is no longer on the new address', async () => { + const { result } = await startToPrompt(); + fakeContext.sphere!.currentIndex = 0; + await act(() => result.current.register('bob_1')); + + expect(result.current.state.registerError).toMatch(/no longer on this address/i); + expect(fakeContext.sphere!.registerNametag).not.toHaveBeenCalled(); + }); + + it('shows "already taken" and returns to the prompt when the pre-check hits', async () => { + fakeContext.resolveNametag = vi.fn(async () => ({ nametag: 'bob' })); + const { result } = await startToPrompt(); + await act(() => result.current.register('bob')); + + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.registerError).toBe('@bob is already taken'); + expect(fakeContext.sphere!.registerNametag).not.toHaveBeenCalled(); + }); + + it('still registers when availability cannot be verified (relay enforces uniqueness)', async () => { + fakeContext.resolveNametag = vi.fn(async () => { + throw new Error('relay down'); + }); + const { result } = await startToPrompt(); + await act(() => result.current.register('bob_2')); + + expect(fakeContext.sphere!.registerNametag).toHaveBeenCalledWith('bob_2'); + expect(result.current.state.step).toBe('complete'); + }); + + it('surfaces registration failures inline and keeps the prompt', async () => { + fakeContext.sphere = makeSphere({ + registerNametag: vi.fn(async () => { + throw new Error('Failed to register Unicity ID. It may already be taken.'); + }), + }); + const { result } = await startToPrompt(); + await act(() => result.current.register('bob_3')); + + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.registerError).toContain('may already be taken'); + }); + + it('treats ALREADY_INITIALIZED as a lost recovery race, not an error', async () => { + const err = Object.assign(new Error('already has a nametag'), { + code: 'ALREADY_INITIALIZED', + }); + fakeContext.sphere = makeSphere({ + registerNametag: vi.fn(async () => { + throw err; + }), + }); + const { result } = await startToPrompt(); + fakeContext.sphere.identity = { chainPubkey: NEW_PUBKEY, nametag: 'raced' }; + await act(() => result.current.register('bob_4')); + + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('recovered'); + expect(result.current.state.completedNametag).toBe('raced'); + }); +}); + +describe('useNewAddressFlow — skip & recovery race', () => { + it('skip completes without registering', async () => { + const { result } = setup(); + await act(() => result.current.start()); + act(() => result.current.skip()); + + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('skipped'); + expect(result.current.state.completedNametag).toBeNull(); + expect(fakeContext.sphere!.registerNametag).not.toHaveBeenCalled(); + }); + + it('a background nametag:recovered event while the prompt is open completes the flow', async () => { + const { result } = setup(); + await act(() => result.current.start()); + expect(result.current.state.step).toBe('nametag_input'); + + const handlers = fakeContext.sphere!._handlers; + await waitFor(() => expect(handlers['nametag:recovered']).toBeDefined()); + + act(() => handlers['nametag:recovered']({ nametag: 'zoe' })); + + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('recovered'); + expect(result.current.state.completedNametag).toBe('zoe'); + }); + + it('clearRegisterError dismisses the inline error only', async () => { + const { result } = setup(); + await act(() => result.current.start()); + await act(() => result.current.register('ab')); + expect(result.current.state.registerError).not.toBeNull(); + + act(() => result.current.clearRegisterError()); + expect(result.current.state.registerError).toBeNull(); + expect(result.current.state.step).toBe('nametag_input'); + }); +}); diff --git a/tests/unit/no-direct-in-ui.test.ts b/tests/unit/no-direct-in-ui.test.ts index b9ab38da..cdeaadc2 100644 --- a/tests/unit/no-direct-in-ui.test.ts +++ b/tests/unit/no-direct-in-ui.test.ts @@ -3,10 +3,13 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -// Files converted to pubkey-first display by #411. LookupModal (My Public Keys), -// DocsPage and DevelopersPage are intentionally NOT listed — DIRECT lives there. +// Files converted to pubkey-first display by #411 (+ the #413 derive-address +// flow). LookupModal (My Public Keys), DocsPage and DevelopersPage are +// intentionally NOT listed — DIRECT lives there. const CHANGED_UI_FILES = [ 'src/components/wallet/shared/components/AddressSelector.tsx', + 'src/components/wallet/shared/modals/NewAddressModal.tsx', + 'src/components/wallet/shared/hooks/useNewAddressFlow.ts', 'src/components/wallet/L3/modals/AddressManagerModal.tsx', 'src/components/wallet/L3/modals/SendModal.tsx', 'src/components/wallet/L3/modals/SendPaymentRequestModal.tsx', From 0c5de402b3db6412d46196d5fd07f225c4060716 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Tue, 14 Jul 2026 20:28:45 +0300 Subject: [PATCH 2/4] style: align the Unicity ID prompt with the onboarding nametag screen (#413) Same visual language as NametagScreen / RegisterNametagModal: wallet dialog chrome (WalletScreen asModal + ModalHeader), @unicity-suffixed input with availability-colored border (amber for cannot-verify), gradient Register with arrow, 'Skip for now' text button. Flow logic unchanged. --- .../wallet/shared/modals/NewAddressModal.tsx | 372 +++++++++--------- .../unit/components/NewAddressModal.test.tsx | 8 +- 2 files changed, 188 insertions(+), 192 deletions(-) diff --git a/src/components/wallet/shared/modals/NewAddressModal.tsx b/src/components/wallet/shared/modals/NewAddressModal.tsx index 2bee0c40..06fcca3d 100644 --- a/src/components/wallet/shared/modals/NewAddressModal.tsx +++ b/src/components/wallet/shared/modals/NewAddressModal.tsx @@ -3,14 +3,19 @@ * address (#413). Shared by AddressSelector ("New") and AddressManagerModal * ("Derive New Address"). * + * Visually mirrors the onboarding NametagScreen / RegisterNametagModal + * (@unicity-suffixed input, availability-colored border, gradient Register, + * "Skip for now"), hosted in the wallet's standard dialog chrome. + * * Rendered through a portal: both hosts sit inside transformed/stacked - * containers where a plain `fixed` overlay would clip or mis-position. + * containers where the dialog's `fixed` overlay would clip or mis-position. */ import { useState, useCallback, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { motion, AnimatePresence } from 'framer-motion'; -import { X, Loader2, CheckCircle2, AlertCircle, AlertTriangle } from 'lucide-react'; +import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, ArrowRight, MapPin } from 'lucide-react'; import { isValidNametag } from '@unicitylabs/sphere-sdk'; +import { WalletScreen } from '../../ui/WalletScreen'; +import { ModalHeader } from '../../ui'; import { truncateId } from '../../../../utils/identifiers'; import { useNewAddressFlow, @@ -50,10 +55,10 @@ export function NewAddressModal({ isOpen, onClose }: NewAddressModalProps) { } }, [isOpen, start, reset]); - // Focus the input when the prompt appears. + // Focus the input when the prompt appears (after the dialog spring-in). useEffect(() => { if (state.step === 'nametag_input') { - const timer = setTimeout(() => inputRef.current?.focus(), 100); + const timer = setTimeout(() => inputRef.current?.focus(), 400); return () => clearTimeout(timer); } }, [state.step]); @@ -133,205 +138,196 @@ export function NewAddressModal({ isOpen, onClose }: NewAddressModalProps) { return () => window.removeEventListener('keydown', onKeyDown); }, [isOpen, handleRequestClose]); - const showFormatHint = cleanTag.length > 0 && !isValid && !state.registerError; + const showFormatHint = cleanTag.length > 0 && !isValid; + + const inputBorderClass = + availability === 'taken' + ? 'border-red-400 dark:border-red-500/50 focus:border-red-500' + : availability === 'available' + ? 'border-emerald-400 dark:border-emerald-500/50 focus:border-emerald-500' + : availability === 'unknown' + ? 'border-amber-400 dark:border-amber-500/50 focus:border-amber-500' + : 'border-neutral-200 dark:border-white/8 focus:border-orange-500'; const modal = ( - - {isOpen && ( - <> - - -
- {/* Header */} -
-

- New Address -

- + + + +
+ {state.step === 'deriving' && ( +
+ +

+ Generating new address... +

+ {state.slow && ( +

+ Network is slow — still working... +

+ )} +
+ )} + + {state.step === 'nametag_input' && ( + <> + {state.newAddress && ( +
+ + Address #{state.newAddress.index} + + + {truncateId(state.newAddress.chainPubkey)} +
+ )} - {state.step === 'deriving' && ( -
- -

- Generating new address... -

- {state.slow && ( -

- Network is slow — still working... -

- )} -
- )} +

+ Choose a unique Unicity ID for this + address to receive tokens easily, or skip for now. +

- {(state.step === 'nametag_input' || state.step === 'registering') && ( - <> - {state.newAddress && ( -
- - Address #{state.newAddress.index} - - - {truncateId(state.newAddress.chainPubkey)} - -
+
+
+
+ {availability === 'checking' && ( + + )} + {availability === 'available' && ( + + )} + {availability === 'taken' && ( + + )} + {availability === 'unknown' && ( + )} + + @unicity + +
+ { + if (e.key === 'Enter') handleRegister(); + }} + placeholder="id" + maxLength={32} + className={`w-full bg-neutral-100 dark:bg-white/4 border-2 rounded-xl py-3 pl-4 pr-28 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-600 focus:outline-none focus:bg-white dark:focus:bg-white/6 transition-all ${inputBorderClass}`} + /> +
-

- Choose a Unicity ID for this address, or skip for now. + {/* Availability / validation status — fixed height to prevent layout shift */} +

+ {showFormatHint && ( +

+ {NAMETAG_FORMAT_HINT} +

+ )} + {!showFormatHint && availability === 'taken' && ( +

+ @{cleanTag} is already taken

+ )} + {!showFormatHint && availability === 'available' && ( +

+ @{cleanTag} is available +

+ )} + {!showFormatHint && availability === 'unknown' && ( +

+ Can't verify availability right now — registration will fail if the ID + is taken. +

+ )} +
- {state.step === 'registering' ? ( -
- -

- Registering @{cleanTag}... -

-

- - Don't close this window -

-
- ) : ( - <> - {/* Nametag input */} -
- - @ - - { - if (e.key === 'Enter') handleRegister(); - }} - placeholder="nametag" - maxLength={32} - className="w-full pl-7 pr-8 py-2.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:border-orange-500 transition-colors" - /> - {/* Availability indicator */} - - {availability === 'checking' && ( - - )} - {availability === 'available' && ( - - )} - {availability === 'taken' && ( - - )} - {availability === 'unknown' && ( - - )} - -
+ - {/* Availability / validation / error — fixed height, no layout shift */} -
- {state.registerError && ( -

{state.registerError}

- )} - {!state.registerError && showFormatHint && ( -

{NAMETAG_FORMAT_HINT}

- )} - {!state.registerError && availability === 'taken' && ( -

@{cleanTag} is already taken

- )} - {!state.registerError && availability === 'available' && ( -

@{cleanTag} is available

- )} - {!state.registerError && availability === 'unknown' && ( -

- Can't verify availability right now — registration will fail if the - ID is taken. -

- )} -
+ - {/* Buttons */} -
- - -
- - )} - + {state.registerError && ( +

+ {state.registerError} +

)} +
+ + )} - {state.step === 'complete' && state.outcome !== 'skipped' && ( -
- -

- {state.outcome === 'recovered' ? ( - <> - @{state.completedNametag} was - recovered for this address - - ) : ( - <> - @{state.completedNametag} is ready - - )} -

-
- )} + {state.step === 'registering' && ( +
+ +

+ Registering @{cleanTag}... +

+

+ + Don't close this window +

+
+ )} - {state.step === 'error' && ( -
- -

{state.error}

-
- - -
-
+ {state.step === 'complete' && state.outcome !== 'skipped' && ( +
+ +

+ {state.outcome === 'recovered' ? ( + <> + @{state.completedNametag} was + recovered for this address + + ) : ( + <> + @{state.completedNametag} is ready + )} +

+
+ )} + + {state.step === 'error' && ( +
+ +

{state.error}

+
+ +
- - - )} - +
+ )} +
+
); return createPortal(modal, document.body); diff --git a/tests/unit/components/NewAddressModal.test.tsx b/tests/unit/components/NewAddressModal.test.tsx index 4fcb0ab1..5e752509 100644 --- a/tests/unit/components/NewAddressModal.test.tsx +++ b/tests/unit/components/NewAddressModal.test.tsx @@ -61,7 +61,7 @@ describe('NewAddressModal — nametag prompt', () => { it('disables Register and shows the SDK format hint for an invalid ID', () => { renderModal(); - fireEvent.change(screen.getByPlaceholderText('nametag'), { target: { value: 'ab' } }); + fireEvent.change(screen.getByPlaceholderText('id'), { target: { value: 'ab' } }); expect(screen.getByText(/use a-z, 0-9/i)).toBeDefined(); const register = screen.getByRole('button', { name: 'Register' }) as HTMLButtonElement; @@ -71,7 +71,7 @@ describe('NewAddressModal — nametag prompt', () => { it('treats an unverifiable availability as "cannot verify", not as free', async () => { flow.checkAvailability = vi.fn(async () => 'unknown' as const); renderModal(); - fireEvent.change(screen.getByPlaceholderText('nametag'), { + fireEvent.change(screen.getByPlaceholderText('id'), { target: { value: 'validname' }, }); @@ -90,7 +90,7 @@ describe('NewAddressModal — nametag prompt', () => { it('blocks Register when the ID is taken', async () => { flow.checkAvailability = vi.fn(async () => 'taken' as const); renderModal(); - fireEvent.change(screen.getByPlaceholderText('nametag'), { + fireEvent.change(screen.getByPlaceholderText('id'), { target: { value: 'takenname' }, }); @@ -104,7 +104,7 @@ describe('NewAddressModal — nametag prompt', () => { it('Skip hands off to the flow', () => { renderModal(); - fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + fireEvent.click(screen.getByRole('button', { name: 'Skip for now' })); expect(flow.skip).toHaveBeenCalledTimes(1); }); From 58ad5afef8e79209db47abc8601c1cce1082f8af Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Tue, 14 Jul 2026 20:38:31 +0300 Subject: [PATCH 3/4] style: present the Unicity ID prompt as a slide-in wallet screen (#413) Same presentation as onboarding/RegisterNametagModal: WalletScreen slide-in inside the wallet panel with the screen-variant header, instead of a centered overlay dialog. AddressSelector renders it outside its relative wrapper so the panel shell is the positioned ancestor; portal removed. --- .../shared/components/AddressSelector.tsx | 10 +++++++-- .../wallet/shared/modals/NewAddressModal.tsx | 22 +++++++------------ .../unit/components/NewAddressModal.test.tsx | 10 ++++----- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/components/wallet/shared/components/AddressSelector.tsx b/src/components/wallet/shared/components/AddressSelector.tsx index 1be6189b..20212f79 100644 --- a/src/components/wallet/shared/components/AddressSelector.tsx +++ b/src/components/wallet/shared/components/AddressSelector.tsx @@ -166,8 +166,11 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { if (compact) { return ( -
+ <> + {/* Slide-in screen: rendered outside the relative wrapper so it fills + the wallet panel (nearest positioned ancestor), like Register ID. */} {newAddressModal} +
+ ); } // Full mode return ( -
+ <> {newAddressModal} +
+ ); } diff --git a/src/components/wallet/shared/modals/NewAddressModal.tsx b/src/components/wallet/shared/modals/NewAddressModal.tsx index 06fcca3d..6898d07d 100644 --- a/src/components/wallet/shared/modals/NewAddressModal.tsx +++ b/src/components/wallet/shared/modals/NewAddressModal.tsx @@ -3,16 +3,13 @@ * address (#413). Shared by AddressSelector ("New") and AddressManagerModal * ("Derive New Address"). * - * Visually mirrors the onboarding NametagScreen / RegisterNametagModal - * (@unicity-suffixed input, availability-colored border, gradient Register, - * "Skip for now"), hosted in the wallet's standard dialog chrome. - * - * Rendered through a portal: both hosts sit inside transformed/stacked - * containers where the dialog's `fixed` overlay would clip or mis-position. + * A slide-in screen INSIDE the wallet panel — the same presentation as the + * onboarding NametagScreen / RegisterNametagModal (@unicity-suffixed input, + * availability-colored border, gradient Register, "Skip for now"). Hosts must + * render it where the wallet panel shell is the nearest positioned ancestor. */ import { useState, useCallback, useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, ArrowRight, MapPin } from 'lucide-react'; +import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, ArrowRight } from 'lucide-react'; import { isValidNametag } from '@unicitylabs/sphere-sdk'; import { WalletScreen } from '../../ui/WalletScreen'; import { ModalHeader } from '../../ui'; @@ -149,12 +146,11 @@ export function NewAddressModal({ isOpen, onClose }: NewAddressModalProps) { ? 'border-amber-400 dark:border-amber-500/50 focus:border-amber-500' : 'border-neutral-200 dark:border-white/8 focus:border-orange-500'; - const modal = ( - + return ( + @@ -329,6 +325,4 @@ export function NewAddressModal({ isOpen, onClose }: NewAddressModalProps) {
); - - return createPortal(modal, document.body); } diff --git a/tests/unit/components/NewAddressModal.test.tsx b/tests/unit/components/NewAddressModal.test.tsx index 5e752509..7edd3247 100644 --- a/tests/unit/components/NewAddressModal.test.tsx +++ b/tests/unit/components/NewAddressModal.test.tsx @@ -108,18 +108,16 @@ describe('NewAddressModal — nametag prompt', () => { expect(flow.skip).toHaveBeenCalledTimes(1); }); - it('cannot be closed while registering — backdrop, X and Escape are all inert', () => { + it('cannot be closed while registering — back button and Escape are inert', () => { flow.state = makeState({ step: 'registering' }); const onClose = vi.fn(); render(); expect(screen.getByText(/don't close this window/i)).toBeDefined(); - const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement; - fireEvent.click(backdrop); - const xButton = document.querySelector('button:disabled') as HTMLButtonElement; - expect(xButton).not.toBeNull(); - fireEvent.click(xButton); + const backButton = document.querySelector('button:disabled') as HTMLButtonElement; + expect(backButton).not.toBeNull(); + fireEvent.click(backButton); fireEvent.keyDown(window, { key: 'Escape' }); expect(onClose).not.toHaveBeenCalled(); From 422b83f2ff351db51f19b41f161fa4c8e79b6d5d Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Tue, 14 Jul 2026 20:48:40 +0300 Subject: [PATCH 4/4] fix: host the New Address screen at wallet-panel level (#413) The slide-in screen rendered from the header painted BELOW the wallet content (same z-10, earlier DOM order) - tab bar bled through. WalletPanel now owns the flow like RegisterNametagModal (rendered last in the panel); AddressSelector delegates via the new onNewAddress prop and keeps internal hosting only as a fallback. --- src/components/wallet/WalletPanel.tsx | 12 +++++++++- .../shared/components/AddressSelector.tsx | 22 ++++++++++++++----- .../components/addressEntryPoints.test.tsx | 12 ++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/components/wallet/WalletPanel.tsx b/src/components/wallet/WalletPanel.tsx index cff19298..1db4ddf7 100644 --- a/src/components/wallet/WalletPanel.tsx +++ b/src/components/wallet/WalletPanel.tsx @@ -6,6 +6,7 @@ import { useIdentity, useWalletStatus, useSphereContext } from '../../sdk'; import { useIncomingPaymentRequests } from './L3/hooks/useIncomingPaymentRequests'; import { useUIState } from '../../hooks/useUIState'; import { RegisterNametagModal } from './shared/components/RegisterNametagModal'; +import { NewAddressModal } from './shared/modals/NewAddressModal'; import { AddressSelector } from './shared/components'; import { CreateWalletFlow } from './onboarding/CreateWalletFlow'; @@ -17,6 +18,9 @@ export function WalletPanel() { const [isRequestsOpen, setIsRequestsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isNametagModalOpen, setIsNametagModalOpen] = useState(false); + // #413: derive-address flow — hosted at panel level (like RegisterNametagModal) + // so the slide-in screen paints above the wallet content in DOM order. + const [isNewAddressOpen, setIsNewAddressOpen] = useState(false); const { isLoading: isWalletLoading, walletExists, error: walletError } = useWalletStatus(); const { identity, nametag, isLoading: isLoadingIdentity } = useIdentity(); const { initProgress } = useSphereContext(); @@ -196,7 +200,7 @@ export function WalletPanel() { )}
- + setIsNewAddressOpen(true)} />
@@ -262,6 +266,12 @@ export function WalletPanel() { isOpen={isNametagModalOpen} onClose={() => setIsNametagModalOpen(false)} /> + + {/* New Address flow (#413) */} + setIsNewAddressOpen(false)} + />
); } diff --git a/src/components/wallet/shared/components/AddressSelector.tsx b/src/components/wallet/shared/components/AddressSelector.tsx index 20212f79..71b38c73 100644 --- a/src/components/wallet/shared/components/AddressSelector.tsx +++ b/src/components/wallet/shared/components/AddressSelector.tsx @@ -18,9 +18,17 @@ function truncateNametag(nametag: string, maxLength: number = 20): string { interface AddressSelectorProps { /** Compact mode - just show nametag with small dropdown trigger */ compact?: boolean; + /** + * #413: preferred hosting for the derive-address flow. When provided, the + * "New" button delegates to the host (WalletPanel renders NewAddressModal + * at panel level so the slide-in screen paints above the wallet content). + * Without it the selector renders the flow itself, anchored to the nearest + * positioned ancestor. + */ + onNewAddress?: () => void; } -export function AddressSelector({ compact = true }: AddressSelectorProps) { +export function AddressSelector({ compact = true, onNewAddress }: AddressSelectorProps) { const [showDropdown, setShowDropdown] = useState(false); const [copied, setCopied] = useState<'nametag' | 'address' | false>(false); const [isSwitching, setIsSwitching] = useState(false); @@ -122,8 +130,12 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { const handleNewClick = useCallback(() => { if (!sphere || isSwitching) return; setShowDropdown(false); - setShowNewAddressModal(true); - }, [sphere, isSwitching]); + if (onNewAddress) { + onNewAddress(); + } else { + setShowNewAddressModal(true); + } + }, [sphere, isSwitching, onNewAddress]); const handleNewAddressModalClose = useCallback(() => { setShowNewAddressModal(false); @@ -139,8 +151,8 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { return addr.chainPubkey; }, []); - // New-address flow (#413) — shared between compact and full modes - const newAddressModal = ( + // New-address flow (#413) — fallback hosting when the parent doesn't take it + const newAddressModal = onNewAddress ? null : ( ); diff --git a/tests/unit/components/addressEntryPoints.test.tsx b/tests/unit/components/addressEntryPoints.test.tsx index 3b8d730c..b8be14e0 100644 --- a/tests/unit/components/addressEntryPoints.test.tsx +++ b/tests/unit/components/addressEntryPoints.test.tsx @@ -62,6 +62,18 @@ describe('AddressSelector — "New" opens the Unicity ID flow', () => { // No direct derivation from the selector anymore. expect(fakeSphere.switchToAddress).toBeUndefined(); }); + + it('delegates to the panel host when onNewAddress is provided', () => { + const onNewAddress = vi.fn(); + render(, { wrapper: Wrapper }); + + fireEvent.click(screen.getByText(`${PUBKEY.slice(0, 6)}...${PUBKEY.slice(-4)}`)); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + + expect(onNewAddress).toHaveBeenCalledTimes(1); + // The selector must not double-host the flow in this mode. + expect(screen.queryByTestId('new-address-modal')).toBeNull(); + }); }); describe('AddressManagerModal — "Derive New Address" opens the Unicity ID flow', () => {