diff --git a/__tests__/LandingScreen-test.tsx b/__tests__/LandingScreen-test.tsx index afafaac..bffa30f 100644 --- a/__tests__/LandingScreen-test.tsx +++ b/__tests__/LandingScreen-test.tsx @@ -7,38 +7,32 @@ jest.mock('expo-router', () => ({ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), + navigate: jest.fn(), back: jest.fn(), }), + Stack: { Screen: () => null }, })); -// Mock expo-constants -jest.mock('expo-constants', () => ({ - expoConfig: { - extra: { - provider: { - name: 'AC2-Controller', - primaryColor: '#3B82F6', - secondaryColor: '#E1EFFF', - accentColor: '#10B981', - welcomeMessage: 'Your identity, connected.', - showAccounts: true, - showPasskeys: true, - showIdentities: true, - showConnections: true, - }, - }, - }, +// Mock react-native-mmkv — the sessions/messages stores back the chat list and +// instantiate MMKV at import time, which has no native module under jest. +jest.mock('react-native-mmkv', () => ({ + createMMKV: () => ({ + getString: jest.fn(() => undefined), + set: jest.fn(), + }), })); -// Mock useProvider hook +// Mock useProvider — pulled in transitively by MenuDrawer; the real module loads +// the native keystore, which is unavailable under jest. jest.mock('@/hooks/useProvider', () => ({ useProvider: () => ({ key: { store: { clear: jest.fn() } }, identity: { store: { clear: jest.fn() } }, account: { store: { clear: jest.fn() } }, passkey: { store: { clear: jest.fn() } }, - identities: [{ did: 'did:key:z6Mkh...' }], - accounts: [{ address: 'ADDR123...', balance: 100 }], + keys: [], + identities: [], + accounts: [], passkeys: [], sessions: [], }), @@ -49,12 +43,23 @@ jest.mock('@expo/vector-icons', () => ({ MaterialIcons: 'MaterialIcons', })); +// Isolate the chat list under test from heavy presentational wrappers that rely +// on @gorhom/bottom-sheet context (provided at the app root, not in this unit). +jest.mock('@/components/MenuDrawer', () => { + const ReactModule = require('react'); + return { + MenuDrawer: ({ children }: { children: React.ReactNode }) => + ReactModule.createElement(ReactModule.Fragment, null, children), + }; +}); +jest.mock('@/components/ServiceSecretKeyVaultModal', () => ({ + ServiceSecretKeyVaultModal: () => null, +})); + describe('', () => { - it('renders the core landing actions', () => { + it('shows the empty state when there are no connections', () => { const { getByText } = render(); - expect(getByText('Pair')).toBeTruthy(); - expect(getByText('Diagnostics')).toBeTruthy(); - expect(getByText('Reset Wallet')).toBeTruthy(); + expect(getByText('No chats available yet')).toBeTruthy(); }); }); diff --git a/app.config.js b/app.config.js index b435d40..098ecf5 100644 --- a/app.config.js +++ b/app.config.js @@ -65,13 +65,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..cb49372 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,6 +16,12 @@ import { bootstrap } from '@/lib/keystore/bootstrap'; import { PreventScreenshotProvider } from '@/providers/PreventScreenshotProvider'; import React from 'react'; import { ReactKeystoreOptions } from '@algorandfoundation/react-native-keystore'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; +import * as SplashScreen from 'expo-splash-screen'; + +// 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/chat.tsx b/app/chat.tsx index 6f1c80a..b5844fc 100644 --- a/app/chat.tsx +++ b/app/chat.tsx @@ -1,94 +1,38 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { AC2TelemetryTraceModal } from '@/components/ac2/TelemetryTraceModal'; +import { ChatHeaderActions } from '@/components/chat/ChatHeaderActions'; +import { AppText } from '@/components/Text'; +import { ChatInput } from '@/components/chat/ChatInput'; +import { ChatTimeline } from '@/components/chat/ChatTimeline'; +import { ThreadBar } from '@/components/chat/ThreadBar'; +import { buildTimeline, type TimelineEntry } from '@/components/chat/timeline'; +import { useAc2Responders } from '@/hooks/useAc2Responders'; +import { useConnection } from '@/hooks/useConnection'; +import { ac2MessagesStore, clearAc2Messages } from '@/stores/ac2Messages'; +import { clearMessages, messagesStore } from '@/stores/messages'; +import { theme } from '@/theme'; +import { MaterialIcons } from '@expo/vector-icons'; +import { BottomSheetModal } from '@gorhom/bottom-sheet'; +import { useStore } from '@tanstack/react-store'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, - StyleSheet, - Text, - View, - TextInput, - TouchableOpacity, FlatList, - ScrollView, - KeyboardAvoidingView, - Platform, Keyboard, + NativeScrollEvent, + NativeSyntheticEvent, + Platform, + StyleSheet, + TouchableOpacity, + View, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { MaterialIcons } from '@expo/vector-icons'; -import { useStore } from '@tanstack/react-store'; -import type { - AC2KeyRequest as KeyRequestMessage, - AC2SigningRequest as SigningRequestMessage, -} from '@algorandfoundation/ac2-sdk/schema'; -import { messagesStore, Message, clearMessages } from '@/stores/messages'; -import { ac2MessagesStore, Ac2MessageEntry, clearAc2Messages } from '@/stores/ac2Messages'; -import { useConnection } from '@/hooks/useConnection'; -import { useAc2Responders } from '@/hooks/useAc2Responders'; - -// Unified timeline entry — keeps free-text chat and AC2 protocol messages -// in the same scroll view while preserving their distinct typing/rendering. -type TimelineEntry = - | { kind: 'text'; id: string; timestamp: number; data: Message } - | { kind: 'ac2'; id: string; timestamp: number; data: Ac2MessageEntry } - | { - kind: 'typing'; - id: string; - timestamp: number; - text: string; - presence: 'thinking' | 'tool' | 'typing'; - detail?: string | null; - }; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -// A durable "tool card": one tool/exec step the agent ran during a turn, -// rendered distinctly from chat bubbles. Collapsed by default — only the tool -// name (and a one-line command preview) shows, keeping a busy turn's exec -// activity from flooding the conversation. Tapping the header expands the card -// to reveal the full command and (potentially long) output. -function ToolActivityCard({ message }: { message: Message }) { - const [expanded, setExpanded] = useState(false); - const toolName = message.tool || 'tool'; - const hasOutput = !!message.output && message.output.trim().length > 0; - const hasCommand = !!message.command && message.command.trim().length > 0; - const hasBody = hasOutput || hasCommand; - return ( - - setExpanded((v) => !v)} - activeOpacity={hasBody ? 0.6 : 1} - disabled={!hasBody} - > - - {toolName} - {/* When collapsed, surface a compact one-line command preview so the - user can tell what ran without expanding the whole card. */} - {!expanded && hasCommand && ( - - {message.command} - - )} - - {new Date(message.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - {hasBody && ( - - )} - - {expanded && hasCommand && {`$ ${message.command}`}} - {expanded && hasOutput && {message.output}} - - ); -} +// Thread id used for messages persisted before multi-conversation support. +const DEFAULT_THID = 'default'; export default function ChatScreen() { const router = useRouter(); + const insets = useSafeAreaInsets(); const params = useLocalSearchParams<{ origin: string; requestId: string }>(); const [inputText, setInputText] = useState(''); const { @@ -120,9 +64,6 @@ export default function ChatScreen() { requestId: params.requestId || '', }); - // Thread id used for messages persisted before multi-conversation support. - const DEFAULT_THID = 'default'; - // All chat messages on this connection across every conversation thread — // used to derive the thread switcher. Legacy messages carry no `thid` and // are treated as the `default` thread. @@ -203,50 +144,16 @@ export default function ChatScreen() { return set; }, [ac2Messages]); - const timeline: TimelineEntry[] = [ - ...textMessages.map( - (m): TimelineEntry => ({ - kind: 'text', - id: `t-${m.id}`, - timestamp: m.timestamp, - data: m, - }), - ), - ...threadAc2Messages.map( - (m): TimelineEntry => ({ - kind: 'ac2', - id: `a-${m.id}`, - timestamp: m.receivedAt, - data: m, - }), - ), - ].sort((a, b) => a.timestamp - b.timestamp); - - // While the agent is working on a reply, render an ephemeral indicator - // instead of a final message bubble: - // - "Agent is thinking…" once the agent has acked the message and the - // model is running (no tokens yet); - // - "Agent is typing…" (with a live preview of the partial text) once - // the reply starts streaming. - // Both are driven by the agent's out-of-band presence frames; we also fall - // back to `typing` whenever partial stream text exists. Once the stream - // goes idle the accumulated text is committed as a normal peer message by - // `useConnection`. - const presence: 'thinking' | 'tool' | 'typing' | null = activeStreamText - ? 'typing' - : agentPresence; - if (presence) { - timeline.push({ - kind: 'typing', - id: 'active-stream', - timestamp: Date.now(), - text: activeStreamText, - presence, - detail: agentPresenceDetail, - }); - } + const timeline: TimelineEntry[] = buildTimeline({ + textMessages, + ac2Messages: threadAc2Messages, + agentPresence, + activeStreamText, + agentPresenceDetail, + }); - const flatListRef = useRef(null); + const flatListRef = useRef>(null); + const telemetryModalRef = useRef(null); // Whether the list is currently scrolled to (near) the bottom. We only // auto-scroll on new content when the user is already at the bottom, so // scrolling up to read earlier messages / expand a tool card is never @@ -263,6 +170,15 @@ export default function ChatScreen() { const [isHeartbeatVisible, setIsHeartbeatVisible] = useState(false); + // Height of the on-screen keyboard, driven by the OS keyboard events. Because + // edge-to-edge is enabled the Android window does NOT resize when the keyboard + // opens, so we lift the composer ourselves with a state-driven spacer below it + // (see the render). This is owned here, at the screen, rather than inside + // ChatInput — and because it's plain state it always resets to 0 on hide, with + // no residual padding left behind (the failure mode of KeyboardAvoidingView in + // this RN/new-arch/edge-to-edge combination). + const [keyboardHeight, setKeyboardHeight] = useState(0); + useEffect(() => { if (isConnected) { setIsHeartbeatVisible(true); @@ -273,13 +189,7 @@ export default function ChatScreen() { // Track how far the list is from the bottom so auto-scroll only kicks in // when the user hasn't deliberately scrolled up. - const handleScroll = (e: { - nativeEvent: { - contentOffset: { y: number }; - contentSize: { height: number }; - layoutMeasurement: { height: number }; - }; - }) => { + const handleScroll = (e: NativeSyntheticEvent) => { // Only trust scroll offsets when the user is driving the scroll. This keeps // programmatic `scrollToEnd` events (fired while content is still growing // during streaming) from corrupting the "at bottom" state and stalling @@ -309,14 +219,21 @@ export default function ChatScreen() { } }; - // Scroll to bottom when keyboard opens + // Track the keyboard so the screen-level spacer can lift the composer above + // it, and snap the list to the bottom as it opens. iOS exposes the `Will` + // events (smoother, fire before the animation); Android only the `Did` events. useEffect(() => { - const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { + const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const showSub = Keyboard.addListener(showEvt, (e) => { + setKeyboardHeight(e.endCoordinates.height); flatListRef.current?.scrollToEnd({ animated: true }); }); + const hideSub = Keyboard.addListener(hideEvt, () => setKeyboardHeight(0)); return () => { - keyboardDidShowListener.remove(); + showSub.remove(); + hideSub.remove(); }; }, []); @@ -325,316 +242,117 @@ export default function ChatScreen() { router.back(); }; + const handleClear = () => { + if (address) { + clearMessages(address, params.origin || '', params.requestId || ''); + } + clearAc2Messages(address || '', params.origin || '', params.requestId || ''); + }; + const handleSend = () => { if (inputText.trim()) { send(inputText.trim()); + Keyboard.dismiss(); setInputText(''); } }; - const renderItem = ({ item }: { item: TimelineEntry }) => { - if (item.kind === 'text') { - const m = item.data; - // Durable tool-activity card — render the agent's tool/exec step (command - // + output) as a distinct, expandable card rather than a chat bubble. - if (m.kind === 'tool') { - return ; - } - return ( - - - {m.text} - - - {new Date(m.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - - ); - } + const headerTitle = isConnected + ? 'Connected' + : isLoading + ? 'Connecting...' + : isError + ? 'Error' + : 'Disconnected'; + + // Height of the spacer that sits below the composer. While the keyboard is + // open we lift the composer to its top; while it's closed we just clear the + // nav bar / home indicator. On Android with edge-to-edge the keyboard's + // reported height excludes the navigation bar inset, so the keys overlap the + // composer by that amount unless we add it back. + const composerSpacer = + keyboardHeight > 0 + ? keyboardHeight + (Platform.OS === 'android' ? insets.bottom : 0) + : insets.bottom; - // The agent is working on a reply — show a "thinking…" indicator until - // the first token lands, then a "typing…" indicator with a live preview - // of the text received so far. - if (item.kind === 'typing') { - const isThinking = item.presence === 'thinking'; - const isTool = item.presence === 'tool'; - // Choose an icon + label per presence: thinking (model running), tool - // (running an operation, optionally named), or typing (streaming reply). - const iconName = isThinking ? 'psychology' : isTool ? 'build' : 'more-horiz'; - const label = isThinking - ? 'Agent is thinking…' - : isTool - ? item.detail - ? `Agent is running ${item.detail}…` - : 'Agent is working…' - : 'Agent is typing…'; - return ( - - {item.text.trim().length > 0 && ( - {item.text} - )} - - - {label} - - - ); - } - - // AC2 protocol message — rendered as a distinct, monospaced card so the - // protocol surface is visually obvious in the reference UI. - const m = item.data; - const isOutbound = m.direction === 'outbound'; - const isInboundSigningRequest = !isOutbound && m.envelope.type === 'ac2/SigningRequest'; - const isInboundKeyRequest = !isOutbound && m.envelope.type === 'ac2/KeyRequest'; - const req = isInboundSigningRequest ? (m.envelope as SigningRequestMessage) : null; - const keyReq = isInboundKeyRequest ? (m.envelope as KeyRequestMessage) : null; - const actionable = req ?? keyReq; - const actioned = actionable ? actionedRequestIds.has(actionable.id) : false; - const expired = - actionable?.expires_time !== undefined && actionable.expires_time * 1000 < Date.now(); - - return ( - - - - {m.envelope.type} - {isOutbound ? '→ peer' : 'peer →'} - - {req && {req.body.description}} - {keyReq && ( - - The agent is requesting an identity key ({keyReq.body.key_type}) for{' '} - {keyReq.body.for_operation}. - - )} - - {JSON.stringify(m.envelope.body, null, 2)} - - {req && ( - - {actioned ? ( - Actioned - ) : expired ? ( - Expired - ) : ( - <> - handleReject(req)} - disabled={!isConnected} - > - - Reject - - handleApprove(req)} - disabled={!isConnected} - > - - Approve & Sign - - - )} - - )} - {keyReq && ( - - {actioned ? ( - Actioned - ) : expired ? ( - Expired - ) : ( - <> - handleRejectKey(keyReq)} - disabled={!isConnected} - > - - Reject - - handleApproveKey(keyReq)} - disabled={!isConnected} - > - - Grant Identity - - - )} - + return ( + + + + + router.back()} style={styles.headerBack}> + + + + + {headerTitle} + + + {isConnected ? ( + telemetryModalRef.current?.present()} + /> + ) : ( + )} - - {new Date(item.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - ); - }; - - return ( - - ( - router.back()} style={{ marginLeft: 10 }}> - - - ), - headerRight: () => - isConnected ? ( - - {isHeartbeatVisible && ( - - )} - { - if (address) { - clearMessages(address, params.origin || '', params.requestId || ''); - } - clearAc2Messages(address || '', params.origin || '', params.requestId || ''); - }} - style={{ marginRight: 15 }} - > - - - - - - - ) : null, - }} - /> - - {/* NOTE: do NOT wrap this body in a TouchableWithoutFeedback to dismiss - the keyboard — that wrapper steals the touch responder and prevents - the FlatList below from ever receiving scroll/pan gestures (the list - would only scroll after focusing the input reshuffled the - responder). The keyboard is dismissed via the FlatList's - keyboardDismissMode/keyboardShouldPersistTaps instead. */} - - {/* Conversation switcher — one connection multiplexes several - threads. Tapping a chip switches the active conversation - (and sends ac2/ConversationOpen so the agent follows); the - "+" opens a brand-new conversation. */} - - - {threads.map((thid) => { - const isActive = thid === activeThid; - return ( - { - if (!isActive) openConversation(thid); - }} - onLongPress={() => { - if (thid !== DEFAULT_THID) { - Alert.alert('Close conversation?', `Close "${threadLabel(thid)}"?`, [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Close', - style: 'destructive', - onPress: () => closeConversation(thid), - }, - ]); - } - }} - > - - - {threadLabel(thid)} - - - ); - })} - openConversation()} - disabled={!isConnected} - > - - New - - - - item.id} - contentContainerStyle={styles.messageList} - inverted={false} - onScroll={handleScroll} - onScrollBeginDrag={handleScrollBeginDrag} - onScrollEndDrag={handleMomentumScrollEnd} - onMomentumScrollEnd={handleMomentumScrollEnd} - scrollEventThrottle={16} - onContentSizeChange={maybeScrollToEnd} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - /> + {/* NOTE: do NOT wrap this body in a TouchableWithoutFeedback to dismiss + the keyboard — that wrapper steals the touch responder and prevents + the FlatList below from ever receiving scroll/pan gestures (the list + would only scroll after focusing the input reshuffled the + responder). The keyboard is dismissed via the FlatList's + keyboardDismissMode/keyboardShouldPersistTaps instead. */} + + openConversation(thid)} + onCloseThread={closeConversation} + onNewThread={() => openConversation()} + /> + + + + + + {/* Lifts the composer above the keyboard while it's open, and clears the + nav bar / home indicator while it's closed (see composerSpacer). + Plain state, so it resets cleanly on hide. */} + + - - - - - - - - + telemetryModalRef.current?.dismiss()} + /> ); } @@ -642,295 +360,35 @@ export default function ChatScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F8FAFC', - }, - messageList: { - padding: 16, - // Extra bottom breathing room so the last bubble — especially the live - // "thinking…/typing…/running…" indicator that appears while the agent is - // actively replying or executing a tool — is never clipped against the - // input bar after an auto-scroll-to-end. - paddingBottom: 48, - flexGrow: 1, - }, - threadBar: { - backgroundColor: '#FFFFFF', - borderBottomWidth: 1, - borderBottomColor: '#E2E8F0', - }, - threadBarContent: { - paddingHorizontal: 12, - paddingVertical: 8, - gap: 8, - alignItems: 'center', - }, - threadChip: { - flexDirection: 'row', - alignItems: 'center', - gap: 5, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - backgroundColor: '#EEF2FF', - borderWidth: 1, - borderColor: '#C7D2FE', - }, - threadChipActive: { - backgroundColor: '#6366F1', - borderColor: '#6366F1', - }, - threadChipText: { - fontSize: 13, - fontWeight: '600', - color: '#4338CA', - }, - threadChipTextActive: { - color: '#FFFFFF', - }, - threadNewChip: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - borderWidth: 1, - borderColor: '#BFDBFE', - borderStyle: 'dashed', - backgroundColor: '#F8FAFF', - }, - threadNewChipText: { - fontSize: 13, - fontWeight: '700', - color: '#3B82F6', - }, - messageBubble: { - maxWidth: '85%', - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 18, - marginBottom: 8, - elevation: 1, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 1, - }, - myMessage: { - alignSelf: 'flex-end', - backgroundColor: '#3B82F6', - borderBottomRightRadius: 4, - borderTopRightRadius: 16, - borderTopLeftRadius: 16, - borderBottomLeftRadius: 16, - }, - peerMessage: { - alignSelf: 'flex-start', - backgroundColor: '#E2E8F0', - borderBottomLeftRadius: 4, - borderTopRightRadius: 16, - borderTopLeftRadius: 16, - borderBottomRightRadius: 16, - }, - messageText: { - fontSize: 16, - lineHeight: 22, - }, - myMessageText: { - color: 'white', - }, - peerMessageText: { - color: '#1E293B', - }, - typingHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - marginTop: 4, - }, - typingLabel: { - fontSize: 13, - fontStyle: 'italic', - color: '#6366F1', - }, - toolCard: { - alignSelf: 'stretch', - backgroundColor: '#0F172A', - borderRadius: 12, - padding: 12, - marginBottom: 8, - borderWidth: 1, - borderColor: '#1E293B', - borderLeftWidth: 4, - borderLeftColor: '#6366F1', - }, - toolHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - marginBottom: 6, - }, - toolName: { - fontSize: 12, - fontWeight: '700', - color: '#A5B4FC', - }, - toolTime: { - fontSize: 10, - color: '#64748B', - marginLeft: 'auto', - }, - toolCommand: { - fontFamily: 'monospace', - fontSize: 12, - color: '#7DD3FC', - marginBottom: 4, - marginTop: 4, + backgroundColor: theme.colors.chat.appBg, }, - toolCommandPreview: { + flex: { flex: 1, - fontFamily: 'monospace', - fontSize: 11, - color: '#64748B', }, - toolOutput: { - fontFamily: 'monospace', - fontSize: 12, - color: '#CBD5E1', - }, - toolToggle: { + header: { + minHeight: 64, + backgroundColor: theme.colors.bg.header, + paddingLeft: theme.spacing.sm, + paddingRight: theme.spacing.base, + paddingVertical: theme.spacing.md, flexDirection: 'row', alignItems: 'center', - gap: 4, - marginTop: 6, - }, - toolToggleText: { - fontSize: 12, - color: '#A5B4FC', - fontWeight: '600', - }, - timestamp: { - fontSize: 10, - marginTop: 4, - alignSelf: 'flex-end', - color: 'rgba(0,0,0,0.5)', - }, - myTimestamp: { - color: 'rgba(255,255,255,0.7)', - }, - inputContainer: { - flexDirection: 'row', - paddingHorizontal: 12, - paddingVertical: 8, - backgroundColor: 'white', - borderTopWidth: 1, - borderTopColor: '#E2E8F0', - alignItems: 'flex-end', - paddingBottom: Platform.OS === 'ios' ? 8 : 12, - }, - input: { - flex: 1, - backgroundColor: '#F1F5F9', - borderRadius: 22, - paddingHorizontal: 16, - paddingVertical: 10, - marginRight: 10, - fontSize: 16, - maxHeight: 120, - color: '#1E293B', + justifyContent: 'space-between', }, - sendButton: { - backgroundColor: '#3B82F6', - width: 44, - height: 44, - borderRadius: 22, + headerBack: { + width: 36, + height: 36, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.bg.white, justifyContent: 'center', alignItems: 'center', - marginBottom: 2, - }, - sendButtonDisabled: { - backgroundColor: '#CBD5E1', - }, - ac2Bubble: { - alignSelf: 'stretch', - backgroundColor: '#EEF2FF', - borderRadius: 12, - padding: 12, - marginBottom: 8, - borderWidth: 1, - borderColor: '#C7D2FE', - }, - ac2Inbound: { - borderLeftWidth: 4, - borderLeftColor: '#6366F1', + marginLeft: theme.spacing.sm, }, - ac2Outbound: { - borderRightWidth: 4, - borderRightColor: '#6366F1', - }, - ac2Header: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - marginBottom: 6, - }, - ac2Type: { - fontSize: 12, - fontWeight: '700', - color: '#4338CA', + headerTitle: { flex: 1, + marginHorizontal: theme.spacing.sm, }, - ac2Direction: { - fontSize: 11, - color: '#6366F1', - fontWeight: '600', - }, - ac2Body: { - fontFamily: 'monospace', - fontSize: 12, - color: '#1E1B4B', - }, - ac2Description: { - fontSize: 14, - color: '#1E1B4B', - marginBottom: 6, - fontWeight: '500', - }, - ac2Actions: { - flexDirection: 'row', - justifyContent: 'flex-end', - gap: 8, - marginTop: 8, - }, - ac2Btn: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 8, - }, - ac2Approve: { - backgroundColor: '#10B981', - }, - ac2Reject: { - backgroundColor: '#EF4444', - }, - ac2BtnText: { - color: '#fff', - fontWeight: '600', - fontSize: 13, - }, - ac2Actioned: { - fontSize: 12, - fontWeight: '600', - color: '#6B7280', - fontStyle: 'italic', - }, - ac2Expired: { - fontSize: 12, - fontWeight: '600', - color: '#B91C1C', - fontStyle: 'italic', + headerRight: { + width: 36, }, }); diff --git a/app/index.tsx b/app/index.tsx index a247f8a..fcc612f 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,62 +1,36 @@ -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() { + // Static weight files (not the variable font): React Native's font managers + // do NOT drive a variable font's `wght` axis from `fontWeight` on either + // platform — a single variable .ttf only ever renders its default (regular) + // instance, and on Android it falls back to the system font entirely. Each + // weight is loaded from its own static file and selected by `fontFamily`. + const [fontsLoaded] = useFonts({ + 'PlusJakartaSans-Regular': require('../assets/fonts/PlusJakartaSans-Regular.ttf'), + 'PlusJakartaSans-Medium': require('../assets/fonts/PlusJakartaSans-Medium.ttf'), + 'PlusJakartaSans-SemiBold': require('../assets/fonts/PlusJakartaSans-SemiBold.ttf'), + 'PlusJakartaSans-Bold': require('../assets/fonts/PlusJakartaSans-Bold.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/app/landing.tsx b/app/landing.tsx index 72c197d..e521ef2 100644 --- a/app/landing.tsx +++ b/app/landing.tsx @@ -1,124 +1,241 @@ -/** - * Minimal landing screen for the AC2 reference controller. - * - * Surface is intentionally tiny: - * - identifies the controller by its first identity DID, - * - links to `scan` to pair a new Liquid Auth session, - * - links to `connections` (the diagnostics panel) to inspect connections, - * their granted agent identity keys, and per-thread conversations. - * - * Everything else (accounts, passkeys browser, identities browser, import, - * balance/activity cards) was removed to keep this app as a reference for - * the AC2 SDK rather than a full wallet. - */ - -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; +import { Button } from '@/components/Button'; +import { ChatRow, ChatThread } from '@/components/ChatRow'; +import { MenuDrawer, MenuDrawerHandle } from '@/components/MenuDrawer'; +import { AppText } from '@/components/Text'; +import { WarningBanner } from '@/components/WarningBanner'; +import { messagesStore } from '@/stores/messages'; +import { sessionsStore, type Session } from '@/stores/sessions'; +import { palette, theme } from '@/theme'; import { MaterialIcons } from '@expo/vector-icons'; -import Logo from '../components/Logo'; -import { useProvider } from '@/hooks/useProvider'; +import { BottomSheetModal } from '@gorhom/bottom-sheet'; +import { useStore } from '@tanstack/react-store'; +import { Stack, useRouter } from 'expo-router'; +import React, { useMemo, useRef } from 'react'; +import { FlatList, Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { ServiceSecretKeyVaultModal } from '@/components/ServiceSecretKeyVaultModal'; +// dummy required actions for demo purposes +const REQUIRED_ACTIONS = ['Backup Mnemonic']; + +// Avatar color pairs cycled deterministically per connection so a given agent +// keeps a stable look across renders. Drawn from the shared palette. +const AVATAR_COLORS: { color: string; bg: string }[] = [ + { color: palette.blue[500], bg: palette.blue[100] }, + { color: palette.green[500], bg: '#e6f4ea' }, + { color: palette.red[500], bg: '#fce8e6' }, + { color: palette.indigo[500], bg: palette.indigo[50] }, + { color: palette.yellow[500], bg: palette.yellow[100] }, +]; + +/** Stable avatar palette pick from a connection origin. */ +function avatarFor(origin: string): { color: string; bg: string } { + let hash = 0; + for (let i = 0; i < origin.length; i++) hash = (hash * 31 + origin.charCodeAt(i)) | 0; + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; +} + +/** Human-friendly host label from a connection origin. */ +function hostFromOrigin(origin: string): string { + try { + return new URL(origin).host; + } catch { + return origin.replace(/^https?:\/\//, ''); + } +} + +/** Relative "time ago" label for the most recent activity on a connection. */ +function formatRelative(ms: number): string { + if (!ms) return ''; + const diff = Date.now() - ms; + const min = Math.floor(diff / 60000); + if (min < 1) return 'now'; + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + return `${Math.floor(hr / 24)}d ago`; +} + +/** Fallback preview text for a connection that has no chat messages yet. */ +function statusPreview(status: Session['status']): string { + switch (status) { + case 'active': + return 'Connected · no messages yet'; + case 'failed': + return 'Connection failed'; + default: + return 'Disconnected · tap to reconnect'; + } +} + +// ─── Screen ─────────────────────────────────────────────────────────────────── export default function LandingScreen() { + const hasRequiredActions = REQUIRED_ACTIONS.length > 0; + const drawerRef = useRef(null); + const vaultModalRef = useRef(null); + const connectNewAgentModalRef = useRef(null); + const router = useRouter(); - const { identities, sessions, key, account, identity, passkey } = useProvider(); - const activeIdentity = identities[0]; + + const sessions = useStore(sessionsStore, (state) => state.sessions); + const messages = useStore(messagesStore, (state) => state.messages); + + // One chat row per persisted connection, most recently active first. The + // preview is the connection's latest chat message (falling back to a status + // line) and the timestamp tracks last activity. + const chats = useMemo(() => { + return [...sessions] + .sort((a, b) => b.lastActivity - a.lastActivity) + .map((session) => { + const lastMessage = messages + .filter((m) => m.origin === session.origin && m.requestId === session.id && m.text.trim()) + .sort((a, b) => b.timestamp - a.timestamp)[0]; + const { color, bg } = avatarFor(session.origin); + const thread: ChatThread = { + id: `${session.origin}-${session.id}`, + name: hostFromOrigin(session.origin), + preview: lastMessage?.text ?? statusPreview(session.status), + timestamp: formatRelative(session.lastActivity), + avatarColor: color, + avatarBg: bg, + }; + return { session, thread }; + }); + }, [sessions, messages]); return ( - - - - - - AC2 Reference - Liquid Auth · AC2 SDK + + + + + + + drawerRef.current?.openDrawer()}> + + + + + AC2 Controller + - - - Controller DID - - {activeIdentity?.did || 'No identity found'} - - + +