Prompt is too long
This commit is contained in:
@@ -1,35 +0,0 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/hello-wave';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Link } from 'expo-router';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{' '}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
web: 'F12',
|
||||
})}
|
||||
</ThemedText>{' '}
|
||||
to open developer tools.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<Link href="/modal">
|
||||
<Link.Trigger>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
||||
<Link.MenuAction
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={() => alert('Share pressed')}
|
||||
/>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => alert('Delete pressed')}
|
||||
/>
|
||||
</Link.Menu>
|
||||
</Link.Menu>
|
||||
</Link>
|
||||
|
||||
<ThemedText>
|
||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
@@ -1,25 +1,17 @@
|
||||
import '../global.css';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
<SafeAreaProvider>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
<StatusBar style="light" />
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { View, Text, Pressable, ScrollView, Alert } from 'react-native';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAudioRecorder } from '../hooks/use-audio-recorder';
|
||||
import { useAudioPlayer } from '../hooks/use-audio-player';
|
||||
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
|
||||
|
||||
export default function AudioTestScreen() {
|
||||
const [permissionGranted, setPermissionGranted] = useState(false);
|
||||
const [recordingStatus, setRecordingStatus] = useState<'idle' | 'recording' | 'processing'>('idle');
|
||||
const [lastRecordingSize, setLastRecordingSize] = useState<number | null>(null);
|
||||
const [playbackStatus, setPlaybackStatus] = useState<string>('No audio playing');
|
||||
const [lastRecordedBlob, setLastRecordedBlob] = useState<Blob | null>(null);
|
||||
|
||||
const audioRecorder = useAudioRecorder({
|
||||
sampleRate: 16000,
|
||||
numberOfChannels: 1,
|
||||
});
|
||||
|
||||
const audioPlayer = useAudioPlayer();
|
||||
|
||||
useEffect(() => {
|
||||
checkAndRequestPermissions();
|
||||
}, []);
|
||||
|
||||
async function checkAndRequestPermissions() {
|
||||
try {
|
||||
const { status } = await getRecordingPermissionsAsync();
|
||||
|
||||
if (status === 'granted') {
|
||||
setPermissionGranted(true);
|
||||
} else {
|
||||
const { granted } = await requestRecordingPermissionsAsync();
|
||||
setPermissionGranted(granted);
|
||||
|
||||
if (!granted) {
|
||||
Alert.alert(
|
||||
'Permission Required',
|
||||
'Microphone permission is required to test audio recording.'
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AudioTest] Permission error:', error);
|
||||
Alert.alert('Error', 'Failed to check/request microphone permissions');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartRecording() {
|
||||
if (!permissionGranted) {
|
||||
Alert.alert('Permission Required', 'Please grant microphone permission first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setRecordingStatus('recording');
|
||||
setLastRecordingSize(null);
|
||||
await audioRecorder.start();
|
||||
} catch (error: any) {
|
||||
console.error('[AudioTest] Start recording error:', error);
|
||||
Alert.alert('Recording Error', error.message);
|
||||
setRecordingStatus('idle');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopRecording() {
|
||||
try {
|
||||
setRecordingStatus('processing');
|
||||
const audioBlob = await audioRecorder.stop();
|
||||
|
||||
setLastRecordingSize(audioBlob.size);
|
||||
setLastRecordedBlob(audioBlob);
|
||||
setRecordingStatus('idle');
|
||||
|
||||
Alert.alert(
|
||||
'Recording Complete',
|
||||
`Audio recorded successfully!\nSize: ${audioBlob.size} bytes\nType: ${audioBlob.type}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('[AudioTest] Stop recording error:', error);
|
||||
Alert.alert('Recording Error', error.message);
|
||||
setRecordingStatus('idle');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlayLastRecording() {
|
||||
if (!lastRecordedBlob) {
|
||||
Alert.alert('No Recording', 'Please record audio first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPlaybackStatus('Playing last recording...');
|
||||
const duration = await audioPlayer.play(lastRecordedBlob);
|
||||
setPlaybackStatus(`Playback complete (${duration.toFixed(2)}s)`);
|
||||
} catch (error: any) {
|
||||
console.error('[AudioTest] Playback error:', error);
|
||||
Alert.alert('Playback Error', error.message);
|
||||
setPlaybackStatus('Playback failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlayTestAudio() {
|
||||
// Create a test audio blob (silent audio for testing)
|
||||
// In a real scenario, this would be audio from the server
|
||||
try {
|
||||
setPlaybackStatus('Playing test audio...');
|
||||
|
||||
// For now, we'll show an alert since we don't have a test audio file
|
||||
Alert.alert(
|
||||
'Test Audio',
|
||||
'This would play a test audio file. Try recording and playing back instead.'
|
||||
);
|
||||
setPlaybackStatus('Test audio not implemented');
|
||||
} catch (error: any) {
|
||||
console.error('[AudioTest] Test playback error:', error);
|
||||
Alert.alert('Playback Error', error.message);
|
||||
setPlaybackStatus('Test playback failed');
|
||||
}
|
||||
}
|
||||
|
||||
function handleStopPlayback() {
|
||||
audioPlayer.stop();
|
||||
setPlaybackStatus('Playback stopped');
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white dark:bg-black">
|
||||
<View className="p-6">
|
||||
{/* Header */}
|
||||
<Text className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Audio Test
|
||||
</Text>
|
||||
<Text className="text-base text-gray-600 dark:text-gray-400 mb-8">
|
||||
Test audio recording and playback functionality
|
||||
</Text>
|
||||
|
||||
{/* Permission Status */}
|
||||
<View className="mb-8">
|
||||
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Permission Status
|
||||
</Text>
|
||||
<View className={`p-4 rounded-lg ${permissionGranted ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}>
|
||||
<Text className={`text-base ${permissionGranted ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'}`}>
|
||||
{permissionGranted ? '✓ Microphone permission granted' : '✗ Microphone permission denied'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Recording Section */}
|
||||
<View className="mb-8">
|
||||
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Audio Recording
|
||||
</Text>
|
||||
|
||||
{/* Recording Status */}
|
||||
<View className="mb-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</Text>
|
||||
<Text className="text-base text-gray-900 dark:text-white">
|
||||
{recordingStatus === 'idle' && 'Ready to record'}
|
||||
{recordingStatus === 'recording' && '🔴 Recording...'}
|
||||
{recordingStatus === 'processing' && 'Processing...'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Recording Controls */}
|
||||
<View className="flex-row gap-3 mb-4">
|
||||
<Pressable
|
||||
onPress={handleStartRecording}
|
||||
disabled={recordingStatus !== 'idle' || !permissionGranted}
|
||||
className={`flex-1 p-4 rounded-lg items-center ${
|
||||
recordingStatus !== 'idle' || !permissionGranted
|
||||
? 'bg-gray-300 dark:bg-gray-700'
|
||||
: 'bg-blue-500 dark:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
<Text className={`text-base font-semibold ${
|
||||
recordingStatus !== 'idle' || !permissionGranted
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: 'text-white'
|
||||
}`}>
|
||||
Start Recording
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={handleStopRecording}
|
||||
disabled={recordingStatus !== 'recording'}
|
||||
className={`flex-1 p-4 rounded-lg items-center ${
|
||||
recordingStatus !== 'recording'
|
||||
? 'bg-gray-300 dark:bg-gray-700'
|
||||
: 'bg-red-500 dark:bg-red-600'
|
||||
}`}
|
||||
>
|
||||
<Text className={`text-base font-semibold ${
|
||||
recordingStatus !== 'recording'
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: 'text-white'
|
||||
}`}>
|
||||
Stop Recording
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Last Recording Info */}
|
||||
{lastRecordingSize !== null && (
|
||||
<View className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
|
||||
<Text className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-1">
|
||||
Last Recording
|
||||
</Text>
|
||||
<Text className="text-base text-blue-900 dark:text-blue-100">
|
||||
Size: {lastRecordingSize} bytes
|
||||
</Text>
|
||||
<Text className="text-sm text-blue-600 dark:text-blue-400 mt-1">
|
||||
Type: audio/m4a
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Playback Section */}
|
||||
<View className="mb-8">
|
||||
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Audio Playback
|
||||
</Text>
|
||||
|
||||
{/* Playback Status */}
|
||||
<View className="mb-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</Text>
|
||||
<Text className="text-base text-gray-900 dark:text-white">
|
||||
{playbackStatus}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Playback Controls */}
|
||||
<View className="gap-3">
|
||||
<Pressable
|
||||
onPress={handlePlayLastRecording}
|
||||
disabled={!lastRecordedBlob}
|
||||
className={`p-4 rounded-lg items-center ${
|
||||
!lastRecordedBlob
|
||||
? 'bg-gray-300 dark:bg-gray-700'
|
||||
: 'bg-green-500 dark:bg-green-600'
|
||||
}`}
|
||||
>
|
||||
<Text className={`text-base font-semibold ${
|
||||
!lastRecordedBlob
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: 'text-white'
|
||||
}`}>
|
||||
Play Last Recording
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={handlePlayTestAudio}
|
||||
className="p-4 rounded-lg items-center bg-purple-500 dark:bg-purple-600"
|
||||
>
|
||||
<Text className="text-base font-semibold text-white">
|
||||
Play Test Audio
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={handleStopPlayback}
|
||||
className="p-4 rounded-lg items-center bg-orange-500 dark:bg-orange-600"
|
||||
>
|
||||
<Text className="text-base font-semibold text-white">
|
||||
Stop Playback
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Audio Settings Info */}
|
||||
<View className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Audio Configuration
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
• Sample Rate: 16000 Hz
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
• Channels: 1 (Mono)
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
• Format: M4A/AAC
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-400">
|
||||
• Optimized for voice/speech
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { View, ScrollView, KeyboardAvoidingView, Platform, Pressable, Text, TextInput, Keyboard } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// Simple unique ID generator
|
||||
let messageIdCounter = 0;
|
||||
function generateMessageId(): string {
|
||||
return `msg_${Date.now()}_${messageIdCounter++}`;
|
||||
}
|
||||
import { useWebSocket } from '@/hooks/use-websocket';
|
||||
import { useAudioRecorder } from '@/hooks/use-audio-recorder';
|
||||
import { useAudioPlayer } from '@/hooks/use-audio-player';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { ConnectionStatus } from '@/components/connection-status';
|
||||
import { UserMessage, AssistantMessage, ActivityLog, ToolCall } from '@/components/message';
|
||||
import { ArtifactDrawer, type Artifact } from '@/components/artifact-drawer';
|
||||
import { ActiveProcesses } from '@/components/active-processes';
|
||||
import { AgentStreamView } from '@/components/agent-stream-view';
|
||||
import { ConversationSelector } from '@/components/conversation-selector';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import type {
|
||||
ActivityLogPayload,
|
||||
SessionInboundMessage,
|
||||
WSInboundMessage,
|
||||
} from '@voice-assistant/server/messages';
|
||||
import type { AgentStatus } from '@voice-assistant/server/acp/types';
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||
|
||||
type MessageEntry =
|
||||
| {
|
||||
type: 'user';
|
||||
id: string;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'assistant';
|
||||
id: string;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'activity';
|
||||
id: string;
|
||||
timestamp: number;
|
||||
activityType: 'system' | 'info' | 'success' | 'error';
|
||||
message: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'tool_call';
|
||||
id: string;
|
||||
timestamp: number;
|
||||
toolName: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
status: 'executing' | 'completed' | 'failed';
|
||||
};
|
||||
|
||||
type ViewMode = 'orchestrator' | 'agent';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
status: AgentStatus;
|
||||
createdAt: Date;
|
||||
type: 'claude';
|
||||
sessionId?: string;
|
||||
error?: string;
|
||||
currentModeId?: string;
|
||||
availableModes?: Array<{ id: string; name: string; description?: string | null }>;
|
||||
}
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
name: string;
|
||||
workingDirectory: string;
|
||||
currentCommand: string;
|
||||
isDead: boolean;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
interface AgentUpdate {
|
||||
timestamp: Date;
|
||||
notification: SessionNotification;
|
||||
}
|
||||
|
||||
export default function VoiceAssistantScreen() {
|
||||
const { settings, isLoading: settingsLoading } = useSettings();
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const ws = useWebSocket(settings.serverUrl, conversationId);
|
||||
const audioRecorder = useAudioRecorder();
|
||||
const audioPlayer = useAudioPlayer({ useSpeaker: settings.useSpeaker });
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [messages, setMessages] = useState<MessageEntry[]>([]);
|
||||
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('');
|
||||
const [isProcessingAudio, setIsProcessingAudio] = useState(false);
|
||||
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [userInput, setUserInput] = useState('');
|
||||
|
||||
// Artifact state
|
||||
const [artifacts, setArtifacts] = useState<Map<string, Artifact>>(new Map());
|
||||
const [currentArtifact, setCurrentArtifact] = useState<Artifact | null>(null);
|
||||
|
||||
// Multi-view navigation state
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('orchestrator');
|
||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||
|
||||
// Agent and command state
|
||||
const [agents, setAgents] = useState<Map<string, Agent>>(new Map());
|
||||
const [commands, setCommands] = useState<Map<string, Command>>(new Map());
|
||||
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(new Map());
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
// Keep screen awake if setting is enabled
|
||||
useEffect(() => {
|
||||
if (settings.keepScreenOn) {
|
||||
activateKeepAwakeAsync('voice-assistant');
|
||||
} else {
|
||||
deactivateKeepAwake('voice-assistant');
|
||||
}
|
||||
}, [settings.keepScreenOn]);
|
||||
|
||||
// Auto-scroll to bottom when messages update
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, [messages, currentAssistantMessage]);
|
||||
|
||||
// WebSocket message handlers
|
||||
useEffect(() => {
|
||||
// Session state handler - initial agents/commands
|
||||
const unsubSessionState = ws.on('session_state', (message) => {
|
||||
if (message.type !== 'session_state') return;
|
||||
const { agents: agentsList, commands: commandsList } = message.payload;
|
||||
|
||||
console.log('[App] Session state received:', agentsList.length, 'agents,', commandsList.length, 'commands');
|
||||
|
||||
// Update agents
|
||||
setAgents(new Map(agentsList.map((a) => [a.id, a as Agent])));
|
||||
|
||||
// Update commands
|
||||
setCommands(new Map(commandsList.map((c) => [c.id, c as Command])));
|
||||
});
|
||||
|
||||
// Agent created handler
|
||||
const unsubAgentCreated = ws.on('agent_created', (message) => {
|
||||
if (message.type !== 'agent_created') return;
|
||||
const { agentId, status, type, currentModeId, availableModes } = message.payload;
|
||||
|
||||
console.log('[App] Agent created:', agentId);
|
||||
|
||||
const agent: Agent = {
|
||||
id: agentId,
|
||||
status: status as AgentStatus,
|
||||
type,
|
||||
createdAt: new Date(),
|
||||
currentModeId,
|
||||
availableModes,
|
||||
};
|
||||
|
||||
setAgents((prev) => new Map(prev).set(agentId, agent));
|
||||
setAgentUpdates((prev) => new Map(prev).set(agentId, []));
|
||||
});
|
||||
|
||||
// Agent update handler
|
||||
const unsubAgentUpdate = ws.on('agent_update', (message) => {
|
||||
if (message.type !== 'agent_update') return;
|
||||
const { agentId, timestamp, notification } = message.payload;
|
||||
|
||||
console.log('[App] Agent update:', agentId);
|
||||
|
||||
setAgentUpdates((prev) => {
|
||||
const updated = new Map(prev);
|
||||
const updates = updated.get(agentId) || [];
|
||||
updated.set(agentId, [
|
||||
...updates,
|
||||
{
|
||||
timestamp: new Date(timestamp),
|
||||
notification,
|
||||
},
|
||||
]);
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
// Agent status handler
|
||||
const unsubAgentStatus = ws.on('agent_status', (message) => {
|
||||
if (message.type !== 'agent_status') return;
|
||||
const { agentId, status, info } = message.payload;
|
||||
|
||||
console.log('[App] Agent status changed:', agentId, status);
|
||||
|
||||
setAgents((prev) => {
|
||||
const updated = new Map(prev);
|
||||
const agent = updated.get(agentId);
|
||||
if (agent) {
|
||||
updated.set(agentId, {
|
||||
...agent,
|
||||
status: status as AgentStatus,
|
||||
sessionId: info.sessionId,
|
||||
error: info.error,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
// Activity log handler
|
||||
const unsubActivity = ws.on('activity_log', (message) => {
|
||||
if (message.type !== 'activity_log') return;
|
||||
const data = message.payload;
|
||||
|
||||
// Handle tool calls
|
||||
if (data.type === 'tool_call' && data.metadata) {
|
||||
const {
|
||||
toolCallId,
|
||||
toolName,
|
||||
arguments: args,
|
||||
} = data.metadata as {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
arguments: unknown;
|
||||
};
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: toolCallId,
|
||||
timestamp: Date.now(),
|
||||
toolName,
|
||||
args,
|
||||
status: 'executing',
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tool results
|
||||
if (data.type === 'tool_result' && data.metadata) {
|
||||
const { toolCallId, result } = data.metadata as {
|
||||
toolCallId: string;
|
||||
result: unknown;
|
||||
};
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.type === 'tool_call' && msg.id === toolCallId
|
||||
? { ...msg, result, status: 'completed' as const }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tool errors
|
||||
if (
|
||||
data.type === 'error' &&
|
||||
data.metadata &&
|
||||
'toolCallId' in data.metadata
|
||||
) {
|
||||
const { toolCallId, error } = data.metadata as {
|
||||
toolCallId: string;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.type === 'tool_call' && msg.id === toolCallId
|
||||
? { ...msg, error, status: 'failed' as const }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Map activity types to message types
|
||||
let activityType: 'system' | 'info' | 'success' | 'error' = 'info';
|
||||
if (data.type === 'error') activityType = 'error';
|
||||
|
||||
// Add user transcripts as user messages
|
||||
if (data.type === 'transcript') {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'user',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
message: data.content,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add assistant messages
|
||||
if (data.type === 'assistant') {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'assistant',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
message: data.content,
|
||||
},
|
||||
]);
|
||||
setCurrentAssistantMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add activity log for other types
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType,
|
||||
message: data.content,
|
||||
metadata: data.metadata,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Assistant chunk handler (streaming)
|
||||
const unsubChunk = ws.on('assistant_chunk', (message) => {
|
||||
if (message.type !== 'assistant_chunk') return;
|
||||
setCurrentAssistantMessage((prev) => prev + message.payload.chunk);
|
||||
});
|
||||
|
||||
// Transcription result handler
|
||||
const unsubTranscription = ws.on('transcription_result', (message) => {
|
||||
if (message.type !== 'transcription_result') return;
|
||||
|
||||
setIsProcessingAudio(false);
|
||||
|
||||
const transcriptText = message.payload.text.trim();
|
||||
|
||||
if (!transcriptText) {
|
||||
// Empty transcription - false positive, resume playback
|
||||
console.log('[App] Empty transcription (false positive) - resuming playback');
|
||||
audioPlayer.resume();
|
||||
} else {
|
||||
// Has content - real speech detected, stop playback
|
||||
console.log('[App] Transcription received - stopping playback');
|
||||
audioPlayer.stop();
|
||||
setIsPlayingAudio(false);
|
||||
setCurrentAssistantMessage('');
|
||||
}
|
||||
});
|
||||
|
||||
// Audio output handler (TTS)
|
||||
const unsubAudioOutput = ws.on('audio_output', async (message) => {
|
||||
if (message.type !== 'audio_output') return;
|
||||
const data = message.payload;
|
||||
|
||||
try {
|
||||
setIsPlayingAudio(true);
|
||||
|
||||
// Create blob-like object with correct mime type (React Native compatible)
|
||||
const mimeType =
|
||||
data.format === 'mp3' ? 'audio/mpeg' : `audio/${data.format}`;
|
||||
const base64Audio = data.audio;
|
||||
|
||||
// Create a Blob-like object that works in React Native
|
||||
const audioBlob = {
|
||||
type: mimeType,
|
||||
size: Math.ceil(base64Audio.length * 3 / 4), // Approximate size from base64
|
||||
arrayBuffer: async () => {
|
||||
// Convert base64 to ArrayBuffer
|
||||
const binaryString = atob(base64Audio);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
},
|
||||
} as Blob;
|
||||
|
||||
// Play audio
|
||||
await audioPlayer.play(audioBlob);
|
||||
|
||||
// Send confirmation back to server (properly typed)
|
||||
const confirmMessage: WSInboundMessage = {
|
||||
type: 'session',
|
||||
message: {
|
||||
type: 'audio_played',
|
||||
id: data.id,
|
||||
},
|
||||
};
|
||||
ws.send(confirmMessage);
|
||||
|
||||
setIsPlayingAudio(false);
|
||||
} catch (error: any) {
|
||||
console.error('[App] Audio playback error:', error);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType: 'error',
|
||||
message: `Audio playback failed: ${error.message}`,
|
||||
},
|
||||
]);
|
||||
setIsPlayingAudio(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Status handler
|
||||
const unsubStatus = ws.on('status', (message) => {
|
||||
if (message.type !== 'status') return;
|
||||
const msg =
|
||||
'message' in message.payload
|
||||
? String(message.payload.message)
|
||||
: `Status: ${message.payload.status}`;
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType: 'info',
|
||||
message: msg,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Conversation loaded handler
|
||||
const unsubConversationLoaded = ws.on('conversation_loaded', (message) => {
|
||||
if (message.type !== 'conversation_loaded') return;
|
||||
const { conversationId, messageCount } = message.payload;
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType: 'success',
|
||||
message: `Loaded conversation with ${messageCount} messages`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Artifact handler
|
||||
const unsubArtifact = ws.on('artifact', (message) => {
|
||||
if (message.type !== 'artifact') return;
|
||||
const artifactData = message.payload;
|
||||
|
||||
console.log('[App] Received artifact:', artifactData.id, artifactData.type, artifactData.title);
|
||||
|
||||
// Store artifact
|
||||
setArtifacts((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(artifactData.id, artifactData);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Show drawer immediately
|
||||
setCurrentArtifact(artifactData);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubSessionState();
|
||||
unsubAgentCreated();
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStatus();
|
||||
unsubActivity();
|
||||
unsubChunk();
|
||||
unsubTranscription();
|
||||
unsubAudioOutput();
|
||||
unsubStatus();
|
||||
unsubConversationLoaded();
|
||||
unsubArtifact();
|
||||
};
|
||||
}, [ws, audioPlayer]);
|
||||
|
||||
// Connection status handler
|
||||
useEffect(() => {
|
||||
if (ws.isConnected) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType: 'success',
|
||||
message: 'WebSocket connected',
|
||||
},
|
||||
]);
|
||||
} else if (messages.length > 0) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType: 'error',
|
||||
message: 'WebSocket disconnected',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [ws.isConnected]);
|
||||
|
||||
// Voice button handler
|
||||
async function handleVoicePress() {
|
||||
if (!ws.isConnected) return;
|
||||
|
||||
// If recording, stop and send
|
||||
if (isRecording) {
|
||||
try {
|
||||
console.log('[App] Stopping recording...');
|
||||
const audioBlob = await audioRecorder.stop();
|
||||
setIsRecording(false);
|
||||
|
||||
const format = audioBlob.type || 'audio/m4a';
|
||||
console.log(
|
||||
`[App] Recording complete: ${audioBlob.size} bytes, format: ${format}`
|
||||
);
|
||||
|
||||
setIsProcessingAudio(true);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType: 'info',
|
||||
message: 'Sending audio to server...',
|
||||
},
|
||||
]);
|
||||
|
||||
// Convert to base64
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
const base64Audio = btoa(
|
||||
new Uint8Array(arrayBuffer).reduce(
|
||||
(data, byte) => data + String.fromCharCode(byte),
|
||||
''
|
||||
)
|
||||
);
|
||||
|
||||
// Send to server (properly typed)
|
||||
const audioMessage: WSInboundMessage = {
|
||||
type: 'session',
|
||||
message: {
|
||||
type: 'audio_chunk',
|
||||
audio: base64Audio,
|
||||
format: format,
|
||||
isLast: true,
|
||||
},
|
||||
};
|
||||
ws.send(audioMessage);
|
||||
|
||||
console.log(
|
||||
`[App] Sent audio: ${audioBlob.size} bytes, format: ${format}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('[App] Recording error:', error);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType: 'error',
|
||||
message: `Failed to record audio: ${error.message}`,
|
||||
},
|
||||
]);
|
||||
setIsRecording(false);
|
||||
}
|
||||
} else {
|
||||
// Start recording
|
||||
try {
|
||||
console.log('[App] Starting recording...');
|
||||
|
||||
// Stop any currently playing audio
|
||||
audioPlayer.stop();
|
||||
setIsPlayingAudio(false);
|
||||
|
||||
await audioRecorder.start();
|
||||
setIsRecording(true);
|
||||
} catch (error: any) {
|
||||
console.error('[App] Failed to start recording:', error);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType: 'error',
|
||||
message: `Failed to start recording: ${error.message}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle artifact click from activity log
|
||||
function handleArtifactClick(artifactId: string) {
|
||||
const artifact = artifacts.get(artifactId);
|
||||
if (artifact) {
|
||||
console.log('[App] Opening artifact:', artifactId);
|
||||
setCurrentArtifact(artifact);
|
||||
} else {
|
||||
console.warn('[App] Artifact not found:', artifactId);
|
||||
}
|
||||
}
|
||||
|
||||
// Close artifact drawer
|
||||
function handleCloseArtifact() {
|
||||
setCurrentArtifact(null);
|
||||
}
|
||||
|
||||
// Handle agent selection
|
||||
function handleSelectAgent(agentId: string) {
|
||||
setActiveAgentId(agentId);
|
||||
setViewMode('agent');
|
||||
}
|
||||
|
||||
// Handle back to orchestrator
|
||||
function handleBackToOrchestrator() {
|
||||
setActiveAgentId(null);
|
||||
setViewMode('orchestrator');
|
||||
}
|
||||
|
||||
// Agent control handlers
|
||||
function handleKillAgent(agentId: string) {
|
||||
console.log('[App] Kill agent:', agentId);
|
||||
// TODO: Implement kill agent API call
|
||||
}
|
||||
|
||||
function handleCancelAgent(agentId: string) {
|
||||
console.log('[App] Cancel agent:', agentId);
|
||||
// TODO: Implement cancel agent API call
|
||||
}
|
||||
|
||||
// Text message handlers
|
||||
function handleSendMessage() {
|
||||
if (!userInput.trim() || !ws.isConnected) return;
|
||||
|
||||
// Stop any currently playing audio
|
||||
audioPlayer.stop();
|
||||
setIsPlayingAudio(false);
|
||||
|
||||
// Send message to server using the hook's method
|
||||
ws.sendUserMessage(userInput);
|
||||
|
||||
// Clear input and reset streaming state
|
||||
setUserInput('');
|
||||
setCurrentAssistantMessage('');
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
console.log('[App] Cancelling operations...');
|
||||
|
||||
// Stop audio playback
|
||||
audioPlayer.stop();
|
||||
setIsPlayingAudio(false);
|
||||
|
||||
// Clear streaming state
|
||||
setCurrentAssistantMessage('');
|
||||
|
||||
// Reset processing state
|
||||
setIsProcessingAudio(false);
|
||||
|
||||
// Send abort request to server (properly typed)
|
||||
const abortMessage: WSInboundMessage = {
|
||||
type: 'session',
|
||||
message: {
|
||||
type: 'abort_request',
|
||||
},
|
||||
};
|
||||
ws.send(abortMessage);
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: 'activity',
|
||||
id: generateMessageId(),
|
||||
timestamp: Date.now(),
|
||||
activityType: 'info',
|
||||
message: 'Operations cancelled',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Compute if we're processing
|
||||
const isInProgress =
|
||||
isProcessingAudio || isPlayingAudio || currentAssistantMessage.length > 0;
|
||||
|
||||
function handleButtonClick() {
|
||||
if (isRecording) {
|
||||
// Stop recording and send the audio
|
||||
handleVoicePress();
|
||||
} else if (isInProgress) {
|
||||
// Cancel processing/playback
|
||||
handleCancel();
|
||||
} else if (userInput.trim()) {
|
||||
// Send text message
|
||||
handleSendMessage();
|
||||
} else {
|
||||
// Start recording
|
||||
handleVoicePress();
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation selection handler
|
||||
function handleSelectConversation(newConversationId: string | null) {
|
||||
// Clear all state
|
||||
setMessages([]);
|
||||
setCurrentAssistantMessage('');
|
||||
setUserInput('');
|
||||
setArtifacts(new Map());
|
||||
setCurrentArtifact(null);
|
||||
setAgents(new Map());
|
||||
setCommands(new Map());
|
||||
setAgentUpdates(new Map());
|
||||
setViewMode('orchestrator');
|
||||
setActiveAgentId(null);
|
||||
|
||||
// Stop any ongoing operations
|
||||
if (isRecording) {
|
||||
audioRecorder.stop().catch(console.error);
|
||||
setIsRecording(false);
|
||||
}
|
||||
audioPlayer.stop();
|
||||
setIsPlayingAudio(false);
|
||||
setIsProcessingAudio(false);
|
||||
|
||||
// Update conversation ID (will trigger WebSocket reconnection)
|
||||
setConversationId(newConversationId);
|
||||
}
|
||||
|
||||
// Render agent stream view
|
||||
if (viewMode === 'agent' && activeAgentId) {
|
||||
const agent = agents.get(activeAgentId);
|
||||
const updates = agentUpdates.get(activeAgentId) || [];
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center">
|
||||
<Text className="text-white">Agent not found</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentStreamView
|
||||
agentId={activeAgentId}
|
||||
agent={agent}
|
||||
updates={updates}
|
||||
onBack={handleBackToOrchestrator}
|
||||
onKillAgent={handleKillAgent}
|
||||
onCancelAgent={handleCancelAgent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render orchestrator view (main chat)
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
className="flex-1 bg-black"
|
||||
>
|
||||
<View className="flex-1">
|
||||
{/* Connection status header with buttons */}
|
||||
<View style={{ paddingTop: insets.top + 16 }}>
|
||||
<View className="flex-row items-center justify-between px-6 pb-4">
|
||||
<View className="flex-1">
|
||||
<ConnectionStatus isConnected={ws.isConnected} />
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<ConversationSelector
|
||||
currentConversationId={conversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
websocket={ws}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => router.push('/settings')}
|
||||
className="bg-zinc-800 p-3 rounded-lg"
|
||||
>
|
||||
<MaterialIcons name="settings" size={20} color="white" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Active processes bar */}
|
||||
<ActiveProcesses
|
||||
agents={Array.from(agents.values())}
|
||||
commands={Array.from(commands.values())}
|
||||
activeProcessId={activeAgentId}
|
||||
activeProcessType={activeAgentId ? 'agent' : null}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onBackToOrchestrator={handleBackToOrchestrator}
|
||||
/>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
className="flex-1"
|
||||
contentContainerClassName="pb-4"
|
||||
>
|
||||
{messages.map((msg) => {
|
||||
if (msg.type === 'user') {
|
||||
return (
|
||||
<UserMessage
|
||||
key={msg.id}
|
||||
message={msg.message}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
return (
|
||||
<AssistantMessage
|
||||
key={msg.id}
|
||||
message={msg.message}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.type === 'activity') {
|
||||
return (
|
||||
<ActivityLog
|
||||
key={msg.id}
|
||||
type={msg.activityType}
|
||||
message={msg.message}
|
||||
timestamp={msg.timestamp}
|
||||
metadata={msg.metadata}
|
||||
onArtifactClick={handleArtifactClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.type === 'tool_call') {
|
||||
return (
|
||||
<ToolCall
|
||||
key={msg.id}
|
||||
toolName={msg.toolName}
|
||||
args={msg.args}
|
||||
result={msg.result}
|
||||
error={msg.error}
|
||||
status={msg.status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Streaming assistant message */}
|
||||
{currentAssistantMessage && (
|
||||
<AssistantMessage
|
||||
message={currentAssistantMessage}
|
||||
timestamp={Date.now()}
|
||||
isStreaming={true}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Input area */}
|
||||
<View className="pt-4 px-6 border-t border-zinc-800" style={{ paddingBottom: Math.max(insets.bottom, 32) }}>
|
||||
{/* Text input */}
|
||||
<TextInput
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
placeholder="Say something..."
|
||||
placeholderTextColor="#71717a"
|
||||
className="bg-zinc-800 text-white rounded-2xl px-4 py-3 mb-4 max-h-32"
|
||||
multiline
|
||||
editable={!isRecording && ws.isConnected}
|
||||
/>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className="flex-row items-center justify-center gap-4">
|
||||
{/* Realtime mode button (placeholder) */}
|
||||
<Pressable
|
||||
disabled={true}
|
||||
className="w-16 h-16 rounded-full bg-zinc-800 items-center justify-center opacity-50"
|
||||
>
|
||||
<View className="w-8 h-8 items-center justify-center">
|
||||
<View className="w-6 h-6 rounded-full border-2 border-white" />
|
||||
<View className="absolute w-3 h-3 rounded-full border-2 border-white" />
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Main action button */}
|
||||
<Pressable
|
||||
onPress={handleButtonClick}
|
||||
disabled={!ws.isConnected}
|
||||
className={`w-20 h-20 rounded-full items-center justify-center ${
|
||||
!ws.isConnected ? 'opacity-50' : 'opacity-100'
|
||||
} ${
|
||||
isRecording
|
||||
? 'bg-red-500'
|
||||
: isInProgress
|
||||
? 'bg-red-600'
|
||||
: userInput.trim()
|
||||
? 'bg-blue-600'
|
||||
: 'bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{isInProgress ? (
|
||||
<View className="relative w-6 h-6">
|
||||
<View className="absolute w-6 h-0.5 bg-white rotate-45" style={{top: 11}} />
|
||||
<View className="absolute w-6 h-0.5 bg-white -rotate-45" style={{top: 11}} />
|
||||
</View>
|
||||
) : isRecording ? (
|
||||
<View className="w-4 h-4 bg-white rounded-full" />
|
||||
) : userInput.trim() ? (
|
||||
<Text className="text-white text-xl">▶</Text>
|
||||
) : (
|
||||
<View className="w-6 h-8 relative">
|
||||
<View className="absolute bottom-0 left-1/2 -ml-2 w-4 h-6 bg-white rounded-t-full" />
|
||||
<View className="absolute bottom-0 left-1/2 -ml-3 w-6 h-1.5 bg-white rounded-full" />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Artifact drawer */}
|
||||
<ArtifactDrawer
|
||||
artifact={currentArtifact}
|
||||
onClose={handleCloseArtifact}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView, TextInput, Switch, Pressable, Alert, ActivityIndicator } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { settings, isLoading, updateSettings, resetSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [serverUrl, setServerUrl] = useState(settings.serverUrl);
|
||||
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
|
||||
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'auto'>(settings.theme);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Update local state when settings load
|
||||
useEffect(() => {
|
||||
setServerUrl(settings.serverUrl);
|
||||
setUseSpeaker(settings.useSpeaker);
|
||||
setKeepScreenOn(settings.keepScreenOn);
|
||||
setTheme(settings.theme);
|
||||
}, [settings]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
const changed =
|
||||
serverUrl !== settings.serverUrl ||
|
||||
useSpeaker !== settings.useSpeaker ||
|
||||
keepScreenOn !== settings.keepScreenOn ||
|
||||
theme !== settings.theme;
|
||||
setHasChanges(changed);
|
||||
}, [serverUrl, useSpeaker, keepScreenOn, theme, settings]);
|
||||
|
||||
function validateServerUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.protocol === 'ws:' || urlObj.protocol === 'wss:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Validate server URL
|
||||
if (!validateServerUrl(serverUrl)) {
|
||||
Alert.alert(
|
||||
'Invalid URL',
|
||||
'Server URL must be a valid WebSocket URL (ws:// or wss://)',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSettings({
|
||||
serverUrl,
|
||||
useSpeaker,
|
||||
keepScreenOn,
|
||||
theme,
|
||||
});
|
||||
|
||||
Alert.alert(
|
||||
'Settings Saved',
|
||||
'Your settings have been saved successfully.',
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Error',
|
||||
'Failed to save settings. Please try again.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
Alert.alert(
|
||||
'Reset Settings',
|
||||
'Are you sure you want to reset all settings to defaults?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Reset',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await resetSettings();
|
||||
Alert.alert('Settings Reset', 'All settings have been reset to defaults.');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to reset settings. Please try again.');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (hasChanges) {
|
||||
Alert.alert(
|
||||
'Discard Changes',
|
||||
'You have unsaved changes. Are you sure you want to go back?',
|
||||
[
|
||||
{ text: 'Stay', style: 'cancel' },
|
||||
{
|
||||
text: 'Discard',
|
||||
style: 'destructive',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (!validateServerUrl(serverUrl)) {
|
||||
Alert.alert(
|
||||
'Invalid URL',
|
||||
'Server URL must be a valid WebSocket URL (ws:// or wss://)',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(serverUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Connection timeout - server did not respond',
|
||||
});
|
||||
setIsTesting(false);
|
||||
}, 5000);
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
});
|
||||
setIsTesting(false);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Connection failed - check URL and network',
|
||||
});
|
||||
setIsTesting(false);
|
||||
};
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Failed to create connection',
|
||||
});
|
||||
setIsTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center">
|
||||
<Text className="text-white text-lg">Loading settings...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
{/* Header */}
|
||||
<View className="px-6 pb-4 border-b border-gray-800" style={{ paddingTop: insets.top + 16 }}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-white text-3xl font-bold">Settings</Text>
|
||||
<Pressable onPress={handleCancel}>
|
||||
<Text className="text-blue-500 text-base font-semibold">Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView className="flex-1">
|
||||
<View className="p-6">
|
||||
{/* Server Configuration */}
|
||||
<View className="mb-8">
|
||||
<Text className="text-white text-lg font-semibold mb-4">
|
||||
Server Configuration
|
||||
</Text>
|
||||
|
||||
<Text className="text-gray-400 text-sm mb-2">WebSocket URL</Text>
|
||||
<TextInput
|
||||
className="bg-zinc-900 text-white p-4 rounded-lg mb-2"
|
||||
placeholder="wss://example.com/ws"
|
||||
placeholderTextColor="#6b7280"
|
||||
value={serverUrl}
|
||||
onChangeText={(text) => {
|
||||
setServerUrl(text);
|
||||
setTestResult(null);
|
||||
}}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
<Text className="text-gray-500 text-xs mb-3">
|
||||
Must be a valid WebSocket URL (ws:// or wss://)
|
||||
</Text>
|
||||
|
||||
{/* Test Connection Button */}
|
||||
<Pressable
|
||||
onPress={handleTestConnection}
|
||||
disabled={isTesting || !validateServerUrl(serverUrl)}
|
||||
className={`p-3 rounded-lg mb-3 flex-row items-center justify-center ${
|
||||
isTesting || !validateServerUrl(serverUrl) ? 'bg-zinc-800' : 'bg-blue-600 active:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
<Text className="text-white font-semibold ml-2">Testing...</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text className="text-white font-semibold">Test Connection</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<View
|
||||
className={`p-3 rounded-lg ${
|
||||
testResult.success
|
||||
? 'bg-green-900/30 border border-green-700'
|
||||
: 'bg-red-900/30 border border-red-700'
|
||||
}`}
|
||||
>
|
||||
<Text className={testResult.success ? 'text-green-400' : 'text-red-400'}>
|
||||
{testResult.message}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Audio Settings */}
|
||||
<View className="mb-8">
|
||||
<Text className="text-white text-lg font-semibold mb-4">
|
||||
Audio
|
||||
</Text>
|
||||
|
||||
<View className="bg-zinc-900 rounded-lg p-4 mb-3">
|
||||
<View className="flex-row justify-between items-center">
|
||||
<View className="flex-1">
|
||||
<Text className="text-white text-base mb-1">Use Speaker</Text>
|
||||
<Text className="text-gray-400 text-sm">
|
||||
Play audio through speaker instead of earpiece
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={useSpeaker}
|
||||
onValueChange={setUseSpeaker}
|
||||
trackColor={{ false: '#374151', true: '#3b82f6' }}
|
||||
thumbColor={useSpeaker ? '#60a5fa' : '#d1d5db'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="bg-zinc-900 rounded-lg p-4">
|
||||
<View className="flex-row justify-between items-center">
|
||||
<View className="flex-1">
|
||||
<Text className="text-white text-base mb-1">Keep Screen On</Text>
|
||||
<Text className="text-gray-400 text-sm">
|
||||
Prevent screen from sleeping during voice sessions
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={keepScreenOn}
|
||||
onValueChange={setKeepScreenOn}
|
||||
trackColor={{ false: '#374151', true: '#3b82f6' }}
|
||||
thumbColor={keepScreenOn ? '#60a5fa' : '#d1d5db'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Theme Settings */}
|
||||
<View className="mb-8">
|
||||
<Text className="text-white text-lg font-semibold mb-4">
|
||||
Theme
|
||||
</Text>
|
||||
|
||||
<View className="bg-zinc-900 rounded-lg p-4 opacity-50">
|
||||
<Text className="text-gray-400 text-sm mb-3">
|
||||
Theme selection (coming soon)
|
||||
</Text>
|
||||
|
||||
{(['dark', 'light', 'auto'] as const).map((themeOption) => (
|
||||
<Pressable
|
||||
key={themeOption}
|
||||
disabled
|
||||
className="flex-row items-center py-2"
|
||||
>
|
||||
<View className={`w-5 h-5 rounded-full border-2 ${
|
||||
theme === themeOption ? 'border-blue-500' : 'border-gray-600'
|
||||
} mr-3 items-center justify-center`}>
|
||||
{theme === themeOption && (
|
||||
<View className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-gray-400 text-base capitalize">
|
||||
{themeOption}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View className="mb-8">
|
||||
<Pressable
|
||||
className={`p-4 rounded-lg mb-3 ${
|
||||
hasChanges ? 'bg-blue-500' : 'bg-blue-500/50'
|
||||
}`}
|
||||
onPress={handleSave}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
<Text className="text-white text-center text-base font-semibold">
|
||||
Save Settings
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
className="p-4 rounded-lg border border-red-500/30"
|
||||
onPress={handleReset}
|
||||
>
|
||||
<Text className="text-red-500 text-center text-base font-semibold">
|
||||
Reset to Defaults
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* App Info */}
|
||||
<View className="border-t border-gray-800 pt-6">
|
||||
<Text className="text-gray-500 text-sm text-center">
|
||||
Voice Assistant Mobile
|
||||
</Text>
|
||||
<Text className="text-gray-600 text-xs text-center mt-1">
|
||||
Version 1.0.0
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user