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
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,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue