206 lines
5.5 KiB
TypeScript
206 lines
5.5 KiB
TypeScript
/**
|
||
* Main App component for the XO Wallet CLI.
|
||
* Uses Ink for terminal rendering with React components.
|
||
*/
|
||
|
||
import React from 'react';
|
||
import { Box, Text, useApp, useInput } from 'ink';
|
||
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
||
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
||
import type { AppConfig } from '../app.js';
|
||
import { colors, logoSmall } from './theme.js';
|
||
|
||
// Screen imports
|
||
import { SeedInputScreen } from './screens/SeedInput.js';
|
||
import { WalletStateScreen } from './screens/WalletState.js';
|
||
import { TemplateListScreen } from './screens/TemplateList.js';
|
||
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
||
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
|
||
import { TransactionScreen } from './screens/Transaction.js';
|
||
|
||
import { MessageDialog } from './components/Dialog.js';
|
||
|
||
/**
|
||
* Props for the App component.
|
||
*/
|
||
interface AppProps {
|
||
config: AppConfig;
|
||
}
|
||
|
||
/**
|
||
* Router component that renders the current screen.
|
||
*/
|
||
function Router(): React.ReactElement {
|
||
const { screen } = useNavigation();
|
||
|
||
switch (screen) {
|
||
case 'seed-input':
|
||
return <SeedInputScreen />;
|
||
case 'wallet':
|
||
return <WalletStateScreen />;
|
||
case 'templates':
|
||
return <TemplateListScreen />;
|
||
case 'wizard':
|
||
return <ActionWizardScreen />;
|
||
case 'invitations':
|
||
return <InvitationScreen />;
|
||
case 'transaction':
|
||
return <TransactionScreen />;
|
||
default:
|
||
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Status bar component shown at the bottom of the screen.
|
||
*/
|
||
function StatusBar(): React.ReactElement {
|
||
const { status } = useStatus();
|
||
const { screen, canGoBack } = useNavigation();
|
||
|
||
return (
|
||
<Box
|
||
borderStyle="single"
|
||
borderColor={colors.border}
|
||
paddingX={1}
|
||
justifyContent="space-between"
|
||
>
|
||
<Text color={colors.primary} bold>{logoSmall}</Text>
|
||
<Text color={colors.textMuted}>{status}</Text>
|
||
<Text color={colors.textMuted}>
|
||
{canGoBack ? 'ESC: Back | ' : ''}q: Quit
|
||
</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Dialog overlay component for modals.
|
||
*/
|
||
function DialogOverlay(): React.ReactElement | null {
|
||
const { dialog, setDialog } = useDialog();
|
||
|
||
// 'custom' dialogs are rendered and managed by the screen itself;
|
||
// we only handle input for the built-in dialog types.
|
||
const isBuiltInDialog = dialog?.visible === true && dialog.type !== 'custom';
|
||
|
||
useInput((input, key) => {
|
||
if (!isBuiltInDialog) return;
|
||
|
||
if (key.return || input === 'y' || input === 'Y') {
|
||
if (dialog.type === 'confirm' && dialog.onConfirm) {
|
||
dialog.onConfirm();
|
||
} else {
|
||
dialog.onCancel?.();
|
||
}
|
||
} else if (key.escape || input === 'n' || input === 'N') {
|
||
dialog.onCancel?.();
|
||
}
|
||
}, { isActive: isBuiltInDialog });
|
||
|
||
if (!isBuiltInDialog) return null;
|
||
|
||
const borderColor = dialog.type === 'error' ? colors.error :
|
||
dialog.type === 'confirm' ? colors.warning :
|
||
colors.info;
|
||
|
||
return (
|
||
<Box
|
||
position="absolute"
|
||
flexDirection="column"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
width="100%"
|
||
height="100%"
|
||
>
|
||
<MessageDialog
|
||
title={dialog.type === 'error' ? '✗ Error' :
|
||
dialog.type === 'confirm' ? '? Confirm' :
|
||
'ℹ Info'}
|
||
message={dialog.message}
|
||
onClose={dialog.onCancel ?? (() => {})}
|
||
type={dialog.type as 'error' | 'info' | 'success'}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Main content wrapper with global keybindings.
|
||
*/
|
||
function MainContent(): React.ReactElement {
|
||
const { exit } = useApp();
|
||
const { goBack, canGoBack } = useNavigation();
|
||
const { screen } = useNavigation();
|
||
const { dialog } = useDialog();
|
||
const appContext = useAppContext();
|
||
|
||
// Global keybindings (disabled when dialog is shown)
|
||
useInput((input, key) => {
|
||
// Don't handle global keys when dialog is shown
|
||
if (dialog?.visible) return;
|
||
|
||
// Quit on 'q' or Ctrl+C
|
||
if (
|
||
// Commenting out 'q'. Its annoying me - It activates in text inputs.
|
||
// input === 'q'
|
||
(key.ctrl && input === 'c')
|
||
) {
|
||
appContext.exit();
|
||
exit();
|
||
}
|
||
|
||
// Go back on Escape
|
||
if (key.escape && canGoBack) {
|
||
goBack();
|
||
|
||
// If we went back to the seed input screen, remove the current engine
|
||
// TODO: This was to support going back to seed input then re-opening your seed, but there is a bug in the engine which prevents it from closing the current
|
||
// storage instance, giving us an error about the database already being opened.
|
||
if (screen === 'seed-input') {
|
||
appContext.appService?.engine.stop();
|
||
appContext.appService = null;
|
||
}
|
||
}
|
||
});
|
||
|
||
return (
|
||
<Box flexDirection="column" height="100%">
|
||
{/* Main content area */}
|
||
<Box flexDirection="column" flexGrow={1}>
|
||
<Router />
|
||
</Box>
|
||
|
||
{/* Status bar */}
|
||
<StatusBar />
|
||
|
||
{/* Dialog overlay */}
|
||
<DialogOverlay />
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Main App component.
|
||
* Sets up providers and renders the main content.
|
||
*/
|
||
export function App({ config }: AppProps): React.ReactElement {
|
||
const { exit } = useApp();
|
||
|
||
// Cleanup will be handled by React when components unmount
|
||
const handleExit = () => {
|
||
exit();
|
||
};
|
||
|
||
return (
|
||
<AppProvider
|
||
config={config}
|
||
onExit={handleExit}
|
||
>
|
||
<NavigationProvider initialScreen="seed-input">
|
||
<MainContent />
|
||
</NavigationProvider>
|
||
</AppProvider>
|
||
);
|
||
}
|