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

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# Rust
/target
# Sensitive local data
profiles_vault.json
profiles_vault.json.backup-*
*.json.tmp
# Frontend
frontend/node_modules/
frontend/dist/
frontend/dist-electron/
frontend/release/
frontend/.vite/

1800
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

12
Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "nostr-manager-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
nostr-sdk = { version = "0.40", features = ["nip44"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4"] }
hex = "0.4"

139
README.md Normal file
View file

@ -0,0 +1,139 @@
# Nostr Feed Manager
A friendly Linux desktop application for managing Nostr profiles and publishing text notes.
It pairs a Rust core (the same library behind the original command-line tool) with a polished
Electron + React interface. All Nostr logic — key generation, signing, relay communication —
runs in the Rust backend, which the GUI talks to over a JSON-lines IPC channel.
## Features
- Create and switch between Nostr profiles (`npub` addresses)
- Publish short text notes to the Nostr network
- Per-relay publish reports: you always know where a note was accepted
- Add, remove, enable/disable, and test relays
- Light / dark / system theme, configurable publish confirmation and key shortening
- Back up your vault from the UI
- Honest about security: the vault is stored in plaintext (as in the original CLI), readable
only by your user account; this is clearly disclosed in the app
## Architecture
```
┌──────────────────────────┐ JSON-lines over stdio ┌──────────────────────────┐
│ Electron (React) GUI │ ────────────────────────────────▶ │ Rust backend │
│ - React + TypeScript │ {"id":1,"method":"publish_note",│ - nostr-sdk 0.40 │
│ - renders in app:// │ "params":{"content":"..."}} │ - vault / keys / signing │
│ - never sees secret keys │ ◀──────────────────────────────── │ - relay communication │
└──────────────────────────┘ {"id":1,"status":"ok","data":…} └──────────────────────────┘
```
- `src/` — the Rust library (`nostr-manager-backend`). Exposes the same functionality as a
command-line binary and as a long-running `serve` process.
- `frontend/electron/` — the Electron main and preload scripts. The main process spawns the
Rust backend and correlates requests by `id`.
- `frontend/src/` — the React renderer. It talks only to a thin `window.backend` bridge;
profile summaries and publish reports contain no secret material.
## Requirements
- Rust (stable) and Cargo
- Node.js 20+ and npm
- Linux with a display server (X11 or Wayland)
The Rust backend also exists as a standalone CLI if you prefer the terminal.
## Building and running the GUI
```sh
# 1. Build the Rust backend (release)
cargo build --release
# 2. Install frontend dependencies
cd frontend
npm install
# 3. Run the app (production-style, served from the built bundle via app://)
npm start
```
During development you can use Vite's live-reloading renderer instead:
```sh
# terminal 1
npm run dev # starts Vite on http://localhost:5173
# terminal 2
NOSTR_GUI_DEV_URL=http://localhost:5173 npm start
```
### Packaging
```sh
cd frontend
npm run dist # builds the renderer + backend and runs electron-builder
```
The unpacked application lands in `frontend/release/linux-unpacked/`; launch it with
`./nost-feed-manager`.
## Command-line usage
The Rust crate builds a single binary with both a CLI and the GUI IPC server:
```sh
cargo run --release -- create "Alice" # create a profile
cargo run --release -- list # list profiles (no secret keys)
cargo run --release -- switch <npub> # select the active profile
cargo run --release -- publish <npub> "Hello" # publish a text note
cargo run --release -- relays list|add|remove|enable|disable|test
cargo run --release -- settings get|set theme|confirm|shorten
cargo run --release -- info # show storage locations
cargo run --release -- serve # JSON-lines IPC server (used by the GUI)
```
## Storage and migration
- The vault (`profiles_vault.json`) and settings live in
`$XDG_DATA_HOME/nost-feed-manager/` (defaulting to `~/.local/share/nost-feed-manager/`),
created with permissions `0700` for the directory and `0600` for the files.
- The original CLI saved `profiles_vault.json` in its working directory. On first launch this
app finds that file, copies it to a timestamped `*.backup-<ts>` next to it, and imports your
profiles into the new location. The original file is left untouched.
- Private keys are stored in the vault in plaintext. Anyone with access to your user account
can read them; a password-encrypted vault is planned for a future version.
## Development
```sh
cd frontend
npm run typecheck # TypeScript (renderer + electron)
npm run lint # ESLint
npm run format:check # Prettier
npm test # Vitest (jsdom), including IPC-level fake backend tests
cd ..
cargo test # Rust unit tests
cargo fmt --check # formatting
cargo clippy --all-targets
```
## Project layout
```
src/ Rust library + CLI + IPC server
app.rs application state loading/persistence
errors.rs structured AppError
ipc.rs JSON-lines serve() loop and request/reply envelope
main.rs CLI entry point
profiles.rs profile create/list/select
publish.rs note publishing with per-relay reports
relays.rs default relays, validation, connection tests
settings.rs theme and user preferences
vault.rs encrypted-vault-ready storage (currently plaintext)
frontend/
electron/ Electron main + preload (backend spawn, IPC, clipboard)
src/ React app (components, screens, state, styles)
src/test/ Vitest suite with a fake backend speaking the real protocol
package.json scripts and electron-builder config
```

0
__init__.py Normal file
View file

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

60
nostr_feed_manager.py Normal file
View file

@ -0,0 +1,60 @@
import subprocess
import json
import os
import sys
from typing import Dict, Any, Optional
# Path to your Rust binary (adjust if you move it)
RUST_BINARY = "/home/avi/Projects/skills/nost-feed-manager/target/release/nostr-manager-backend"
class NostrFeedManager:
"""
A skill for Hermes to manage multiple Nostr profiles.
"""
def __init__(self):
self.vault_file = "/home/avi/Projects/skills/nost-feed-manager/profiles_vault.json"
def _run_rust_command(self, command: str, args: list) -> str:
"""Executes the Rust binary."""
# Ensure the Rust binary exists
if not os.path.exists(RUST_BINARY):
return f"Error: Rust binary not found at {RUST_BINARY}. Please compile it first."
cmd = [RUST_BINARY, command] + args
try:
# Set the environment variable for the Rust binary if needed
env = os.environ.copy()
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10, env=env)
if result.returncode == 0:
return result.stdout.strip()
else:
return f"Error: {result.stderr.strip()}"
except Exception as e:
return f"Execution Error: {str(e)}"
def create_profile(self, label: str) -> str:
"""Create a new Nostr profile."""
return self._run_rust_command("create", [label])
def list_profiles(self) -> str:
"""List all managed profiles."""
return self._run_rust_command("list", [])
def switch_profile(self, npub: str) -> str:
"""Switch the active profile context."""
return self._run_rust_command("switch", [npub])
def publish_action(self, profile_npub: str, content: str, is_private: bool = False, recipient: Optional[str] = None) -> str:
"""Publish a post or DM."""
args = [profile_npub, content, str(is_private).lower()]
if is_private and recipient:
args.append(recipient)
return self._run_rust_command("publish", args)
# Export the class so Hermes can import it if running as a module
if __name__ == "__main__":
# Simple CLI test
manager = NostrFeedManager()
print("Nostr Feed Manager Skill Loaded.")
print("Usage: manager.create_profile('Name')")

57
src/app.rs Normal file
View file

@ -0,0 +1,57 @@
use serde::Serialize;
use crate::errors::AppError;
use crate::profiles::{self, ProfileSummary};
use crate::settings::Settings;
use crate::vault::{self, Vault};
/// Shared application state used by both the CLI and the IPC server.
pub struct App {
pub vault: Vault,
pub settings: Settings,
}
/// Snapshot of everything the UI needs, containing no secret keys.
#[derive(Debug, Clone, Serialize)]
pub struct AppStateView {
pub version: &'static str,
pub vault_path: String,
pub settings_path: String,
pub encrypted_storage: bool,
pub migrated_from: Option<String>,
pub active_profile: Option<ProfileSummary>,
pub profiles: Vec<ProfileSummary>,
pub settings: Settings,
}
impl App {
/// Load the vault (migrating a legacy vault if needed) and settings.
pub fn load() -> Result<Self, AppError> {
Ok(Self {
vault: vault::load_vault()?,
settings: vault::load_settings()?,
})
}
pub fn save_vault(&self) -> Result<(), AppError> {
vault::save_vault(&self.vault)
}
pub fn save_settings(&self) -> Result<(), AppError> {
vault::save_settings(&self.settings)
}
/// A safe view of current state, suitable for sending to a UI.
pub fn state_view(&self) -> AppStateView {
AppStateView {
version: env!("CARGO_PKG_VERSION"),
vault_path: vault::vault_path().to_string_lossy().into_owned(),
settings_path: vault::settings_path().to_string_lossy().into_owned(),
encrypted_storage: vault::is_encrypted(),
migrated_from: self.vault.migrated_from.clone(),
active_profile: profiles::active_summary(&self.vault),
profiles: profiles::summaries(&self.vault),
settings: self.settings.clone(),
}
}
}

182
src/errors.rs Normal file
View file

@ -0,0 +1,182 @@
use std::fmt;
/// Categorises errors so callers can tailor behaviour without string matching.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
/// Filesystem read/write problems.
Io,
/// Problems storing application data.
Storage,
/// JSON serialisation/deserialisation problems.
Json,
/// The vault file exists but cannot be parsed.
VaultMalformed,
/// A requested profile is not stored locally.
ProfileNotFound,
/// A stored secret key is invalid or not valid hex.
InvalidSecret,
/// A relay URL is malformed.
InvalidRelay,
/// No profile is selected.
NoActiveProfile,
/// No relays are enabled.
NoEnabledRelays,
/// A network operation failed.
Network,
/// Publishing failed on every relay.
PublishFailed,
/// The note to publish is empty.
EmptyNote,
/// Event signing failed.
SignFailed,
/// Invalid configuration or user input.
Config,
/// Unexpected internal failure.
Internal,
}
/// Structured application error.
///
/// `message` is a short, jargon-free message safe to show directly to the
/// user. `details` is an optional technical explanation shown only in an
/// expandable area and must never contain secret keys.
#[derive(Debug, Clone)]
pub struct AppError {
kind: ErrorKind,
message: String,
details: Option<String>,
}
impl AppError {
fn new(kind: ErrorKind, message: impl Into<String>, details: Option<String>) -> Self {
Self {
kind,
message: message.into(),
details,
}
}
/// Create an error with no technical detail.
pub fn simple(kind: ErrorKind, message: impl Into<String>) -> Self {
Self::new(kind, message, None)
}
/// Create an error with a technical detail.
pub fn with_details(
kind: ErrorKind,
message: impl Into<String>,
details: impl ToString,
) -> Self {
Self::new(kind, message, Some(details.to_string()))
}
pub fn kind(&self) -> ErrorKind {
self.kind
}
pub fn message(&self) -> &str {
&self.message
}
pub fn details(&self) -> Option<&str> {
self.details.as_deref()
}
pub fn io(context: &str, err: impl fmt::Display) -> Self {
Self::with_details(ErrorKind::Io, format!("{context}."), err)
}
pub fn storage(message: impl Into<String>) -> Self {
Self::simple(ErrorKind::Storage, message)
}
pub fn json(context: &str, err: impl fmt::Display) -> Self {
Self::with_details(ErrorKind::Json, format!("{context}."), err)
}
pub fn vault_malformed(err: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::VaultMalformed,
"Your profile data could not be read. It may have been modified or damaged.",
err,
)
}
pub fn profile_not_found(npub: &str) -> Self {
Self::with_details(
ErrorKind::ProfileNotFound,
"That profile is not stored on this computer.",
format!("No stored profile found for {npub}"),
)
}
pub fn invalid_secret(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::InvalidSecret,
"The stored key for this profile is invalid.",
details,
)
}
pub fn invalid_relay(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::InvalidRelay,
"That doesn't look like a valid relay address. Try something like wss://relay.example.com",
details,
)
}
pub fn no_active_profile() -> Self {
Self::simple(
ErrorKind::NoActiveProfile,
"No profile is selected. Choose a profile before publishing.",
)
}
pub fn no_enabled_relays() -> Self {
Self::simple(
ErrorKind::NoEnabledRelays,
"No relays are enabled. Enable at least one relay before publishing.",
)
}
pub fn network(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::Network,
"Could not connect to the relay. Check your internet connection and try again.",
details,
)
}
pub fn empty_note() -> Self {
Self::simple(ErrorKind::EmptyNote, "Write something before publishing.")
}
pub fn sign_failed(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::SignFailed,
"The note could not be signed.",
details,
)
}
pub fn config(message: impl Into<String>) -> Self {
Self::simple(ErrorKind::Config, message)
}
pub fn internal(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::Internal,
"Something unexpected went wrong.",
details,
)
}
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for AppError {}

242
src/ipc.rs Normal file
View file

@ -0,0 +1,242 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::app::App;
use crate::errors::AppError;
use crate::profiles;
use crate::publish;
use crate::relays;
use crate::settings::Theme;
/// How long to wait for a relay connection test.
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
/// An incoming request envelope: a method plus an id for correlation.
#[derive(Debug, Deserialize)]
pub struct RequestEnvelope {
pub id: u64,
#[serde(flatten)]
pub request: Request,
}
/// Requests supported by the IPC server.
#[derive(Debug, Deserialize)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum Request {
/// Bootstrap: full state snapshot.
Init,
/// Current state snapshot.
GetState,
CreateProfile {
label: String,
},
SelectProfile {
npub: String,
},
PublishNote {
content: String,
},
RelayAdd {
url: String,
},
RelayRemove {
url: String,
},
RelaySetEnabled {
url: String,
enabled: bool,
},
RelayTest {
url: String,
},
SettingsGet,
SettingsUpdate {
theme: Option<Theme>,
confirm_before_publish: Option<bool>,
shorten_npub: Option<bool>,
},
/// Create a timestamped backup of the vault file.
BackupNow,
}
/// A reply envelope carrying either data or a safe user-facing error.
#[derive(Debug, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum Reply<T> {
Ok {
data: T,
},
Error {
message: String,
details: Option<String>,
},
}
/// A full reply written as a single JSON line.
#[derive(Debug, Serialize)]
pub struct ReplyEnvelope {
pub id: u64,
#[serde(flatten)]
pub reply: Reply<serde_json::Value>,
}
/// Run the JSON-lines IPC server on stdin/stdout.
///
/// The Electron main process spawns `nostr-manager-backend serve` and
/// exchanges one JSON object per line. Requests are processed sequentially so
/// the shared state never sees concurrent mutations.
pub async fn serve() -> Result<(), AppError> {
use tokio::io::AsyncBufReadExt;
let mut app = App::load()?;
let stdin = tokio::io::stdin();
let mut lines = tokio::io::BufReader::new(stdin).lines();
let mut stdout = tokio::io::stdout();
while let Some(line) = lines
.next_line()
.await
.map_err(|e| AppError::io("Could not read from stdin", e))?
{
if line.trim().is_empty() {
continue;
}
let envelope: RequestEnvelope = match serde_json::from_str(&line) {
Ok(envelope) => envelope,
Err(e) => {
let reply = Reply::<serde_json::Value>::Error {
message: "The request could not be understood.".to_string(),
details: Some(e.to_string()),
};
write_line(&mut stdout, ReplyEnvelope { id: 0, reply }).await?;
continue;
}
};
let reply = handle(&mut app, envelope.request).await;
write_line(
&mut stdout,
ReplyEnvelope {
id: envelope.id,
reply,
},
)
.await?;
}
Ok(())
}
async fn write_line<W>(writer: &mut W, envelope: ReplyEnvelope) -> Result<(), AppError>
where
W: tokio::io::AsyncWriteExt + Unpin,
{
let mut line = serde_json::to_string(&envelope)
.map_err(|e| AppError::json("Could not prepare a response", e))?;
line.push('\n');
writer
.write_all(line.as_bytes())
.await
.map_err(|e| AppError::io("Could not write a response", e))?;
writer
.flush()
.await
.map_err(|e| AppError::io("Could not flush a response", e))?;
Ok(())
}
async fn handle(app: &mut App, request: Request) -> Reply<serde_json::Value> {
let result = run(app, request).await;
match result {
Ok(value) => Reply::Ok { data: value },
Err(err) => Reply::Error {
message: err.message().to_string(),
details: err.details().map(str::to_string),
},
}
}
async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppError> {
match request {
Request::Init | Request::GetState => Ok(json!(app.state_view())),
Request::CreateProfile { label } => {
let label = normalise_label(&label);
let summary = profiles::create_profile(&mut app.vault, label)?;
app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() }))
}
Request::SelectProfile { npub } => {
profiles::set_active(&mut app.vault, &npub)?;
app.save_vault()?;
Ok(json!(app.state_view()))
}
Request::PublishNote { content } => {
let report = publish::publish_active(&app.vault, &app.settings, &content).await?;
Ok(json!(report))
}
Request::RelayAdd { url } => {
relays::add_relay(&mut app.settings, &url)?;
app.save_settings()?;
Ok(json!(app.settings))
}
Request::RelayRemove { url } => {
relays::remove_relay(&mut app.settings, &url)?;
app.save_settings()?;
Ok(json!(app.settings))
}
Request::RelaySetEnabled { url, enabled } => {
relays::set_enabled(&mut app.settings, &url, enabled)?;
app.save_settings()?;
Ok(json!(app.settings))
}
Request::RelayTest { url } => {
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
Ok(json!(result))
}
Request::SettingsGet => Ok(json!(app.settings)),
Request::BackupNow => {
let backup = crate::vault::backup_file(&crate::vault::vault_path())?;
Ok(json!({ "backup_path": backup.to_string_lossy().into_owned() }))
}
Request::SettingsUpdate {
theme,
confirm_before_publish,
shorten_npub,
} => {
if let Some(theme) = theme {
app.settings.theme = theme;
}
if let Some(confirm) = confirm_before_publish {
app.settings.confirm_before_publish = confirm;
}
if let Some(shorten) = shorten_npub {
app.settings.shorten_npub = shorten;
}
app.save_settings()?;
Ok(json!(app.settings))
}
}
}
/// Trim a profile label and provide a friendly default for empty input.
fn normalise_label(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
"My Profile".to_string()
} else {
trimmed.to_string()
}
}

13
src/lib.rs Normal file
View file

@ -0,0 +1,13 @@
pub mod app;
pub mod errors;
pub mod ipc;
pub mod profiles;
pub mod publish;
pub mod relays;
pub mod settings;
pub mod vault;
pub use errors::AppError;
/// Stable application-data directory name.
pub const APP_DIR_NAME: &str = "nost-feed-manager";

269
src/main.rs Normal file
View file

@ -0,0 +1,269 @@
use std::process::ExitCode;
use nostr_manager_backend::app::App;
use nostr_manager_backend::errors::{AppError, ErrorKind};
use nostr_manager_backend::ipc;
use nostr_manager_backend::profiles;
use nostr_manager_backend::publish;
use nostr_manager_backend::relays;
use nostr_manager_backend::settings::Theme;
use nostr_manager_backend::vault;
const USAGE: &str = "\
nostr-manager-backend <command> [args...]
Commands:
create <label> Create a new profile
list List stored profiles (no secret keys)
switch <npub> Select the active profile
publish <npub> <content> Publish a text note as a specific profile
relays list List configured relays
relays add <url> Add a relay
relays remove <url> Remove a relay
relays enable <url> Enable a relay
relays disable <url> Disable a relay
relays test <url> Test a relay connection
settings get Show application settings
settings set theme <light|dark|system>
settings set confirm <true|false>
settings set shorten <true|false>
info Show storage locations and version
serve Run the JSON-lines IPC server";
#[tokio::main]
async fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let command = match args.get(1) {
Some(c) => c.as_str(),
None => {
eprintln!("{USAGE}");
return ExitCode::from(1);
}
};
let result: Result<String, AppError> = match command {
"serve" => match ipc::serve().await {
Ok(()) => Ok(String::new()),
Err(e) => Err(e),
},
"create" => cli_create(&args),
"list" => cli_list(),
"switch" => cli_switch(&args),
"publish" => cli_publish(&args).await,
"relays" => cli_relays(&args).await,
"settings" => cli_settings(&args),
"info" => cli_info(),
"help" | "--help" | "-h" => {
println!("{USAGE}");
return ExitCode::SUCCESS;
}
other => Err(AppError::simple(
ErrorKind::Config,
format!("Unknown command: {other}"),
)),
};
match result {
Ok(output) => {
if !output.is_empty() {
println!("{output}");
}
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("Error: {}", err.message());
if let Some(details) = err.details() {
eprintln!("Details: {details}");
}
ExitCode::from(1)
}
}
}
fn cli_create(args: &[String]) -> Result<String, AppError> {
let label = args
.get(2)
.cloned()
.unwrap_or_else(|| "New Profile".to_string());
let mut app = App::load()?;
let summary = profiles::create_profile(&mut app.vault, label)?;
app.save_vault()?;
Ok(format!(
"Created profile \"{}\": {}",
summary.label, summary.npub
))
}
fn cli_list() -> Result<String, AppError> {
let app = App::load()?;
let summaries = profiles::summaries(&app.vault);
serde_json::to_string_pretty(&summaries)
.map_err(|e| AppError::json("Could not list profiles", e))
}
fn cli_switch(args: &[String]) -> Result<String, AppError> {
let npub = args
.get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend switch <npub>"))?;
let mut app = App::load()?;
profiles::set_active(&mut app.vault, npub)?;
app.save_vault()?;
Ok(format!("Switched context to: {npub}"))
}
async fn cli_publish(args: &[String]) -> Result<String, AppError> {
if args.len() < 4 {
return Err(AppError::config(
"Usage: nostr-manager-backend publish <npub> <content>",
));
}
let npub = &args[2];
let content = args[3..].join(" ");
let app = App::load()?;
let report = publish::publish_as(&app.vault, &app.settings, npub, &content).await?;
let mut lines = vec![format!("Published: {}", report.event_id)];
if let Some(failed) = report.failed.first() {
lines.push(format!(
"Published to {} relay(s); {} relay(s) did not accept it (e.g. {}).",
report.succeeded.len(),
report.failed.len(),
failed.url
));
}
Ok(lines.join("\n"))
}
async fn cli_relays(args: &[String]) -> Result<String, AppError> {
let sub = args.get(2).ok_or_else(|| {
AppError::config(
"Usage: nostr-manager-backend relays <list|add|remove|enable|disable|test> [...]",
)
})?;
let mut app = App::load()?;
match sub.as_str() {
"list" => {
let relays = app.settings.relays.clone();
serde_json::to_string_pretty(&relays)
.map_err(|e| AppError::json("Could not list relays", e))
}
"add" => {
let url = args
.get(3)
.ok_or_else(|| AppError::config("Usage: relays add <url>"))?;
relays::add_relay(&mut app.settings, url)?;
app.save_settings()?;
Ok(format!("Added relay: {}", relays::normalise_url(url)))
}
"remove" => {
let url = args
.get(3)
.ok_or_else(|| AppError::config("Usage: relays remove <url>"))?;
relays::remove_relay(&mut app.settings, url)?;
app.save_settings()?;
Ok(format!("Removed relay: {url}"))
}
"enable" | "disable" => {
let url = args
.get(3)
.ok_or_else(|| AppError::config(format!("Usage: relays {sub} <url>")))?;
let enabled = sub == "enable";
relays::set_enabled(&mut app.settings, url, enabled)?;
app.save_settings()?;
Ok(format!(
"{} relay: {url}",
if enabled { "Enabled" } else { "Disabled" }
))
}
"test" => {
let url = args
.get(3)
.ok_or_else(|| AppError::config("Usage: relays test <url>"))?;
let result = relays::test_connection(url, std::time::Duration::from_secs(8)).await?;
match result.latency_ms {
Some(ms) => Ok(format!("Connected to {url} (latency {ms} ms)")),
None => Ok(format!("Connected to {url}")),
}
}
other => Err(AppError::config(format!("Unknown relays command: {other}"))),
}
}
fn cli_settings(args: &[String]) -> Result<String, AppError> {
let sub = args
.get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend settings <get|set>"))?;
let mut app = App::load()?;
match sub.as_str() {
"get" => serde_json::to_string_pretty(&app.settings)
.map_err(|e| AppError::json("Could not read settings", e)),
"set" => {
let key = args
.get(3)
.ok_or_else(|| AppError::config("Usage: settings set <key> <value>"))?;
let value = args
.get(4)
.ok_or_else(|| AppError::config("Usage: settings set <key> <value>"))?;
match key.as_str() {
"theme" => {
let theme = Theme::parse(value).ok_or_else(|| {
AppError::config("Theme must be one of: light, dark, system")
})?;
app.settings.theme = theme;
}
"confirm" => {
app.settings.confirm_before_publish = parse_bool(value)?;
}
"shorten" => {
app.settings.shorten_npub = parse_bool(value)?;
}
other => {
return Err(AppError::config(format!(
"Unknown setting: {other}. Valid settings: theme, confirm, shorten"
)));
}
}
app.save_settings()?;
Ok("Settings updated.".to_string())
}
other => Err(AppError::config(format!(
"Unknown settings command: {other}"
))),
}
}
fn parse_bool(value: &str) -> Result<bool, AppError> {
match value {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(AppError::config(format!(
"Expected true or false, got '{value}'"
))),
}
}
fn cli_info() -> Result<String, AppError> {
let app = App::load()?;
let mut lines = vec![
format!("Application version: {}", env!("CARGO_PKG_VERSION")),
format!("Data directory: {}", vault::data_dir().to_string_lossy()),
format!("Vault file: {}", vault::vault_path().to_string_lossy()),
format!(
"Settings file: {}",
vault::settings_path().to_string_lossy()
),
format!("Encrypted storage: {}", vault::is_encrypted()),
];
if let Some(migrated) = &app.vault.migrated_from {
lines.push(format!("Migrated from: {migrated}"));
}
Ok(lines.join("\n"))
}

194
src/main.rs.backup Normal file
View file

@ -0,0 +1,194 @@
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Serialize, Deserialize, Clone)]
struct Profile {
label: String,
public_key: String,
secret_key: String, // Stored as a plaintext hex string
created_at: u64,
}
const VAULT_FILE: &str = "profiles_vault.json";
fn load_vault() -> Result<Vec<Profile>, String> {
if !Path::new(VAULT_FILE).exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(VAULT_FILE)
.map_err(|e| format!("Failed to read {VAULT_FILE}: {e}"))?;
if content.trim().is_empty() {
return Ok(Vec::new());
}
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {VAULT_FILE}: {e}"))
}
fn save_vault(profiles: &[Profile]) -> Result<(), String> {
let content = serde_json::to_string_pretty(profiles)
.map_err(|e| format!("Failed to serialize profiles: {e}"))?;
fs::write(VAULT_FILE, content)
.map_err(|e| format!("Failed to write {VAULT_FILE}: {e}"))
}
fn unix_timestamp() -> Result<u64, String> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|e| format!("System clock error: {e}"))
}
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("Error: {error}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let args: Vec<String> = env::args().collect();
let command = args.get(1).ok_or_else(|| {
[
"Usage: nostr-manager-backend <command> [args...]",
"Commands:",
" create <label>",
" list",
" switch <npub>",
" publish <npub> <content>",
]
.join("\n")
})?;
let result = match command.as_str() {
"create" => {
let label = args
.get(2)
.cloned()
.unwrap_or_else(|| "New Profile".to_string());
let keys = Keys::generate();
// Explicit ::hex prevents conflict with nostr_sdk::prelude::*.
let secret_hex =
::hex::encode(keys.secret_key().to_secret_bytes());
let public_key = keys
.public_key()
.to_bech32()
.map_err(|e| format!("Failed to encode public key: {e}"))?;
let profile = Profile {
label,
public_key: public_key.clone(),
secret_key: secret_hex,
created_at: unix_timestamp()?,
};
let mut profiles = load_vault()?;
profiles.push(profile);
save_vault(&profiles)?;
format!("Created profile: {public_key}")
}
"list" => {
let profiles = load_vault()?;
serde_json::to_string_pretty(&profiles)
.map_err(|e| format!("Failed to serialize profiles: {e}"))?
}
"switch" => {
let npub = args
.get(2)
.ok_or("Usage: nostr-manager-backend switch <npub>")?;
let profiles = load_vault()?;
if !profiles.iter().any(|profile| profile.public_key == *npub) {
return Err(format!("No stored profile found for {npub}"));
}
format!("Switched context to: {npub}")
}
"publish" => {
if args.len() < 4 {
return Err(
"Usage: nostr-manager-backend publish <npub> <content>"
.to_string(),
);
}
let npub = &args[2];
let content = args[3..].join(" ");
let profiles = load_vault()?;
let secret_hex = profiles
.iter()
.find(|profile| profile.public_key == *npub)
.map(|profile| profile.secret_key.clone())
.ok_or_else(|| format!("No stored profile found for {npub}"))?;
let secret_bytes = ::hex::decode(&secret_hex)
.map_err(|e| format!("Stored secret key is not valid hex: {e}"))?;
let secret_key = SecretKey::from_slice(&secret_bytes)
.map_err(|e| format!("Stored secret key is invalid: {e}"))?;
// Keys::from(secret_key) is incorrect for this nostr-sdk API.
let keys = Keys::new(secret_key);
let client = Client::new(keys.clone());
client
.add_relay("wss://relay.damus.io")
.await
.map_err(|e| format!("Failed to add Damus relay: {e}"))?;
client
.add_relay("wss://relay.nostr.band")
.await
.map_err(|e| format!("Failed to add nostr.band relay: {e}"))?;
client.connect().await;
let builder = EventBuilder::new(Kind::TextNote, content);
let event = builder
.sign(&keys)
.await
.map_err(|e| format!("Failed to sign event: {e}"))?;
client
.send_event(&event)
.await
.map_err(|e| format!("Failed to publish event: {e}"))?;
let event_id = event
.id
.to_bech32()
.map_err(|e| format!("Failed to encode event ID: {e}"))?;
format!("Published: {event_id}")
}
_ => {
return Err(format!("Unknown command: {command}"));
}
};
println!("{result}");
Ok(())
}

228
src/profiles.rs Normal file
View file

@ -0,0 +1,228 @@
use nostr_sdk::prelude::*;
use serde::Serialize;
use crate::errors::AppError;
use crate::vault::{unix_timestamp, StoredProfile, Vault};
/// A safe view of a profile that contains no secret key material.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ProfileSummary {
pub label: String,
/// Bech32 `npub`, safe to display and share.
pub npub: String,
/// Unix timestamp of creation.
pub created_at: u64,
pub is_active: bool,
}
/// Create a new profile, generating fresh keys. The new profile becomes the
/// active one when nothing is currently selected.
pub fn create_profile(vault: &mut Vault, label: String) -> Result<ProfileSummary, AppError> {
let keys = Keys::generate();
let secret_hex = ::hex::encode(keys.secret_key().to_secret_bytes());
let public_key = keys
.public_key()
.to_bech32()
.map_err(|e| AppError::internal(format!("Could not encode the public key: {e}")))?;
let created_at = unix_timestamp()?;
let profile = StoredProfile {
label: label.clone(),
public_key: public_key.clone(),
secret_key: secret_hex,
created_at,
};
let is_active = vault.active_profile.is_none();
if vault.active_profile.is_none() {
vault.active_profile = Some(public_key.clone());
}
vault.profiles.push(profile);
Ok(ProfileSummary {
label,
npub: public_key,
created_at,
is_active,
})
}
/// Safe summaries of every stored profile, newest last. Never includes
/// secret keys.
pub fn summaries(vault: &Vault) -> Vec<ProfileSummary> {
vault
.profiles
.iter()
.map(|p| summary_for(vault, p))
.collect()
}
/// Summary of the active profile, if any.
pub fn active_summary(vault: &Vault) -> Option<ProfileSummary> {
let active = vault.active_profile.as_ref()?;
vault
.profiles
.iter()
.find(|p| &p.public_key == active)
.map(|p| summary_for(vault, p))
}
fn summary_for(vault: &Vault, profile: &StoredProfile) -> ProfileSummary {
ProfileSummary {
label: profile.label.clone(),
npub: profile.public_key.clone(),
created_at: profile.created_at,
is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()),
}
}
/// Select the active profile, persisting the choice in the vault.
pub fn set_active(vault: &mut Vault, npub: &str) -> Result<(), AppError> {
if !vault.profiles.iter().any(|p| p.public_key == npub) {
return Err(AppError::profile_not_found(npub));
}
vault.active_profile = Some(npub.to_string());
Ok(())
}
/// Look up the stored secret key for a profile by `npub`.
pub fn find_secret_key<'a>(vault: &'a Vault, npub: &str) -> Result<&'a str, AppError> {
vault
.profiles
.iter()
.find(|p| p.public_key == npub)
.map(|p| p.secret_key.as_str())
.ok_or_else(|| AppError::profile_not_found(npub))
}
/// Look up the stored secret key of the active profile.
pub fn active_secret_key(vault: &Vault) -> Result<&str, AppError> {
let npub = vault
.active_profile
.as_deref()
.ok_or_else(AppError::no_active_profile)?;
find_secret_key(vault, npub)
}
/// Parse and validate a stored hex-encoded secret key.
pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
let bytes = ::hex::decode(hex_str)
.map_err(|e| AppError::invalid_secret(format!("Not valid hex: {e}")))?;
SecretKey::from_slice(&bytes).map_err(|e| AppError::invalid_secret(format!("{e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::Vault;
fn populated_vault() -> Vault {
let mut vault = Vault::empty();
vault.profiles.push(StoredProfile {
label: "Alice".to_string(),
public_key: "npub1alice".to_string(),
secret_key: "00".repeat(32),
created_at: 1,
});
vault.profiles.push(StoredProfile {
label: "Bob".to_string(),
public_key: "npub1bob".to_string(),
secret_key: "11".repeat(32),
created_at: 2,
});
vault
}
#[test]
fn create_profile_generates_valid_keys() {
let mut vault = Vault::empty();
let summary = create_profile(&mut vault, "Newbie".to_string()).expect("should create");
assert!(summary.npub.starts_with("npub1"));
assert_eq!(summary.label, "Newbie");
assert_eq!(vault.profiles.len(), 1);
assert_eq!(vault.profiles[0].public_key, summary.npub);
assert!(
vault.profiles[0].secret_key.len() == 64,
"secret is 32 bytes hex"
);
// New first profile becomes active.
assert!(summary.is_active);
assert_eq!(vault.active_profile.as_deref(), Some(summary.npub.as_str()));
}
#[test]
fn create_profile_keeps_existing_active() {
let mut vault = populated_vault();
vault.active_profile = Some("npub1alice".to_string());
let summary = create_profile(&mut vault, "Carol".to_string()).unwrap();
assert!(!summary.is_active);
assert_eq!(vault.active_profile.as_deref(), Some("npub1alice"));
}
#[test]
fn summaries_never_include_secret_keys() {
let vault = populated_vault();
let json = serde_json::to_string(&summaries(&vault)).unwrap();
assert!(
!json.contains("secret"),
"summaries must not contain secret material"
);
assert!(!json.contains("00".repeat(32).as_str()));
assert!(json.contains("npub1alice"));
}
#[test]
fn set_active_works_and_persists() {
let mut vault = populated_vault();
set_active(&mut vault, "npub1bob").expect("should switch");
assert_eq!(vault.active_profile.as_deref(), Some("npub1bob"));
let summary = active_summary(&vault).unwrap();
assert_eq!(summary.npub, "npub1bob");
assert!(summary.is_active);
}
#[test]
fn set_active_missing_profile_errors() {
let mut vault = populated_vault();
let err = set_active(&mut vault, "npub1ghost").expect_err("must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
}
#[test]
fn active_secret_key_missing_profile_errors() {
let vault = Vault::empty();
let err = active_secret_key(&vault).expect_err("no active profile must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::NoActiveProfile);
}
#[test]
fn find_secret_key_returns_stored_hex() {
let vault = populated_vault();
assert_eq!(
find_secret_key(&vault, "npub1bob").unwrap(),
"11".repeat(32)
);
let err = find_secret_key(&vault, "npub1ghost").expect_err("must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
}
#[test]
fn parse_secret_key_rejects_bad_hex() {
let err = parse_secret_key("not-hex!").expect_err("bad hex must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::InvalidSecret);
}
#[test]
fn parse_secret_key_rejects_wrong_length() {
let err = parse_secret_key("00ff").expect_err("short key must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::InvalidSecret);
}
#[test]
fn parse_secret_key_accepts_valid_hex() {
let key = parse_secret_key("01".repeat(32).as_str()).expect("valid key must parse");
assert_eq!(key.to_secret_bytes().len(), 32);
}
}

322
src/publish.rs Normal file
View file

@ -0,0 +1,322 @@
use std::time::Duration;
use nostr_sdk::prelude::*;
use serde::Serialize;
use crate::errors::{AppError, ErrorKind};
use crate::profiles;
use crate::relays;
use crate::settings::Settings;
use crate::vault::Vault;
/// How long to wait for relays to accept a connection attempt.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// How long to wait for a single relay to accept an event.
const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(15);
/// A relay that rejected a published note.
#[derive(Debug, Clone, Serialize)]
pub struct RelayFailure {
pub url: String,
/// Concise, user-facing reason.
pub error: String,
/// Technical detail for an expandable area.
pub details: Option<String>,
}
/// Result of publishing to the enabled relays.
#[derive(Debug, Clone, Serialize)]
pub struct PublishReport {
/// Bech32 note id of the published event.
pub event_id: String,
pub succeeded: Vec<String>,
pub failed: Vec<RelayFailure>,
}
impl PublishReport {
pub fn succeeded_on_all(&self, total: usize) -> bool {
self.succeeded.len() == total && self.failed.is_empty()
}
pub fn succeeded_on_some(&self) -> bool {
!self.succeeded.is_empty() && !self.failed.is_empty()
}
}
/// Publish a text note with the active profile.
pub async fn publish_active(
vault: &Vault,
settings: &Settings,
content: &str,
) -> Result<PublishReport, AppError> {
validate_content(content)?;
let secret_hex = profiles::active_secret_key(vault)?.to_string();
let secret_key = profiles::parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key);
publish_with_keys(settings, content, &keys).await
}
/// Publish a text note as a specific profile (used by the CLI).
pub async fn publish_as(
vault: &Vault,
settings: &Settings,
npub: &str,
content: &str,
) -> Result<PublishReport, AppError> {
validate_content(content)?;
let secret_hex = profiles::find_secret_key(vault, npub)?.to_string();
let secret_key = profiles::parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key);
publish_with_keys(settings, content, &keys).await
}
/// Reject empty notes before any key or network work happens.
fn validate_content(content: &str) -> Result<(), AppError> {
if content.trim().is_empty() {
return Err(AppError::empty_note());
}
Ok(())
}
async fn publish_with_keys(
settings: &Settings,
content: &str,
keys: &Keys,
) -> Result<PublishReport, AppError> {
let content = content.trim();
if content.is_empty() {
return Err(AppError::empty_note());
}
let relay_urls = relays::enabled_urls(settings);
if relay_urls.is_empty() {
return Err(AppError::no_enabled_relays());
}
// Sign locally before touching the network so a signing failure is
// reported as such rather than as a network error.
let builder = EventBuilder::new(Kind::TextNote, content.to_string());
let event = builder
.sign(keys)
.await
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
let event_id = event
.id
.to_bech32()
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?;
let client = Client::new(keys.clone());
for url in &relay_urls {
client
.add_relay(url.as_str())
.await
.map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?;
}
client.connect().await;
client.wait_for_connection(CONNECT_TIMEOUT).await;
// Send to each relay individually so partial failures are fully reported.
let mut succeeded: Vec<String> = Vec::new();
let mut failed: Vec<RelayFailure> = Vec::new();
for url in &relay_urls {
let relay = match client.relay(url.as_str()).await {
Ok(relay) => relay,
Err(err) => {
let (message, details) = relay_error_message(&err);
failed.push(RelayFailure {
url: url.clone(),
error: message,
details: Some(details),
});
continue;
}
};
match tokio::time::timeout(RELAY_SEND_TIMEOUT, relay.send_event(&event)).await {
Ok(Ok(_)) => succeeded.push(url.clone()),
Ok(Err(err)) => {
let (message, details) = relay_error_message(&err);
failed.push(RelayFailure {
url: url.clone(),
error: message,
details: Some(details),
});
}
Err(_) => failed.push(RelayFailure {
url: url.clone(),
error: "The relay did not respond in time.".to_string(),
details: Some("Timed out while waiting for the relay to accept the note.".into()),
}),
}
}
client.disconnect().await;
if succeeded.is_empty() {
return Err(AppError::publish_failed(failed));
}
Ok(PublishReport {
event_id,
succeeded,
failed,
})
}
/// Build a concise user-facing message plus technical detail from a relay
/// error, without ever including secret material.
///
/// The underlying error types come from transitive dependencies, so the
/// classification is based on the rendered message rather than brittle enum
/// matching across versions.
fn relay_error_message(err: &impl std::fmt::Display) -> (String, String) {
let technical = err.to_string();
let lower = technical.to_lowercase();
let concise = if lower.contains("timed out") || lower.contains("timeout") {
"The relay did not respond in time."
} else if lower.contains("not connected") || lower.contains("not ready") {
"Not connected to this relay."
} else if lower.contains("write disabled") || lower.contains("read disabled") {
"This relay is not accepting notes right now."
} else if lower.contains("shutdown") || lower.contains("termination") {
"The connection to this relay was closed."
} else if lower.contains("too large")
|| lower.contains("too many")
|| lower.contains("invalid")
|| lower.contains("rejected")
|| lower.contains("blocked")
|| lower.contains("expired")
|| lower.contains("relay message")
{
"The relay rejected the note."
} else {
"Could not reach this relay."
};
(concise.to_string(), technical)
}
impl AppError {
/// Publish failed on every enabled relay.
pub fn publish_failed(failed: Vec<RelayFailure>) -> Self {
let detail = failed
.iter()
.map(|f| format!("{}: {}", f.url, f.error))
.collect::<Vec<_>>()
.join("\n");
let message = if failed.is_empty() {
"The note could not be published to any relay.".to_string()
} else {
format!(
"The note could not be published to any of the {} enabled relay(s).",
failed.len()
)
};
AppError::with_details(ErrorKind::PublishFailed, message, detail)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn settings_with_no_relays() -> Settings {
Settings {
relays: Vec::new(),
..Default::default()
}
}
#[test]
fn publish_active_without_profile_errors() {
let vault = Vault::empty();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_active(&vault, &settings, "hello"))
.expect_err("no active profile must error");
assert_eq!(err.kind(), ErrorKind::NoActiveProfile);
}
#[test]
fn publish_as_missing_profile_errors() {
let vault = Vault::empty();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_as(&vault, &settings, "npub1ghost", "hello"))
.expect_err("missing profile must error");
assert_eq!(err.kind(), ErrorKind::ProfileNotFound);
}
#[test]
fn publish_empty_note_errors() {
let vault = Vault::empty();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_active(&vault, &settings, " "))
.expect_err("empty note must error");
assert_eq!(err.kind(), ErrorKind::EmptyNote);
}
#[test]
fn publish_with_no_enabled_relays_errors() {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string()).unwrap();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_active(&vault, &settings, "hello"))
.expect_err("no relays must error");
assert_eq!(err.kind(), ErrorKind::NoEnabledRelays);
}
#[test]
fn publish_with_invalid_stored_key_errors() {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string()).unwrap();
vault.profiles[0].secret_key = "zz-not-hex".to_string();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_active(&vault, &settings, "hello"))
.expect_err("invalid key must error");
assert_eq!(err.kind(), ErrorKind::InvalidSecret);
}
#[test]
fn publish_failed_error_message_is_concise() {
let err = AppError::publish_failed(vec![RelayFailure {
url: "wss://relay.example.com".to_string(),
error: "The relay did not accept the note.".to_string(),
details: None,
}]);
assert_eq!(err.kind(), ErrorKind::PublishFailed);
assert!(err.message().contains("1 enabled relay"));
assert!(err.details().unwrap().contains("wss://relay.example.com"));
}
#[test]
fn publish_report_state_flags() {
let report = PublishReport {
event_id: "note1abc".to_string(),
succeeded: vec!["a".to_string()],
failed: Vec::new(),
};
assert!(report.succeeded_on_all(1));
assert!(!report.succeeded_on_some());
let partial = PublishReport {
event_id: "note1abc".to_string(),
succeeded: vec!["a".to_string()],
failed: vec![RelayFailure {
url: "b".to_string(),
error: "x".to_string(),
details: None,
}],
};
assert!(partial.succeeded_on_some());
assert!(!partial.succeeded_on_all(2));
}
}

182
src/relays.rs Normal file
View file

@ -0,0 +1,182 @@
use std::time::Duration;
use nostr_sdk::prelude::*;
use serde::Serialize;
use crate::errors::AppError;
use crate::settings::{RelayConfig, Settings};
/// Relays used by the original Rust application.
pub fn default_relays() -> Vec<RelayConfig> {
vec![
RelayConfig::new("wss://relay.damus.io"),
RelayConfig::new("wss://relay.nostr.band"),
]
}
/// Validate that a string is a well-formed relay URL.
pub fn validate_url(raw: &str) -> Result<(), AppError> {
let cleaned = raw.trim().trim_end_matches('/');
RelayUrl::parse(cleaned)
.map(|_| ())
.map_err(|e| AppError::invalid_relay(format!("Could not parse relay address '{raw}': {e}")))
}
/// Normalise a relay URL for storage.
pub fn normalise_url(raw: &str) -> &str {
raw.trim().trim_end_matches('/')
}
/// Add a relay, enabled by default. Returns an error if it is already present.
pub fn add_relay(settings: &mut Settings, raw: &str) -> Result<(), AppError> {
let url = normalise_url(raw);
validate_url(url)?;
if settings.relays.iter().any(|r| r.url == url) {
return Err(AppError::config("That relay is already in your list."));
}
settings.relays.push(RelayConfig::new(url));
Ok(())
}
/// Remove a relay by URL.
pub fn remove_relay(settings: &mut Settings, url: &str) -> Result<(), AppError> {
let before = settings.relays.len();
settings.relays.retain(|r| r.url != url);
if settings.relays.len() == before {
return Err(AppError::config("That relay is not in your list."));
}
Ok(())
}
/// Enable or disable a relay.
pub fn set_enabled(settings: &mut Settings, url: &str, enabled: bool) -> Result<(), AppError> {
let relay = settings
.relays
.iter_mut()
.find(|r| r.url == url)
.ok_or_else(|| AppError::config("That relay is not in your list."))?;
relay.enabled = enabled;
Ok(())
}
/// URLs of every enabled relay.
pub fn enabled_urls(settings: &Settings) -> Vec<String> {
settings
.relays
.iter()
.filter(|r| r.enabled)
.map(|r| r.url.clone())
.collect()
}
/// Result of testing a relay connection.
#[derive(Debug, Clone, Serialize)]
pub struct RelayTestResult {
pub url: String,
pub connected: bool,
pub latency_ms: Option<u64>,
}
/// Attempt to open a connection to a relay.
///
/// Uses a throwaway key so the user's secret keys never touch the network
/// during a connection test.
pub async fn test_connection(url: &str, timeout: Duration) -> Result<RelayTestResult, AppError> {
let keys = Keys::generate();
let client = Client::new(keys);
client
.add_relay(url)
.await
.map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?;
let relay = client
.relay(url)
.await
.map_err(|e| AppError::network(format!("Could not look up relay {url}: {e}")))?;
relay
.try_connect(timeout)
.await
.map_err(|e| AppError::network(format!("Connection failed: {e}")))?;
let latency_ms = relay.stats().latency().map(|d| d.as_millis() as u64);
client.disconnect().await;
Ok(RelayTestResult {
url: url.to_string(),
connected: true,
latency_ms,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::Settings;
#[test]
fn default_relays_are_the_original_ones() {
let relays = default_relays();
assert!(relays.iter().any(|r| r.url == "wss://relay.damus.io"));
assert!(relays.iter().any(|r| r.url == "wss://relay.nostr.band"));
assert!(relays.iter().all(|r| r.enabled));
}
#[test]
fn validate_url_accepts_wss_and_ws() {
assert!(validate_url("wss://relay.example.com").is_ok());
assert!(validate_url("ws://relay.example.com").is_ok());
}
#[test]
fn validate_url_rejects_garbage() {
let err = validate_url("not a url").expect_err("must reject");
assert_eq!(err.kind(), crate::errors::ErrorKind::InvalidRelay);
let err2 = validate_url("ftp://example.com").expect_err("must reject ftp");
assert_eq!(err2.kind(), crate::errors::ErrorKind::InvalidRelay);
}
#[test]
fn validate_url_rejects_http_scheme() {
assert!(validate_url("https://relay.example.com").is_err());
}
#[test]
fn add_relay_normalises_and_enables() {
let mut settings = Settings::default();
settings.relays.clear();
add_relay(&mut settings, " wss://relay.example.com/ ").expect("add");
assert_eq!(settings.relays.len(), 1);
assert_eq!(settings.relays[0].url, "wss://relay.example.com");
assert!(settings.relays[0].enabled);
}
#[test]
fn add_relay_rejects_duplicates() {
let mut settings = Settings::default();
settings.relays.clear();
add_relay(&mut settings, "wss://relay.example.com").unwrap();
let err = add_relay(&mut settings, "wss://relay.example.com").expect_err("duplicate");
assert_eq!(err.kind(), crate::errors::ErrorKind::Config);
}
#[test]
fn remove_relay_errors_when_absent() {
let mut settings = Settings::default();
settings.relays.clear();
let err = remove_relay(&mut settings, "wss://relay.example.com").expect_err("absent");
assert_eq!(err.kind(), crate::errors::ErrorKind::Config);
}
#[test]
fn set_enabled_toggles() {
let mut settings = Settings::default();
settings.relays.clear();
add_relay(&mut settings, "wss://relay.example.com").unwrap();
set_enabled(&mut settings, "wss://relay.example.com", false).unwrap();
assert!(enabled_urls(&settings).is_empty());
set_enabled(&mut settings, "wss://relay.example.com", true).unwrap();
assert_eq!(enabled_urls(&settings), vec!["wss://relay.example.com"]);
}
}

67
src/settings.rs Normal file
View file

@ -0,0 +1,67 @@
use serde::{Deserialize, Serialize};
/// A single relay configured for the application.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RelayConfig {
pub url: String,
pub enabled: bool,
}
impl RelayConfig {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
enabled: true,
}
}
}
/// User interface appearance theme.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
Light,
Dark,
System,
}
impl Theme {
/// Parse a theme from a CLI string.
pub fn parse(s: &str) -> Option<Self> {
match s {
"light" => Some(Self::Light),
"dark" => Some(Self::Dark),
"system" => Some(Self::System),
_ => None,
}
}
}
/// Application settings stored alongside the vault.
///
/// This file never contains secret keys.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub theme: Theme,
#[serde(default = "default_true")]
pub confirm_before_publish: bool,
#[serde(default = "default_true")]
pub shorten_npub: bool,
#[serde(default)]
pub relays: Vec<RelayConfig>,
}
fn default_true() -> bool {
true
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: Theme::System,
confirm_before_publish: true,
shorten_npub: true,
relays: crate::relays::default_relays(),
}
}
}

410
src/vault.rs Normal file
View file

@ -0,0 +1,410 @@
use std::env;
use std::fs;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::errors::AppError;
use crate::APP_DIR_NAME;
/// Current vault schema version.
pub const VAULT_VERSION: u32 = 2;
/// Filename of the profiles vault.
pub const VAULT_FILE_NAME: &str = "profiles_vault.json";
/// Filename of the settings file.
pub const SETTINGS_FILE_NAME: &str = "settings.json";
/// A profile stored on disk.
///
/// `secret_key` is stored as a plaintext hex string for now. The storage is
/// deliberately unencrypted in this iteration; it is kept behind the vault
/// module so that encryption can be added later without changing callers.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredProfile {
pub label: String,
/// Bech32 `npub` of the profile.
pub public_key: String,
/// Hex-encoded secret key bytes.
pub secret_key: String,
/// Unix timestamp of creation.
pub created_at: u64,
}
/// On-disk vault containing every stored profile plus the active selection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vault {
pub version: u32,
/// Set when the vault was migrated from a legacy location.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub migrated_from: Option<String>,
/// `npub` of the profile that should stay selected across restarts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_profile: Option<String>,
pub profiles: Vec<StoredProfile>,
}
impl Vault {
/// A fresh, empty vault.
pub fn empty() -> Self {
Self {
version: VAULT_VERSION,
migrated_from: None,
active_profile: None,
profiles: Vec::new(),
}
}
pub fn has_profiles(&self) -> bool {
!self.profiles.is_empty()
}
}
/// Unix timestamp in seconds, with an error instead of panicking.
pub fn unix_timestamp() -> Result<u64, AppError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.map_err(|e| AppError::internal(format!("System clock error: {e}")))
}
/// Stable application-data directory for this app.
///
/// Uses `$XDG_DATA_HOME` when set, otherwise `~/.local/share`.
pub fn data_dir() -> PathBuf {
if let Ok(dir) = env::var("XDG_DATA_HOME") {
if !dir.trim().is_empty() {
return PathBuf::from(dir).join(APP_DIR_NAME);
}
}
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home)
.join(".local")
.join("share")
.join(APP_DIR_NAME)
}
pub fn vault_path() -> PathBuf {
data_dir().join(VAULT_FILE_NAME)
}
pub fn settings_path() -> PathBuf {
data_dir().join(SETTINGS_FILE_NAME)
}
/// Candidate locations for a legacy vault created by the old CLI version.
///
/// The old application wrote `profiles_vault.json` in its working directory.
/// The first candidate is the directory this crate was compiled in (the
/// original project directory); the second is the current working directory.
pub fn legacy_vault_paths() -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = Vec::new();
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(VAULT_FILE_NAME);
if !paths.contains(&manifest) {
paths.push(manifest);
}
if let Ok(cwd) = env::current_dir() {
let cwd_path = cwd.join(VAULT_FILE_NAME);
if !paths.contains(&cwd_path) {
paths.push(cwd_path);
}
}
paths
}
/// Load the vault, migrating a legacy vault on first use if necessary.
///
/// Never panics: a missing or empty file yields an empty vault; a malformed
/// file yields a structured error.
pub fn load_vault() -> Result<Vault, AppError> {
let path = vault_path();
if !path.exists() {
if let Some(vault) = try_migrate_legacy_vault()? {
return Ok(vault);
}
return Ok(Vault::empty());
}
read_vault_from(&path)
}
/// Read and parse a vault from an explicit path.
pub fn read_vault_from(path: &Path) -> Result<Vault, AppError> {
let content = fs::read_to_string(path)
.map_err(|e| AppError::io("Could not read the profile vault", e))?;
parse_vault(&content)
}
/// Parse vault content, accepting both the current object format and the
/// legacy flat-array format used by the first version of the application.
pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
let trimmed = content.trim();
if trimmed.is_empty() {
return Ok(Vault::empty());
}
let value: serde_json::Value = serde_json::from_str(trimmed)
.map_err(|e| AppError::vault_malformed(format!("Could not parse JSON: {e}")))?;
if value.is_array() {
// Legacy format: `[{ "label", "public_key", "secret_key", "created_at" }, ...]`
let profiles: Vec<StoredProfile> = serde_json::from_value(value)
.map_err(|e| AppError::vault_malformed(format!("Invalid profile list: {e}")))?;
if profiles.is_empty() {
return Ok(Vault::empty());
}
return Ok(Vault {
version: VAULT_VERSION,
migrated_from: None,
active_profile: None,
profiles,
});
}
serde_json::from_value(value).map_err(|e| AppError::vault_malformed(format!("{e}")))
}
/// Persist the vault to the stable application-data location with
/// restrictive permissions.
pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
let content = serde_json::to_string_pretty(vault)
.map_err(|e| AppError::json("Could not prepare the profile vault for saving", e))?;
write_restricted(&vault_path(), &content)
}
/// Persist settings with restrictive permissions.
pub fn save_settings(settings: &crate::settings::Settings) -> Result<(), AppError> {
let content = serde_json::to_string_pretty(settings)
.map_err(|e| AppError::json("Could not prepare the settings file for saving", e))?;
write_restricted(&settings_path(), &content)
}
/// Load settings, falling back to defaults when absent or empty.
pub fn load_settings() -> Result<crate::settings::Settings, AppError> {
use crate::settings::Settings;
let path = settings_path();
if !path.exists() {
return Ok(Settings::default());
}
let content = fs::read_to_string(&path)
.map_err(|e| AppError::io("Could not read the settings file", e))?;
if content.trim().is_empty() {
return Ok(Settings::default());
}
serde_json::from_str(&content)
.map_err(|e| AppError::config(format!("The settings file could not be read: {e}")))
}
/// Write a file atomically-ish with mode 0600, creating the parent directory
/// with mode 0700. Uses a temporary file plus rename so a crash mid-write
/// cannot leave a truncated vault behind.
fn write_restricted(path: &Path, content: &str) -> Result<(), AppError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| AppError::io("Could not create the application data folder", e))?;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
let tmp_path = path.with_extension("json.tmp");
{
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)
.map_err(|e| AppError::io("Could not write the data file", e))?;
file.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(|e| AppError::io("Could not protect the data file", e))?;
file.write_all(content.as_bytes())
.map_err(|e| AppError::io("Could not write the data file", e))?;
file.sync_all()
.map_err(|e| AppError::io("Could not save the data file", e))?;
}
fs::rename(&tmp_path, path).map_err(|e| AppError::io("Could not finalise the data file", e))?;
// Re-apply restrictive permissions after rename.
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
Ok(())
}
/// Copy `path` to a timestamped backup next to it, never overwriting an
/// existing backup. Returns the backup path.
pub fn backup_file(path: &Path) -> Result<PathBuf, AppError> {
let timestamp = unix_timestamp()?;
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "vault.json".to_string());
let backup = path.with_file_name(format!("{file_name}.backup-{timestamp}"));
if backup.exists() {
return Ok(backup);
}
fs::copy(path, &backup)
.map_err(|e| AppError::io("Could not create a backup of your profile data", e))?;
let _ = fs::set_permissions(&backup, fs::Permissions::from_mode(0o600));
Ok(backup)
}
/// Look for a vault written by the old CLI and migrate it into the stable
/// application-data location.
///
/// A backup of the legacy file is created before migrating. The legacy file
/// itself is left untouched. Returns `None` when no migratable vault exists.
fn try_migrate_legacy_vault() -> Result<Option<Vault>, AppError> {
for legacy in legacy_vault_paths() {
if !legacy.exists() {
continue;
}
let content = match fs::read_to_string(&legacy) {
Ok(c) => c,
Err(_) => continue,
};
let trimmed = content.trim();
if trimmed.is_empty() {
continue;
}
let vault = match parse_vault(trimmed) {
Ok(v) => v,
Err(_) => continue,
};
if vault.profiles.is_empty() {
// Nothing to migrate; leave the legacy file alone.
continue;
}
let backup = backup_file(&legacy)?;
let mut migrated = vault;
migrated.migrated_from = Some(format!(
"{} (backed up to {})",
legacy.to_string_lossy(),
backup.to_string_lossy()
));
save_vault(&migrated)?;
return Ok(Some(migrated));
}
Ok(None)
}
/// Whether storage is currently encrypted. Always false in this iteration;
/// the vault is stored in plaintext and the limitation is disclosed in the
/// UI. Kept as a single source of truth so callers never assume otherwise.
pub fn is_encrypted() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::ErrorKind;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_vault_path() -> PathBuf {
let dir = env::temp_dir().join(format!(
"nost-feed-manager-test-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
fs::create_dir_all(&dir).unwrap();
dir.join(VAULT_FILE_NAME)
}
fn sample_profile() -> StoredProfile {
StoredProfile {
label: "Alice".to_string(),
public_key: "npub1test".to_string(),
secret_key: "00ff".to_string(),
created_at: 1_700_000_000,
}
}
#[test]
fn parse_vault_accepts_legacy_array_format() {
let json = r#"[
{ "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef", "created_at": 1700000000 }
]"#;
let vault = parse_vault(json).expect("legacy array should parse");
assert_eq!(vault.version, VAULT_VERSION);
assert_eq!(vault.profiles.len(), 1);
assert_eq!(vault.profiles[0].label, "Alice");
assert_eq!(vault.profiles[0].public_key, "npub1abc");
assert_eq!(vault.profiles[0].secret_key, "deadbeef");
assert_eq!(vault.active_profile, None);
}
#[test]
fn parse_vault_accepts_current_object_format() {
let json = r#"{
"version": 2,
"active_profile": "npub1abc",
"profiles": [
{ "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef", "created_at": 1700000000 }
]
}"#;
let vault = parse_vault(json).expect("object format should parse");
assert_eq!(vault.profiles.len(), 1);
assert_eq!(vault.active_profile.as_deref(), Some("npub1abc"));
}
#[test]
fn parse_vault_empty_string_is_empty_vault() {
let vault = parse_vault("").expect("empty content should parse");
assert_eq!(vault.profiles.len(), 0);
}
#[test]
fn parse_vault_malformed_is_error() {
let err = parse_vault("{ not valid json !!").expect_err("malformed json must error");
assert_eq!(err.kind(), ErrorKind::VaultMalformed);
assert!(!err.message().is_empty());
}
#[test]
fn parse_vault_malformed_object_is_error() {
// Valid JSON but the wrong shape (profiles is not a list).
let err =
parse_vault(r#"{"version":2,"profiles":42}"#).expect_err("wrong shape must error");
assert_eq!(err.kind(), ErrorKind::VaultMalformed);
}
#[test]
fn save_and_reload_roundtrip() {
let path = temp_vault_path();
let mut vault = Vault::empty();
vault.profiles.push(sample_profile());
vault.active_profile = Some("npub1test".to_string());
let content = serde_json::to_string_pretty(&vault).unwrap();
let parent = path.parent().unwrap();
fs::create_dir_all(parent).unwrap();
fs::write(&path, content).unwrap();
let reloaded = read_vault_from(&path).expect("reload should succeed");
assert_eq!(reloaded.profiles.len(), 1);
assert_eq!(reloaded.profiles[0].secret_key, "00ff");
assert_eq!(reloaded.active_profile.as_deref(), Some("npub1test"));
}
#[test]
fn load_missing_vault_is_empty() {
// Point the resolver at a non-existent path by writing nothing and
// reading via read_vault_from on a missing file.
let err = read_vault_from(&PathBuf::from("/nonexistent/vault.json"))
.expect_err("missing file should error");
assert_eq!(err.kind(), ErrorKind::Io);
}
#[test]
fn write_restricted_sets_0600_permissions() {
let path = temp_vault_path();
write_restricted(&path, r#"{"ok":true}"#).expect("write should succeed");
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "vault must be readable only by owner");
}
}