feat: add embedded signer and NIP-46 client signer modes

- Add Signer trait with common interface for both signing modes
- Implement EmbeddedSigner: keys stored in encrypted vault (Argon2id + AES-256-GCM)
- Implement Nip46ClientSigner: connects to remote signer via nostrconnect:// URI
- Support both local and remote NIP-46 signers
- Add signer mode selection UI (SignerModeScreen)
- Add IPC endpoints for signer mode management, embedded signer, and NIP-46 client
- Update frontend types, API, and AppProvider
- All tests pass (119 Rust + 110 frontend)
This commit is contained in:
Avi 2026-09-01 20:16:14 -05:00
commit b484bdeb08
24 changed files with 3163 additions and 78 deletions

View file

@ -11,6 +11,7 @@ import { ProfilesScreen } from './screens/ProfilesScreen';
import { ComposeScreen } from './screens/ComposeScreen';
import { RelaysScreen } from './screens/RelaysScreen';
import { SignerScreen } from './screens/SignerScreen';
import { SignerModeScreen } from './screens/SignerModeScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { CreateProfileModal } from './screens/CreateProfileModal';
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
@ -74,6 +75,7 @@ function Shell() {
{screen === 'compose' && <ComposeScreen />}
{screen === 'relays' && <RelaysScreen />}
{screen === 'signer' && <SignerScreen />}
{screen === 'signer-mode' && <SignerModeScreen />}
{screen === 'settings' && <SettingsScreen />}
</main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />

View file

@ -16,7 +16,8 @@ export type IconName =
| 'shield'
| 'publish'
| 'external'
| 'key';
| 'key'
| 'server';
const PATHS: Record<IconName, ReactNode> = {
home: (
@ -101,6 +102,12 @@ const PATHS: Record<IconName, ReactNode> = {
<path d="M18 6l2 2" />
</>
),
server: (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 9h6M9 12h6M9 15h6" />
</>
),
};
interface IconProps {

View file

@ -11,7 +11,8 @@ const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
{ id: 'feed', label: 'Feed', icon: 'list' },
{ id: 'compose', label: 'Compose', icon: 'edit' },
{ id: 'relays', label: 'Relays', icon: 'relay' },
{ id: 'signer', label: 'Signer', icon: 'key' },
{ id: 'signer-mode', label: 'Signer Mode', icon: 'key' },
{ id: 'signer', label: 'Signer (Bunker)', icon: 'server' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
];

View file

@ -1,15 +1,18 @@
import type {
AppState,
BackendResponse,
EmbeddedSignerStatus,
FeedItem,
LinkPreview,
MetadataPublishReport,
Nip46SignerStatus,
PickedImage,
ProfileSummary,
PublishReport,
RelayTestResult,
RevealedKey,
Settings,
SignerMode,
SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
@ -102,6 +105,24 @@ export const api = {
pickImages: () => call<PickedImage[]>('pick_image'),
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
// Signer mode management
signerModeGet: () => call<{ mode: SignerMode }>('signer_mode_get'),
signerModeSet: (mode: SignerMode) => call<AppState>('signer_mode_set', { mode }),
// Embedded signer
embeddedSignerStatus: () => call<EmbeddedSignerStatus>('embedded_signer_status'),
embeddedSignerApprove: (index: number, approved: boolean) =>
call<EmbeddedSignerStatus>('embedded_signer_approve', { index, approved }),
// NIP-46 client signer
nip46Connect: (uri: string, label: string) =>
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved }),
// Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'),

View file

@ -1,4 +1,5 @@
export type Screen = 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings';
export type Screen =
'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'signer-mode' | 'settings';
export const SCREEN_TITLES: Record<Screen, string> = {
home: 'Home',
@ -6,6 +7,7 @@ export const SCREEN_TITLES: Record<Screen, string> = {
profiles: 'Profiles',
compose: 'Compose',
relays: 'Relays',
signer: 'Signer',
signer: 'Signer (Bunker)',
'signer-mode': 'Signer Mode',
settings: 'Settings',
};

View file

@ -1,9 +1,22 @@
export type Theme =
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
/** Active signer mode. */
export type SignerMode = 'embedded' | 'nip46';
/** Lifecycle of the NIP-46 remote signer. */
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
/** Approval request details for user confirmation. */
export interface ApprovalDetails {
method: string;
summary: string;
event_kind?: number;
destination_relays: string[];
content_preview: string;
is_sensitive: boolean;
}
/** A NIP-46 request waiting for the user to approve or reject it. */
export interface PendingApproval {
/** Internal id used to answer this request. */
@ -12,6 +25,8 @@ export interface PendingApproval {
method: string;
/** A short human-readable description of what will be done. */
summary: string;
/** Detailed approval information. */
details?: ApprovalDetails;
}
/** Non-secret snapshot of the NIP-46 remote signer for display. */
@ -21,12 +36,38 @@ export interface SignerStatus {
peer: string | null;
/** Relays used for the connection. */
relays: string[];
/** The relays in `relays` that are actually connected right now. */
connectedRelays: string[];
/** A user-facing error if the signer stopped because of one. */
error: string | null;
/** Requests currently waiting for the user's approval. */
pending: PendingApproval[];
}
/** Embedded signer status. */
export interface EmbeddedSignerStatus {
type: 'embedded';
available: boolean;
active_npub?: string;
pending_count: number;
pending: PendingApproval[];
error?: string;
}
/** NIP-46 client signer status. */
export interface Nip46SignerStatus {
type: 'nip46';
connected: boolean;
signer_pubkey?: string;
relays: string[];
connected_relays: string[];
error?: string;
pending_approvals: PendingApproval[];
}
/** Union of all signer statuses. */
export type AnySignerStatus = EmbeddedSignerStatus | Nip46SignerStatus;
/** A safe view of a profile with no secret key material. */
export interface ProfileSummary {
label: string;
@ -152,6 +193,8 @@ export interface AppState {
failed: RelayFailure[];
content: string;
} | null;
/** Active signer mode. */
signer_mode: SignerMode;
}
/** A secret key revealed after the vault is unlocked. */

View file

@ -0,0 +1,466 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { Alert } from '../components/Alert';
import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
export function SignerModeScreen() {
const {
state,
signerModeGet,
signerModeSet,
embeddedSignerStatus,
nip46Status,
nip46Connect,
nip46Disconnect,
nip46Approve,
embeddedSignerApprove,
refresh,
} = useApp();
const [mode, setMode] = useState<SignerMode>('embedded');
const [embeddedStatus, setEmbeddedStatus] = useState<EmbeddedSignerStatus | null>(null);
const [nip46StatusState, setNip46StatusState] = useState<Nip46SignerStatus | null>(null);
const [uri, setUri] = useState('');
const [label, setLabel] = useState('Remote Signer');
const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
const [loading, setLoading] = useState(true);
const isNip46Active = mode === 'nip46' && nip46StatusState?.connected;
const isEmbeddedActive = mode === 'embedded' && embeddedStatus?.available;
const refreshStatus = useCallback(async () => {
try {
const modeResult = await signerModeGet();
setMode(modeResult.mode);
if (modeResult.mode === 'embedded') {
const status = await embeddedSignerStatus();
setEmbeddedStatus(status);
} else {
const status = await nip46Status();
setNip46StatusState(status);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [signerModeGet, embeddedSignerStatus, nip46Status]);
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
// Poll for pending approvals
useEffect(() => {
const timer = window.setInterval(() => {
void refreshStatus();
}, 2000);
return () => window.clearInterval(timer);
}, [refreshStatus]);
const vaultLocked = state?.vault_locked ?? false;
const onModeChange = async (newMode: SignerMode) => {
setError(null);
try {
await signerModeSet(newMode);
setMode(newMode);
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onNip46Connect = async (event: FormEvent) => {
event.preventDefault();
const trimmed = uri.trim();
if (!trimmed.startsWith('nostrconnect://')) {
setError('Paste the nostrconnect:// link from your Nostr app.');
return;
}
setError(null);
setConnecting(true);
try {
const status = await nip46Connect(trimmed, label.trim() || 'Remote Signer');
setNip46StatusState(status);
setUri('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setConnecting(false);
}
};
const onNip46Disconnect = async () => {
setError(null);
try {
const status = await nip46Disconnect();
setNip46StatusState(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onEmbeddedApprove = async (index: number, approved: boolean) => {
setError(null);
try {
const status = await embeddedSignerApprove(index, approved);
setEmbeddedStatus(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const modeBadge = () => {
if (mode === 'embedded') {
return isEmbeddedActive ? (
<Badge tone="success">Embedded (Active)</Badge>
) : (
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>
Embedded {vaultLocked ? '(Vault Locked)' : '(Ready)'}
</Badge>
);
}
return isNip46Active ? (
<Badge tone="success">NIP-46 (Connected)</Badge>
) : (
<Badge tone={nip46StatusState?.error ? 'danger' : 'neutral'}>
NIP-46 {nip46StatusState?.error ? '(Error)' : '(Disconnected)'}
</Badge>
);
};
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<div>
<h1>Signer Mode</h1>
<p className="page-subtitle">
Choose how your keys are managed and where signing happens.
</p>
</div>
</header>
<section className="card">
<header className="card-header">
<h2>Current Mode</h2>
<div className="signer-badge">{modeBadge()}</div>
</header>
<div className="card-body">
<div className="mode-options">
<label className={`mode-option${mode === 'embedded' ? ' active' : ''}`}>
<input
type="radio"
name="signer-mode"
value="embedded"
checked={mode === 'embedded'}
onChange={() => void onModeChange('embedded')}
disabled={loading}
/>
<div className="mode-option-content">
<h3>Embedded Signer</h3>
<p>
Keys are stored locally in your encrypted vault. Signing happens on this device.
<br />
<span className="muted">Best for: Simplicity, offline use, full control.</span>
</p>
<ul className="mode-features">
<li>✓ Keys never leave this device</li>
<li>✓ Works offline</li>
<li>✓ Encrypted vault with password</li>
<li>⚠ If vault unlocked, malware could sign</li>
</ul>
</div>
</label>
<label className={`mode-option${mode === 'nip46' ? ' active' : ''}`}>
<input
type="radio"
name="signer-mode"
value="nip46"
checked={mode === 'nip46'}
onChange={() => void onModeChange('nip46')}
disabled={loading}
/>
<div className="mode-option-content">
<h3>NIP-46 Remote Signer</h3>
<p>
Connect to an external signer (bunker) like Nostr Connect, Amber, or a
self-hosted bunker.
<br />
<span className="muted">
Best for: Hardware wallets, mobile signers, key isolation.
</span>
</p>
<ul className="mode-features">
<li>✓ Private key never on this device</li>
<li>✓ Use hardware wallet or mobile app</li>
<li>✓ Approve each request on signer device</li>
<li>⚠ Requires signer to be online</li>
</ul>
</div>
</label>
</div>
{vaultLocked && mode === 'embedded' && (
<Alert tone="warning" title="Vault is locked">
The embedded signer needs an unlocked vault to sign.{' '}
<a
href="#"
onClick={(e) => {
e.preventDefault();
void refresh();
}}
>
Unlock vault
</a>
before using embedded signing.
</Alert>
)}
{error && <ErrorText>{error}</ErrorText>}
</div>
</section>
{mode === 'embedded' && embeddedStatus && (
<section className="card">
<header className="card-header">
<h2>Embedded Signer Status</h2>
</header>
<div className="card-body">
<dl className="info-list">
<div>
<dt>Active Profile</dt>
<dd>
{embeddedStatus.active_npub ? (
<code className="mono">{embeddedStatus.active_npub}</code>
) : (
<span className="muted">None selected</span>
)}
</dd>
</div>
<div>
<dt>Pending Approvals</dt>
<dd>{embeddedStatus.pending_count}</dd>
</div>
</dl>
{embeddedStatus.pending.length > 0 && (
<div className="signer-pending">
<p className="hint">
The active profile needs approval for the following operations:
</p>
{embeddedStatus.pending.map((request, index) => (
<div key={request.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{request.method}</code>
<p>{request.summary}</p>
{request.details?.content_preview && (
<p className="content-preview">"{request.details.content_preview}"</p>
)}
{request.details?.is_sensitive && (
<span className="sensitive-badge">Sensitive operation</span>
)}
</div>
<div className="settings-inline">
<Button
variant="primary"
size="sm"
onClick={() => void onEmbeddedApprove(index, true)}
>
<Icon name="check" size={14} />
Approve
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void onEmbeddedApprove(index, false)}
>
<Icon name="trash" size={14} />
Reject
</Button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
{mode === 'nip46' && (
<section className="card">
<header className="card-header">
<h2>NIP-46 Connection</h2>
</header>
<div className="card-body">
{isNip46Active && nip46StatusState ? (
<div className="signer-actions">
<p className="hint">
Connected to{' '}
<code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code>
via {nip46StatusState.connected_relays.length} of{' '}
{nip46StatusState.relays.length} relays.
</p>
<dl className="info-list">
<div>
<dt>Signer</dt>
<dd>
<code className="mono">{nip46StatusState.signer_pubkey}</code>
</dd>
</div>
<div>
<dt>Relays</dt>
<dd>
{nip46StatusState.relays.map((relay, i) => {
const connected = nip46StatusState!.connected_relays.includes(relay);
return (
<span
key={i}
className={`mono signer-relay${connected ? ' is-connected' : ''}`}
>
{relay}
</span>
);
})}
</dd>
</div>
</dl>
{nip46StatusState.error && (
<Alert tone="error" title="Connection error">
{nip46StatusState.error}
</Alert>
)}
<Button variant="danger" onClick={() => void onNip46Disconnect()}>
<Icon name="trash" size={16} />
Disconnect
</Button>
</div>
) : (
<form onSubmit={onNip46Connect} noValidate>
<div className="field">
<label htmlFor="nip46-uri" className="visually-hidden">
nostrconnect:// link
</label>
<input
id="nip46-uri"
type="text"
placeholder="nostrconnect://…"
value={uri}
onChange={(e) => setUri(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<p className="hint">
In your Nostr app, choose "use a remote signer" and copy the link here.
</p>
</div>
<div className="field">
<label htmlFor="nip46-label">Connection Label</label>
<input
id="nip46-label"
type="text"
placeholder="e.g. Amber, Hardware Wallet, Self-hosted Bunker"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
{error && <ErrorText>{error}</ErrorText>}
<div className="settings-inline">
<Button
variant="primary"
type="submit"
loading={connecting}
disabled={uri.trim().length === 0 || vaultLocked}
>
<Icon name="key" size={16} />
Connect
</Button>
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
<Icon name="refresh" size={16} />
Refresh
</Button>
</div>
</form>
)}
{nip46StatusState?.pending_approvals.length &&
nip46StatusState.pending_approvals.length > 0 && (
<div className="signer-pending">
<h3>Pending Approvals ({nip46StatusState.pending_approvals.length})</h3>
{nip46StatusState.pending_approvals.map((request) => (
<div key={request.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{request.method}</code>
<p>{request.summary}</p>
{request.details?.content_preview && (
<p className="content-preview">"{request.details.content_preview}"</p>
)}
{request.details?.is_sensitive && (
<span className="sensitive-badge">Sensitive operation</span>
)}
</div>
<div className="settings-inline">
<Button
variant="primary"
size="sm"
onClick={() => void nip46Approve(request.id, true)}
>
<Icon name="check" size={14} />
Approve
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void nip46Approve(request.id, false)}
>
<Icon name="trash" size={14} />
Reject
</Button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
<section className="card">
<header className="card-header">
<h2>Security Notes</h2>
</header>
<div className="card-body">
<ul className="security-notes">
<li>
<strong>Embedded mode:</strong> Your keys are encrypted at rest with Argon2id +
AES-256-GCM. When unlocked, they exist in memory. A compromised OS or malware could
extract them.
</li>
<li>
<strong>NIP-46 mode:</strong> Your private key never touches this device. The signer
(Amber, Nostr Connect, bunker) holds the key and you approve each operation there.
</li>
<li>
<strong>Switching modes:</strong> You can switch modes anytime without changing your
public key. In NIP-46 mode, you'll need to import your key into the external signer
first.
</li>
<li>
<strong>Revocation:</strong> In NIP-46 mode, disconnect revokes the connection. The
signer will reject future requests from this app.
</li>
</ul>
</div>
</section>
</div>
</div>
);
}

View file

@ -12,6 +12,7 @@ const EMPTY_STATUS: SignerStatus = {
phase: 'stopped',
peer: null,
relays: [],
connectedRelays: [],
error: null,
pending: [],
};
@ -144,11 +145,25 @@ export function SignerScreen() {
<dt>Relays</dt>
<dd>
{status.relays.length > 0 ? (
status.relays.map((relay) => (
<span key={relay} className="mono signer-relay">
{relay}
</span>
))
<>
{status.relays.map((relay) => {
const connected = status.connectedRelays.includes(relay);
return (
<span
key={relay}
className={`mono signer-relay${connected ? ' is-connected' : ''}`}
title={connected ? 'Connected' : 'No connection yet'}
>
{relay}
</span>
);
})}
{status.phase === 'connecting' && status.connectedRelays.length === 0 && (
<span className="muted signer-relay-hint">
Waiting for a relay to answer…
</span>
)}
</>
) : (
<span className="muted">None</span>
)}

View file

@ -10,15 +10,18 @@ import {
import { api, BackendError } from '../lib/api';
import type {
AppState,
EmbeddedSignerStatus,
FeedItem,
LinkPreview,
MetadataPublishReport,
Nip46SignerStatus,
PickedImage,
ProfileSummary,
PublishReport,
RelayTestResult,
RevealedKey,
Settings,
SignerMode,
SignerStatus,
Theme,
UpdateApplyReport,
@ -68,6 +71,18 @@ interface AppContextValue {
pickImages: () => Promise<PickedImage[]>;
uploadImage: (token: string) => Promise<UploadedImage>;
linkPreview: (url: string) => Promise<LinkPreview | null>;
// Signer mode management
signerModeGet: () => Promise<{ mode: SignerMode }>;
signerModeSet: (mode: SignerMode) => Promise<AppState>;
// Embedded signer
embeddedSignerStatus: () => Promise<EmbeddedSignerStatus>;
embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>;
// NIP-46 client signer
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated)
signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>;
@ -228,6 +243,32 @@ export function AppProvider({ children }: { children: ReactNode }) {
return next;
}, []);
// Signer mode management
const signerModeGet = useCallback(() => api.signerModeGet(), []);
const signerModeSet = useCallback(
(mode: SignerMode) => applyState(api.signerModeSet(mode)),
[applyState],
);
// Embedded signer
const embeddedSignerStatus = useCallback(() => api.embeddedSignerStatus(), []);
const embeddedSignerApprove = useCallback(
(index: number, approved: boolean) => api.embeddedSignerApprove(index, approved),
[],
);
// NIP-46 client signer
const nip46Connect = useCallback(
(uri: string, label: string) => api.nip46Connect(uri, label),
[],
);
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46Approve = useCallback(
(id: string, approved: boolean) => api.nip46Approve(id, approved),
[],
);
const setVaultPassword = useCallback(
(currentPassword: string | null, newPassword: string) =>
applyState(api.setVaultPassword(currentPassword, newPassword)),
@ -301,6 +342,14 @@ export function AppProvider({ children }: { children: ReactNode }) {
pickImages,
uploadImage,
linkPreview,
signerModeGet,
signerModeSet,
embeddedSignerStatus,
embeddedSignerApprove,
nip46Connect,
nip46Disconnect,
nip46Status,
nip46Approve,
signerConnect,
signerDisconnect,
signerStatus,
@ -350,6 +399,14 @@ export function AppProvider({ children }: { children: ReactNode }) {
pickImages,
uploadImage,
linkPreview,
signerModeGet,
signerModeSet,
embeddedSignerStatus,
embeddedSignerApprove,
nip46Connect,
nip46Disconnect,
nip46Status,
nip46Approve,
signerConnect,
signerDisconnect,
signerStatus,

View file

@ -2213,6 +2213,16 @@ select {
font-size: 12px;
}
.signer-relay.is-connected {
border-color: var(--success);
color: var(--success);
}
.signer-relay-hint {
margin-left: 4px;
font-size: 12px;
}
.signer-actions {
display: flex;
flex-direction: column;

View file

@ -48,6 +48,47 @@ describe('SignerScreen', () => {
expect(backend.requests.some((r) => r.method === 'signer_connect')).toBe(true);
});
it('marks connected relays while the handshake is still in progress', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SignerScreen />);
// The signer is dialling the link's relays: one has answered, one has not.
backend.setSigner({
phase: 'connecting',
peer: 'ab12',
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [],
});
expect(await screen.findByText('Connecting…')).toBeInTheDocument();
const connected = screen.getByTitle('Connected');
expect(connected).toHaveTextContent('wss://relay.damus.io');
const pending = screen.getByTitle('No connection yet');
expect(pending).toHaveTextContent('wss://relay.nostr.band');
// No "waiting" hint while at least one relay is already up.
expect(screen.queryByText('Waiting for a relay to answer…')).not.toBeInTheDocument();
});
it('shows a waiting hint when no relay has answered yet', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SignerScreen />);
backend.setSigner({
phase: 'connecting',
peer: 'ab12',
relays: ['wss://relay.nostr.band'],
connectedRelays: [],
error: null,
pending: [],
});
expect(await screen.findByText('Waiting for a relay to answer…')).toBeInTheDocument();
});
it('disconnects an active connection', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
@ -80,6 +121,7 @@ describe('SignerScreen', () => {
phase: 'connected',
peer: 'ab12',
relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [
{ id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' },
@ -115,6 +157,7 @@ describe('SignerScreen', () => {
phase: 'connected',
peer: '79ab',
relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }],
});

View file

@ -4,6 +4,7 @@ import type {
ProfileSummary,
RelayTestResult,
Settings,
SignerMode,
SignerStatus,
} from '../lib/types';
@ -44,6 +45,7 @@ export function makeState(overrides?: Partial<AppState>): AppState {
profiles: [alice, bob],
settings,
last_publish: null,
signer_mode: 'embedded' as SignerMode,
...overrides,
};
}
@ -72,7 +74,15 @@ export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>)
}
export function makeSignerStatus(overrides?: Partial<SignerStatus>): SignerStatus {
return { phase: 'stopped', peer: null, relays: [], error: null, pending: [], ...overrides };
return {
phase: 'stopped',
peer: null,
relays: [],
connectedRelays: [],
error: null,
pending: [],
...overrides,
};
}
/**
@ -226,6 +236,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
phase: 'connected',
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [],
}),

View file

@ -324,6 +324,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
phase: 'connected',
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [],
};