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:
commit
7e3bac345c
68 changed files with 18262 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
1800
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal 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
139
README.md
Normal 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
0
__init__.py
Normal file
5
frontend/.prettierignore
Normal file
5
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
release
|
||||
coverage
|
||||
6
frontend/.prettierrc
Normal file
6
frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"semi": true
|
||||
}
|
||||
193
frontend/electron/main.ts
Normal file
193
frontend/electron/main.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
7
frontend/electron/preload.ts
Normal file
7
frontend/electron/preload.ts
Normal 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
19
frontend/eslint.config.js
Normal 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
16
frontend/index.html
Normal 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
9241
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
73
frontend/package.json
Normal file
73
frontend/package.json
Normal 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
64
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/Alert.tsx
Normal file
27
frontend/src/components/Alert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/Avatar.tsx
Normal file
20
frontend/src/components/Avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/Badge.tsx
Normal file
7
frontend/src/components/Badge.tsx
Normal 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>;
|
||||
}
|
||||
32
frontend/src/components/Button.tsx
Normal file
32
frontend/src/components/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/Card.tsx
Normal file
22
frontend/src/components/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/CopyButton.tsx
Normal file
54
frontend/src/components/CopyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/EmptyState.tsx
Normal file
23
frontend/src/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/ErrorText.tsx
Normal file
15
frontend/src/components/ErrorText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/Icon.tsx
Normal file
113
frontend/src/components/Icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
frontend/src/components/Modal.tsx
Normal file
77
frontend/src/components/Modal.tsx
Normal 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}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="modal-body">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
71
frontend/src/components/Sidebar.tsx
Normal file
71
frontend/src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
frontend/src/components/Spinner.tsx
Normal file
8
frontend/src/components/Spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/Toggle.tsx
Normal file
23
frontend/src/components/Toggle.tsx
Normal 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
55
frontend/src/lib/api.ts
Normal 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),
|
||||
};
|
||||
65
frontend/src/lib/format.ts
Normal file
65
frontend/src/lib/format.ts
Normal 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();
|
||||
}
|
||||
9
frontend/src/lib/navigation.ts
Normal file
9
frontend/src/lib/navigation.ts
Normal 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
59
frontend/src/lib/types.ts
Normal 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
15
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
208
frontend/src/screens/ComposeScreen.tsx
Normal file
208
frontend/src/screens/ComposeScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
frontend/src/screens/CreateProfileModal.tsx
Normal file
134
frontend/src/screens/CreateProfileModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
frontend/src/screens/HomeScreen.tsx
Normal file
333
frontend/src/screens/HomeScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
frontend/src/screens/ProfilesScreen.tsx
Normal file
120
frontend/src/screens/ProfilesScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
frontend/src/screens/RelaysScreen.tsx
Normal file
187
frontend/src/screens/RelaysScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
frontend/src/screens/SettingsScreen.tsx
Normal file
168
frontend/src/screens/SettingsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
frontend/src/state/AppProvider.tsx
Normal file
211
frontend/src/state/AppProvider.tsx
Normal 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
1280
frontend/src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
77
frontend/src/test/App.test.tsx
Normal file
77
frontend/src/test/App.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
158
frontend/src/test/ComposeScreen.test.tsx
Normal file
158
frontend/src/test/ComposeScreen.test.tsx
Normal 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('');
|
||||
});
|
||||
});
|
||||
55
frontend/src/test/CreateProfileModal.test.tsx
Normal file
55
frontend/src/test/CreateProfileModal.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
66
frontend/src/test/HomeScreen.test.tsx
Normal file
66
frontend/src/test/HomeScreen.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
65
frontend/src/test/ProfilesScreen.test.tsx
Normal file
65
frontend/src/test/ProfilesScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
87
frontend/src/test/RelaysScreen.test.tsx
Normal file
87
frontend/src/test/RelaysScreen.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
frontend/src/test/SettingsScreen.test.tsx
Normal file
67
frontend/src/test/SettingsScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
172
frontend/src/test/apiMock.ts
Normal file
172
frontend/src/test/apiMock.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
191
frontend/src/test/fakeBackend.ts
Normal file
191
frontend/src/test/fakeBackend.ts
Normal 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>;
|
||||
};
|
||||
}
|
||||
54
frontend/src/test/format.test.ts
Normal file
54
frontend/src/test/format.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
frontend/src/test/publishFlow.test.tsx
Normal file
29
frontend/src/test/publishFlow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
7
frontend/src/test/render.tsx
Normal file
7
frontend/src/test/render.tsx
Normal 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>);
|
||||
}
|
||||
24
frontend/src/test/setup.ts
Normal file
24
frontend/src/test/setup.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
15
frontend/tsconfig.electron.json
Normal file
15
frontend/tsconfig.electron.json
Normal 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
21
frontend/tsconfig.json
Normal 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
23
frontend/vite.config.ts
Normal 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
60
nostr_feed_manager.py
Normal 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
57
src/app.rs
Normal 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
182
src/errors.rs
Normal 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
242
src/ipc.rs
Normal 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
13
src/lib.rs
Normal 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
269
src/main.rs
Normal 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
194
src/main.rs.backup
Normal 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
228
src/profiles.rs
Normal 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
322
src/publish.rs
Normal 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
182
src/relays.rs
Normal 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
67
src/settings.rs
Normal 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
410
src/vault.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue