Compare commits

..

3 commits

Author SHA1 Message Date
Avi
5635f7910a checkpoint: Home profiles list, dev host fix, full-width profiles card 2026-08-26 14:32:48 -05:00
Avi
835b0a02fd 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.
2026-08-26 14:32:22 -05:00
Avi
71e580f474 Trim white margins from sidebar logo artwork
The 512px source had ~25% baked-in white padding on every side, so the
38px sidebar tile showed a small bird floating in a white square.
Cropped to the content bounding box plus a 6% pad (keeps wing/beak
tips clear of the rounded corners); the img already fills the tile via
object-fit: cover. Window/packaged icons untouched.
2026-08-25 14:36:02 -05:00
7 changed files with 212 additions and 159 deletions

View file

@ -6,13 +6,14 @@ verified green at the moment this file was written.
## Where things are ## Where things are
- Project: `/home/avi/Projects/Nostr_Keynctr` - Project: `/home/avi/Projects/Nostr_Keynctr`
- Git repo: `master` @ `fa5ba08` ("Security: upgrade nostr stack 0.40->0.45 - Git repo: `master` @ `835b0a0` ("frontend: list all profiles on Home, pin
clearing 11 RustSec advisories"). Before it: `bf10ae3` (Checkpoint: clarify dev host, widen profiles card"). Before it: `71e580f` (Trim white margins
NIP-42 status), `e210f17` (Checkpoint: DRY modal save lifecycle / R3), from sidebar logo artwork), `f29d4f9` (Checkpoint 0.45 migration),
`7098e75` (refactor: share modal save lifecycle), `e6a1efc` (R4), `fa5ba08` (Security: upgrade nostr stack 0.40->0.45), `bf10ae3`, `e210f17`,
`6d5063d` (R2), `1c428a9` (R1). `7098e75`, `e6a1efc`, `6d5063d`, `1c428a9`.
- Working tree: only the long-standing user changes remain — `concept3.svg` - Working tree: still DIRTY with the long-standing user changes — `concept3.svg`
deleted, `KeynectrAppIconPossibility02.jpeg` untracked. deleted, `KeynectrAppIconPossibility02.jpeg` untracked. These are NOT part
of the frontend commit above and remain uncommitted.
## What was completed ## What was completed
@ -41,6 +42,7 @@ verified green at the moment this file was written.
## Commits added most recently ## Commits added most recently
- `835b0a0` frontend: list all profiles on Home, pin dev host, widen profiles card
- `fa5ba08` Security: upgrade nostr stack 0.40->0.45 clearing 11 RustSec advisories - `fa5ba08` Security: upgrade nostr stack 0.40->0.45 clearing 11 RustSec advisories
## Verification commands run (all green) ## Verification commands run (all green)
@ -1349,3 +1351,76 @@ $B rename <npub> <new-name> # rename + publish the new name
- Relay config at time of writing (all enabled): nos.lol, relay.primal.net, l484.com - Relay config at time of writing (all enabled): nos.lol, relay.primal.net, l484.com
(user's own), damus.io, snort.social, soloco.nl; relay.nostr.band added but was (user's own), damus.io, snort.social, soloco.nl; relay.nostr.band added but was
unreachable (handshake timeout) — retry enabling later. unreachable (handshake timeout) — retry enabling later.
## Checkpoint — frontend home screen improvements (2026-08-26)
A stopping point you can return to if this session is closed.
### Where things are
- Project: `/home/avi/Projects/Nostr_Keynctr`
- Git repo: `master`. **Working tree is DIRTY** — the changes below are local
edits and have NOT been committed yet (see "outstanding items").
- Dev-run fix also applied: `frontend/vite.config.ts` now binds the dev server
to `host: '127.0.0.1'` so Electron (`NOSTR_GUI_DEV_URL=http://127.0.0.1:5173`)
and Vite agree on address and the window is no longer a white box.
### What was completed (user-facing)
1. **Profile pictures now show on Home.** The Home screen's active-profile
`Avatar` was missing the `picture` prop, so the image only appeared on the
Profiles screen. `HomeScreen.tsx` now passes `picture={profile.picture}`.
2. **All profiles listed on Home.** Replaced the single "Active profile" card
with a "Your profiles" card listing every profile (avatar + picture, label,
shortened npub, Active badge). Each non-active row has a working **Select**
button (calls `selectProfile`); a **Manage** link goes to the Profiles
screen and an **Add profile** button is at the bottom.
3. **`Badge` now accepts `className`** so the Active badge can be inline next to
the name.
4. **Relays card removed from Home.** The "Relays" card (live connection status,
refresh, "enable a relay" link) is no longer shown on Home — relay status
lives on the Relays screen. Removed the now-unused relay-check logic
(`relayTest`/`runCheck`/`statuses`/`testing`/`enabled`/`Spinner`) and the
corresponding test.
5. **Profile card now full-width on Home.** `.home-grid` was a two-column grid
(profiles + relays); with Relays gone the profiles card only filled the left
half. Changed it to a single `1fr` column and dropped the now-redundant
`@media (max-width: 900px)` override, so "Your profiles" matches the width
of "Most recent publication" below it. No JSX change.
### Files changed
- `frontend/src/screens/HomeScreen.tsx` (profiles list + picture prop + select; relays removed)
- `frontend/src/components/Badge.tsx` (add `className` prop)
- `frontend/src/styles.css` (`.home-profile-list` / `.home-profile-row` / etc.)
- `frontend/src/test/HomeScreen.test.tsx` (scope copy-button test to Alice's row; drop relay-status test)
- `frontend/vite.config.ts` (dev server `host: '127.0.0.1'`)
### Verification commands run (all green)
Frontend (`frontend/`): `npm run typecheck` clean; `npm run lint` clean;
`npm test` 15 files / 98 tests pass; `npm run format:check` clean (after
`npm run format`).
### How to use / reproduce
Dev mode (two terminals, from `~/Projects/Nostr_Keynctr/frontend`):
```
# Terminal 1
npm run dev # prints Local: http://127.0.0.1:5173/
# Terminal 2
NOSTR_GUI_DEV_URL=http://127.0.0.1:5173 npm start
```
Home now shows every profile with its picture, and selecting one there updates
the active profile immediately.
### Outstanding items
- Committed as `835b0a0`. The working tree still has unrelated user changes
(`concept3.svg` deleted, `KeynectrAppIconPossibility02.jpeg` untracked) that
were intentionally left out of the commit.
- The dev-server `host: '127.0.0.1'` change is intentional; if you later need
LAN access to the dev server, set it back to `true` and use the matching URL.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

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,
}, },