Add Nostr Feed Manager: Rust backend with Electron + React GUI

- Rust library (nostr-manager-backend) with CLI and JSON-lines IPC serve mode:
  profiles, publishing with per-relay reports, relays, settings, vault storage
  and legacy-vault migration
- Electron + React + TypeScript desktop GUI using the same backend over stdio IPC
- Vitest suite with a fake backend speaking the real protocol
- electron-builder linux packaging; README with build and usage instructions
This commit is contained in:
Avi 2026-08-03 16:05:59 -05:00
commit 7e3bac345c
68 changed files with 18262 additions and 0 deletions

5
frontend/.prettierignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
dist-electron
release
coverage

6
frontend/.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all",
"semi": true
}

193
frontend/electron/main.ts Normal file
View file

@ -0,0 +1,193 @@
import { app, BrowserWindow, clipboard, ipcMain, protocol } from 'electron';
import { spawn, type ChildProcess } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import * as path from 'node:path';
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.json': 'application/json',
'.map': 'application/json',
};
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
}
let backend: ChildProcess | null = null;
let backendStarted = false;
const pending = new Map<number, PendingRequest>();
let nextId = 1;
function resolveBackendPath(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'nostr-manager-backend');
}
// Development: the crate builds to <project>/target/release.
return path.join(app.getAppPath(), '..', 'target', 'release', 'nostr-manager-backend');
}
function startBackend(): void {
if (backendStarted && backend && backend.exitCode === null) {
return;
}
backendStarted = true;
const bin = resolveBackendPath();
backend = spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'pipe'] });
const stdout = backend.stdout;
const stderr = backend.stderr;
const stdin = backend.stdin;
if (!stdout || !stderr || !stdin) {
console.error('[backend] failed to capture backend streams.');
return;
}
const reader = createInterface({ input: stdout });
reader.on('line', (line) => {
let envelope: { id?: number };
try {
envelope = JSON.parse(line);
} catch {
return;
}
if (typeof envelope.id === 'number') {
const entry = pending.get(envelope.id);
if (entry) {
pending.delete(envelope.id);
entry.resolve(envelope);
}
}
});
stderr.on('data', (chunk: Buffer) => {
// Backend diagnostics go to stderr only; never secrets, never forwarded.
console.error('[backend]', chunk.toString().trim());
});
backend.on('error', (err) => {
console.error('[backend] failed to start:', err.message);
});
backend.on('exit', (code) => {
const error = new Error(
`The Rust backend exited unexpectedly${code === null ? '' : ` (code ${code})`}.`,
);
for (const [, entry] of pending) {
entry.reject(error);
}
pending.clear();
backend = null;
});
}
async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
startBackend();
const stdin = backend?.stdin;
if (!backend || !stdin || stdin.destroyed) {
throw new Error('The Rust backend is not available.');
}
const id = nextId++;
const payload = { id, method, ...params };
const response = new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject });
});
stdin.write(`${JSON.stringify(payload)}\n`);
return response;
}
function createWindow(): void {
const window = new BrowserWindow({
width: 1160,
height: 760,
minWidth: 920,
minHeight: 640,
title: 'Nostr Feed Manager',
backgroundColor: '#f6f4f0',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
const devServer = process.env.NOSTR_GUI_DEV_URL;
if (devServer) {
void window.loadURL(devServer);
} else {
void window.loadURL('app://nfm/index.html');
}
}
app.whenReady().then(() => {
protocol.handle('app', (request) => {
const { pathname } = new URL(request.url);
let relative = decodeURIComponent(pathname);
if (relative.endsWith('/')) {
relative += 'index.html';
}
const safe = path
.normalize(relative)
.replace(/^(\.\.[/\\])+/, '')
.replace(/^[/\\]+/, '');
const filePath = path.join(app.getAppPath(), 'dist', safe);
try {
const data = readFileSync(filePath);
const type = MIME[path.extname(filePath)] ?? 'application/octet-stream';
return new Response(data, { headers: { 'content-type': type } });
} catch {
return new Response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } });
}
});
ipcMain.handle(
'backend:request',
(_event, payload: { method: string; params?: Record<string, unknown> }) => {
const params = payload.params ?? {};
return backendRequest(payload.method, params);
},
);
ipcMain.handle('clipboard:write', (_event, text: string) => {
clipboard.writeText(String(text));
return true;
});
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('before-quit', () => {
if (backend) {
backend.kill();
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

View file

@ -0,0 +1,7 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('backend', {
request: (method: string, params?: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke('backend:request', { method, params }),
copyText: (text: string): Promise<void> => ipcRenderer.invoke('clipboard:write', text),
});

19
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,19 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
export default tseslint.config(
{ ignores: ['dist/**', 'dist-electron/**', 'release/**', 'node_modules/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}', 'electron/**/*.ts'],
plugins: {
'react-hooks': reactHooks,
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
},
},
);

16
frontend/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: http://localhost:*; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'"
/>
<title>Nostr Feed Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

9241
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

73
frontend/package.json Normal file
View file

@ -0,0 +1,73 @@
{
"name": "nost-feed-manager",
"productName": "Nostr Feed Manager",
"version": "0.1.0",
"description": "A friendly Linux desktop app for managing Nostr profiles and publishing text notes.",
"private": true,
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p tsconfig.json && vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src electron eslint.config.js",
"lint:fix": "eslint src electron eslint.config.js --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"electron:build": "tsc -p tsconfig.electron.json",
"start": "npm run electron:build && electron .",
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^26.1.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
"typescript": "^5.6.3",
"typescript-eslint": "^8.15.0",
"vite": "^5.4.11",
"vitest": "^2.1.8"
},
"build": {
"appId": "dev.nostfeedmanager.desktop",
"productName": "Nostr Feed Manager",
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"dist/**",
"dist-electron/**",
"package.json"
],
"extraResources": [
{
"from": "../target/release/nostr-manager-backend",
"to": "nostr-manager-backend"
}
],
"linux": {
"target": [
"dir"
],
"category": "Network",
"executableName": "nost-feed-manager"
}
}
}

64
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,64 @@
import { useState } from 'react';
import { Sidebar } from './components/Sidebar';
import { Spinner } from './components/Spinner';
import { Alert } from './components/Alert';
import { HomeScreen } from './screens/HomeScreen';
import { ProfilesScreen } from './screens/ProfilesScreen';
import { ComposeScreen } from './screens/ComposeScreen';
import { RelaysScreen } from './screens/RelaysScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { CreateProfileModal } from './screens/CreateProfileModal';
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
import type { Screen } from './lib/navigation';
function Shell() {
const { state, loading, bootstrapError } = useApp();
const [screen, setScreen] = useState<Screen>('home');
const [createOpen, setCreateOpen] = useState(false);
useThemeSync(state?.settings.theme);
if (loading) {
return (
<div className="center-screen">
<Spinner label="Loading…" />
</div>
);
}
if (bootstrapError) {
return (
<div className="center-screen">
<div className="card" style={{ maxWidth: 480 }}>
<Alert tone="error" title="Could not start the application">
{bootstrapError}
</Alert>
</div>
</div>
);
}
return (
<div className="app-shell">
<Sidebar screen={screen} onNavigate={setScreen} />
<main className="main" id="main-content">
{screen === 'home' && (
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
)}
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
{screen === 'compose' && <ComposeScreen />}
{screen === 'relays' && <RelaysScreen />}
{screen === 'settings' && <SettingsScreen />}
</main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
</div>
);
}
export default function App() {
return (
<AppProvider>
<Shell />
</AppProvider>
);
}

View file

@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
export type AlertTone = 'success' | 'warning' | 'error' | 'info';
interface AlertProps {
tone: AlertTone;
title?: string;
/** Concise technical detail shown in an expandable area. */
details?: string | null;
children?: ReactNode;
}
export function Alert({ tone, title, details, children }: AlertProps) {
const role = tone === 'error' || tone === 'warning' ? 'alert' : 'status';
return (
<div className={`alert alert-${tone}`} role={role}>
{title && <strong className="alert-title">{title}</strong>}
{children && <div className="alert-body">{children}</div>}
{details && (
<details className="alert-details">
<summary>Technical details</summary>
<pre>{details}</pre>
</details>
)}
</div>
);
}

View file

@ -0,0 +1,20 @@
import { avatarColorFor, initialsFor } from '../lib/format';
interface AvatarProps {
npub: string;
label: string;
size?: 'md' | 'lg';
}
export function Avatar({ npub, label, size = 'md' }: AvatarProps) {
const colors = avatarColorFor(npub);
return (
<span
className={`avatar avatar-${size}`}
style={{ backgroundColor: colors.background, color: colors.foreground }}
aria-hidden="true"
>
{initialsFor(label)}
</span>
);
}

View file

@ -0,0 +1,7 @@
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>;
}

View file

@ -0,0 +1,32 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'md' | 'sm';
loading?: boolean;
children: ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled,
children,
className,
type = 'button',
...rest
}: ButtonProps) {
return (
<button
type={type}
className={`btn btn-${variant} btn-${size}${className ? ` ${className}` : ''}`}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...rest}
>
{loading && <span className="spinner spinner-sm" aria-hidden="true" />}
{children}
</button>
);
}

View file

@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
interface CardProps {
title?: string;
actions?: ReactNode;
className?: string;
children: ReactNode;
}
export function Card({ title, actions, className, children }: CardProps) {
return (
<section className={`card${className ? ` ${className}` : ''}`}>
{title && (
<header className="card-header">
<h2>{title}</h2>
{actions && <div className="card-actions">{actions}</div>}
</header>
)}
<div className="card-body">{children}</div>
</section>
);
}

View file

@ -0,0 +1,54 @@
import { useState } from 'react';
import { useApp } from '../state/AppProvider';
import { Icon } from './Icon';
interface CopyButtonProps {
text: string;
/** Accessible name for the action, e.g. "public key". */
label?: string;
copiedLabel?: string;
/** When true, show only the icon with the label as the accessible name. */
iconOnly?: boolean;
size?: 'md' | 'sm';
}
export function CopyButton({
text,
label = 'Copy',
copiedLabel = 'Copied',
iconOnly = false,
size = 'sm',
}: CopyButtonProps) {
const { copyText } = useApp();
const [copied, setCopied] = useState(false);
const onCopy = async () => {
try {
await copyText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
// Clipboard is best-effort; the button simply stays in its default state.
}
};
const accessibleName = `Copy ${label}`.trim();
return (
<button
type="button"
className={`btn btn-ghost btn-${size}${iconOnly ? ' btn-icon' : ''}`}
onClick={onCopy}
aria-label={accessibleName}
title={accessibleName}
>
{iconOnly ? (
<Icon name={copied ? 'check' : 'copy'} size={16} />
) : copied ? (
copiedLabel
) : (
label
)}
</button>
);
}

View file

@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="empty-state">
{icon && (
<div className="empty-state-icon" aria-hidden="true">
{icon}
</div>
)}
<h3>{title}</h3>
{description && <p>{description}</p>}
{action && <div className="empty-state-action">{action}</div>}
</div>
);
}

View file

@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
interface ErrorTextProps {
id?: string;
children: ReactNode;
}
/** Inline field error, associated with a field via `id`/`aria-describedby`. */
export function ErrorText({ id, children }: ErrorTextProps) {
return (
<p className="field-error" id={id} role="alert">
{children}
</p>
);
}

View file

@ -0,0 +1,113 @@
import type { ReactNode } from 'react';
export type IconName =
| 'home'
| 'users'
| 'edit'
| 'relay'
| 'settings'
| 'copy'
| 'plus'
| 'check'
| 'refresh'
| 'trash'
| 'info'
| 'shield'
| 'publish'
| 'external';
const PATHS: Record<IconName, ReactNode> = {
home: (
<>
<path d="M3 11.5 12 4l9 7.5" />
<path d="M5.5 9.8V20h13V9.8" />
</>
),
users: (
<>
<circle cx="9" cy="8" r="3.5" />
<path d="M3 20c.8-3.5 3.2-5.5 6-5.5s5.2 2 6 5.5" />
<circle cx="17" cy="9" r="2.5" />
<path d="M16 14.5c1.8.3 3.5 1.5 4.3 3.5" />
</>
),
edit: <path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" />,
relay: (
<>
<circle cx="12" cy="12" r="2.5" />
<path d="M7.5 7.5a6.5 6.5 0 0 0 0 9" />
<path d="M16.5 7.5a6.5 6.5 0 0 1 0 9" />
<path d="M4.5 4.5a10.5 10.5 0 0 0 0 15" />
<path d="M19.5 4.5a10.5 10.5 0 0 1 0 15" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9 17 7M7 17l-2.1 2.1" />
</>
),
copy: (
<>
<rect x="9" y="9" width="12" height="12" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>
),
plus: <path d="M12 5v14M5 12h14" />,
check: <path d="M4 12.5 9.5 18 20 6.5" />,
refresh: (
<>
<path d="M20 11a8 8 0 1 0-2.34 5.66" />
<path d="M20 4v7h-7" />
</>
),
trash: (
<>
<path d="M4 7h16" />
<path d="M10 11v6M14 11v6" />
<path d="M6 7l1 13h10l1-13" />
<path d="M9 7V4h6v3" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 8h.01" />
<path d="M12 11v5" />
</>
),
shield: <path d="M12 3l7 3v5c0 4.4-2.8 8-7 10-4.2-2-7-5.6-7-10V6z" />,
publish: <path d="M12 19V5M5 12l7-7 7 7" />,
external: (
<>
<path d="M14 4h6v6" />
<path d="M20 4l-9 9" />
<path d="M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-5" />
</>
),
};
interface IconProps {
name: IconName;
size?: number;
className?: string;
}
export function Icon({ name, size = 20, className }: IconProps) {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{PATHS[name]}
</svg>
);
}

View file

@ -0,0 +1,77 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
}
export function Modal({ open, title, onClose, children }: ModalProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
const previouslyFocused = document.activeElement as HTMLElement | null;
const container = containerRef.current;
const frame = requestAnimationFrame(() => {
container?.focus();
});
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', onKeyDown);
previouslyFocused?.focus?.();
};
}, [open, onClose]);
if (!open) {
return null;
}
return createPortal(
<div
className="modal-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
ref={containerRef}
className="modal"
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
>
<header className="modal-header">
<h2>{title}</h2>
<button
type="button"
className="btn btn-ghost btn-sm btn-icon"
aria-label="Close dialog"
onClick={onClose}
>
&times;
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body,
);
}

View file

@ -0,0 +1,71 @@
import { useApp } from '../state/AppProvider';
import type { Screen } from '../lib/navigation';
import { shortenNpub } from '../lib/format';
import { Avatar } from './Avatar';
import { CopyButton } from './CopyButton';
import { Icon, type IconName } from './Icon';
const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
{ id: 'home', label: 'Home', icon: 'home' },
{ id: 'profiles', label: 'Profiles', icon: 'users' },
{ id: 'compose', label: 'Compose', icon: 'edit' },
{ id: 'relays', label: 'Relays', icon: 'relay' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
];
interface SidebarProps {
screen: Screen;
onNavigate: (screen: Screen) => void;
}
export function Sidebar({ screen, onNavigate }: SidebarProps) {
const { state } = useApp();
const active = state?.active_profile ?? null;
const shorten = state?.settings.shorten_npub ?? true;
return (
<aside className="sidebar">
<div className="sidebar-brand">
<span className="sidebar-logo" aria-hidden="true">
<Icon name="shield" size={22} />
</span>
<div>
<strong>Nostr Feed</strong>
<span className="sidebar-subtitle">Manager</span>
</div>
</div>
<nav className="sidebar-nav" aria-label="Main navigation">
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
className={`nav-item${screen === item.id ? ' is-active' : ''}`}
aria-current={screen === item.id ? 'page' : undefined}
onClick={() => onNavigate(item.id)}
>
<Icon name={item.icon} size={19} />
<span>{item.label}</span>
</button>
))}
</nav>
<div className="sidebar-footer">
{active ? (
<div className="sidebar-profile">
<Avatar npub={active.npub} label={active.label} />
<div className="sidebar-profile-meta">
<span className="sidebar-profile-name">{active.label}</span>
<span className="mono" title={active.npub}>
{shortenNpub(active.npub, shorten)}
</span>
</div>
<CopyButton text={active.npub} label="" copiedLabel="" />
</div>
) : (
<p className="sidebar-noprofile">No profile selected</p>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,8 @@
export function Spinner({ label }: { label?: string }) {
return (
<span className="spinner-wrap" role="status">
<span className="spinner" aria-hidden="true" />
{label && <span className="spinner-label">{label}</span>}
</span>
);
}

View file

@ -0,0 +1,23 @@
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
disabled?: boolean;
}
export function Toggle({ checked, onChange, label, disabled }: ToggleProps) {
return (
<label className={`toggle${disabled ? ' is-disabled' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
disabled={disabled}
/>
<span className="toggle-track" aria-hidden="true">
<span className="toggle-thumb" />
</span>
<span className="toggle-label">{label}</span>
</label>
);
}

55
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,55 @@
import type {
AppState,
BackendResponse,
ProfileSummary,
PublishReport,
RelayTestResult,
Settings,
} from './types';
declare global {
interface Window {
backend: {
request(method: string, params?: Record<string, unknown>): Promise<unknown>;
copyText(text: string): Promise<void>;
};
}
}
/** A safe error with an optional expandable technical detail. */
export class BackendError extends Error {
readonly details?: string | null;
constructor(message: string, details?: string | null) {
super(message);
this.name = 'BackendError';
this.details = details;
}
}
async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
const envelope = (await window.backend.request(method, params)) as BackendResponse<T>;
if (envelope.status === 'error') {
throw new BackendError(envelope.message, envelope.details);
}
return envelope.data;
}
export const api = {
init: () => call<AppState>('init'),
getState: () => call<AppState>('get_state'),
createProfile: (label: string) =>
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label }),
selectProfile: (npub: string) => call<AppState>('select_profile', { npub }),
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
relayAdd: (url: string) => call<Settings>('relay_add', { url }),
relayRemove: (url: string) => call<Settings>('relay_remove', { url }),
relaySetEnabled: (url: string, enabled: boolean) =>
call<Settings>('relay_set_enabled', { url, enabled }),
relayTest: (url: string) => call<RelayTestResult>('relay_test', { url }),
settingsUpdate: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => call<Settings>('settings_update', patch),
backupNow: () => call<{ backup_path: string }>('backup_now'),
copyText: (text: string) => window.backend.copyText(text),
};

View file

@ -0,0 +1,65 @@
/**
* Shorten an npub (or similar bech32 id) for display: `npub1abcd...wxyz8`.
* Keeps the full value when the string is short or shortening is disabled.
*/
export function shortenNpub(npub: string, enabled: boolean): string {
if (!enabled) {
return npub;
}
if (npub.length <= 16) {
return npub;
}
const prefix = npub.slice(0, 10);
const suffix = npub.slice(-8);
return `${prefix}...${suffix}`;
}
/**
* Human-friendly date from a Unix timestamp.
* Uses the "short" date format for compactness.
*/
export function formatDate(unixSeconds: number, now: Date = new Date()): string {
const date = new Date(unixSeconds * 1000);
if (Number.isNaN(date.getTime())) {
return 'unknown';
}
const ageDays = (now.getTime() - date.getTime()) / 86_400_000;
if (ageDays >= 0 && ageDays < 1) {
return 'today';
}
if (ageDays >= 1 && ageDays < 7) {
return `${Math.floor(ageDays)} day${Math.floor(ageDays) === 1 ? '' : 's'} ago`;
}
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/** A distinct, human-friendly colour pair derived from an npub for avatar circles. */
const AVATAR_COLORS = [
{ background: '#d8b4fe', foreground: '#4c1d95' },
{ background: '#bae6fd', foreground: '#0c4a6e' },
{ background: '#a7f3d0', foreground: '#064e3b' },
{ background: '#fde68a', foreground: '#78350f' },
{ background: '#fbcfe8', foreground: '#831843' },
{ background: '#c7d2fe', foreground: '#312e81' },
];
export function avatarColorFor(npub: string): { background: string; foreground: string } {
let hash = 0;
for (let i = 0; i < npub.length; i += 1) {
hash = (hash * 31 + npub.charCodeAt(i)) >>> 0;
}
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}
/** The first letter of a label, uppercased, for avatar initials. */
export function initialsFor(label: string): string {
const trimmed = label.trim();
if (trimmed.length === 0) {
return '?';
}
return trimmed.charAt(0).toUpperCase();
}

View file

@ -0,0 +1,9 @@
export type Screen = 'home' | 'profiles' | 'compose' | 'relays' | 'settings';
export const SCREEN_TITLES: Record<Screen, string> = {
home: 'Home',
profiles: 'Profiles',
compose: 'Compose',
relays: 'Relays',
settings: 'Settings',
};

59
frontend/src/lib/types.ts Normal file
View file

@ -0,0 +1,59 @@
export type Theme = 'light' | 'dark' | 'system';
/** A safe view of a profile with no secret key material. */
export interface ProfileSummary {
label: string;
/** Bech32 npub. */
npub: string;
/** Unix timestamp of creation. */
created_at: number;
is_active: boolean;
}
export interface RelayConfig {
url: string;
enabled: boolean;
}
export interface Settings {
theme: Theme;
confirm_before_publish: boolean;
shorten_npub: boolean;
relays: RelayConfig[];
}
export interface RelayFailure {
url: string;
/** Concise user-facing reason. */
error: string;
/** Technical detail shown in an expandable area. */
details?: string | null;
}
export interface PublishReport {
/** Bech32 note id. */
event_id: string;
succeeded: string[];
failed: RelayFailure[];
}
export interface RelayTestResult {
url: string;
connected: boolean;
latency_ms?: number | null;
}
export interface AppState {
version: string;
vault_path: string;
settings_path: string;
encrypted_storage: boolean;
migrated_from: string | null;
active_profile: ProfileSummary | null;
profiles: ProfileSummary[];
settings: Settings;
}
/** Wire envelope returned by the Rust backend. */
export type BackendResponse<T> =
{ status: 'ok'; data: T } | { status: 'error'; message: string; details?: string | null };

15
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element #root not found');
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);

View file

@ -0,0 +1,208 @@
import { useState } from 'react';
import { Alert } from '../components/Alert';
import { Avatar } from '../components/Avatar';
import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
import { CopyButton } from '../components/CopyButton';
import { Icon } from '../components/Icon';
import { Modal } from '../components/Modal';
import { shortenNpub } from '../lib/format';
import { useApp } from '../state/AppProvider';
const SOFT_LIMIT = 10_000;
export function ComposeScreen() {
const { state, publishNote, recordPublishFailure, lastPublish } = useApp();
const [content, setContent] = useState('');
const [publishing, setPublishing] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const active = state?.active_profile ?? null;
const enabledCount = state?.settings.relays.filter((relay) => relay.enabled).length ?? 0;
const confirmBefore = state?.settings.confirm_before_publish ?? true;
const shorten = state?.settings.shorten_npub ?? true;
const trimmed = content.trim();
const canPublish = trimmed.length > 0 && active !== null && enabledCount > 0 && !publishing;
const doPublish = async () => {
setPublishing(true);
try {
await publishNote(content);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const details =
err instanceof Error && 'details' in err
? (err as { details?: string | null }).details
: null;
recordPublishFailure(message, details);
} finally {
setPublishing(false);
}
};
const onPublishClick = () => {
if (!canPublish) {
return;
}
if (confirmBefore) {
setConfirmOpen(true);
} else {
void doPublish();
}
};
const lastReport = lastPublish?.report ?? null;
const lastError = lastPublish?.error ?? null;
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<div>
<h1>Compose</h1>
<p className="page-subtitle">Write a short note and publish it to the Nostr network.</p>
</div>
</header>
<section className="card compose-card">
<div className="compose-profile">
{active ? (
<>
<Avatar npub={active.npub} label={active.label} />
<div>
<span className="profile-name">{active.label}</span>
<code className="mono" title={active.npub}>
{shortenNpub(active.npub, shorten)}
</code>
</div>
</>
) : (
<Badge tone="warning">No profile selected</Badge>
)}
{enabledCount === 0 && <Badge tone="warning">No relays enabled</Badge>}
</div>
<div className="field">
<label htmlFor="note-content" className="visually-hidden">
Note content
</label>
<textarea
id="note-content"
className="note-editor"
rows={8}
placeholder="What's on your mind?"
value={content}
onChange={(event) => setContent(event.target.value)}
disabled={publishing}
maxLength={50_000}
/>
<div className="compose-footer">
<span
className={`char-count${content.length > SOFT_LIMIT ? ' is-warn' : ''}`}
aria-live="polite"
>
{content.length.toLocaleString()} characters
{content.length > SOFT_LIMIT && ' · long notes may be rejected by some relays'}
</span>
<div className="compose-actions">
<Button
variant="ghost"
onClick={() => setContent('')}
disabled={content.length === 0 || publishing}
>
Clear
</Button>
<Button
variant="primary"
onClick={onPublishClick}
loading={publishing}
disabled={!canPublish}
title={
!active
? 'Select a profile before publishing'
: enabledCount === 0
? 'Enable a relay before publishing'
: trimmed.length === 0
? 'Write something to publish'
: undefined
}
>
<Icon name="publish" size={18} />
{publishing ? 'Publishing…' : 'Publish'}
</Button>
</div>
</div>
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Result</h2>
</header>
<div className="card-body">
{lastReport ? (
<div className="publish-result">
<Badge tone={lastReport.failed.length === 0 ? 'success' : 'warning'}>
{lastReport.failed.length === 0 ? (
<>
<Icon name="check" size={14} /> Published
</>
) : (
<>
Published to {lastReport.succeeded.length} of{' '}
{lastReport.succeeded.length + lastReport.failed.length} relays
</>
)}
</Badge>
<span className="mono" title={lastReport.event_id}>
{shortenNpub(lastReport.event_id, true)}
</span>
<CopyButton text={lastReport.event_id} label="event ID" />
{lastReport.failed.length > 0 && (
<details className="alert-details">
<summary>Relays that didn't accept it</summary>
<ul className="relay-result-list">
{lastReport.failed.map((failure) => (
<li key={failure.url} className="bad">
<span className="mono">{failure.url}</span> {failure.error}
</li>
))}
</ul>
</details>
)}
</div>
) : lastError ? (
<Alert tone="error" title="Publication failed" details={lastPublish?.details}>
{lastError}
</Alert>
) : (
<p className="muted">Nothing published yet in this session.</p>
)}
</div>
</section>
<Modal open={confirmOpen} title="Confirm publication" onClose={() => setConfirmOpen(false)}>
<p>
Publish this note to {enabledCount} enabled relay{enabledCount === 1 ? '' : 's'}? Notes
on Nostr are public and cannot be edited.
</p>
<div className="modal-actions">
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => {
setConfirmOpen(false);
void doPublish();
}}
>
Publish
</Button>
</div>
</Modal>
</div>
</div>
);
}

View file

@ -0,0 +1,134 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { useApp } from '../state/AppProvider';
import { shortenNpub } from '../lib/format';
import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { Modal } from '../components/Modal';
interface CreateProfileModalProps {
open: boolean;
onClose: () => void;
}
type Phase = 'form' | 'creating' | 'success';
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
const { state, createProfile } = useApp();
const [label, setLabel] = useState('');
const [phase, setPhase] = useState<Phase>('form');
const [error, setError] = useState<string | null>(null);
const [createdNpub, setCreatedNpub] = useState<string | null>(null);
const [errorId] = useState(() => `create-profile-error-${Math.random().toString(36).slice(2)}`);
const inputRef = useRef<HTMLInputElement>(null);
const shorten = state?.settings.shorten_npub ?? true;
useEffect(() => {
if (open) {
setLabel('');
setPhase('form');
setError(null);
setCreatedNpub(null);
// Let the modal mount before focusing.
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [open]);
const canSubmit = label.trim().length > 0 && phase !== 'creating';
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) {
return;
}
setError(null);
setPhase('creating');
try {
const summary = await createProfile(label.trim());
setCreatedNpub(summary.npub);
setPhase('success');
} catch (err) {
setPhase('form');
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<Modal open={open} title="Create a Nostr profile" onClose={onClose}>
{phase === 'success' && createdNpub ? (
<div className="create-success">
<div className="create-success-icon" aria-hidden="true">
<Icon name="check" size={26} />
</div>
<h3>Profile created!</h3>
<p>
Your new profile is ready to use. Its public address (<code>npub</code>) is:
</p>
<code className="npub-chip mono" title={createdNpub}>
{shortenNpub(createdNpub, shorten)}
</code>
<p className="muted">
Share the <code>npub</code> freely. Your private key stays on this computer.
</p>
<div className="modal-actions">
<Button variant="primary" onClick={onClose}>
Done
</Button>
</div>
</div>
) : (
<form onSubmit={onSubmit} noValidate>
<div className="create-explainer">
<p>
A Nostr identity is a pair of keys: a public address (<code>npub</code>) that anyone
can see and share, and a private key that lets you sign notes. The app generates both
for you right now.
</p>
<ul>
<li>
The <code>npub</code> is <strong>public</strong> share it anywhere.
</li>
<li>
The <strong>private key</strong> controls your identity and must stay{' '}
<strong>secret</strong>. This app keeps it on this computer and never shows it.
</li>
</ul>
</div>
<div className="field">
<label htmlFor="profile-label">Profile name</label>
<input
ref={inputRef}
id="profile-label"
type="text"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="e.g. Alex"
maxLength={60}
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? true : undefined}
autoComplete="off"
/>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={phase === 'creating'}>
Cancel
</Button>
<Button
variant="primary"
type="submit"
loading={phase === 'creating'}
disabled={!canSubmit}
>
{phase === 'creating' ? 'Creating…' : 'Create profile'}
</Button>
</div>
</form>
)}
</Modal>
);
}

View file

@ -0,0 +1,333 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert } from '../components/Alert';
import { Avatar } from '../components/Avatar';
import { Badge } from '../components/Badge';
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 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' };
}
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;
if (state && state.profiles.length === 0) {
return (
<div className="screen">
<div className="screen-inner">
<EmptyState
icon={<Icon name="users" size={30} />}
title="Welcome to Nostr Feed Manager"
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.
</span>
}
action={
<Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} />
Create your first profile
</Button>
}
/>
<FirstRunGuide />
</div>
</div>
);
}
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<div>
<h1>Home</h1>
<p className="page-subtitle">
{active
? `Publishing as ${active.label}`
: 'Select a profile to start publishing notes.'}
</p>
</div>
<Button variant="primary" onClick={() => onNavigate('compose')}>
<Icon name="edit" size={18} />
Compose note
</Button>
</header>
<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>
<Button
variant="ghost"
size="sm"
onClick={() => void runCheck()}
loading={testing}
disabled={enabled.length === 0}
>
<Icon name="refresh" size={16} />
Refresh
</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>
)}
</div>
</section>
</div>
<section className="card">
<header className="card-header">
<h2>Most recent publication</h2>
</header>
<div className="card-body">
<PublicationResult
lastPublish={lastPublish}
onNavigateCompose={() => onNavigate('compose')}
/>
</div>
</section>
</div>
</div>
);
}
function PublicationResult({
lastPublish,
onNavigateCompose,
}: {
lastPublish: ReturnType<typeof useApp>['lastPublish'];
onNavigateCompose: () => void;
}) {
if (!lastPublish) {
return (
<p className="muted">
You haven't published anything yet.{' '}
<button type="button" className="linklike" onClick={onNavigateCompose}>
Compose your first note
</button>
.
</p>
);
}
if (lastPublish.error) {
return (
<Alert tone="error" title="Publication failed" details={lastPublish.details}>
{lastPublish.error}
</Alert>
);
}
const report = lastPublish.report;
if (!report) {
return null;
}
if (report.failed.length === 0) {
return (
<div className="publish-result success">
<Badge tone="success">
<Icon name="check" size={14} /> Published
</Badge>
<span className="mono" title={report.event_id}>
{shortenNpub(report.event_id, true)}
</span>
<CopyButton text={report.event_id} label="event ID" />
</div>
);
}
return (
<div className="publish-result">
<Alert tone="warning" title="Partially published">
The note reached {report.succeeded.length} of{' '}
{report.succeeded.length + report.failed.length} enabled relays. Event ID:{' '}
<code className="mono" title={report.event_id}>
{shortenNpub(report.event_id, true)}
</code>
</Alert>
<CopyButton text={report.event_id} label="event ID" />
<details className="alert-details">
<summary>Relay results</summary>
<ul className="relay-result-list">
{report.succeeded.map((url) => (
<li key={url} className="ok">
<span className="mono">{url}</span> accepted
</li>
))}
{report.failed.map((failure) => (
<li key={failure.url} className="bad">
<span className="mono">{failure.url}</span> {failure.error}
</li>
))}
</ul>
</details>
</div>
);
}
function FirstRunGuide() {
const steps: { title: string; body: string }[] = [
{
title: '1 · Create a profile',
body: 'The app generates a public npub address and a private key for you. They are stored only on this computer.',
},
{
title: '2 · Share your npub',
body: 'Your npub is public and safe to share. Never share your private key with anyone.',
},
{
title: '3 · Publish a note',
body: 'Write a note in Compose and publish it. Your note is signed locally and sent to the enabled relays.',
},
];
return (
<div className="first-run-guide">
<h2>How it works</h2>
<ol>
{steps.map((step) => (
<li key={step.title}>
<strong>{step.title}</strong>
<p>{step.body}</p>
</li>
))}
</ol>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useState } from 'react';
import { Avatar } from '../components/Avatar';
import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
import { CopyButton } from '../components/CopyButton';
import { EmptyState } from '../components/EmptyState';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { formatDate, shortenNpub } from '../lib/format';
import { useApp } from '../state/AppProvider';
interface ProfilesScreenProps {
onCreateProfile: () => void;
}
export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
const { state, selectProfile } = useApp();
const [selecting, setSelecting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`);
const profiles = state?.profiles ?? [];
const shorten = state?.settings.shorten_npub ?? true;
if (profiles.length === 0) {
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<h1>Profiles</h1>
</header>
<EmptyState
icon={<Icon name="users" size={30} />}
title="No profiles yet"
description="Create a profile to get your own Nostr identity — a public npub address you can share, with a private key kept safely on this computer."
action={
<Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} />
Create Profile
</Button>
}
/>
</div>
</div>
);
}
const onSelect = async (npub: string) => {
setError(null);
setSelecting(npub);
try {
await selectProfile(npub);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSelecting(null);
}
};
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<div>
<h1>Profiles</h1>
<p className="page-subtitle">
Your private keys never appear here they stay on this computer.
</p>
</div>
<Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} />
Create Profile
</Button>
</header>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
<div className="profile-grid">
{profiles.map((profile) => (
<article
key={profile.npub}
className={`profile-card${profile.is_active ? ' is-active' : ''}`}
>
<div className="profile-card-top">
<Avatar npub={profile.npub} label={profile.label} size="lg" />
<div className="profile-card-meta">
<h3>{profile.label}</h3>
<code className="mono" title={profile.npub}>
{shortenNpub(profile.npub, shorten)}
</code>
</div>
{profile.is_active && <Badge tone="success">Active</Badge>}
</div>
<p className="profile-card-date">Created {formatDate(profile.created_at)}</p>
<div className="profile-card-actions">
<CopyButton text={profile.npub} label="public key" />
{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>
</article>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,187 @@
import { useState, type FormEvent } from 'react';
import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { Toggle } from '../components/Toggle';
import { useApp } from '../state/AppProvider';
type RelayTestState = 'idle' | 'testing' | 'ok' | 'failed';
interface RelayStatus {
state: RelayTestState;
latency?: number | null;
error?: string;
}
export function RelaysScreen() {
const { state, relayAdd, relayRemove, relaySetEnabled, relayTest } = useApp();
const [newUrl, setNewUrl] = useState('');
const [addError, setAddError] = useState<string | null>(null);
const [addErrorId] = useState(() => `relay-add-error-${Math.random().toString(36).slice(2)}`);
const [statuses, setStatuses] = useState<Record<string, RelayStatus>>({});
const relays = state?.settings.relays ?? [];
const updateStatus = (url: string, status: RelayStatus) => {
setStatuses((prev) => ({ ...prev, [url]: status }));
};
const validateLocal = (raw: string): boolean => {
const cleaned = raw.trim().replace(/\/+$/, '');
try {
const parsed = new URL(cleaned);
return parsed.protocol === 'wss:' || parsed.protocol === 'ws:';
} catch {
return false;
}
};
const onAdd = async (event: FormEvent) => {
event.preventDefault();
const cleaned = newUrl.trim().replace(/\/+$/, '');
setAddError(null);
if (!validateLocal(cleaned)) {
setAddError('Enter a valid relay address, like wss://relay.example.com');
return;
}
try {
await relayAdd(cleaned);
setNewUrl('');
} catch (err) {
setAddError(err instanceof Error ? err.message : String(err));
}
};
const onTest = async (url: string) => {
updateStatus(url, { state: 'testing' });
try {
const result = await relayTest(url);
updateStatus(url, { state: 'ok', latency: result.latency_ms });
} catch (err) {
updateStatus(url, {
state: 'failed',
error: err instanceof Error ? err.message : String(err),
});
}
};
const onRemove = async (url: string) => {
await relayRemove(url);
setStatuses((prev) => {
const next = { ...prev };
delete next[url];
return next;
});
};
const onToggle = async (url: string, enabled: boolean) => {
await relaySetEnabled(url, enabled);
};
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<div>
<h1>Relays</h1>
<p className="page-subtitle">
Relays receive and distribute your notes. Enable the ones you want to publish to.
</p>
</div>
</header>
<form className="relay-add-form" onSubmit={onAdd} noValidate>
<div className="field grow">
<label htmlFor="relay-url" className="visually-hidden">
Relay address
</label>
<input
id="relay-url"
type="text"
placeholder="wss://relay.example.com"
value={newUrl}
onChange={(event) => setNewUrl(event.target.value)}
aria-describedby={addError ? addErrorId : undefined}
aria-invalid={addError ? true : undefined}
autoComplete="off"
spellCheck={false}
/>
{addError && <ErrorText id={addErrorId}>{addError}</ErrorText>}
</div>
<Button variant="primary" type="submit" disabled={newUrl.trim().length === 0}>
<Icon name="plus" size={18} />
Add relay
</Button>
</form>
{relays.length === 0 ? (
<p className="muted">No relays yet. Add one above to start publishing.</p>
) : (
<ul className="relay-list">
{relays.map((relay) => {
const status = statuses[relay.url];
return (
<li key={relay.url} className="relay-row">
<div className="relay-row-main">
<Toggle
checked={relay.enabled}
onChange={(enabled) => void onToggle(relay.url, enabled)}
label={`${relay.enabled ? 'Disable' : 'Enable'} ${relay.url}`}
/>
<div className="relay-row-info">
<code className="mono">{relay.url}</code>
<span className="relay-row-status">
{!relay.enabled ? (
<Badge tone="neutral">Disabled</Badge>
) : !status || status.state === 'idle' ? (
<Badge tone="info">Not tested yet</Badge>
) : status.state === 'testing' ? (
<Badge tone="info">Testing</Badge>
) : status.state === 'ok' ? (
<Badge tone="success">
Connected{status.latency != null ? ` · ${status.latency} ms` : ''}
</Badge>
) : (
<Badge tone="danger">Unavailable</Badge>
)}
{status?.state === 'failed' && status.error && (
<span className="relay-error-text" title={status.error}>
{status.error}
</span>
)}
</span>
</div>
</div>
<div className="relay-row-actions">
<Button
variant="secondary"
size="sm"
loading={status?.state === 'testing'}
onClick={() => void onTest(relay.url)}
>
<Icon name="refresh" size={15} />
Test
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void onRemove(relay.url)}
aria-label={`Remove ${relay.url}`}
>
<Icon name="trash" size={15} />
</Button>
</div>
</li>
);
})}
</ul>
)}
<p className="muted small">
Publishing reports each relay's result, so you always know where your note was accepted.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,168 @@
import { useState } from 'react';
import { Alert } from '../components/Alert';
import { Button } from '../components/Button';
import { CopyButton } from '../components/CopyButton';
import { Icon } from '../components/Icon';
import { Toggle } from '../components/Toggle';
import type { Theme } from '../lib/types';
import { useApp } from '../state/AppProvider';
export function SettingsScreen() {
const { state, updateSettings, backupNow } = useApp();
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
const settings = state?.settings;
const vaultPath = state?.vault_path ?? '';
const encrypted = state?.encrypted_storage ?? false;
const migratedFrom = state?.migrated_from ?? null;
if (!settings) {
return null;
}
const onTheme = async (theme: Theme) => {
await updateSettings({ theme });
};
const onConfirmToggle = async (confirm_before_publish: boolean) => {
await updateSettings({ confirm_before_publish });
};
const onShortenToggle = async (shorten_npub: boolean) => {
await updateSettings({ shorten_npub });
};
const onBackup = async () => {
setBackupMessage(null);
try {
const result = await backupNow();
setBackupMessage({ ok: true, text: `Backup created at ${result.backup_path}` });
} catch (err) {
setBackupMessage({ ok: false, text: err instanceof Error ? err.message : String(err) });
}
};
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<h1>Settings</h1>
</header>
<section className="card">
<header className="card-header">
<h2>Appearance</h2>
</header>
<div className="card-body settings-block">
<div className="field">
<label htmlFor="theme-select">Theme</label>
<select
id="theme-select"
value={settings.theme}
onChange={(event) => void onTheme(event.target.value as Theme)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
<p className="hint">System follows your desktop's light or dark preference.</p>
</div>
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Publishing</h2>
</header>
<div className="card-body settings-block">
<Toggle
checked={settings.confirm_before_publish}
onChange={onConfirmToggle}
label="Ask before publishing a note"
/>
<p className="hint">
Helps prevent accidentally publishing something you didn't mean to.
</p>
<Toggle
checked={settings.shorten_npub}
onChange={onShortenToggle}
label="Show shortened public keys"
/>
<p className="hint">
Display npubs like <code>npub1abcd...wxyz8</code> instead of the full address. Full
keys are always one click away.
</p>
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Storage</h2>
</header>
<div className="card-body settings-block">
<div className="path-row">
<div>
<span className="field-label">Vault file location</span>
<code className="mono path-value">{vaultPath}</code>
</div>
<CopyButton text={vaultPath} label="vault path" />
</div>
<p className="hint">
Your profiles, including private keys, are stored in this file. It is readable only by
your user account.
</p>
{!encrypted && (
<Alert tone="warning" title="Storage is not encrypted">
Profile data is saved in plaintext on this computer (as in the original CLI). Anyone
with access to your user account can read your keys. A password-encrypted vault is
planned for a future version.
</Alert>
)}
<div className="settings-inline">
<Button variant="secondary" onClick={() => void onBackup()}>
<Icon name="shield" size={16} />
Create a backup now
</Button>
{backupMessage && (
<Alert tone={backupMessage.ok ? 'success' : 'error'}>{backupMessage.text}</Alert>
)}
</div>
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Advanced</h2>
</header>
<div className="card-body settings-block">
<dl className="info-list">
<div>
<dt>Application</dt>
<dd>Nostr Feed Manager v{state?.version ?? '?'}</dd>
</div>
<div>
<dt>Backend</dt>
<dd>Rust (nostr-sdk)</dd>
</div>
<div>
<dt>Interface</dt>
<dd>React + Electron</dd>
</div>
{migratedFrom && (
<div>
<dt>Migrated from</dt>
<dd className="mono">{migratedFrom}</dd>
</div>
)}
</dl>
<p className="hint">
The original command-line application saved <code>profiles_vault.json</code> in its
working directory. On first launch this app finds that file, backs it up, and imports
your profiles into the location above. The original file is left untouched.
</p>
</div>
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,211 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { api, BackendError } from '../lib/api';
import type {
AppState,
ProfileSummary,
PublishReport,
RelayTestResult,
Settings,
Theme,
} from '../lib/types';
export interface LastPublish {
report: PublishReport | null;
error: string | null;
details: string | null;
at: number;
}
interface AppContextValue {
state: AppState | null;
loading: boolean;
bootstrapError: string | null;
lastPublish: LastPublish | null;
refresh: () => Promise<void>;
createProfile: (label: string) => Promise<ProfileSummary>;
selectProfile: (npub: string) => Promise<void>;
publishNote: (content: string) => Promise<PublishReport>;
recordPublishFailure: (message: string, details?: string | null) => void;
clearLastPublish: () => void;
relayAdd: (url: string) => Promise<Settings>;
relayRemove: (url: string) => Promise<Settings>;
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
relayTest: (url: string) => Promise<RelayTestResult>;
updateSettings: (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => Promise<Settings>;
backupNow: () => Promise<{ backup_path: string }>;
copyText: (text: string) => Promise<void>;
}
const AppContext = createContext<AppContextValue | null>(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AppState | null>(null);
const [loading, setLoading] = useState(true);
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
const [lastPublish, setLastPublish] = useState<LastPublish | null>(null);
const refresh = useCallback(async () => {
const fresh = await api.getState();
setState(fresh);
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const initial = await api.init();
if (!cancelled) {
setState(initial);
}
} catch (error) {
if (!cancelled) {
setBootstrapError(error instanceof BackendError ? error.message : String(error));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, []);
const createProfile = useCallback(async (label: string): Promise<ProfileSummary> => {
const result = await api.createProfile(label);
setState(result.state);
return result.profile;
}, []);
const selectProfile = useCallback(async (npub: string) => {
const fresh = await api.selectProfile(npub);
setState(fresh);
}, []);
const publishNote = useCallback(async (content: string): Promise<PublishReport> => {
const report = await api.publishNote(content);
setLastPublish({ report, error: null, details: null, at: Date.now() });
return report;
}, []);
const recordPublishFailure = useCallback((message: string, details?: string | null) => {
setLastPublish({ report: null, error: message, details: details ?? null, at: Date.now() });
}, []);
const clearLastPublish = useCallback(() => {
setLastPublish(null);
}, []);
const applySettings = useCallback((fresh: Settings) => {
setState((prev) => (prev ? { ...prev, settings: fresh } : prev));
return fresh;
}, []);
const relayAdd = useCallback(
async (url: string) => applySettings(await api.relayAdd(url)),
[applySettings],
);
const relayRemove = useCallback(
async (url: string) => applySettings(await api.relayRemove(url)),
[applySettings],
);
const relaySetEnabled = useCallback(
async (url: string, enabled: boolean) => applySettings(await api.relaySetEnabled(url, enabled)),
[applySettings],
);
const relayTest = useCallback((url: string) => api.relayTest(url), []);
const updateSettings = useCallback(
async (patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>) =>
applySettings(await api.settingsUpdate(patch)),
[applySettings],
);
const backupNow = useCallback(() => api.backupNow(), []);
const copyText = useCallback((text: string) => api.copyText(text), []);
useThemeSync(state?.settings.theme);
const value = useMemo<AppContextValue>(
() => ({
state,
loading,
bootstrapError,
lastPublish,
refresh,
createProfile,
selectProfile,
publishNote,
recordPublishFailure,
clearLastPublish,
relayAdd,
relayRemove,
relaySetEnabled,
relayTest,
updateSettings,
backupNow,
copyText,
}),
[
state,
loading,
bootstrapError,
lastPublish,
refresh,
createProfile,
selectProfile,
publishNote,
recordPublishFailure,
clearLastPublish,
relayAdd,
relayRemove,
relaySetEnabled,
relayTest,
updateSettings,
backupNow,
copyText,
],
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export function useApp(): AppContextValue {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within an AppProvider');
}
return context;
}
/** Apply the requested theme (respecting system preference for `system`). */
export function applyTheme(theme: Theme): void {
const prefersDark =
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light';
}
/** Keep the document theme in sync with settings, watching system changes. */
export function useThemeSync(theme: Theme | undefined): void {
useEffect(() => {
if (!theme) {
return;
}
const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => applyTheme(theme);
sync();
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}, [theme]);
}

1280
frontend/src/styles.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import { makeEmptyState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
function renderApp(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend);
return { user: userEvent.setup(), backend };
}
describe('App', () => {
it('shows the first-run guide and create button when there are no profiles', async () => {
const backend = createFakeBackend(makeEmptyState());
const { user } = renderApp(backend);
render(<App />);
expect(await screen.findByText('Welcome to Nostr Feed Manager')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create your first profile/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 }));
expect(
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
).toBeInTheDocument();
});
it('renders the main screen after loading with an existing profile', async () => {
const backend = createFakeBackend();
renderApp(backend);
render(<App />);
expect(await screen.findByRole('heading', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByText('Alice', { selector: '.profile-name' })).toBeInTheDocument();
});
it('keeps secret key material out of the rendered UI', async () => {
const backend = createFakeBackend();
const { user } = renderApp(backend);
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
await user.click(screen.getByRole('button', { name: 'Profiles' }));
await screen.findByRole('heading', { name: 'Profiles' });
const body = document.body.textContent ?? '';
expect(body).not.toMatch(/secret/i);
expect(body).not.toMatch(/nsec1/i);
});
it('applies and persists the selected dark theme', async () => {
const backend = createFakeBackend();
const { user } = renderApp(backend);
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
await user.click(screen.getByRole('button', { name: 'Settings' }));
await screen.findByRole('heading', { name: 'Settings' });
await user.selectOptions(screen.getByLabelText('Theme'), 'dark');
await waitFor(() => {
expect(backend.state.settings.theme).toBe('dark');
});
expect(document.documentElement.dataset.theme).toBe('dark');
});
it('shows a friendly message when the backend cannot start', async () => {
const backend = createFakeBackend();
backend.nextErrors.init = { message: 'The backend exited unexpectedly.' };
renderApp(backend);
render(<App />);
expect(await screen.findByText('Could not start the application')).toBeInTheDocument();
expect(screen.getByText('The backend exited unexpectedly.')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,158 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComposeScreen } from '../screens/ComposeScreen';
import { renderWithApp } from './render';
import { makeEmptyState, makePublishReport } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
function setup(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend);
return { user: userEvent.setup() };
}
function publishButton() {
return screen.getByRole('button', { name: /Publish/i });
}
describe('ComposeScreen', () => {
it('disables publishing when the note is empty', async () => {
const backend = createFakeBackend();
setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByRole('heading', { name: 'Compose' });
expect(publishButton()).toBeDisabled();
const editor = screen.getByLabelText('Note content');
await userEvent.setup().type(editor, 'Hello Nostr');
expect(publishButton()).toBeEnabled();
});
it('shows the active profile and a character count', async () => {
const backend = createFakeBackend();
setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByText('Alice');
const editor = screen.getByLabelText('Note content');
await userEvent.setup().type(editor, 'Hello');
expect(screen.getByText(/5 characters/)).toBeInTheDocument();
});
it('disables publishing when no profile is selected', async () => {
const backend = createFakeBackend(makeEmptyState({ active_profile: null, profiles: [] }));
setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByRole('heading', { name: 'Compose' });
expect(screen.getByText('No profile selected')).toBeInTheDocument();
await userEvent.setup().type(screen.getByLabelText('Note content'), 'Hello');
expect(publishButton()).toBeDisabled();
});
it('disables publishing when no relays are enabled', async () => {
const backend = createFakeBackend({
...makeEmptyState(),
settings: {
...makeEmptyState().settings,
relays: [{ url: 'wss://relay.damus.io', enabled: false }],
},
});
setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByRole('heading', { name: 'Compose' });
await userEvent.setup().type(screen.getByLabelText('Note content'), 'Hello');
expect(publishButton()).toBeDisabled();
});
it('publishes and shows the event id with a copy control', async () => {
const backend = createFakeBackend();
const { user } = setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByText('Alice');
const editor = screen.getByLabelText('Note content');
await user.type(editor, 'Hello Nostr');
// Confirmation is enabled by default.
await user.click(publishButton());
const dialog = await screen.findByRole('dialog', { name: 'Confirm publication' });
await user.click(within(dialog).getByRole('button', { name: 'Publish' }));
const report = backend.publishReport;
expect(await screen.findByText('Published')).toBeInTheDocument();
expect(screen.getByTitle(report.event_id)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Copy event ID' }));
await waitFor(() => {
expect(backend.copied).toContain(report.event_id);
});
});
it('reports partial relay failures with expandable details', async () => {
const backend = createFakeBackend();
backend.publishReport = makePublishReport({
succeeded: ['wss://relay.damus.io'],
failed: [
{
url: 'wss://relay.nostr.band',
error: 'The relay did not respond in time.',
details: 'timeout',
},
],
});
backend.state.settings.confirm_before_publish = false;
const { user } = setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByText('Alice');
await user.type(screen.getByLabelText('Note content'), 'Hello');
await user.click(publishButton());
expect(await screen.findByText(/Published to 1 of 2 relays/i)).toBeInTheDocument();
expect(screen.getByText(/relay.nostr.band/i)).toBeInTheDocument();
});
it('shows a clear error when publishing fails entirely', async () => {
const backend = createFakeBackend();
backend.failNextPublish('The note could not be published to any relay.');
backend.state.settings.confirm_before_publish = false;
const { user } = setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByText('Alice');
await user.type(screen.getByLabelText('Note content'), 'Hello');
await user.click(publishButton());
expect(
await screen.findByText('The note could not be published to any relay.'),
).toBeInTheDocument();
});
it('does not publish when the confirmation dialog is cancelled', async () => {
const backend = createFakeBackend();
const { user } = setup(backend);
renderWithApp(<ComposeScreen />);
await screen.findByText('Alice');
await user.type(screen.getByLabelText('Note content'), 'Hello');
await user.click(publishButton());
await screen.findByRole('dialog', { name: 'Confirm publication' });
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByText('Published')).not.toBeInTheDocument();
expect(backend.publishReport.event_id).not.toBe('Published');
});
it('clears the note when Clear is clicked', async () => {
const backend = createFakeBackend();
const { user } = setup(backend);
renderWithApp(<ComposeScreen />);
await user.type(screen.getByLabelText('Note content'), 'Hello');
await user.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.getByLabelText('Note content')).toHaveValue('');
});
});

View file

@ -0,0 +1,55 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CreateProfileModal } from '../screens/CreateProfileModal';
import { renderWithApp } from './render';
import { makeEmptyState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
describe('CreateProfileModal', () => {
it('creates a profile through the backend and shows a success confirmation', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
const onClose = vi.fn();
renderWithApp(<CreateProfileModal open onClose={onClose} />);
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
const input = screen.getByLabelText('Profile name');
await userEvent.setup().type(input, 'Sam');
await waitFor(() => {
expect(input).toBeEnabled();
});
await userEvent.setup().click(screen.getByRole('button', { name: 'Create profile' }));
expect(await screen.findByText('Profile created!')).toBeInTheDocument();
await waitFor(() => {
expect(backend.state.profiles.some((p) => p.label === 'Sam')).toBe(true);
});
await userEvent.setup().click(screen.getByRole('button', { name: 'Done' }));
expect(onClose).toHaveBeenCalled();
});
it('keeps the create button disabled while the label is empty', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
expect(screen.getByRole('button', { name: 'Create profile' })).toBeDisabled();
});
it('closes without creating when Cancel is clicked', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
const onClose = vi.fn();
renderWithApp(<CreateProfileModal open onClose={onClose} />);
await userEvent.setup().type(screen.getByLabelText('Profile name'), 'Sam');
await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalled();
expect(backend.state.profiles).toHaveLength(0);
});
});

View file

@ -0,0 +1,66 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HomeScreen } from '../screens/HomeScreen';
import { renderWithApp } from './render';
import { ALICE, makeEmptyState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
function renderHome(
backend: ReturnType<typeof createFakeBackend>,
overrides: { onNavigate?: () => void; onCreateProfile?: () => void } = {},
) {
installFakeBackend(backend);
return {
user: userEvent.setup(),
onNavigate: overrides.onNavigate ?? vi.fn(),
onCreateProfile: overrides.onCreateProfile ?? vi.fn(),
};
}
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()} />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText(/npub1alice\.\.\./)).toBeInTheDocument();
const compose = screen.getByRole('button', { name: /Compose note/i });
await userEvent.setup().click(compose);
expect(onNavigate).toHaveBeenCalledWith('compose');
});
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()} />);
await screen.findByText('Alice');
await userEvent.setup().click(screen.getByRole('button', { name: 'Copy full npub' }));
await waitFor(() => {
expect(backend.copied).toContain(ALICE);
});
});
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} />);
expect(await screen.findByText('Welcome to Nostr Feed Manager')).toBeInTheDocument();
await userEvent
.setup()
.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

@ -0,0 +1,65 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProfilesScreen } from '../screens/ProfilesScreen';
import { renderWithApp } from './render';
import { ALICE, BOB, makeEmptyState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
describe('ProfilesScreen', () => {
it('renders profile cards without any secret key material', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getAllByText(/Created /i).length).toBeGreaterThanOrEqual(1);
const body = document.body.textContent ?? '';
expect(body).not.toMatch(/secret/i);
expect(body).not.toMatch(/nsec1/i);
});
it('selects a profile when the Select button is clicked', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
await screen.findByText('Alice');
await user.click(screen.getByRole('button', { name: 'Select' }));
await waitFor(() => {
expect(backend.state.active_profile?.npub).toBe(BOB);
});
});
it('disables Select for the active profile and copies public keys', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
await screen.findByText('Alice');
const selectedButtons = screen.getAllByRole('button', { name: 'Selected' });
expect(selectedButtons).toHaveLength(1);
expect(selectedButtons[0]).toBeDisabled();
await user.click(screen.getAllByRole('button', { name: 'Copy public key' })[0]);
await waitFor(() => {
expect(backend.copied).toContain(ALICE);
});
});
it('opens create-profile flow and shows empty state when there are no profiles', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
const onCreateProfile = vi.fn();
renderWithApp(<ProfilesScreen onCreateProfile={onCreateProfile} />);
expect(await screen.findByText('No profiles yet')).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: 'Create Profile' }));
expect(onCreateProfile).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,87 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RelaysScreen } from '../screens/RelaysScreen';
import { renderWithApp } from './render';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
describe('RelaysScreen', () => {
it('lists configured relays with their state', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<RelaysScreen />);
expect(await screen.findByText('wss://relay.damus.io')).toBeInTheDocument();
expect(screen.getByText('wss://relay.nostr.band')).toBeInTheDocument();
});
it('adds a valid relay and rejects an invalid one', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<RelaysScreen />);
const input = screen.getByLabelText('Relay address');
await user.type(input, 'not-a-url');
await user.click(screen.getByRole('button', { name: 'Add relay' }));
expect(await screen.findByText(/valid relay address/i)).toBeInTheDocument();
expect(backend.state.settings.relays).toHaveLength(2);
await user.clear(input);
await user.type(input, 'wss://relay.primal.net/');
await user.click(screen.getByRole('button', { name: 'Add relay' }));
await waitFor(() => {
expect(backend.state.settings.relays.some((r) => r.url === 'wss://relay.primal.net')).toBe(
true,
);
});
expect(input).toHaveValue('');
});
it('disables a relay with the toggle', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<RelaysScreen />);
const toggle = await screen.findByRole('checkbox', {
name: /Disable wss:\/\/relay.damus.io/,
});
await user.click(toggle);
await waitFor(() => {
expect(
backend.state.settings.relays.find((r) => r.url === 'wss://relay.damus.io')?.enabled,
).toBe(false);
});
});
it('tests a connection and shows a failure for unreachable relays', async () => {
const backend = createFakeBackend();
backend.relayErrors.add('wss://relay.damus.io');
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<RelaysScreen />);
const testButtons = await screen.findAllByRole('button', { name: /Test/ });
expect(testButtons.length).toBeGreaterThan(0);
await user.click(testButtons[0]);
expect(await screen.findByText('Unavailable')).toBeInTheDocument();
});
it('removes a relay', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<RelaysScreen />);
await user.click(await screen.findByRole('button', { name: 'Remove wss://relay.damus.io' }));
await waitFor(() => {
expect(backend.state.settings.relays.some((r) => r.url === 'wss://relay.damus.io')).toBe(
false,
);
});
});
});

View file

@ -0,0 +1,67 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SettingsScreen } from '../screens/SettingsScreen';
import { renderWithApp } from './render';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
describe('SettingsScreen', () => {
it('shows the vault file location and warns that storage is unencrypted', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SettingsScreen />);
expect(
await screen.findByText('/home/user/.local/share/nost-feed-manager/profiles_vault.json'),
).toBeInTheDocument();
expect(screen.getByText('Storage is not encrypted')).toBeInTheDocument();
});
it('changes the theme and persists it', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SettingsScreen />);
const themeSelect = await screen.findByLabelText('Theme');
fireEvent.change(themeSelect, { target: { value: 'dark' } });
await waitFor(() => {
expect(backend.state.settings.theme).toBe('dark');
});
expect(document.documentElement.dataset.theme).toBe('dark');
});
it('toggles publish confirmation and npub shortening', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('checkbox', { name: 'Ask before publishing a note' }));
await waitFor(() => {
expect(backend.state.settings.confirm_before_publish).toBe(false);
});
await user.click(screen.getByRole('checkbox', { name: 'Show shortened public keys' }));
await waitFor(() => {
expect(backend.state.settings.shorten_npub).toBe(false);
});
});
it('creates a backup when requested', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Create a backup now/i }));
expect(await screen.findByText(/Backup created at/)).toBeInTheDocument();
});
it('shows application version information', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SettingsScreen />);
expect(await screen.findByText(/Nostr Feed Manager v/)).toBeInTheDocument();
expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,172 @@
import type {
AppState,
PublishReport,
ProfileSummary,
RelayTestResult,
Settings,
} from '../lib/types';
export const ALICE = 'npub1aliceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
export const BOB = 'npub1bobbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
/** A realistic default state with one active profile and two enabled relays. */
export function makeState(overrides?: Partial<AppState>): AppState {
const alice: ProfileSummary = {
label: 'Alice',
npub: ALICE,
created_at: 1700000000,
is_active: true,
};
const bob: ProfileSummary = {
label: 'Bob',
npub: BOB,
created_at: 1700000100,
is_active: false,
};
const settings: Settings = {
theme: 'system',
confirm_before_publish: true,
shorten_npub: true,
relays: [
{ url: 'wss://relay.damus.io', enabled: true },
{ url: 'wss://relay.nostr.band', enabled: true },
],
};
return {
version: '0.1.0',
vault_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json',
settings_path: '/home/user/.local/share/nost-feed-manager/settings.json',
encrypted_storage: false,
migrated_from: null,
active_profile: alice,
profiles: [alice, bob],
settings,
...overrides,
};
}
/** An empty state, used for first-run tests. */
export function makeEmptyState(overrides?: Partial<AppState>): AppState {
return {
...makeState(),
profiles: [],
active_profile: null,
...overrides,
};
}
export function makePublishReport(overrides?: Partial<PublishReport>): PublishReport {
return {
event_id: 'note1abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopq',
succeeded: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
failed: [],
...overrides,
};
}
export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>): RelayTestResult {
return { url, connected: true, latency_ms: 42, ...overrides };
}
/**
* A configurable mock of the `../lib/api` module. Each test creates one,
* registers it with `vi.mock`, and can inspect/override behaviour.
*/
export interface ApiMock {
api: {
init: ReturnType<typeof vi.fn>;
getState: ReturnType<typeof vi.fn>;
createProfile: ReturnType<typeof vi.fn>;
selectProfile: ReturnType<typeof vi.fn>;
publishNote: ReturnType<typeof vi.fn>;
relayAdd: ReturnType<typeof vi.fn>;
relayRemove: ReturnType<typeof vi.fn>;
relaySetEnabled: ReturnType<typeof vi.fn>;
relayTest: ReturnType<typeof vi.fn>;
settingsUpdate: ReturnType<typeof vi.fn>;
backupNow: ReturnType<typeof vi.fn>;
copyText: ReturnType<typeof vi.fn>;
};
/** Current state object backing init/getState. */
state: AppState;
setState: (next: AppState) => void;
}
export function createApiMock(initial: AppState = makeState()): ApiMock {
let state: AppState = initial;
const api = {
init: vi.fn(async () => state),
getState: vi.fn(async () => state),
createProfile: vi.fn(async (label: string) => {
const profile: ProfileSummary = {
label,
npub: `npub1created${Math.random().toString(36).slice(2, 14)}`,
created_at: Math.floor(Date.now() / 1000),
is_active: false,
};
state = {
...state,
profiles: [...state.profiles, profile],
active_profile: state.active_profile ?? profile,
};
return { profile, state };
}),
selectProfile: vi.fn(async (npub: string) => {
const target = state.profiles.find((p) => p.npub === npub) ?? null;
state = {
...state,
active_profile: target,
profiles: state.profiles.map((p) => ({ ...p, is_active: p.npub === npub })),
};
return state;
}),
publishNote: vi.fn(async () => makePublishReport()),
relayAdd: vi.fn(async (url: string) => {
const nextSettings: Settings = {
...state.settings,
relays: [...state.settings.relays, { url, enabled: true }],
};
state = { ...state, settings: nextSettings };
return nextSettings;
}),
relayRemove: vi.fn(async (url: string) => {
const nextSettings: Settings = {
...state.settings,
relays: state.settings.relays.filter((r) => r.url !== url),
};
state = { ...state, settings: nextSettings };
return nextSettings;
}),
relaySetEnabled: vi.fn(async (url: string, enabled: boolean) => {
const nextSettings: Settings = {
...state.settings,
relays: state.settings.relays.map((r) => (r.url === url ? { ...r, enabled } : r)),
};
state = { ...state, settings: nextSettings };
return nextSettings;
}),
relayTest: vi.fn(async (url: string) => makeRelayTest(url)),
settingsUpdate: vi.fn(
async (
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => {
const nextSettings: Settings = { ...state.settings, ...patch };
state = { ...state, settings: nextSettings };
return nextSettings;
},
),
backupNow: vi.fn(async () => ({
backup_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json.backup-1',
})),
copyText: vi.fn(async () => undefined),
};
return {
api,
state,
setState: (next: AppState) => {
state = next;
},
};
}

View file

@ -0,0 +1,191 @@
import type {
AppState,
BackendResponse,
ProfileSummary,
PublishReport,
RelayTestResult,
Settings,
} from '../lib/types';
import { makePublishReport, makeRelayTest, makeState } from './apiMock';
/**
* An in-memory stand-in for the Rust `serve` IPC server. Exposes the same
* JSON envelope protocol, so the real `api.ts` layer can be exercised as-is.
*/
export interface FakeBackend {
api: {
request(method: string, params?: Record<string, unknown>): Promise<BackendResponse<unknown>>;
copyText(text: string): Promise<void>;
};
state: AppState;
setState: (next: AppState) => void;
copied: string[];
/** Configure the next publish to fail with this message/details. */
failNextPublish: (message: string, details?: string) => void;
/** Custom publish report returned by `publish_note`. */
publishReport: PublishReport;
/** Relay URLs that fail connection tests. */
relayErrors: Set<string>;
/** Per-method canned error override. */
nextErrors: Record<string, { message: string; details?: string }>;
}
export function createFakeBackend(initial?: AppState): FakeBackend {
let state: AppState = initial ?? makeState();
let publishFailure: { message: string; details?: string } | null = null;
const backend: FakeBackend = {
api: {
async request(method, params = {}) {
if (backend.nextErrors[method]) {
const { message, details } = backend.nextErrors[method];
return { status: 'error', message, details };
}
try {
const data = await dispatch(method, params);
return { status: 'ok', data };
} catch (error) {
return {
status: 'error',
message: error instanceof Error ? error.message : String(error),
details:
error instanceof Error && 'details' in error
? (error as { details?: string }).details
: undefined,
};
}
},
async copyText(text: string) {
backend.copied.push(text);
},
},
state,
setState(next) {
state = next;
backend.state = next;
},
copied: [],
failNextPublish(message, details) {
publishFailure = { message, details };
},
publishReport: makePublishReport(),
relayErrors: new Set(),
nextErrors: {},
};
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
switch (method) {
case 'init':
case 'get_state':
return state;
case 'create_profile': {
const label = String(params.label ?? '');
const profile: ProfileSummary = {
label,
npub: `npub1created${Math.random().toString(36).slice(2, 14)}`,
created_at: Math.floor(Date.now() / 1000),
is_active: state.active_profile === null,
};
const next: AppState = {
...state,
profiles: [...state.profiles, profile],
active_profile: state.active_profile ?? profile,
};
backend.setState(next);
return { profile, state: next };
}
case 'select_profile': {
const npub = String(params.npub);
const target = state.profiles.find((p) => p.npub === npub) ?? null;
const next: AppState = {
...state,
active_profile: target,
profiles: state.profiles.map((p) => ({ ...p, is_active: p.npub === npub })),
};
backend.setState(next);
return next;
}
case 'publish_note': {
if (publishFailure) {
const failure = publishFailure;
publishFailure = null;
throw Object.assign(new Error(failure.message), { details: failure.details });
}
const report = { ...backend.publishReport };
if (backend.relayErrors.size > 0) {
report.failed = [...backend.relayErrors].map((url) => ({
url,
error: 'The relay did not respond in time.',
details: 'timeout',
}));
report.succeeded = report.succeeded.filter((url) => !backend.relayErrors.has(url));
}
return report;
}
case 'relay_add': {
const url = String(params.url);
const nextSettings: Settings = {
...state.settings,
relays: [...state.settings.relays, { url, enabled: true }],
};
backend.setState({ ...state, settings: nextSettings });
return nextSettings;
}
case 'relay_remove': {
const url = String(params.url);
const nextSettings: Settings = {
...state.settings,
relays: state.settings.relays.filter((r) => r.url !== url),
};
backend.setState({ ...state, settings: nextSettings });
return nextSettings;
}
case 'relay_set_enabled': {
const url = String(params.url);
const enabled = Boolean(params.enabled);
const nextSettings: Settings = {
...state.settings,
relays: state.settings.relays.map((r) => (r.url === url ? { ...r, enabled } : r)),
};
backend.setState({ ...state, settings: nextSettings });
return nextSettings;
}
case 'relay_test': {
const url = String(params.url);
if (backend.relayErrors.has(url)) {
throw new Error('Could not connect to the relay.');
}
const result: RelayTestResult = makeRelayTest(url);
return result;
}
case 'settings_update': {
const nextSettings: Settings = { ...state.settings, ...params };
backend.setState({ ...state, settings: nextSettings });
return nextSettings;
}
case 'backup_now':
return { backup_path: `${state.vault_path}.backup-1` };
default:
throw new Error(`Unknown method: ${method}`);
}
}
return backend;
}
export function installFakeBackend(backend: FakeBackend): void {
window.backend = backend.api as unknown as {
request(method: string, params?: Record<string, unknown>): Promise<unknown>;
copyText(text: string): Promise<void>;
};
}

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { avatarColorFor, formatDate, initialsFor, shortenNpub } from '../lib/format';
describe('shortenNpub', () => {
it('shortens long npubs to a prefix and suffix', () => {
const npub = 'npub1abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH';
expect(shortenNpub(npub, true)).toBe('npub1abcde...ABCDEFGH');
});
it('returns the full npub when shortening is disabled', () => {
const npub = 'npub1abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH';
expect(shortenNpub(npub, false)).toBe(npub);
});
it('leaves short strings unchanged', () => {
expect(shortenNpub('note1abc', true)).toBe('note1abc');
});
});
describe('formatDate', () => {
it('describes recent timestamps in relative terms', () => {
const now = new Date('2025-01-10T12:00:00Z');
expect(formatDate(Math.floor(now.getTime() / 1000), now)).toBe('today');
expect(formatDate(Math.floor((now.getTime() - 3 * 86_400_000) / 1000), now)).toBe('3 days ago');
});
it('renders a localised date for older timestamps', () => {
const now = new Date('2025-01-10T12:00:00Z');
const out = formatDate(Math.floor(new Date('2024-05-01T12:00:00Z').getTime() / 1000), now);
expect(out).toMatch(/May|1/);
});
it('handles invalid timestamps gracefully', () => {
expect(formatDate(Number.NaN)).toBe('unknown');
});
});
describe('initialsFor and avatarColorFor', () => {
it('derives an initial from a label', () => {
expect(initialsFor('Alice')).toBe('A');
expect(initialsFor(' bob ')).toBe('B');
expect(initialsFor(' ')).toBe('?');
});
it('derives a stable colour from an npub', () => {
const a = avatarColorFor('npub1aaa');
const b = avatarColorFor('npub1aaa');
const c = avatarColorFor('npub1bbb');
expect(a).toEqual(b);
expect(a.background).toBeTruthy();
expect(a.foreground).toBeTruthy();
expect(c).not.toEqual(a);
});
});

View file

@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
describe('publication flow across screens', () => {
it('publishes from Compose and shows the result on Home', async () => {
const backend = createFakeBackend();
backend.state.settings.confirm_before_publish = false;
installFakeBackend(backend);
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
await user.click(screen.getByRole('button', { name: /Compose note/i }));
await screen.findByRole('heading', { name: 'Compose' });
await user.type(screen.getByLabelText('Note content'), 'Hello from the flow test');
await user.click(screen.getByRole('button', { name: /Publish/i }));
expect(await screen.findByText('Published')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Home' }));
await screen.findByRole('heading', { name: 'Home' });
expect(await screen.findByText('Published')).toBeInTheDocument();
expect(screen.getByTitle(backend.publishReport.event_id)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,7 @@
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
import { AppProvider } from '../state/AppProvider';
export function renderWithApp(ui: ReactElement) {
return render(<AppProvider>{ui}</AppProvider>);
}

View file

@ -0,0 +1,24 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
if (!window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string): MediaQueryList =>
({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}) as MediaQueryList,
});
}

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist-electron",
"rootDir": "electron"
},
"include": ["electron"]
}

21
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "vite.config.ts"]
}

23
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import type { UserConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
},
build: {
outDir: 'dist',
emptyOutDir: true,
base: './',
} as UserConfig['build'],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
css: false,
},
});