chore: cleanup, a11y and state migrations

This commit is contained in:
Alejandro Gómez
2025-12-14 16:32:45 +01:00
parent f2ffc406d5
commit e5c871617e
35 changed files with 1658 additions and 225 deletions

View File

@@ -109,83 +109,84 @@ export default function CommandLauncher({
<VisuallyHidden>
<DialogTitle>Command Launcher</DialogTitle>
</VisuallyHidden>
<Command
label="Command Launcher"
className="grimoire-command-content"
shouldFilter={false}
>
<div className="command-launcher-wrapper">
<Command.Input
value={input}
onValueChange={setInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="command-input"
/>
<Command
label="Command Launcher"
className="grimoire-command-content"
shouldFilter={false}
>
<div className="command-launcher-wrapper">
<Command.Input
value={input}
onValueChange={setInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="command-input"
autoFocus
/>
<Command.List className="command-list">
<Command.Empty className="command-empty">
{commandName
? `No command found: ${commandName}`
: "Start typing..."}
</Command.Empty>
<Command.List className="command-list">
<Command.Empty className="command-empty">
{commandName
? `No command found: ${commandName}`
: "Start typing..."}
</Command.Empty>
{categories.map((category) => (
<Command.Group
key={category}
heading={category}
className="command-group"
>
{filteredCommands
.filter(([_, cmd]) => cmd.category === category)
.map(([name, cmd]) => {
const isExactMatch = name === commandName;
return (
<Command.Item
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="command-item"
data-exact-match={isExactMatch}
>
<div className="command-item-content">
<div className="command-item-name">
<span className="command-name">{name}</span>
{cmd.synopsis !== name && (
<span className="command-args">
{cmd.synopsis.replace(name, "").trim()}
</span>
)}
{isExactMatch && (
<span className="command-match-indicator">
</span>
)}
{categories.map((category) => (
<Command.Group
key={category}
heading={category}
className="command-group"
>
{filteredCommands
.filter(([_, cmd]) => cmd.category === category)
.map(([name, cmd]) => {
const isExactMatch = name === commandName;
return (
<Command.Item
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="command-item"
data-exact-match={isExactMatch}
>
<div className="command-item-content">
<div className="command-item-name">
<span className="command-name">{name}</span>
{cmd.synopsis !== name && (
<span className="command-args">
{cmd.synopsis.replace(name, "").trim()}
</span>
)}
{isExactMatch && (
<span className="command-match-indicator">
</span>
)}
</div>
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
</div>
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
</div>
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="command-footer">
<div>
<kbd></kbd> navigate
<kbd></kbd> execute
<kbd>esc</kbd> close
<div className="command-footer">
<div>
<kbd></kbd> navigate
<kbd></kbd> execute
<kbd>esc</kbd> close
</div>
{recognizedCommand && (
<div className="command-footer-status">Ready to execute</div>
)}
</div>
{recognizedCommand && (
<div className="command-footer-status">Ready to execute</div>
)}
</div>
</div>
</Command>
</DialogContent>
</Dialog>
</Command>
</DialogContent>
</Dialog>
);
}

View File

@@ -335,7 +335,12 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
// 8. Generic Tags - NEW (a-z, A-Z filters excluding e, p, t, d)
const genericTags = Object.entries(filter)
.filter(([key]) => key.startsWith("#") && key.length === 2 && !["#e", "#p", "#t", "#d"].includes(key))
.filter(
([key]) =>
key.startsWith("#") &&
key.length === 2 &&
!["#e", "#p", "#t", "#d"].includes(key),
)
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
if (genericTags.length > 0) {

View File

@@ -0,0 +1,180 @@
import { Component, ReactNode, ErrorInfo } from "react";
import { AlertTriangle, RefreshCw, Home } from "lucide-react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
level?: "app" | "window";
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const { onError, level = "window" } = this.props;
// Log to console for development
console.error(`[ErrorBoundary:${level}] Caught error:`, error, errorInfo);
// Call custom error handler if provided
if (onError) {
onError(error, errorInfo);
}
this.setState({
error,
errorInfo,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
handleReload = () => {
window.location.reload();
};
render() {
const { hasError, error, errorInfo } = this.state;
const { children, fallback, level = "window" } = this.props;
if (hasError) {
// Use custom fallback if provided
if (fallback) {
return fallback;
}
// App-level error: full screen error
if (level === "app") {
return (
<div className="h-screen w-screen flex items-center justify-center bg-background p-8">
<div className="max-w-2xl w-full border border-destructive bg-card p-8 space-y-6">
<div className="flex items-center gap-3 text-destructive">
<AlertTriangle className="h-8 w-8" />
<h1 className="text-2xl font-bold">Application Error</h1>
</div>
<div className="space-y-4 text-sm">
<p className="text-muted-foreground">
Grimoire encountered a critical error and cannot continue. You
can try reloading the application or clearing your browser
data.
</p>
{error && (
<div className="bg-muted p-4 font-mono text-xs overflow-auto max-h-48">
<div className="text-destructive font-semibold mb-2">
{error.name}: {error.message}
</div>
{error.stack && (
<pre className="text-muted-foreground whitespace-pre-wrap">
{error.stack}
</pre>
)}
</div>
)}
{errorInfo?.componentStack && (
<details className="text-muted-foreground">
<summary className="cursor-pointer hover:text-foreground">
Component Stack
</summary>
<pre className="mt-2 text-xs bg-muted p-4 overflow-auto max-h-48">
{errorInfo.componentStack}
</pre>
</details>
)}
</div>
<div className="flex gap-3">
<button
onClick={this.handleReload}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90"
>
<RefreshCw className="h-4 w-4" />
Reload Application
</button>
<button
onClick={() => {
localStorage.clear();
this.handleReload();
}}
className="flex items-center gap-2 px-4 py-2 border border-border hover:bg-accent"
>
<Home className="h-4 w-4" />
Reset & Reload
</button>
</div>
<p className="text-xs text-muted-foreground">
If this problem persists, please report it on GitHub with the
error details above.
</p>
</div>
</div>
);
}
// Window-level error: inline error display
return (
<div className="h-full w-full flex items-center justify-center p-4 bg-background">
<div className="max-w-lg w-full border border-destructive/50 bg-card p-6 space-y-4">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold">Window Error</h2>
</div>
<p className="text-sm text-muted-foreground">
This window encountered an error. You can try reopening it or
continue using other windows.
</p>
{error && (
<div className="bg-muted p-3 font-mono text-xs">
<div className="text-destructive font-semibold">
{error.name}: {error.message}
</div>
</div>
)}
<button
onClick={this.handleReset}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 text-sm"
>
<RefreshCw className="h-4 w-4" />
Try Again
</button>
</div>
</div>
);
}
return children;
}
}

View File

@@ -89,6 +89,7 @@ export default function Home() {
onClick={() => setCommandLauncherOpen(true)}
className="p-1 text-muted-foreground hover:text-accent transition-colors cursor-crosshair"
title="Launch command (Cmd+K)"
aria-label="Launch command palette"
>
<Terminal className="size-4" />
</button>

View File

@@ -11,10 +11,15 @@ export function TabBar() {
createWorkspace();
};
// Sort workspaces by number
const sortedWorkspaces = Object.values(workspaces).sort(
(a, b) => a.number - b.number,
);
return (
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
<div className="flex items-center gap-1 flex-nowrap">
{Object.values(workspaces).map((ws) => (
{sortedWorkspaces.map((ws) => (
<button
key={ws.id}
onClick={() => setActiveWorkspace(ws.id)}
@@ -25,7 +30,7 @@ export function TabBar() {
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{ws.label}
{ws.label && ws.label.trim() ? `${ws.number} ${ws.label}` : ws.number}
</button>
))}
<Button
@@ -33,6 +38,7 @@ export function TabBar() {
size="icon"
className="h-6 w-6 ml-1 flex-shrink-0"
onClick={handleNewTab}
aria-label="Create new workspace"
>
<Plus className="h-3 w-3" />
</Button>

View File

@@ -69,8 +69,9 @@ export function WinViewer() {
const continuer = isLast ? " " : "│ ";
// Workspace header
const wsDisplay = ws.label ? `${ws.number} "${ws.label}"` : `${ws.number}`;
lines.push(
`${prefix} ${isActive ? "●" : "○"} workspace: "${ws.label}" (${wsId})`,
`${prefix} ${isActive ? "●" : "○"} workspace: ${wsDisplay} (${wsId})`,
);
// Window IDs

View File

@@ -1,20 +1,44 @@
import { Component, ReactNode } from "react";
import { AlertCircle } from "lucide-react";
import { Component, ReactNode, Suspense, lazy } from "react";
import { AlertCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { WindowInstance } from "@/types/app";
import { NipRenderer } from "./NipRenderer";
import ManPage from "./ManPage";
import ReqViewer from "./ReqViewer";
import { EventDetailViewer } from "./EventDetailViewer";
import { ProfileViewer } from "./ProfileViewer";
import EncodeViewer from "./EncodeViewer";
import DecodeViewer from "./DecodeViewer";
import { RelayViewer } from "./RelayViewer";
import KindRenderer from "./KindRenderer";
import KindsViewer from "./KindsViewer";
import NipsViewer from "./NipsViewer";
import { DebugViewer } from "./DebugViewer";
import ConnViewer from "./ConnViewer";
// Lazy load all viewer components for better code splitting
const NipRenderer = lazy(() =>
import("./NipRenderer").then((m) => ({ default: m.NipRenderer })),
);
const ManPage = lazy(() => import("./ManPage"));
const ReqViewer = lazy(() => import("./ReqViewer"));
const EventDetailViewer = lazy(() =>
import("./EventDetailViewer").then((m) => ({ default: m.EventDetailViewer })),
);
const ProfileViewer = lazy(() =>
import("./ProfileViewer").then((m) => ({ default: m.ProfileViewer })),
);
const EncodeViewer = lazy(() => import("./EncodeViewer"));
const DecodeViewer = lazy(() => import("./DecodeViewer"));
const RelayViewer = lazy(() =>
import("./RelayViewer").then((m) => ({ default: m.RelayViewer })),
);
const KindRenderer = lazy(() => import("./KindRenderer"));
const KindsViewer = lazy(() => import("./KindsViewer"));
const NipsViewer = lazy(() => import("./NipsViewer"));
const DebugViewer = lazy(() =>
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
);
const ConnViewer = lazy(() => import("./ConnViewer"));
// Loading fallback component
function ViewerLoading() {
return (
<div className="h-full w-full flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">Loading...</p>
</div>
</div>
);
}
interface WindowRendererProps {
window: WindowInstance;
@@ -168,7 +192,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
return (
<WindowErrorBoundary windowTitle={window.title} onClose={onClose}>
<div className="h-full w-full overflow-auto">{content}</div>
<Suspense fallback={<ViewerLoading />}>
<div className="h-full w-full overflow-auto">{content}</div>
</Suspense>
</WindowErrorBoundary>
);
}

View File

@@ -3,6 +3,7 @@ import { WindowInstance } from "@/types/app";
import { WindowToolbar } from "./WindowToolbar";
import { WindowRenderer } from "./WindowRenderer";
import { useDynamicWindowTitle } from "./DynamicWindowTitle";
import { ErrorBoundary } from "./ErrorBoundary";
interface WindowTileProps {
id: string;
@@ -47,7 +48,9 @@ export function WindowTile({
return (
<MosaicWindow path={path} title={title} renderToolbar={renderToolbar}>
<WindowRenderer window={window} onClose={() => onClose(id)} />
<ErrorBoundary level="window">
<WindowRenderer window={window} onClose={() => onClose(id)} />
</ErrorBoundary>
</MosaicWindow>
);
}

View File

@@ -21,8 +21,7 @@ export function WindowToolbar({
if (!window) return;
// Get command string (existing or reconstructed)
const commandString =
window.commandString || reconstructCommand(window);
const commandString = window.commandString || reconstructCommand(window);
// Set edit mode state
setEditMode({
@@ -43,6 +42,7 @@ export function WindowToolbar({
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={handleEdit}
title="Edit command"
aria-label="Edit command"
>
<Pencil className="size-4" />
</button>
@@ -52,6 +52,7 @@ export function WindowToolbar({
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={onClose}
title="Close window"
aria-label="Close window"
>
<X className="size-4" />
</button>

View File

@@ -79,7 +79,8 @@ export function Mention({ node }: MentionNodeProps) {
if (!options.showEventEmbeds) {
return (
<span className="text-muted-foreground font-mono text-sm">
{node.encoded || `naddr:${pointer.identifier || pointer.pubkey.slice(0, 8)}...`}
{node.encoded ||
`naddr:${pointer.identifier || pointer.pubkey.slice(0, 8)}...`}
</span>
);
}

View File

@@ -83,7 +83,7 @@ export default function UserMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="link">
<Button size="sm" variant="link" aria-label={account ? "User menu" : "Log in"}>
{account ? (
<UserAvatar pubkey={account.pubkey} />
) : (

127
src/core/logic.test.ts Normal file
View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import { findLowestAvailableWorkspaceNumber } from "./logic";
describe("findLowestAvailableWorkspaceNumber", () => {
describe("basic number assignment", () => {
it("should return 1 when no workspaces exist", () => {
const result = findLowestAvailableWorkspaceNumber({});
expect(result).toBe(1);
});
it("should return 2 when only workspace 1 exists", () => {
const workspaces = {
id1: { number: 1 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(2);
});
it("should return 4 when workspaces 1, 2, 3 exist", () => {
const workspaces = {
id1: { number: 1 },
id2: { number: 2 },
id3: { number: 3 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(4);
});
});
describe("gap detection", () => {
it("should return 2 when workspaces 1, 3, 4 exist", () => {
const workspaces = {
id1: { number: 1 },
id3: { number: 3 },
id4: { number: 4 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(2);
});
it("should return 1 when workspaces 2, 3, 4 exist", () => {
const workspaces = {
id2: { number: 2 },
id3: { number: 3 },
id4: { number: 4 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(1);
});
it("should return 3 when workspaces 1, 2, 4, 5 exist", () => {
const workspaces = {
id1: { number: 1 },
id2: { number: 2 },
id4: { number: 4 },
id5: { number: 5 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(3);
});
it("should return 2 when workspaces 1, 3 exist", () => {
const workspaces = {
id1: { number: 1 },
id3: { number: 3 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(2);
});
it("should return first gap when multiple gaps exist", () => {
const workspaces = {
id1: { number: 1 },
id5: { number: 5 },
id10: { number: 10 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(2);
});
});
describe("large numbers", () => {
it("should return 3 when workspaces 1, 2, 100 exist", () => {
const workspaces = {
id1: { number: 1 },
id2: { number: 2 },
id100: { number: 100 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(3);
});
it("should handle large sequential numbers correctly", () => {
const workspaces = {
id100: { number: 100 },
id101: { number: 101 },
id102: { number: 102 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(1);
});
});
describe("unordered workspaces", () => {
it("should handle workspaces in random order", () => {
const workspaces = {
id5: { number: 5 },
id1: { number: 1 },
id3: { number: 3 },
id7: { number: 7 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(2);
});
it("should find lowest gap regardless of insertion order", () => {
const workspaces = {
id10: { number: 10 },
id2: { number: 2 },
id8: { number: 8 },
id1: { number: 1 },
};
const result = findLowestAvailableWorkspaceNumber(workspaces);
expect(result).toBe(3);
});
});
});

View File

@@ -2,12 +2,37 @@ import { v4 as uuidv4 } from "uuid";
import type { MosaicNode } from "react-mosaic-component";
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
/**
* Finds the lowest available workspace number.
* - If workspaces have numbers [1, 2, 4], returns 3
* - If workspaces have numbers [1, 2, 3], returns 4
* - If workspaces have numbers [2, 3, 4], returns 1
*/
export const findLowestAvailableWorkspaceNumber = (
workspaces: Record<string, { number: number }>,
): number => {
// Get all workspace numbers as a Set for O(1) lookup
const numbers = new Set(Object.values(workspaces).map((ws) => ws.number));
// If no workspaces exist, start at 1
if (numbers.size === 0) return 1;
// Find first gap starting from 1
let candidate = 1;
while (numbers.has(candidate)) {
candidate++;
}
return candidate;
};
/**
* Creates a new, empty workspace.
*/
export const createWorkspace = (
state: GrimoireState,
label: string,
number: number,
label?: string,
): GrimoireState => {
const newId = uuidv4();
return {
@@ -17,6 +42,7 @@ export const createWorkspace = (
...state.workspaces,
[newId]: {
id: newId,
number,
label,
layout: null,
windowIds: [],
@@ -302,7 +328,9 @@ export const deleteWorkspace = (
export const updateWindow = (
state: GrimoireState,
windowId: string,
updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>,
updates: Partial<
Pick<WindowInstance, "props" | "title" | "commandString" | "appId">
>,
): GrimoireState => {
const window = state.windows[windowId];
if (!window) {

View File

@@ -1,32 +1,79 @@
import { useEffect } from "react";
import { useEffect, useCallback } from "react";
import { useAtom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { GrimoireState, AppId, WindowInstance } from "@/types/app";
import { useLocale } from "@/hooks/useLocale";
import * as Logic from "./logic";
import {
CURRENT_VERSION,
validateState,
migrateState,
} from "@/lib/migrations";
import { toast } from "sonner";
// Initial State Definition - Empty canvas on first load
const initialState: GrimoireState = {
__version: CURRENT_VERSION,
windows: {},
activeWorkspaceId: "default",
workspaces: {
default: {
id: "default",
label: "1",
number: 1,
windowIds: [],
layout: null,
},
},
};
// Custom storage with error handling
// Custom storage with error handling and migrations
const storage = createJSONStorage<GrimoireState>(() => ({
getItem: (key: string) => {
try {
const value = localStorage.getItem(key);
if (!value) return null;
// Parse and validate/migrate state
const parsed = JSON.parse(value);
const storedVersion = parsed.__version || 5;
// Check if migration is needed
if (storedVersion < CURRENT_VERSION) {
console.log(
`[Storage] State version outdated (v${storedVersion}), migrating...`,
);
const migrated = migrateState(parsed);
// Save migrated state back to localStorage
localStorage.setItem(key, JSON.stringify(migrated));
toast.success("State Updated", {
description: `Migrated from v${storedVersion} to v${CURRENT_VERSION}`,
duration: 3000,
});
return JSON.stringify(migrated);
}
// Validate current version state
if (!validateState(parsed)) {
console.warn(
"[Storage] State validation failed, resetting to initial state",
);
toast.error("State Corrupted", {
description: "Your state was corrupted and has been reset.",
duration: 5000,
});
return null; // Return null to use initialState
}
return value;
} catch (error) {
console.warn("Failed to read from localStorage:", error);
console.error("[Storage] Failed to read from localStorage:", error);
toast.error("Failed to Load State", {
description: "Using default state.",
duration: 5000,
});
return null;
}
},
@@ -43,6 +90,10 @@ const storage = createJSONStorage<GrimoireState>(() => ({
console.error(
"localStorage quota exceeded. State will not be persisted.",
);
toast.error("Storage Full", {
description: "Could not save state. Storage quota exceeded.",
duration: 5000,
});
}
}
},
@@ -74,15 +125,18 @@ export const useGrimoire = () => {
}
}, [state.locale, browserLocale, setState]);
return {
state,
locale: state.locale || browserLocale,
activeWorkspace: state.workspaces[state.activeWorkspaceId],
createWorkspace: () => {
const count = Object.keys(state.workspaces).length + 1;
setState((prev) => Logic.createWorkspace(prev, count.toString()));
},
addWindow: (appId: AppId, props: any, title?: string, commandString?: string) =>
// Wrap all callbacks in useCallback for stable references
const createWorkspace = useCallback(() => {
setState((prev) => {
const nextNumber = Logic.findLowestAvailableWorkspaceNumber(
prev.workspaces,
);
return Logic.createWorkspace(prev, nextNumber);
});
}, [setState]);
const addWindow = useCallback(
(appId: AppId, props: any, title?: string, commandString?: string) =>
setState((prev) =>
Logic.addWindow(prev, {
appId,
@@ -91,17 +145,39 @@ export const useGrimoire = () => {
commandString,
}),
),
updateWindow: (windowId: string, updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>) =>
setState((prev) => Logic.updateWindow(prev, windowId, updates)),
removeWindow: (id: string) =>
setState((prev) => Logic.removeWindow(prev, id)),
moveWindowToWorkspace: (windowId: string, targetWorkspaceId: string) =>
[setState],
);
const updateWindow = useCallback(
(
windowId: string,
updates: Partial<
Pick<WindowInstance, "props" | "title" | "commandString" | "appId">
>,
) => setState((prev) => Logic.updateWindow(prev, windowId, updates)),
[setState],
);
const removeWindow = useCallback(
(id: string) => setState((prev) => Logic.removeWindow(prev, id)),
[setState],
);
const moveWindowToWorkspace = useCallback(
(windowId: string, targetWorkspaceId: string) =>
setState((prev) =>
Logic.moveWindowToWorkspace(prev, windowId, targetWorkspaceId),
),
updateLayout: (layout: any) =>
setState((prev) => Logic.updateLayout(prev, layout)),
setActiveWorkspace: (id: string) =>
[setState],
);
const updateLayout = useCallback(
(layout: any) => setState((prev) => Logic.updateLayout(prev, layout)),
[setState],
);
const setActiveWorkspace = useCallback(
(id: string) =>
setState((prev) => {
// Validate target workspace exists
if (!prev.workspaces[id]) {
@@ -133,9 +209,33 @@ export const useGrimoire = () => {
// Normal workspace switch
return { ...prev, activeWorkspaceId: id };
}),
setActiveAccount: (pubkey: string | undefined) =>
[setState],
);
const setActiveAccount = useCallback(
(pubkey: string | undefined) =>
setState((prev) => Logic.setActiveAccount(prev, pubkey)),
setActiveAccountRelays: (relays: any) =>
[setState],
);
const setActiveAccountRelays = useCallback(
(relays: any) =>
setState((prev) => Logic.setActiveAccountRelays(prev, relays)),
[setState],
);
return {
state,
locale: state.locale || browserLocale,
activeWorkspace: state.workspaces[state.activeWorkspaceId],
createWorkspace,
addWindow,
updateWindow,
removeWindow,
moveWindowToWorkspace,
updateLayout,
setActiveWorkspace,
setActiveAccount,
setActiveAccountRelays,
};
};

View File

@@ -72,7 +72,7 @@ export function useAccountSync() {
} catch (error) {
console.warn(
`Skipping invalid relay URL in Kind 10002 event: ${tag[1]}`,
error
error,
);
}
}
@@ -82,7 +82,11 @@ export function useAccountSync() {
inbox: inboxRelays
.map((url) => {
try {
return { url: normalizeRelayURL(url), read: true, write: false };
return {
url: normalizeRelayURL(url),
read: true,
write: false,
};
} catch {
return null;
}
@@ -91,7 +95,11 @@ export function useAccountSync() {
outbox: outboxRelays
.map((url) => {
try {
return { url: normalizeRelayURL(url), read: false, write: true };
return {
url: normalizeRelayURL(url),
read: false,
write: true,
};
} catch {
return null;
}
@@ -107,5 +115,5 @@ export function useAccountSync() {
subscription.unsubscribe();
storeSubscription.unsubscribe();
};
}, [activeAccount?.pubkey, eventStore]);
}, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]);
}

View File

@@ -98,7 +98,7 @@ export function useNostrEvent(
} else {
console.warn("[useNostrEvent] Unknown pointer type:", pointer);
}
}, [pointerKey]);
}, [pointer, pointerKey]);
return event;
}

View File

@@ -47,6 +47,13 @@ export function useReqTimeline(
);
}, [eventsMap]);
// Stabilize filters and relays for dependency array
// Using JSON.stringify and .join() for deep comparison - this is intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableRelays = useMemo(() => relays, [relays.join(",")]);
useEffect(() => {
if (relays.length === 0) {
setLoading(false);
@@ -113,7 +120,7 @@ export function useReqTimeline(
return () => {
subscription.unsubscribe();
};
}, [id, JSON.stringify(filters), relays.join(","), limit, stream]);
}, [id, stableFilters, stableRelays, limit, stream, eventStore]);
return {
events: events || [],

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import type { NostrEvent, Filter } from "nostr-tools";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { createTimelineLoader } from "@/services/loaders";
@@ -35,6 +35,13 @@ export function useTimeline(
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Stabilize filters and relays for dependency array
// Using JSON.stringify and .join() for deep comparison - this is intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableRelays = useMemo(() => relays, [relays.join(",")]);
// Load events into store
useEffect(() => {
if (relays.length === 0) return;
@@ -64,7 +71,7 @@ export function useTimeline(
});
return () => subscription.unsubscribe();
}, [id, relays.length, limit]);
}, [id, stableRelays, limit, eventStore, stableFilters]);
// Watch store for matching events
const timeline = useObservableMemo(() => {

View File

@@ -223,3 +223,34 @@
height: 4px;
margin: -2px 0;
}
/* Accessibility: Focus indicators for keyboard navigation */
@layer base {
/* Focus-visible for buttons and interactive elements */
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* Focus-visible for input elements */
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 0;
}
/* Focus-visible for command launcher items */
[cmdk-item]:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: -2px;
}
/* Focus-visible for tab buttons */
.tabbar-button:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
}

View File

@@ -174,9 +174,10 @@ export function transitionAuthState(
}
return noChange;
default:
default: {
// Exhaustive check
const _exhaustive: never = currentStatus;
return _exhaustive;
}
}
}

View File

@@ -4,7 +4,7 @@ export interface ParsedCommand {
commandName: string;
args: string[];
fullInput: string;
command?: typeof manPages[string];
command?: (typeof manPages)[string];
props?: any;
title?: string;
error?: string;

269
src/lib/error-handler.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* Global Error Handler
*
* Captures and handles:
* - Unhandled promise rejections
* - Uncaught errors
* - LocalStorage quota exceeded errors
* - Network failures
*/
export interface ErrorContext {
timestamp: Date;
userAgent: string;
url: string;
activeAccount?: string;
errorType: string;
}
export interface ErrorReport {
error: Error | string;
context: ErrorContext;
stack?: string;
}
type ErrorCallback = (report: ErrorReport) => void;
class GlobalErrorHandler {
private callbacks: ErrorCallback[] = [];
private initialized = false;
/**
* Initialize global error handlers
*/
initialize() {
if (this.initialized) {
console.warn("[ErrorHandler] Already initialized");
return;
}
// Handle unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
event.preventDefault(); // Prevent default console error
const error =
event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
this.report(error, "unhandled_rejection");
});
// Handle uncaught errors
window.addEventListener("error", (event) => {
event.preventDefault(); // Prevent default console error
const error = event.error || new Error(event.message);
this.report(error, "uncaught_error");
});
// Wrap localStorage methods to catch quota errors
this.wrapLocalStorage();
this.initialized = true;
console.log("[ErrorHandler] Global error handler initialized");
}
/**
* Register a callback to be notified of errors
*/
onError(callback: ErrorCallback) {
this.callbacks.push(callback);
return () => {
this.callbacks = this.callbacks.filter((cb) => cb !== callback);
};
}
/**
* Report an error
*/
report(error: Error | string, errorType: string) {
const report: ErrorReport = {
error: error instanceof Error ? error : new Error(String(error)),
context: {
timestamp: new Date(),
userAgent: navigator.userAgent,
url: window.location.href,
errorType,
},
stack:
error instanceof Error ? error.stack : new Error(String(error)).stack,
};
// Try to get active account from localStorage
try {
const state = localStorage.getItem("grimoire_v6");
if (state) {
const parsed = JSON.parse(state);
report.context.activeAccount = parsed.activeAccount?.pubkey;
}
} catch {
// Ignore localStorage read errors
}
// Log to console
console.error("[ErrorHandler]", {
type: errorType,
error: report.error,
context: report.context,
});
// Notify callbacks
this.callbacks.forEach((callback) => {
try {
callback(report);
} catch (err) {
console.error("[ErrorHandler] Callback error:", err);
}
});
}
/**
* Wrap localStorage methods to catch quota exceeded errors
*/
private wrapLocalStorage() {
const originalSetItem = localStorage.setItem.bind(localStorage);
localStorage.setItem = (key: string, value: string) => {
try {
originalSetItem(key, value);
} catch (error) {
if (
error instanceof DOMException &&
error.name === "QuotaExceededError"
) {
this.report(
new Error(
`LocalStorage quota exceeded while saving key: ${key} (${(value.length / 1024).toFixed(2)}KB)`,
),
"localstorage_quota",
);
// Attempt to free space by removing old data
this.handleQuotaExceeded();
// Try again after cleanup
try {
originalSetItem(key, value);
} catch (retryError) {
// If still fails, notify user
this.report(
new Error(
"LocalStorage quota exceeded even after cleanup. Data may be lost.",
),
"localstorage_quota_critical",
);
throw retryError;
}
} else {
this.report(error as Error, "localstorage_error");
throw error;
}
}
};
}
/**
* Handle localStorage quota exceeded by removing old data
*/
private handleQuotaExceeded() {
console.warn(
"[ErrorHandler] LocalStorage quota exceeded, attempting cleanup...",
);
try {
// Strategy: Keep critical data, remove caches
const keysToKeep = ["grimoire_v6"]; // Core state
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && !keysToKeep.includes(key)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => {
try {
localStorage.removeItem(key);
console.log(`[ErrorHandler] Removed localStorage key: ${key}`);
} catch {
// Ignore removal errors
}
});
console.log(
`[ErrorHandler] Removed ${keysToRemove.length} localStorage items`,
);
} catch (error) {
console.error("[ErrorHandler] Failed to clean up localStorage:", error);
}
}
/**
* Clear all error callbacks (for testing)
*/
clearCallbacks() {
this.callbacks = [];
}
}
// Export singleton instance
export const errorHandler = new GlobalErrorHandler();
/**
* Initialize error handling with UI notifications
*/
export function initializeErrorHandling() {
errorHandler.initialize();
// Register default callback for user notifications
errorHandler.onError((report) => {
const error = report.error;
const { errorType } = report.context;
// Critical errors that require user attention
if (errorType === "localstorage_quota_critical") {
showErrorToast(
"Storage Full",
"Your browser storage is full. Some data may not be saved. Consider clearing browser data or exporting your settings.",
"error",
);
} else if (errorType === "localstorage_quota") {
showErrorToast(
"Storage Warning",
"Browser storage is nearly full. Old data was cleaned up automatically.",
"warning",
);
} else if (errorType === "unhandled_rejection") {
// Only show user-facing promise rejections
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes("fetch") || errorMessage.includes("relay")) {
showErrorToast(
"Network Error",
"Failed to connect to relay or fetch data. Please check your connection.",
"error",
);
}
}
});
}
/**
* Show error toast notification
*/
function showErrorToast(
title: string,
message: string,
type: "error" | "warning" = "error",
) {
// Dynamic import to avoid circular dependency
import("sonner").then(({ toast }) => {
if (type === "error") {
toast.error(title, { description: message, duration: 5000 });
} else {
toast.warning(title, { description: message, duration: 5000 });
}
});
}

View File

@@ -44,16 +44,15 @@ function formatList(items: string[], maxDisplay: number): string {
export function formatEventIds(ids: string[], maxDisplay = 2): string {
if (!ids || ids.length === 0) return "";
const encoded = ids
.map((id) => {
try {
const note = nip19.noteEncode(id);
return truncateBech32(note);
} catch {
// Fallback for invalid IDs: truncate hex
return id.length > 16 ? `${id.slice(0, 8)}...${id.slice(-6)}` : id;
}
});
const encoded = ids.map((id) => {
try {
const note = nip19.noteEncode(id);
return truncateBech32(note);
} catch {
// Fallback for invalid IDs: truncate hex
return id.length > 16 ? `${id.slice(0, 8)}...${id.slice(-6)}` : id;
}
});
return formatList(encoded, maxDisplay);
}

View File

@@ -1,21 +1,89 @@
/**
* Structured logging utility with log levels
* Only logs debug messages in development mode
* Provides context-aware logging with timestamps and user context
*/
type LogLevel = "debug" | "info" | "warn" | "error";
interface LogContext {
timestamp: string;
context: string;
user?: string;
action?: string;
}
interface LogEntry {
level: LogLevel;
message: string;
context: LogContext;
data?: unknown;
}
class Logger {
private context: string;
private static logs: LogEntry[] = [];
private static maxLogs = 100; // Keep last 100 logs in memory
constructor(context: string) {
this.context = context;
}
private log(level: LogLevel, message: string, data?: unknown) {
// Format message
const prefix = `[${this.context}]`;
const logMessage = data !== undefined ? [prefix, message, data] : [prefix, message];
private getLogContext(action?: string): LogContext {
const ctx: LogContext = {
timestamp: new Date().toISOString(),
context: this.context,
};
// Try to get active user from state
try {
const state = localStorage.getItem("grimoire_v6");
if (state) {
const parsed = JSON.parse(state);
if (parsed.activeAccount?.pubkey) {
ctx.user = parsed.activeAccount.pubkey.slice(0, 8); // First 8 chars for privacy
}
}
} catch {
// Ignore localStorage errors
}
if (action) {
ctx.action = action;
}
return ctx;
}
private log(
level: LogLevel,
message: string,
data?: unknown,
action?: string,
) {
const logContext = this.getLogContext(action);
const entry: LogEntry = {
level,
message,
context: logContext,
data,
};
// Store in memory for debugging
Logger.logs.push(entry);
if (Logger.logs.length > Logger.maxLogs) {
Logger.logs.shift();
}
// Format for console
const prefix = `[${logContext.timestamp}] [${logContext.context}]`;
const userInfo = logContext.user ? ` [user:${logContext.user}]` : "";
const actionInfo = logContext.action
? ` [action:${logContext.action}]`
: "";
const fullPrefix = `${prefix}${userInfo}${actionInfo}`;
const logMessage =
data !== undefined ? [fullPrefix, message, data] : [fullPrefix, message];
switch (level) {
case "debug":
@@ -32,25 +100,48 @@ class Logger {
break;
case "error":
console.error(...logMessage);
// Could send to error tracking service here
break;
}
}
debug(message: string, data?: unknown) {
this.log("debug", message, data);
debug(message: string, data?: unknown, action?: string) {
this.log("debug", message, data, action);
}
info(message: string, data?: unknown) {
this.log("info", message, data);
info(message: string, data?: unknown, action?: string) {
this.log("info", message, data, action);
}
warn(message: string, data?: unknown) {
this.log("warn", message, data);
warn(message: string, data?: unknown, action?: string) {
this.log("warn", message, data, action);
}
error(message: string, error?: unknown) {
this.log("error", message, error);
error(message: string, error?: unknown, action?: string) {
this.log("error", message, error, action);
}
/**
* Get recent logs for debugging
*/
static getRecentLogs(level?: LogLevel): LogEntry[] {
if (level) {
return Logger.logs.filter((log) => log.level === level);
}
return [...Logger.logs];
}
/**
* Clear logs from memory
*/
static clearLogs() {
Logger.logs = [];
}
/**
* Export logs as JSON for debugging
*/
static exportLogs(): string {
return JSON.stringify(Logger.logs, null, 2);
}
}

170
src/lib/migrations.test.ts Normal file
View File

@@ -0,0 +1,170 @@
import { describe, it, expect } from "vitest";
import { migrateState, validateState, CURRENT_VERSION } from "./migrations";
describe("migrations", () => {
describe("v6 to v7 migration", () => {
it("should convert numeric labels to number field", () => {
const oldState = {
__version: 6,
windows: {},
activeWorkspaceId: "ws1",
workspaces: {
ws1: {
id: "ws1",
label: "1",
layout: null,
windowIds: [],
},
ws2: {
id: "ws2",
label: "2",
layout: null,
windowIds: [],
},
},
};
const migrated = migrateState(oldState);
expect(migrated.__version).toBe(CURRENT_VERSION);
expect(migrated.workspaces.ws1.number).toBe(1);
expect(migrated.workspaces.ws1.label).toBeUndefined();
expect(migrated.workspaces.ws2.number).toBe(2);
expect(migrated.workspaces.ws2.label).toBeUndefined();
});
it("should convert non-numeric labels to number with label", () => {
const oldState = {
__version: 6,
windows: {},
activeWorkspaceId: "ws1",
workspaces: {
ws1: {
id: "ws1",
label: "Main",
layout: null,
windowIds: [],
},
ws2: {
id: "ws2",
label: "Development",
layout: null,
windowIds: [],
},
},
};
const migrated = migrateState(oldState);
expect(migrated.__version).toBe(CURRENT_VERSION);
expect(migrated.workspaces.ws1.number).toBe(1);
expect(migrated.workspaces.ws1.label).toBe("Main");
expect(migrated.workspaces.ws2.number).toBe(2);
expect(migrated.workspaces.ws2.label).toBe("Development");
});
it("should handle mixed numeric and text labels", () => {
const oldState = {
__version: 6,
windows: {},
activeWorkspaceId: "ws1",
workspaces: {
ws1: {
id: "ws1",
label: "1",
layout: null,
windowIds: [],
},
ws2: {
id: "ws2",
label: "Main",
layout: null,
windowIds: [],
},
ws3: {
id: "ws3",
label: "3",
layout: null,
windowIds: [],
},
},
};
const migrated = migrateState(oldState);
expect(migrated.__version).toBe(CURRENT_VERSION);
expect(migrated.workspaces.ws1.number).toBe(1);
expect(migrated.workspaces.ws1.label).toBeUndefined();
expect(migrated.workspaces.ws2.number).toBe(2);
expect(migrated.workspaces.ws2.label).toBe("Main");
expect(migrated.workspaces.ws3.number).toBe(3);
expect(migrated.workspaces.ws3.label).toBeUndefined();
});
it("should validate migrated state", () => {
const oldState = {
__version: 6,
windows: {},
activeWorkspaceId: "ws1",
workspaces: {
ws1: {
id: "ws1",
label: "1",
layout: null,
windowIds: [],
},
},
};
const migrated = migrateState(oldState);
expect(validateState(migrated)).toBe(true);
});
});
describe("validateState", () => {
it("should validate correct state structure", () => {
const state = {
__version: CURRENT_VERSION,
windows: {},
activeWorkspaceId: "default",
workspaces: {
default: {
id: "default",
number: 1,
layout: null,
windowIds: [],
},
},
};
expect(validateState(state)).toBe(true);
});
it("should reject state without __version", () => {
const state = {
windows: {},
activeWorkspaceId: "default",
workspaces: {
default: {
id: "default",
number: 1,
layout: null,
windowIds: [],
},
},
};
expect(validateState(state)).toBe(false);
});
it("should reject state with missing workspaces", () => {
const state = {
__version: CURRENT_VERSION,
windows: {},
activeWorkspaceId: "default",
};
expect(validateState(state)).toBe(false);
});
});
});

291
src/lib/migrations.ts Normal file
View File

@@ -0,0 +1,291 @@
/**
* State Migration System
*
* Handles schema version upgrades and state validation
* Ensures data integrity across application updates
*/
import { GrimoireState } from "@/types/app";
import { toast } from "sonner";
export const CURRENT_VERSION = 7;
/**
* Migration function type
*/
type MigrationFn = (state: any) => any;
/**
* Migration registry - add new migrations here
* Each migration transforms state from version N to N+1
*/
const migrations: Record<number, MigrationFn> = {
// Migration from v5 to v6 - adds __version field
5: (state: any) => {
return {
__version: 6,
...state,
};
},
// Migration from v6 to v7 - separates workspace number from label
6: (state: any) => {
const migratedWorkspaces: Record<string, any> = {};
// Convert each workspace from old format (label as string) to new format (number + optional label)
for (const [id, workspace] of Object.entries(state.workspaces || {})) {
const ws = workspace as any;
// Try to parse the label as a number
const parsedNumber = parseInt(ws.label, 10);
if (!isNaN(parsedNumber)) {
// Label is numeric - use it as the number, no label
migratedWorkspaces[id] = {
...ws,
number: parsedNumber,
label: undefined,
};
} else {
// Label is not numeric - assign it the next available number, keep label
// Find the highest number used so far
const usedNumbers = Object.values(migratedWorkspaces).map(
(w: any) => w.number,
);
const maxNumber = usedNumbers.length > 0 ? Math.max(...usedNumbers) : 0;
migratedWorkspaces[id] = {
...ws,
number: maxNumber + 1,
label: ws.label,
};
}
}
return {
...state,
__version: 7,
workspaces: migratedWorkspaces,
};
},
};
/**
* Validate state structure
* Basic checks to ensure state is not corrupted
*/
export function validateState(state: any): state is GrimoireState {
try {
// Must be an object
if (!state || typeof state !== "object") {
return false;
}
// Must have required top-level fields
if (
!state.windows ||
!state.workspaces ||
!state.activeWorkspaceId ||
typeof state.__version !== "number"
) {
return false;
}
// Windows must be an object
if (typeof state.windows !== "object") {
return false;
}
// Workspaces must be an object
if (typeof state.workspaces !== "object") {
return false;
}
// Active workspace must exist
if (!state.workspaces[state.activeWorkspaceId]) {
return false;
}
// All window IDs in workspaces must exist in windows
for (const workspace of Object.values(state.workspaces)) {
if (!Array.isArray((workspace as any).windowIds)) {
return false;
}
for (const windowId of (workspace as any).windowIds) {
if (!state.windows[windowId]) {
return false;
}
}
}
return true;
} catch (error) {
console.error("[Migrations] Validation error:", error);
return false;
}
}
/**
* Migrate state from old version to current version
* Applies migrations sequentially
*/
export function migrateState(state: any): GrimoireState {
let currentState = state;
const startVersion = state.__version || 5; // Default to 5 if no version
console.log(
`[Migrations] Migrating from v${startVersion} to v${CURRENT_VERSION}`,
);
// Apply migrations sequentially
for (let version = startVersion; version < CURRENT_VERSION; version++) {
const migration = migrations[version];
if (migration) {
console.log(`[Migrations] Applying migration v${version} -> v${version + 1}`);
try {
currentState = migration(currentState);
} catch (error) {
console.error(`[Migrations] Migration v${version} failed:`, error);
throw new Error(
`Failed to migrate from version ${version} to ${version + 1}`,
);
}
}
}
// Validate migrated state
if (!validateState(currentState)) {
throw new Error("Migrated state failed validation");
}
return currentState as GrimoireState;
}
/**
* Load state from localStorage with migration and validation
*/
export function loadStateWithMigration(
key: string,
initialState: GrimoireState,
): GrimoireState {
try {
const stored = localStorage.getItem(key);
if (!stored) {
return initialState;
}
const parsed = JSON.parse(stored);
// Check if migration is needed
const storedVersion = parsed.__version || 5;
if (storedVersion < CURRENT_VERSION) {
console.log(
`[Migrations] State version outdated (v${storedVersion}), migrating...`,
);
const migrated = migrateState(parsed);
// Save migrated state
localStorage.setItem(key, JSON.stringify(migrated));
toast.success("State Updated", {
description: `Migrated from v${storedVersion} to v${CURRENT_VERSION}`,
});
return migrated;
}
// Validate current version state
if (!validateState(parsed)) {
console.warn("[Migrations] State validation failed, using initial state");
toast.error("State Corrupted", {
description: "Your state was corrupted and has been reset.",
});
return initialState;
}
return parsed;
} catch (error) {
console.error("[Migrations] Failed to load state:", error);
toast.error("Failed to Load State", {
description: "Using default state. Your data may have been lost.",
});
return initialState;
}
}
/**
* Export state to JSON file
*/
export function exportState(state: GrimoireState): void {
try {
const json = JSON.stringify(state, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `grimoire-state-v${state.__version}-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("State Exported", {
description: "Your state has been downloaded as JSON",
});
} catch (error) {
console.error("[Migrations] Export failed:", error);
toast.error("Export Failed", {
description: "Could not export state",
});
}
}
/**
* Import state from JSON file
*/
export function importState(
file: File,
callback: (state: GrimoireState) => void,
): void {
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = e.target?.result as string;
const parsed = JSON.parse(json);
// Validate and migrate imported state
const storedVersion = parsed.__version || 5;
let finalState: GrimoireState;
if (storedVersion < CURRENT_VERSION) {
console.log(
`[Migrations] Imported state is v${storedVersion}, migrating...`,
);
finalState = migrateState(parsed);
} else if (!validateState(parsed)) {
throw new Error("Imported state failed validation");
} else {
finalState = parsed;
}
callback(finalState);
toast.success("State Imported", {
description: `Loaded state from v${storedVersion}`,
});
} catch (error) {
console.error("[Migrations] Import failed:", error);
toast.error("Import Failed", {
description: "Invalid or corrupted state file",
});
}
};
reader.onerror = () => {
toast.error("Import Failed", {
description: "Could not read file",
});
};
reader.readAsText(file);
}

View File

@@ -51,7 +51,11 @@ export async function getRelayInfo(
const info = await fetchRelayInfo(normalizedUrl);
if (info) {
await db.relayInfo.put({ url: normalizedUrl, info, fetchedAt: Date.now() });
await db.relayInfo.put({
url: normalizedUrl,
info,
fetchedAt: Date.now(),
});
}
return info;
@@ -72,7 +76,10 @@ export async function getCachedRelayInfo(
const cached = await db.relayInfo.get(normalizedUrl);
return cached?.info ?? null;
} catch (error) {
console.warn(`NIP-11: Failed to get cached relay info for ${wsUrl}:`, error);
console.warn(
`NIP-11: Failed to get cached relay info for ${wsUrl}:`,
error,
);
return null;
}
}
@@ -86,15 +93,19 @@ export async function getRelayInfoBatch(
const results = new Map<string, RelayInformation>();
// Normalize URLs first
const normalizedUrls = wsUrls.map((url) => {
try {
return normalizeRelayURL(url);
} catch {
return null;
}
}).filter((url): url is string => url !== null);
const normalizedUrls = wsUrls
.map((url) => {
try {
return normalizeRelayURL(url);
} catch {
return null;
}
})
.filter((url): url is string => url !== null);
const infos = await Promise.all(normalizedUrls.map((url) => getRelayInfo(url)));
const infos = await Promise.all(
normalizedUrls.map((url) => getRelayInfo(url)),
);
infos.forEach((info, i) => {
if (info) results.set(normalizedUrls[i], info);

View File

@@ -68,7 +68,10 @@ export function parseOpenCommand(args: string[]): ParsedOpenCommand {
try {
return normalizeRelayURL(url);
} catch (error) {
console.warn(`Skipping invalid relay hint in nevent: ${url}`, error);
console.warn(
`Skipping invalid relay hint in nevent: ${url}`,
error,
);
return null;
}
})
@@ -87,7 +90,10 @@ export function parseOpenCommand(args: string[]): ParsedOpenCommand {
try {
return normalizeRelayURL(url);
} catch (error) {
console.warn(`Skipping invalid relay hint in naddr: ${url}`, error);
console.warn(
`Skipping invalid relay hint in naddr: ${url}`,
error,
);
return null;
}
})

View File

@@ -14,7 +14,9 @@ describe("normalizeRelayURL", () => {
it("should normalize URLs with and without trailing slash to the same value", () => {
const withTrailingSlash = normalizeRelayURL("wss://theforest.nostr1.com/");
const withoutTrailingSlash = normalizeRelayURL("wss://theforest.nostr1.com");
const withoutTrailingSlash = normalizeRelayURL(
"wss://theforest.nostr1.com",
);
expect(withTrailingSlash).toBe(withoutTrailingSlash);
});
@@ -64,7 +66,9 @@ describe("normalizeRelayURL", () => {
});
it("should handle complex URLs with path, port, and query", () => {
const result = normalizeRelayURL("wss://relay.example.com:8080/path?key=value");
const result = normalizeRelayURL(
"wss://relay.example.com:8080/path?key=value",
);
expect(result).toBe("wss://relay.example.com:8080/path?key=value");
});
@@ -79,7 +83,9 @@ describe("normalizeRelayURL", () => {
});
it("should throw on whitespace-only string", () => {
expect(() => normalizeRelayURL(" ")).toThrow("Relay URL cannot be empty");
expect(() => normalizeRelayURL(" ")).toThrow(
"Relay URL cannot be empty",
);
});
it("should throw TypeError on null input", () => {
@@ -89,7 +95,9 @@ describe("normalizeRelayURL", () => {
it("should throw TypeError on undefined input", () => {
expect(() => normalizeRelayURL(undefined as any)).toThrow(TypeError);
expect(() => normalizeRelayURL(undefined as any)).toThrow("must be a string");
expect(() => normalizeRelayURL(undefined as any)).toThrow(
"must be a string",
);
});
it("should throw TypeError on non-string input (number)", () => {
@@ -111,7 +119,9 @@ describe("normalizeRelayURL", () => {
});
it("should handle URLs with special characters in query", () => {
const result = normalizeRelayURL("wss://relay.example.com?key=<script>alert('xss')</script>");
const result = normalizeRelayURL(
"wss://relay.example.com?key=<script>alert('xss')</script>",
);
expect(result).toContain("wss://relay.example.com/");
// Note: URL encoding is handled by browser's URL parsing
});

View File

@@ -18,9 +18,7 @@ import { normalizeURL as applesauceNormalizeURL } from "applesauce-core/helpers"
export function normalizeRelayURL(url: string): string {
// Input validation
if (typeof url !== "string") {
throw new TypeError(
`Relay URL must be a string, received: ${typeof url}`
);
throw new TypeError(`Relay URL must be a string, received: ${typeof url}`);
}
const trimmed = url.trim();
@@ -44,7 +42,7 @@ export function normalizeRelayURL(url: string): string {
throw new Error(
`Failed to normalize relay URL "${url}": ${
error instanceof Error ? error.message : String(error)
}`
}`,
);
}
}

View File

@@ -79,11 +79,7 @@ describe("parseReqCommand", () => {
it("should combine explicit relays with nprofile relay hints and normalize all", () => {
const nprofile =
"nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p";
const result = parseReqCommand([
"-a",
nprofile,
"wss://relay.damus.io",
]);
const result = parseReqCommand(["-a", nprofile, "wss://relay.damus.io"]);
// All relays should be normalized
expect(result.relays).toEqual([
"wss://r.x.com/",
@@ -144,10 +140,7 @@ describe("parseReqCommand", () => {
const hex = "a".repeat(64);
const npub =
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
const result = parseReqCommand([
"-a",
`${hex},${npub},user@domain.com`,
]);
const result = parseReqCommand(["-a", `${hex},${npub},user@domain.com`]);
expect(result.filter.authors).toEqual([
hex,
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
@@ -239,10 +232,7 @@ describe("parseReqCommand", () => {
const hex = "a".repeat(64);
const npub =
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
const result = parseReqCommand([
"-p",
`${hex},${npub},user@domain.com`,
]);
const result = parseReqCommand(["-p", `${hex},${npub},user@domain.com`]);
expect(result.filter["#p"]).toEqual([
hex,
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
@@ -364,10 +354,7 @@ describe("parseReqCommand", () => {
});
it("should normalize relays with and without trailing slash to same value", () => {
const result = parseReqCommand([
"wss://relay.com",
"wss://relay.com/",
]);
const result = parseReqCommand(["wss://relay.com", "wss://relay.com/"]);
// Should deduplicate because they normalize to the same URL
expect(result.relays).toEqual(["wss://relay.com/", "wss://relay.com/"]);
});
@@ -469,11 +456,7 @@ describe("parseReqCommand", () => {
});
it("should parse comma-separated values with spaces", () => {
const result = parseReqCommand([
"--tag",
"a",
"value1, value2, value3",
]);
const result = parseReqCommand(["--tag", "a", "value1, value2, value3"]);
expect(result.filter["#a"]).toEqual(["value1", "value2", "value3"]);
});

View File

@@ -6,26 +6,33 @@ import "./index.css";
import "react-mosaic-component/react-mosaic-component.css";
import { Toaster } from "sonner";
import { TooltipProvider } from "./components/ui/tooltip";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { initializeErrorHandling } from "./lib/error-handler";
// Add dark class to html element for default dark theme
document.documentElement.classList.add("dark");
// Initialize global error handling
initializeErrorHandling();
createRoot(document.getElementById("root")!).render(
<EventStoreProvider eventStore={eventStore}>
<TooltipProvider>
<Toaster
position="top-center"
theme="dark"
toastOptions={{
style: {
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
border: "1px solid hsl(var(--border))",
borderRadius: 0,
},
}}
/>
<Root />
</TooltipProvider>
</EventStoreProvider>,
<ErrorBoundary level="app">
<EventStoreProvider eventStore={eventStore}>
<TooltipProvider>
<Toaster
position="top-center"
theme="dark"
toastOptions={{
style: {
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
border: "1px solid hsl(var(--border))",
borderRadius: 0,
},
}}
/>
<Root />
</TooltipProvider>
</EventStoreProvider>
</ErrorBoundary>,
);

View File

@@ -114,7 +114,9 @@ class RelayStateManager {
connected: relay.connected$.pipe(startWith(relay.connected)),
notices: relay.notice$.pipe(
startWith(Array.isArray(relay.notices) ? relay.notices : []),
map(notice => Array.isArray(notice) ? notice : (notice ? [notice] : []))
map((notice) =>
Array.isArray(notice) ? notice : notice ? [notice] : [],
),
),
challenge: relay.challenge$.pipe(startWith(relay.challenge)),
authenticated: relay.authenticated$.pipe(startWith(relay.authenticated)),
@@ -201,7 +203,8 @@ class RelayStateManager {
// Priority 3: New challenge (or challenge change)
else if (
challenge &&
(!state.currentChallenge || state.currentChallenge.challenge !== challenge)
(!state.currentChallenge ||
state.currentChallenge.challenge !== challenge)
) {
const preference = this.authPreferences.get(url);
authEvent = { type: "CHALLENGE_RECEIVED", challenge, preference };
@@ -220,9 +223,12 @@ class RelayStateManager {
if (authEvent) {
const transition = transitionAuthState(state.authStatus, authEvent);
logger.info(`${url} auth transition: ${state.authStatus}${transition.newStatus}`, {
event: authEvent.type,
});
logger.info(
`${url} auth transition: ${state.authStatus}${transition.newStatus}`,
{
event: authEvent.type,
},
);
// Update state
state.authStatus = transition.newStatus;
@@ -537,9 +543,7 @@ class RelayStateManager {
state.currentChallenge &&
this.isChallengeExpired(state.currentChallenge.receivedAt)
) {
console.log(
`[RelayStateManager] Challenge expired for ${state.url}`,
);
console.log(`[RelayStateManager] Challenge expired for ${state.url}`);
state.currentChallenge = undefined;
if (state.authStatus === "challenge_received") {
state.authStatus = "none";

View File

@@ -27,7 +27,8 @@ export interface WindowInstance {
export interface Workspace {
id: string;
label: string;
number: number; // Numeric identifier for shortcuts (e.g., Cmd+1, Cmd+2)
label?: string; // Optional user-editable label
layout: MosaicNode<string> | null;
windowIds: string[];
}
@@ -45,6 +46,7 @@ export interface UserRelays {
}
export interface GrimoireState {
__version: number; // Schema version for migrations
windows: Record<string, WindowInstance>;
workspaces: Record<string, Workspace>;
activeWorkspaceId: string;