232 lines
6.4 KiB
TypeScript
232 lines
6.4 KiB
TypeScript
/**
|
|
* Main App component for the XO Wallet CLI.
|
|
* Uses Ink for terminal rendering with React components.
|
|
*/
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import { Box, Text, useApp } from 'ink';
|
|
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
|
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
|
import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js';
|
|
import type { AppConfig } from '../app.js';
|
|
import type {
|
|
GitUpdateService,
|
|
UpdateCheck,
|
|
UpdateLaunchRequest,
|
|
} from '../updater/git-update-service.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 { ConfirmDialog, MessageDialog } from './components/Dialog.js';
|
|
|
|
/**
|
|
* Props for the App component.
|
|
*/
|
|
interface AppProps {
|
|
config: AppConfig;
|
|
updater: GitUpdateService;
|
|
onUpdateAndRestart: (request: UpdateLaunchRequest) => void;
|
|
}
|
|
|
|
/**
|
|
* 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 />;
|
|
default:
|
|
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Status bar component shown at the bottom of the screen.
|
|
*/
|
|
function StatusBar({ update }: { update: UpdateCheck | null }): 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={update?.status === 'available' ? colors.warning : colors.textMuted}>
|
|
{update?.status === 'available' ? 'Update available · press U' : 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 } = useDialog();
|
|
|
|
if (!dialog?.visible) return null;
|
|
|
|
const contents = dialog.type === 'confirm' ? (
|
|
<ConfirmDialog
|
|
title={dialog.title ?? "Confirm"}
|
|
message={dialog.message}
|
|
onConfirm={dialog.onConfirm ?? (() => {})}
|
|
onCancel={dialog.onCancel ?? (() => {})}
|
|
confirmLabel={dialog.confirmLabel}
|
|
cancelLabel={dialog.cancelLabel}
|
|
/>
|
|
) : (
|
|
<MessageDialog
|
|
title={dialog.type === 'error' ? 'Error' : 'Info'}
|
|
message={dialog.message}
|
|
onClose={dialog.onCancel ?? (() => {})}
|
|
type={dialog.type}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Box
|
|
position="absolute"
|
|
flexDirection="column"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
width="100%"
|
|
height="100%"
|
|
>
|
|
{contents}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Main content wrapper with global keybindings.
|
|
*/
|
|
function MainContent({
|
|
updater,
|
|
onUpdateAndRestart,
|
|
}: Pick<AppProps, 'updater' | 'onUpdateAndRestart'>): React.ReactElement {
|
|
const { goBack, canGoBack } = useNavigation();
|
|
const { screen } = useNavigation();
|
|
const appContext = useAppContext();
|
|
const [update, setUpdate] = useState<UpdateCheck | null>(null);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
updater.check().then((result) => {
|
|
if (active) setUpdate(result);
|
|
}).catch(() => {
|
|
// Update checks are advisory and must never prevent wallet use.
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [updater]);
|
|
|
|
// Global keybindings — auto-blocked when any dialog/overlay is capturing input.
|
|
useBlockableInput((input, key) => {
|
|
// Quit on Ctrl+C
|
|
if (key.ctrl && input === 'c') {
|
|
void appContext.exit();
|
|
return;
|
|
}
|
|
|
|
if ((input === 'u' || input === 'U') && update?.status === 'available') {
|
|
if (!update.canApply) {
|
|
appContext.showError(update.blockReason ?? 'This update cannot be applied automatically.');
|
|
return;
|
|
}
|
|
|
|
void appContext.confirm(
|
|
`Branch: ${update.branch}\nInstalled: ${update.currentCommit.slice(0, 7)}\nAvailable: ${update.targetCommit.slice(0, 7)}\n\n${update.summary}`,
|
|
{
|
|
title: 'XO update available',
|
|
confirmLabel: 'Update and restart',
|
|
cancelLabel: 'Later',
|
|
},
|
|
).then(async (confirmed) => {
|
|
if (!confirmed) return;
|
|
onUpdateAndRestart(update);
|
|
await appContext.exit();
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 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 update={update} />
|
|
|
|
{/* Dialog overlay */}
|
|
<DialogOverlay />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Main App component.
|
|
* Sets up providers and renders the main content.
|
|
*/
|
|
export function App({ config, updater, onUpdateAndRestart }: AppProps): React.ReactElement {
|
|
const { exit } = useApp();
|
|
|
|
// Cleanup will be handled by React when components unmount
|
|
const handleExit = () => {
|
|
exit();
|
|
};
|
|
|
|
return (
|
|
<AppProvider
|
|
config={config}
|
|
onExit={handleExit}
|
|
>
|
|
<InputLayerProvider>
|
|
<NavigationProvider initialScreen="seed-input">
|
|
<MainContent updater={updater} onUpdateAndRestart={onUpdateAndRestart} />
|
|
</NavigationProvider>
|
|
</InputLayerProvider>
|
|
</AppProvider>
|
|
);
|
|
}
|