diff --git a/__fixtures__/chat-threads.ts b/__fixtures__/chat-threads.ts new file mode 100644 index 0000000..47b8182 --- /dev/null +++ b/__fixtures__/chat-threads.ts @@ -0,0 +1,28 @@ +import { ChatThread } from '@/components/ChatRow'; + +export const mockChatThreads: ChatThread[] = [ + { + id: '1', + name: 'Finance Agent', + preview: 'Your transaction has been verified successfully.', + timestamp: '2m ago', + avatarColor: '#1a73e8', + avatarBg: '#e3f2fd', + }, + { + id: '2', + name: 'GitHub Agent', + preview: 'Please sign the commit with your passkey to proceed.', + timestamp: '14m ago', + avatarColor: '#34a853', + avatarBg: '#e6f4ea', + }, + { + id: '3', + name: 'Lofty AI', + preview: 'Welcome back! Your portfolio update is ready.', + timestamp: '1h ago', + avatarColor: '#ea4335', + avatarBg: '#fce8e6', + }, +]; diff --git a/ac2-controller.code-workspace b/ac2-controller.code-workspace index e94bda3..d84704c 100644 --- a/ac2-controller.code-workspace +++ b/ac2-controller.code-workspace @@ -4,10 +4,6 @@ "name": "ROOT", "path": ".", }, - { - "name": "ac2-sdk", - "path": "packages/ac2-sdk", - }, ], "settings": { "npm.packageManager": "pnpm", diff --git a/app.config.js b/app.config.js index b435d40..580a0ad 100644 --- a/app.config.js +++ b/app.config.js @@ -47,12 +47,13 @@ module.exports = { splash: { image: './assets/splash.png', resizeMode: 'contain', - backgroundColor: '#ffffff', + backgroundColor: '#1a73e8', + imageWidth: 578, }, android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png', - backgroundColor: '#ffffff', + backgroundColor: '#1a73e8', }, edgeToEdgeEnabled: true, predictiveBackGestureEnabled: false, @@ -65,13 +66,22 @@ module.exports = { }, plugins: [ 'expo-router', + [ + 'expo-font', + { + fonts: [ + './assets/fonts/PlusJakartaSans-VariableFont_wght.ttf', + './assets/fonts/PlusJakartaSans-Italic-VariableFont_wght.ttf', + ], + }, + ], [ 'expo-splash-screen', { - image: './assets/images/splash-icon.png', - imageWidth: 200, + image: './assets/splash-icon.png', + imageWidth: 578, resizeMode: 'contain', - backgroundColor: '#ffffff', + backgroundColor: '#1a73e8', dark: { backgroundColor: '#000000', }, diff --git a/app/_layout.tsx b/app/_layout.tsx index e75c769..74fe19b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,21 +1,27 @@ // MUST be first: installs `global.crypto` before any `@noble/hashes` import // is evaluated. See `lib/runtime/install-crypto.ts`. +import { bootstrap } from '@/lib/keystore/bootstrap'; import '@/lib/runtime/install-crypto'; -import { useEventListener } from 'expo'; -import { Stack } from 'expo-router'; -import { keyStore } from '@/stores/keystore'; -import { keyStoreHooks } from '@/stores/before-after'; +import { globalPolyfill, setupNavigatorPolyfill } from '@/lib/runtime/polyfill'; +import { PreventScreenshotProvider } from '@/providers/PreventScreenshotProvider'; +import { ReactNativeProvider, WalletProvider } from '@/providers/ReactNativeProvider'; import { accountsStore } from '@/stores/accounts'; +import { keyStoreHooks } from '@/stores/before-after'; import { identitiesStore } from '@/stores/identities'; -import { ReactNativeProvider, WalletProvider } from '@/providers/ReactNativeProvider'; +import { keyStore } from '@/stores/keystore'; import { passkeysStore } from '@/stores/passkeys'; -import { registerGlobals } from 'react-native-webrtc'; -import { globalPolyfill, setupNavigatorPolyfill } from '@/lib/runtime/polyfill'; +import { ReactKeystoreOptions } from '@algorandfoundation/react-native-keystore'; import ReactNativePasskeyAutofill from '@algorandfoundation/react-native-passkey-autofill'; -import { bootstrap } from '@/lib/keystore/bootstrap'; -import { PreventScreenshotProvider } from '@/providers/PreventScreenshotProvider'; +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; +import { useEventListener } from 'expo'; +import { Stack } from 'expo-router'; +import * as SplashScreen from 'expo-splash-screen'; import React from 'react'; -import { ReactKeystoreOptions } from '@algorandfoundation/react-native-keystore'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { registerGlobals } from 'react-native-webrtc'; + +// Keep the splash screen visible while we fetch resources +SplashScreen.preventAutoHideAsync(); globalPolyfill(); registerGlobals(); @@ -84,10 +90,14 @@ export default function RootLayout() { }); return ( - + - + + + + + - + ); } diff --git a/app/index.tsx b/app/index.tsx index a247f8a..fc9ba61 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,62 +1,31 @@ -import React from 'react'; -import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; -import { Redirect } from 'expo-router'; -import Constants from 'expo-constants'; -import { useStore } from '@tanstack/react-store'; -import { logsStore } from '@/stores/logs'; -import Logo from '@/components/Logo'; import { useProvider } from '@/hooks/useProvider'; +import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { useFonts } from 'expo-font'; +import { Redirect } from 'expo-router'; +import * as SplashScreen from 'expo-splash-screen'; +import React from 'react'; + export default function Index() { + const [fontsLoaded] = useFonts({ + 'PlusJakartaSans-Regular': require('../assets/fonts/PlusJakartaSans-VariableFont_wght.ttf'), + 'PlusJakartaSans-Medium': require('../assets/fonts/PlusJakartaSans-VariableFont_wght.ttf'), + 'PlusJakartaSans-SemiBold': require('../assets/fonts/PlusJakartaSans-VariableFont_wght.ttf'), + 'PlusJakartaSans-Bold': require('../assets/fonts/PlusJakartaSans-VariableFont_wght.ttf'), + 'PlusJakartaSans-Italic': require('../assets/fonts/PlusJakartaSans-Italic-VariableFont_wght.ttf'), + ...MaterialIcons.font, + ...FontAwesome6.font, + }); const { keys, status } = useProvider(); - const logs = useStore(logsStore, (state) => state.logs); - const lastLog = logs.length > 0 ? logs[0].message : 'Initializing...'; - const config = Constants.expoConfig?.extra?.provider || { - primaryColor: '#3B82F6', - }; + React.useEffect(() => { + SplashScreen.hideAsync(); + }, []); - if (status === 'loading') { - return ( - - - - - {lastLog} - Securing your keys and passkeys - - - ); + if (!fontsLoaded || status === 'loading') { + return null; } if (keys.length > 0) return ; return ; } - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#F8FAFC', - padding: 24, - }, - logo: { - marginBottom: 40, - }, - content: { - marginTop: 24, - alignItems: 'center', - }, - text: { - fontSize: 18, - fontWeight: '600', - color: '#1E293B', - textAlign: 'center', - }, - subtext: { - marginTop: 8, - fontSize: 14, - color: '#64748B', - textAlign: 'center', - }, -}); diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png index d8ab5d1..515a9de 100644 Binary files a/assets/adaptive-icon.png and b/assets/adaptive-icon.png differ diff --git a/assets/favicon.png b/assets/favicon.png index fb82802..6a81472 100644 Binary files a/assets/favicon.png and b/assets/favicon.png differ diff --git a/assets/fonts/PlusJakartaSans-Italic-VariableFont_wght.ttf b/assets/fonts/PlusJakartaSans-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000..1ccaf53 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-Italic-VariableFont_wght.ttf differ diff --git a/assets/fonts/PlusJakartaSans-VariableFont_wght.ttf b/assets/fonts/PlusJakartaSans-VariableFont_wght.ttf new file mode 100644 index 0000000..080a386 Binary files /dev/null and b/assets/fonts/PlusJakartaSans-VariableFont_wght.ttf differ diff --git a/assets/icon.png b/assets/icon.png index 3b6aec2..c72e220 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/assets/images/android-icon-background.png b/assets/images/android-icon-background.png deleted file mode 100644 index 5ffefc5..0000000 Binary files a/assets/images/android-icon-background.png and /dev/null differ diff --git a/assets/images/android-icon-foreground.png b/assets/images/android-icon-foreground.png deleted file mode 100644 index 3a9e501..0000000 Binary files a/assets/images/android-icon-foreground.png and /dev/null differ diff --git a/assets/images/android-icon-monochrome.png b/assets/images/android-icon-monochrome.png deleted file mode 100644 index 77484eb..0000000 Binary files a/assets/images/android-icon-monochrome.png and /dev/null differ diff --git a/assets/images/favicon.png b/assets/images/favicon.png deleted file mode 100644 index 408bd74..0000000 Binary files a/assets/images/favicon.png and /dev/null differ diff --git a/assets/images/icon.png b/assets/images/icon.png deleted file mode 100644 index 7165a53..0000000 Binary files a/assets/images/icon.png and /dev/null differ diff --git a/assets/images/partial-react-logo.png b/assets/images/partial-react-logo.png deleted file mode 100644 index 66fd957..0000000 Binary files a/assets/images/partial-react-logo.png and /dev/null differ diff --git a/assets/images/react-logo.png b/assets/images/react-logo.png deleted file mode 100644 index 9d72a9f..0000000 Binary files a/assets/images/react-logo.png and /dev/null differ diff --git a/assets/images/react-logo@2x.png b/assets/images/react-logo@2x.png deleted file mode 100644 index 2229b13..0000000 Binary files a/assets/images/react-logo@2x.png and /dev/null differ diff --git a/assets/images/react-logo@3x.png b/assets/images/react-logo@3x.png deleted file mode 100644 index a99b203..0000000 Binary files a/assets/images/react-logo@3x.png and /dev/null differ diff --git a/assets/images/splash-icon.png b/assets/images/splash-icon.png deleted file mode 100644 index 03d6f6b..0000000 Binary files a/assets/images/splash-icon.png and /dev/null differ diff --git a/assets/partial-react-logo.png b/assets/partial-react-logo.png index 7b6d3f1..1e66157 100644 Binary files a/assets/partial-react-logo.png and b/assets/partial-react-logo.png differ diff --git a/assets/react-logo.png b/assets/react-logo.png index aa0006b..a5268c5 100644 Binary files a/assets/react-logo.png and b/assets/react-logo.png differ diff --git a/assets/react-logo@2x.png b/assets/react-logo@2x.png index 2b6c79e..b9ab7d7 100644 Binary files a/assets/react-logo@2x.png and b/assets/react-logo@2x.png differ diff --git a/assets/react-logo@3x.png b/assets/react-logo@3x.png index 03b7fca..2072791 100644 Binary files a/assets/react-logo@3x.png and b/assets/react-logo@3x.png differ diff --git a/assets/splash-icon.png b/assets/splash-icon.png index bfb3832..d72fc9a 100644 Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ diff --git a/assets/splash.png b/assets/splash.png index 035d629..0d511a4 100644 Binary files a/assets/splash.png and b/assets/splash.png differ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..78ff301 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,28 @@ +const path = require('path'); + +module.exports = function (api) { + api.cache(true); + + return { + // use the expo preset + presets: ['babel-preset-expo'], + // Ignore sibling workspace projects under /packages for this app build. + ignore: [ + (filename) => { + if (!filename) return false; + return filename.includes(`${path.sep}packages${path.sep}`); + }, + ], + // other config + plugins: [ + [ + 'react-native-unistyles/plugin', + { + // pass root folder of your application + root: 'app', + }, + ], + 'react-native-worklets/plugin', + ], + }; +}; diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..cfcbb29 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { ActivityIndicator, StyleProp, TouchableOpacity, ViewStyle } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { AppText } from './Text'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type ButtonVariant = 'primary' | 'outline' | 'ghost' | 'pill' | 'pillLight' | 'white' | 'link'; +type ButtonSize = 'sm' | 'md' | 'lg'; +type ButtonColor = 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'; + +interface ButtonProps { + label: string; + onPress: () => void; + variant?: ButtonVariant; + size?: ButtonSize; + color?: ButtonColor; + fullWidth?: boolean; + disabled?: boolean; + loading?: boolean; + leftIcon?: React.ReactNode; + style?: StyleProp; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function Button({ + label, + onPress, + variant = 'primary', + size = 'md', + color = 'primary', + fullWidth = false, + disabled = false, + loading = false, + leftIcon, + style, +}: ButtonProps) { + const { theme } = useUnistyles(); + stylesheet.useVariants({ variant, size }); + + const colorValue = getColorValue(theme, color); + const isFilled = + variant === 'primary' || + variant === 'pill' || + variant === 'outline' || + variant === 'pillLight' || + variant === 'white'; + const spinnerColor = + variant === 'white' ? theme.colors.fg.onLight : isFilled ? theme.colors.fg.inverse : colorValue; + + const textColorMap: Record = { + primary: 'inverse', + outline: 'inverse', + ghost: 'muted', + pill: 'inverse', + pillLight: 'inverse', + white: 'neutral', + link: 'primary', + }; + + const colorStyle = { + ...(variant === 'primary' || variant === 'pill' || variant === 'pillLight' + ? { backgroundColor: colorValue } + : {}), + ...(variant === 'outline' ? { borderColor: colorValue, backgroundColor: colorValue } : {}), + ...(variant === 'white' ? { backgroundColor: theme.colors.bg.white } : {}), + ...(variant === 'link' || variant === 'ghost' ? { backgroundColor: 'transparent' } : {}), + }; + + return ( + + {loading ? ( + + ) : ( + <> + {leftIcon} + + {label} + + + )} + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const getColorValue = (theme: any, colorName: ButtonColor) => { + const colorMap: Record = { + primary: theme.colors.brand.primary, + secondary: theme.colors.brand.soft, + success: theme.colors.state.success, + error: theme.colors.state.danger, + info: theme.colors.brand.primary, + warning: theme.colors.state.warning, + }; + return colorMap[colorName]; +}; + +const stylesheet = StyleSheet.create((theme) => ({ + base: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing.sm, + variants: { + variant: { + primary: { + backgroundColor: theme.colors.brand.primary, + borderRadius: theme.borderRadius.md, + ...theme.shadows.primary, + }, + outline: { + backgroundColor: 'transparent', + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border.default, + }, + ghost: { + backgroundColor: 'transparent', + borderRadius: theme.borderRadius.md, + }, + pill: { + backgroundColor: theme.colors.brand.primary, + borderRadius: theme.borderRadius.full, + ...theme.shadows.md, + }, + pillLight: { + backgroundColor: theme.colors.bg.surface, + borderRadius: theme.borderRadius.full, + ...theme.shadows.md, + }, + white: { + backgroundColor: theme.colors.bg.white, + borderRadius: theme.borderRadius.full, + }, + link: { + backgroundColor: 'transparent', + borderRadius: 0, + paddingVertical: 0, + paddingHorizontal: 0, + }, + }, + size: { + sm: { + paddingVertical: theme.spacing.sm, + paddingHorizontal: theme.spacing.md, + }, + md: { + paddingVertical: theme.spacing.md, + paddingHorizontal: theme.spacing.base, + }, + lg: { + paddingVertical: theme.spacing.base, + paddingHorizontal: theme.spacing.xl, + }, + }, + }, + }, + fullWidth: { + width: '100%', + }, + linkReset: { + borderRadius: 0, + paddingVertical: 0, + }, + disabled: { + opacity: 0.5, + }, + whiteLabel: { + color: theme.colors.fg.onLight, + }, +})); diff --git a/components/ChatRow.tsx b/components/ChatRow.tsx new file mode 100644 index 0000000..f90b7a3 --- /dev/null +++ b/components/ChatRow.tsx @@ -0,0 +1,92 @@ +import { AppText } from '@/components/Text'; +import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; +import React from 'react'; +import { Pressable, View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ChatThread { + id: string; + name: string; + preview: string; + timestamp: string; + avatarColor: string; + avatarBg: string; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ChatRow({ + item, + onPress, +}: { + item: ChatThread; + onPress: (item: ChatThread) => void; +}) { + return ( + onPress(item)}> + + + + + + + + {item.name} + + + {item.timestamp} + + + + {item.preview} + + + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: 'row', + alignItems: 'center', + minHeight: 76, + paddingHorizontal: theme.spacing.sm, + paddingVertical: theme.spacing.md, + gap: theme.spacing.md, + backgroundColor: theme.colors.bg.surface, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border.default, + }, + avatar: { + width: 44, + height: 44, + borderRadius: 22, + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + }, + rowContent: { + flex: 1, + gap: 2, + justifyContent: 'center', + }, + rowTop: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing.sm, + }, + rowName: { + flex: 1, + }, + rowPreview: { + flex: 1, + }, +})); diff --git a/components/ConnectNewAgentModal.tsx b/components/ConnectNewAgentModal.tsx new file mode 100644 index 0000000..69c69bd --- /dev/null +++ b/components/ConnectNewAgentModal.tsx @@ -0,0 +1,273 @@ +import { useProvider } from '@/hooks/useProvider'; +import { MaterialIcons } from '@expo/vector-icons'; +import { BottomSheetModal } from '@gorhom/bottom-sheet'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useRouter } from 'expo-router'; +import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +export interface ConnectNewAgentModalHandle { + open: () => void; + close: () => void; +} + +function isValidURL(urlString: string) { + try { + new URL(urlString); + return true; + } catch { + return false; + } +} + +export const ConnectNewAgentModal = forwardRef< + ConnectNewAgentModalHandle, + { children?: React.ReactNode } +>(function ConnectNewAgentModal(_props, ref) { + const modalRef = useRef(null); + const router = useRouter(); + const { accounts } = useProvider(); + const [permission, requestPermission] = useCameraPermissions(); + const [scanned, setScanned] = useState(false); + + useImperativeHandle(ref, () => ({ + open: () => { + setScanned(false); + modalRef.current?.present(); + }, + close: () => { + modalRef.current?.dismiss(); + }, + })); + + useEffect(() => { + if (!permission) { + requestPermission(); + } + }, [permission, requestPermission]); + + const handleBarcodeScanned = async (scanningResult: { type: string; data: string }) => { + if (scanned) return; + setScanned(true); + let { data } = scanningResult; + + const lowerData = data.toLowerCase(); + // Support fido: and liquid: deeplinks + if (lowerData.startsWith('fido:')) { + try { + Alert.alert( + 'FIDO Link', + 'FIDO links are not supported in this modal. Please use the main Scan screen.', + [{ text: 'OK' }], + ); + modalRef.current?.dismiss(); + } catch { + Alert.alert('Error', 'Could not process FIDO link'); + modalRef.current?.dismiss(); + } + return; + } + + if (!lowerData.startsWith('liquid:')) { + Alert.alert('Error', 'Unsupported QR code. Only liquid: links are supported.'); + setScanned(false); + return; + } + + // Handle liquid: links + let processedData = data; + if (lowerData.startsWith('liquid://')) { + processedData = 'https://' + data.substring(9); + } else if (lowerData.startsWith('liquid:')) { + processedData = 'https://' + data.substring(7); + } + + if (isValidURL(processedData)) { + if (accounts.length === 0) { + Alert.alert('Error', 'No accounts found. Please create or import an account first.'); + modalRef.current?.dismiss(); + return; + } + + const url = new URL(processedData); + console.log('URL detected:', processedData); + console.log('URL host:', url.host); + + // Extract requestId from query parameter or pathname + let requestId = url.searchParams.get('requestId'); + let pathname = url.pathname; + + if (!requestId && pathname && pathname !== '/') { + // Handle liquid:/// case + const segments = pathname.split('/').filter(Boolean); + if (segments.length === 1) { + requestId = segments[0]; + pathname = '/'; // Clear it from origin + } + } + + if (!requestId) { + Alert.alert('Error', 'Invalid QR code: missing requestId'); + setScanned(false); + return; + } + + let origin = `${url.protocol}//${url.host}`; + if (pathname && pathname !== '/') { + origin += pathname; + } + + modalRef.current?.dismiss(); + router.push({ + pathname: '/chat', + params: { origin, requestId }, + }); + return; + } + + Alert.alert('Error', 'Invalid liquid link format.'); + setScanned(false); + }; + + if (!permission) { + return ( + + + Loading camera... + + + ); + } + + if (!permission.granted) { + return ( + + + We need your permission to show the camera + + Grant Permission + + + + ); + } + + return ( + + + + + + Scan Liquid Auth QR Code + modalRef.current?.dismiss()} + > + + + + + + Align QR code within the frame + + + + + + ); +}); + +const styles = StyleSheet.create({ + modalContainer: { + flex: 1, + backgroundColor: '#000000', + }, + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#1e1e1e', + padding: 20, + }, + camera: { + flex: 1, + }, + message: { + textAlign: 'center', + paddingBottom: 10, + color: 'white', + fontSize: 16, + }, + button: { + backgroundColor: '#3B82F6', + padding: 12, + borderRadius: 8, + marginTop: 16, + }, + buttonText: { + color: 'white', + fontWeight: 'bold', + textAlign: 'center', + }, + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'space-between', + padding: 20, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 10, + }, + title: { + color: 'white', + fontSize: 18, + fontWeight: '600', + flex: 1, + }, + closeButton: { + padding: 8, + marginRight: -8, + }, + scanAreaContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + scanArea: { + width: 250, + height: 250, + borderWidth: 2, + borderColor: '#3B82F6', + backgroundColor: 'transparent', + borderRadius: 20, + }, + scanText: { + color: 'white', + marginTop: 20, + fontSize: 16, + fontWeight: '500', + }, +}); diff --git a/components/MenuDrawer.tsx b/components/MenuDrawer.tsx new file mode 100644 index 0000000..5c4e4c7 --- /dev/null +++ b/components/MenuDrawer.tsx @@ -0,0 +1,148 @@ +import { AppText } from '@/components/Text'; +import { useProvider } from '@/hooks/useProvider'; +import { identitiesStore } from '@/stores/identities'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useStore } from '@tanstack/react-store'; +import * as Clipboard from 'expo-clipboard'; +import { useRouter } from 'expo-router'; +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; +import { Alert, Pressable, View } from 'react-native'; +import DrawerLayout, { + DrawerLayoutMethods, +} from 'react-native-gesture-handler/ReanimatedDrawerLayout'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { StyleSheet } from 'react-native-unistyles'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface MenuDrawerHandle { + openDrawer: () => void; + closeDrawer: () => void; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export const MenuDrawer = forwardRef( + function MenuDrawer({ children }, ref) { + const drawerRef = useRef(null); + const router = useRouter(); + const { key, account, identity, passkey } = useProvider(); + const identities = useStore(identitiesStore, (state) => state.identities); + const activeIdentity = identities.length > 0 ? identities[0] : null; + + useImperativeHandle(ref, () => ({ + openDrawer: () => drawerRef.current?.openDrawer(), + closeDrawer: () => drawerRef.current?.closeDrawer(), + })); + + return ( + ( + + {activeIdentity && ( + + + Controller DID + + + + {activeIdentity.did || 'No identity found'} + + { + await Clipboard.setStringAsync(activeIdentity.did || ''); + Alert.alert('Copied', 'DID copied to clipboard'); + }} + > + + + + + )} + + + Alert.alert( + 'Reset Wallet', + 'Are you sure you want to reset your wallet? This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Reset', + style: 'destructive', + onPress: async () => { + await key.store.clear(); + await account.store.clear(); + await identity.store.clear(); + await passkey.store.clear(); + router.replace('/onboarding'); + }, + }, + ], + ) + } + > + + Reset Wallet + + + + )} + > + {children} + + ); + }, +); + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create((theme) => ({ + panel: { + flex: 1, + backgroundColor: theme.colors.bg.surface, + paddingHorizontal: theme.spacing.base, + justifyContent: 'space-between', + }, + didCard: { + paddingVertical: theme.spacing.md, + paddingHorizontal: theme.spacing.md, + marginBottom: theme.spacing.md, + backgroundColor: theme.colors.bg.surface, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.colors.border.default, + }, + cardLabel: { + fontSize: 12, + marginBottom: theme.spacing.sm, + color: theme.colors.fg.muted, + }, + didRow: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + }, + didText: { + flex: 1, + fontSize: 12, + color: theme.colors.fg.default, + fontFamily: 'monospace', + }, + copyButton: { + padding: theme.spacing.xs, + }, + menu: { + flex: 1, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.md, + paddingVertical: theme.spacing.md, + }, +})); diff --git a/components/ServiceSecretKeyVaultModal.tsx b/components/ServiceSecretKeyVaultModal.tsx new file mode 100644 index 0000000..6ee3024 --- /dev/null +++ b/components/ServiceSecretKeyVaultModal.tsx @@ -0,0 +1,413 @@ +import type { Key } from '@algorandfoundation/keystore'; +import { MaterialIcons } from '@expo/vector-icons'; +import { + BottomSheetBackdrop, + BottomSheetModal, + BottomSheetScrollView, + BottomSheetTextInput, +} from '@gorhom/bottom-sheet'; +import { forwardRef, useCallback, useState, type ComponentProps } from 'react'; +import { Alert, Pressable, View } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +import { useProvider } from '@/hooks/useProvider'; +import { Button } from './Button'; +import { AppText } from './Text'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface SecretKeyVaultModalProps { + onDismiss?: () => void; +} + +// ─── Key Row ───────────────────────────────────────────────────────────────── + +function SecretKeyRow({ item, onRemove }: { item: Key; onRemove: (id: string) => void }) { + const { theme } = useUnistyles(); + const { key } = useProvider(); + const label = (item.metadata?.serviceName as string | undefined) ?? item.id.slice(0, 8); + const [revealed, setRevealed] = useState(null); + const [revealing, setRevealing] = useState(false); + + const handleToggleVisible = async () => { + if (revealed !== null) { + setRevealed(null); + return; + } + setRevealing(true); + try { + const keyData = await key.store.export(item.id); + if (keyData.privateKey) { + setRevealed(new TextDecoder().decode(keyData.privateKey)); + } + } catch (e) { + console.error(e); + Alert.alert('Error', 'Failed to reveal the key.'); + } finally { + setRevealing(false); + } + }; + + return ( + + + + + + + {label} + + + {revealed ?? '••••••••••••••••'} + + + + + + + Alert.alert('Remove Key', `Remove the key for "${label}"?`, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Remove', style: 'destructive', onPress: () => onRemove(item.id) }, + ]) + } + hitSlop={8} + style={stylesheet.iconButton} + > + + + + ); +} + +// ─── Modal ──────────────────────────────────────────────────────────────────── + +export const ServiceSecretKeyVaultModal = forwardRef( + ({ onDismiss }, ref) => { + const { keys, key } = useProvider(); + const { theme } = useUnistyles(); + const [serviceName, setServiceName] = useState(''); + const [keyValue, setKeyValue] = useState(''); + const [keyVisible, setKeyVisible] = useState(false); + const [isAdding, setIsAdding] = useState(false); + const [loading, setLoading] = useState(false); + + const secretKeys = keys.filter((k) => k.type === 'secret-key'); + + const renderBackdrop = useCallback( + (props: ComponentProps) => ( + + ), + [], + ); + + const resetForm = () => { + setServiceName(''); + setKeyValue(''); + setKeyVisible(false); + setIsAdding(false); + }; + + const handleAdd = async () => { + if (!serviceName.trim() || !keyValue.trim()) return; + setLoading(true); + try { + await key.store.import({ + type: 'secret-key', + algorithm: 'raw', + extractable: true, + metadata: { serviceName: serviceName.trim() }, + privateKey: new TextEncoder().encode(keyValue.trim()), + } as any); + resetForm(); + } catch (e) { + console.error(e); + Alert.alert('Error', 'Failed to store the key. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleRemove = async (id: string) => { + try { + await key.store.remove(id); + } catch { + Alert.alert('Error', 'Failed to remove the key.'); + } + }; + + return ( + + + {/* Header */} + + + + Secret Key Vault + + onDismiss?.()} hitSlop={8}> + + + + + {/* Scrollable key list */} + + {secretKeys.length === 0 ? ( + + + + {'No secret keys stored yet.\nAdd a key to use with agent services.'} + + + ) : ( + + + STORED KEYS ({secretKeys.length}) + + {secretKeys.map((k) => ( + + ))} + + )} + + + {/* Fixed footer — add button or add form */} + + {isAdding ? ( + + + ADD NEW KEY + + + + Service Name + + + + + + Secret Key + + + + setKeyVisible((v) => !v)} + hitSlop={8} + style={stylesheet.eyeButton} + > + + + + + +