feat(onboarding): first-run screen offers import-existing-account and external-signer paths

- Home first-run now has three entry points: create a new profile, I already
  have an account (opens ImportProfileModal), and Sign in with a signer
  (navigates to Signer Mode where Amber/NIP-46 pairing lives).
- Copy states the per-mode truth: local-vault keys vs remote signer where the
  private key never lives on this device.
- Tests updated to assert all three entry points; 116 frontend tests green.
This commit is contained in:
Avi 2026-09-23 09:00:07 -05:00
commit 13a66f28d8
6 changed files with 76 additions and 20 deletions

View file

@ -14,6 +14,7 @@ import { SignerScreen } from './screens/SignerScreen';
import { SignerModeScreen } from './screens/SignerModeScreen'; import { SignerModeScreen } from './screens/SignerModeScreen';
import { SettingsScreen } from './screens/SettingsScreen'; import { SettingsScreen } from './screens/SettingsScreen';
import { CreateProfileModal } from './screens/CreateProfileModal'; import { CreateProfileModal } from './screens/CreateProfileModal';
import { ImportProfileModal } from './screens/ImportProfileModal';
import { AppProvider, useApp, useThemeSync } from './state/AppProvider'; import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
import type { Screen } from './lib/navigation'; import type { Screen } from './lib/navigation';
@ -21,6 +22,7 @@ function Shell() {
const { state, loading, bootstrapError } = useApp(); const { state, loading, bootstrapError } = useApp();
const [screen, setScreen] = useState<Screen>('home'); const [screen, setScreen] = useState<Screen>('home');
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false); const [unlockOpen, setUnlockOpen] = useState(false);
useThemeSync(state?.settings.theme); useThemeSync(state?.settings.theme);
@ -68,7 +70,11 @@ function Shell() {
</div> </div>
)} )}
{screen === 'home' && ( {screen === 'home' && (
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} /> <HomeScreen
onNavigate={setScreen}
onCreateProfile={() => setCreateOpen(true)}
onImportProfile={() => setImportOpen(true)}
/>
)} )}
{screen === 'feed' && <FeedScreen onNavigate={setScreen} />} {screen === 'feed' && <FeedScreen onNavigate={setScreen} />}
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />} {screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
@ -79,6 +85,7 @@ function Shell() {
{screen === 'settings' && <SettingsScreen />} {screen === 'settings' && <SettingsScreen />}
</main> </main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} /> <CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
<ImportProfileModal open={importOpen} onClose={() => setImportOpen(false)} />
<UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} /> <UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} />
</div> </div>
); );

View file

@ -15,9 +15,10 @@ import { useApp } from '../state/AppProvider';
interface HomeScreenProps { interface HomeScreenProps {
onNavigate: (screen: Screen) => void; onNavigate: (screen: Screen) => void;
onCreateProfile: () => void; onCreateProfile: () => void;
onImportProfile: () => void;
} }
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { export function HomeScreen({ onNavigate, onCreateProfile, onImportProfile }: HomeScreenProps) {
const { state, selectProfile } = useApp(); const { state, selectProfile } = useApp();
const { publications, fullyPublished, loading, error } = useProfilePublications(); const { publications, fullyPublished, loading, error } = useProfilePublications();
const [selecting, setSelecting] = useState<string | null>(null); const [selecting, setSelecting] = useState<string | null>(null);
@ -43,16 +44,27 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
title="Welcome to Keynctr" title="Welcome to Keynctr"
description={ description={
<span> <span>
You haven't created a profile yet. A Nostr profile is your identity on the public A Nostr profile is your identity on the public Nostr network — a <code>npub</code>{' '}
Nostr network — a <code>npub</code> address you can share, plus a private key kept address you can share. You can create a new one here (its private key is generated
safely on this computer. Create your first profile to start publishing notes. and kept in this computer&rsquo;s encrypted vault), import an account you already
have, or connect an external signer like Amber so the private key never lives on
this device.
</span> </span>
} }
action={ action={
<Button variant="primary" onClick={onCreateProfile}> <div className="onboarding-actions">
<Icon name="plus" size={18} /> <Button variant="primary" onClick={onCreateProfile}>
Create your first profile <Icon name="plus" size={18} />
</Button> Create a new profile
</Button>
<Button variant="secondary" onClick={onImportProfile}>
<Icon name="key" size={18} />I already have an account
</Button>
<Button variant="ghost" onClick={() => onNavigate('signer-mode')}>
<Icon name="server" size={18} />
Sign in with a signer (Amber, NIP-46)
</Button>
</div>
} }
/> />
<FirstRunGuide /> <FirstRunGuide />

View file

@ -1402,6 +1402,18 @@ select {
margin-top: 8px; margin-top: 8px;
} }
.onboarding-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.onboarding-actions .btn {
min-width: 260px;
justify-content: center;
}
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
Home Home
------------------------------------------------------------------------- */ ------------------------------------------------------------------------- */

View file

@ -16,10 +16,12 @@ describe('App', () => {
render(<App />); render(<App />);
expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument(); expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create your first profile/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Create a new profile/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /I already have an account/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Sign in with a signer/i })).toBeInTheDocument();
expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument(); expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Create your first profile/i })); await user.click(screen.getByRole('button', { name: /Create a new profile/i }));
expect( expect(
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }), await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
).toBeInTheDocument(); ).toBeInTheDocument();

View file

@ -7,13 +7,18 @@ import { createFakeBackend, installFakeBackend } from './fakeBackend';
function renderHome( function renderHome(
backend: ReturnType<typeof createFakeBackend>, backend: ReturnType<typeof createFakeBackend>,
overrides: { onNavigate?: () => void; onCreateProfile?: () => void } = {}, overrides: {
onNavigate?: () => void;
onCreateProfile?: () => void;
onImportProfile?: () => void;
} = {},
) { ) {
installFakeBackend(backend); installFakeBackend(backend);
return { return {
user: userEvent.setup(), user: userEvent.setup(),
onNavigate: overrides.onNavigate ?? vi.fn(), onNavigate: overrides.onNavigate ?? vi.fn(),
onCreateProfile: overrides.onCreateProfile ?? vi.fn(), onCreateProfile: overrides.onCreateProfile ?? vi.fn(),
onImportProfile: overrides.onImportProfile ?? vi.fn(),
}; };
} }
@ -21,7 +26,9 @@ describe('HomeScreen', () => {
it('shows the active profile, a shortened npub, and a compose button', async () => { it('shows the active profile, a shortened npub, and a compose button', async () => {
const backend = createFakeBackend(); const backend = createFakeBackend();
const { onNavigate } = renderHome(backend); const { onNavigate } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />); renderWithApp(
<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
// The active profile appears in the profile list with its shortened npub. // The active profile appears in the profile list with its shortened npub.
const profileList = await screen.findByRole('listbox'); const profileList = await screen.findByRole('listbox');
@ -36,7 +43,9 @@ describe('HomeScreen', () => {
it('copies the complete npub when the copy button is clicked', async () => { it('copies the complete npub when the copy button is clicked', async () => {
const backend = createFakeBackend(); const backend = createFakeBackend();
renderHome(backend); renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />); renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
// The active profile row carries the "Selected" badge; find Alice via the profile list. // The active profile row carries the "Selected" badge; find Alice via the profile list.
const profileList = await screen.findByRole('listbox'); const profileList = await screen.findByRole('listbox');
@ -51,20 +60,32 @@ describe('HomeScreen', () => {
it('shows the first-run state and guides the user to create a profile', async () => { it('shows the first-run state and guides the user to create a profile', async () => {
const backend = createFakeBackend(makeEmptyState()); const backend = createFakeBackend(makeEmptyState());
const { onCreateProfile } = renderHome(backend); const { onCreateProfile, onImportProfile } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />); renderWithApp(
<HomeScreen
onNavigate={vi.fn()}
onCreateProfile={onCreateProfile}
onImportProfile={onImportProfile}
/>,
);
expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument(); expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /I already have an account/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Sign in with a signer/i })).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: /Create a new profile/i }));
expect(onCreateProfile).toHaveBeenCalled();
await userEvent await userEvent
.setup() .setup()
.click(screen.getByRole('button', { name: /Create your first profile/i })); .click(screen.getByRole('button', { name: /I already have an account/i }));
expect(onCreateProfile).toHaveBeenCalled(); expect(onImportProfile).toHaveBeenCalled();
}); });
it('selects a profile when its row is clicked (not just the Select button)', async () => { it('selects a profile when its row is clicked (not just the Select button)', async () => {
const backend = createFakeBackend(); const backend = createFakeBackend();
renderHome(backend); renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />); renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
const bobRow = (await screen.findByText('Bob')).closest('.home-profile-row') as HTMLElement; const bobRow = (await screen.findByText('Bob')).closest('.home-profile-row') as HTMLElement;
// Clicking the name (not the Select button) should select the profile. // Clicking the name (not the Select button) should select the profile.

View file

@ -23,7 +23,9 @@ function makeItem(overrides: Partial<FeedItem> & { id: string; relays: string[]
function renderHome(backend: ReturnType<typeof createFakeBackend>) { function renderHome(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend); installFakeBackend(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />); renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
} }
async function waitForData() { async function waitForData() {