frontend: list all profiles on Home, pin dev host, widen profiles card

- Home now shows every profile with its picture, an inline Active badge,
  and a working Select button (was only the active profile).
- Remove the Relays card from Home (status lives on the Relays screen) and
  its unused connection-check logic/test.
- Pin Vite dev server to 127.0.0.1 so Electron and the dev server agree on
  the address (fixes white-box window in dev mode).
- Make the profiles card full-width to match the publication card below.
- Badge now accepts className.
This commit is contained in:
Avi 2026-08-26 14:32:22 -05:00
commit 835b0a02fd
5 changed files with 130 additions and 152 deletions

View file

@ -2,6 +2,16 @@ import type { ReactNode } from 'react';
export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'; export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
export function Badge({ tone = 'neutral', children }: { tone?: BadgeTone; children: ReactNode }) { export function Badge({
return <span className={`badge badge-${tone}`}>{children}</span>; tone = 'neutral',
className,
children,
}: {
tone?: BadgeTone;
className?: string;
children: ReactNode;
}) {
return (
<span className={`badge badge-${tone}${className ? ` ${className}` : ''}`}>{children}</span>
);
} }

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useState } from 'react';
import { Alert } from '../components/Alert'; import { Alert } from '../components/Alert';
import { Avatar } from '../components/Avatar'; import { Avatar } from '../components/Avatar';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
@ -6,67 +6,27 @@ import { Button } from '../components/Button';
import { CopyButton } from '../components/CopyButton'; import { CopyButton } from '../components/CopyButton';
import { EmptyState } from '../components/EmptyState'; import { EmptyState } from '../components/EmptyState';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { Spinner } from '../components/Spinner';
import { shortenNpub } from '../lib/format'; import { shortenNpub } from '../lib/format';
import type { Screen } from '../lib/navigation'; import type { Screen } from '../lib/navigation';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
type RelayStatus = 'checking' | 'connected' | 'failed';
interface HomeScreenProps { interface HomeScreenProps {
onNavigate: (screen: Screen) => void; onNavigate: (screen: Screen) => void;
onCreateProfile: () => void; onCreateProfile: () => void;
} }
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
const { state, relayTest, lastPublish } = useApp(); const { state, lastPublish, selectProfile } = useApp();
const [statuses, setStatuses] = useState<Record<string, { state: RelayStatus; detail?: string }>>( const [selecting, setSelecting] = useState<string | null>(null);
{},
);
const [testing, setTesting] = useState(false);
const enabled = useMemo( const onSelect = async (npub: string) => {
() => state?.settings.relays.filter((r) => r.enabled) ?? [], setSelecting(npub);
[state?.settings.relays],
);
const runCheck = useCallback(async () => {
setTesting(true);
const entries: Record<string, { state: RelayStatus; detail?: string }> = {};
for (const relay of enabled) {
entries[relay.url] = { state: 'checking' };
}
setStatuses({ ...entries });
await Promise.all(
enabled.map(async (relay) => {
try { try {
const result = await relayTest(relay.url); await selectProfile(npub);
entries[relay.url] = { } finally {
state: 'connected', setSelecting(null);
detail: result.latency_ms != null ? `${result.latency_ms} ms` : 'connected',
};
} catch (error) {
entries[relay.url] = {
state: 'failed',
detail: error instanceof Error ? error.message : String(error),
};
} }
}), };
);
setStatuses({ ...entries });
setTesting(false);
}, [enabled, relayTest]);
useEffect(() => {
if (enabled.length > 0) {
void runCheck();
} else {
setStatuses({});
}
}, [enabled, runCheck]);
const connectedCount = Object.values(statuses).filter((s) => s.state === 'connected').length;
const failedCount = Object.values(statuses).filter((s) => s.state === 'failed').length;
const active = state?.active_profile ?? null; const active = state?.active_profile ?? null;
const shorten = state?.settings.shorten_npub ?? true; const shorten = state?.settings.shorten_npub ?? true;
@ -119,94 +79,72 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
<div className="home-grid"> <div className="home-grid">
<section className="card"> <section className="card">
<header className="card-header"> <header className="card-header">
<h2>Active profile</h2> <h2>Your profiles</h2>
</header>
<div className="card-body">
{active ? (
<div className="active-profile-row">
<Avatar npub={active.npub} label={active.label} size="lg" />
<div className="active-profile-meta">
<span className="profile-name">{active.label}</span>
<code className="mono" title={active.npub}>
{shortenNpub(active.npub, shorten)}
</code>
</div>
<CopyButton text={active.npub} label="full npub" />
</div>
) : (
<p className="muted">
No profile selected.{' '}
<button type="button" className="linklike" onClick={() => onNavigate('profiles')}>
Choose a profile
</button>{' '}
to get started.
</p>
)}
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Relays</h2>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => void runCheck()} onClick={() => onNavigate('profiles')}
loading={testing} aria-label="Manage profiles"
disabled={enabled.length === 0}
> >
<Icon name="refresh" size={16} /> <Icon name="users" size={16} />
Refresh Manage
</Button> </Button>
</header> </header>
<div className="card-body"> <div className="card-body">
{enabled.length === 0 ? ( <ul className="home-profile-list">
<p className="muted"> {state?.profiles.map((profile) => (
No relays enabled.{' '} <li
<button type="button" className="linklike" onClick={() => onNavigate('relays')}> key={profile.npub}
Enable a relay className={`home-profile-row${profile.is_active ? ' is-active' : ''}`}
</button>{' '} >
to publish notes. <Avatar
</p> npub={profile.npub}
) : testing && Object.keys(statuses).length === 0 ? ( label={profile.label}
<Spinner label="Testing connections…" /> size="lg"
) : ( picture={profile.picture}
<ul className="relay-status-list">
{enabled.map((relay) => {
const status = statuses[relay.url];
return (
<li key={relay.url} className="relay-status-row">
<span
className={`status-dot ${
status?.state === 'connected'
? 'is-ok'
: status?.state === 'failed'
? 'is-bad'
: 'is-pending'
}`}
aria-hidden="true"
/> />
<span className="mono relay-url">{relay.url}</span> <div className="active-profile-meta">
<span className="relay-status-text"> <span className="profile-name">
{status?.state === 'connected' {profile.label}
? `Connected${status.detail ? ` · ${status.detail}` : ''}` {profile.is_active && (
: status?.state === 'failed' <Badge tone="success" className="home-active-badge">
? 'Unavailable' Active
: 'Checking…'} </Badge>
)}
</span> </span>
<code className="mono" title={profile.npub}>
{shortenNpub(profile.npub, shorten)}
</code>
</div>
<div className="home-profile-actions">
<CopyButton text={profile.npub} label="full npub" />
{profile.is_active ? (
<Button variant="secondary" size="sm" disabled>
Selected
</Button>
) : (
<Button
variant="secondary"
size="sm"
loading={selecting === profile.npub}
onClick={() => void onSelect(profile.npub)}
>
Select
</Button>
)}
</div>
</li> </li>
); ))}
})}
</ul> </ul>
)} <Button
{failedCount > 0 && variant="primary"
!testing && size="sm"
enabled.length > 0 && onClick={onCreateProfile}
Object.keys(statuses).length === enabled.length && ( className="home-add-profile"
<p className="muted small"> >
{connectedCount} of {enabled.length} enabled relays reachable. <Icon name="plus" size={16} />
</p> Add profile
)} </Button>
</div> </div>
</section> </section>
</div> </div>

View file

@ -1003,14 +1003,8 @@ select {
.home-grid { .home-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) {
.home-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} gap: 20px;
} }
.active-profile-row { .active-profile-row {
@ -1067,6 +1061,50 @@ select {
object-fit: cover; object-fit: cover;
} }
.home-profile-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.home-profile-row {
display: flex;
align-items: center;
gap: 14px;
padding: 8px;
border-radius: 8px;
border: 1px solid transparent;
}
.home-profile-row.is-active {
background: var(--token-item-bg, rgba(0, 0, 0, 0.04));
border-color: var(--token-border, rgba(0, 0, 0, 0.12));
}
.home-profile-row .active-profile-meta {
flex: 1;
min-width: 0;
}
.home-profile-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.home-active-badge {
margin-left: 8px;
vertical-align: middle;
}
.home-add-profile {
margin-top: 14px;
}
.relay-status-list { .relay-status-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

View file

@ -1,4 +1,4 @@
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { HomeScreen } from '../screens/HomeScreen'; import { HomeScreen } from '../screens/HomeScreen';
import { renderWithApp } from './render'; import { renderWithApp } from './render';
@ -36,8 +36,8 @@ describe('HomeScreen', () => {
renderHome(backend); renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />); renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
await screen.findByText('Alice'); const aliceRow = (await screen.findByText('Alice')).closest('.home-profile-row') as HTMLElement;
await userEvent.setup().click(screen.getByRole('button', { name: 'Copy full npub' })); await userEvent.setup().click(within(aliceRow).getByRole('button', { name: 'Copy full npub' }));
await waitFor(() => { await waitFor(() => {
expect(backend.copied).toContain(ALICE); expect(backend.copied).toContain(ALICE);
}); });
@ -54,13 +54,4 @@ describe('HomeScreen', () => {
.click(screen.getByRole('button', { name: /Create your first profile/i })); .click(screen.getByRole('button', { name: /Create your first profile/i }));
expect(onCreateProfile).toHaveBeenCalled(); expect(onCreateProfile).toHaveBeenCalled();
}); });
it('shows live relay connection status from the backend', async () => {
const backend = createFakeBackend();
renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
expect(await screen.findAllByText(/Connected/)).not.toHaveLength(0);
expect(screen.getAllByText('wss://relay.damus.io')).not.toHaveLength(0);
});
}); });

View file

@ -6,6 +6,7 @@ import type { UserConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
host: '127.0.0.1',
port: 5173, port: 5173,
strictPort: true, strictPort: true,
}, },