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

View file

@ -15,9 +15,10 @@ import { useApp } from '../state/AppProvider';
interface HomeScreenProps {
onNavigate: (screen: Screen) => void;
onCreateProfile: () => void;
onImportProfile: () => void;
}
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
export function HomeScreen({ onNavigate, onCreateProfile, onImportProfile }: HomeScreenProps) {
const { state, selectProfile } = useApp();
const { publications, fullyPublished, loading, error } = useProfilePublications();
const [selecting, setSelecting] = useState<string | null>(null);
@ -43,16 +44,27 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
title="Welcome to Keynctr"
description={
<span>
You haven't created a profile yet. A Nostr profile is your identity on the public
Nostr network — a <code>npub</code> address you can share, plus a private key kept
safely on this computer. Create your first profile to start publishing notes.
A Nostr profile is your identity on the public Nostr network — a <code>npub</code>{' '}
address you can share. You can create a new one here (its private key is generated
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>
}
action={
<div className="onboarding-actions">
<Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} />
Create your first profile
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 />

View file

@ -1402,6 +1402,18 @@ select {
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
------------------------------------------------------------------------- */

View file

@ -16,10 +16,12 @@ describe('App', () => {
render(<App />);
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();
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(
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
).toBeInTheDocument();

View file

@ -7,13 +7,18 @@ import { createFakeBackend, installFakeBackend } from './fakeBackend';
function renderHome(
backend: ReturnType<typeof createFakeBackend>,
overrides: { onNavigate?: () => void; onCreateProfile?: () => void } = {},
overrides: {
onNavigate?: () => void;
onCreateProfile?: () => void;
onImportProfile?: () => void;
} = {},
) {
installFakeBackend(backend);
return {
user: userEvent.setup(),
onNavigate: overrides.onNavigate ?? 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 () => {
const backend = createFakeBackend();
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.
const profileList = await screen.findByRole('listbox');
@ -36,7 +43,9 @@ describe('HomeScreen', () => {
it('copies the complete npub when the copy button is clicked', async () => {
const backend = createFakeBackend();
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.
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 () => {
const backend = createFakeBackend(makeEmptyState());
const { onCreateProfile } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />);
const { onCreateProfile, onImportProfile } = renderHome(backend);
renderWithApp(
<HomeScreen
onNavigate={vi.fn()}
onCreateProfile={onCreateProfile}
onImportProfile={onImportProfile}
/>,
);
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
.setup()
.click(screen.getByRole('button', { name: /Create your first profile/i }));
expect(onCreateProfile).toHaveBeenCalled();
.click(screen.getByRole('button', { name: /I already have an account/i }));
expect(onImportProfile).toHaveBeenCalled();
});
it('selects a profile when its row is clicked (not just the Select button)', async () => {
const backend = createFakeBackend();
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;
// 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>) {
installFakeBackend(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
}
async function waitForData() {