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 function Badge({ tone = 'neutral', children }: { tone?: BadgeTone; children: ReactNode }) {
return <span className={`badge badge-${tone}`}>{children}</span>;
export function Badge({
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 { Avatar } from '../components/Avatar';
import { Badge } from '../components/Badge';
@ -6,67 +6,27 @@ import { Button } from '../components/Button';
import { CopyButton } from '../components/CopyButton';
import { EmptyState } from '../components/EmptyState';
import { Icon } from '../components/Icon';
import { Spinner } from '../components/Spinner';
import { shortenNpub } from '../lib/format';
import type { Screen } from '../lib/navigation';
import { useApp } from '../state/AppProvider';
type RelayStatus = 'checking' | 'connected' | 'failed';
interface HomeScreenProps {
onNavigate: (screen: Screen) => void;
onCreateProfile: () => void;
}
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
const { state, relayTest, lastPublish } = useApp();
const [statuses, setStatuses] = useState<Record<string, { state: RelayStatus; detail?: string }>>(
{},
);
const [testing, setTesting] = useState(false);
const { state, lastPublish, selectProfile } = useApp();
const [selecting, setSelecting] = useState<string | null>(null);
const enabled = useMemo(
() => state?.settings.relays.filter((r) => r.enabled) ?? [],
[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' };
const onSelect = async (npub: string) => {
setSelecting(npub);
try {
await selectProfile(npub);
} finally {
setSelecting(null);
}
setStatuses({ ...entries });
await Promise.all(
enabled.map(async (relay) => {
try {
const result = await relayTest(relay.url);
entries[relay.url] = {
state: 'connected',
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 shorten = state?.settings.shorten_npub ?? true;
@ -119,94 +79,72 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
<div className="home-grid">
<section className="card">
<header className="card-header">
<h2>Active profile</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>
<h2>Your profiles</h2>
<Button
variant="ghost"
size="sm"
onClick={() => void runCheck()}
loading={testing}
disabled={enabled.length === 0}
onClick={() => onNavigate('profiles')}
aria-label="Manage profiles"
>
<Icon name="refresh" size={16} />
Refresh
<Icon name="users" size={16} />
Manage
</Button>
</header>
<div className="card-body">
{enabled.length === 0 ? (
<p className="muted">
No relays enabled.{' '}
<button type="button" className="linklike" onClick={() => onNavigate('relays')}>
Enable a relay
</button>{' '}
to publish notes.
</p>
) : testing && Object.keys(statuses).length === 0 ? (
<Spinner label="Testing connections…" />
) : (
<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>
<span className="relay-status-text">
{status?.state === 'connected'
? `Connected${status.detail ? ` · ${status.detail}` : ''}`
: status?.state === 'failed'
? 'Unavailable'
: 'Checking…'}
</span>
</li>
);
})}
</ul>
)}
{failedCount > 0 &&
!testing &&
enabled.length > 0 &&
Object.keys(statuses).length === enabled.length && (
<p className="muted small">
{connectedCount} of {enabled.length} enabled relays reachable.
</p>
)}
<ul className="home-profile-list">
{state?.profiles.map((profile) => (
<li
key={profile.npub}
className={`home-profile-row${profile.is_active ? ' is-active' : ''}`}
>
<Avatar
npub={profile.npub}
label={profile.label}
size="lg"
picture={profile.picture}
/>
<div className="active-profile-meta">
<span className="profile-name">
{profile.label}
{profile.is_active && (
<Badge tone="success" className="home-active-badge">
Active
</Badge>
)}
</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>
))}
</ul>
<Button
variant="primary"
size="sm"
onClick={onCreateProfile}
className="home-add-profile"
>
<Icon name="plus" size={16} />
Add profile
</Button>
</div>
</section>
</div>

View file

@ -1003,16 +1003,10 @@ select {
.home-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr;
gap: 20px;
}
@media (max-width: 900px) {
.home-grid {
grid-template-columns: 1fr;
}
}
.active-profile-row {
display: flex;
align-items: center;
@ -1067,6 +1061,50 @@ select {
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 {
list-style: none;
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 { HomeScreen } from '../screens/HomeScreen';
import { renderWithApp } from './render';
@ -36,8 +36,8 @@ describe('HomeScreen', () => {
renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
await screen.findByText('Alice');
await userEvent.setup().click(screen.getByRole('button', { name: 'Copy full npub' }));
const aliceRow = (await screen.findByText('Alice')).closest('.home-profile-row') as HTMLElement;
await userEvent.setup().click(within(aliceRow).getByRole('button', { name: 'Copy full npub' }));
await waitFor(() => {
expect(backend.copied).toContain(ALICE);
});
@ -54,13 +54,4 @@ describe('HomeScreen', () => {
.click(screen.getByRole('button', { name: /Create your first profile/i }));
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({
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
},