Skip to content

Commit 390c309

Browse files
committed
Merge branch 'mobile-app' of github.com:PostHog/Array into mobile-app
2 parents 992c15a + 169d400 commit 390c309

File tree

12 files changed

+458
-133
lines changed

12 files changed

+458
-133
lines changed

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"nativewind": "^4.2.1",
3333
"react": "19.1.0",
3434
"react-native": "0.81.5",
35+
"react-native-keyboard-controller": "1.18.5",
3536
"react-native-reanimated": "~4.1.1",
3637
"react-native-safe-area-context": "~5.6.2",
3738
"react-native-screens": "~4.16.0",

apps/mobile/src/app/_layout.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Stack } from "expo-router";
55
import { StatusBar } from "expo-status-bar";
66
import { useEffect } from "react";
77
import { ActivityIndicator, View } from "react-native";
8+
import { KeyboardProvider } from "react-native-keyboard-controller";
89
import { SafeAreaProvider } from "react-native-safe-area-context";
910
import { useAuthStore } from "../stores/authStore";
1011

@@ -50,12 +51,14 @@ function RootLayoutNav() {
5051
export default function RootLayout() {
5152
return (
5253
<SafeAreaProvider>
53-
<QueryClientProvider client={queryClient}>
54-
<View className="flex-1 bg-dark-bg">
55-
<RootLayoutNav />
56-
<StatusBar style="light" />
57-
</View>
58-
</QueryClientProvider>
54+
<KeyboardProvider>
55+
<QueryClientProvider client={queryClient}>
56+
<View className="flex-1 bg-dark-bg">
57+
<RootLayoutNav />
58+
<StatusBar style="light" />
59+
</View>
60+
</QueryClientProvider>
61+
</KeyboardProvider>
5962
</SafeAreaProvider>
6063
);
6164
}

apps/mobile/src/app/chat.tsx

Lines changed: 60 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,82 @@
11
import { Stack } from "expo-router";
2-
import { useState } from "react";
3-
import {
4-
KeyboardAvoidingView,
5-
Platform,
6-
Text,
7-
TextInput,
8-
TouchableOpacity,
9-
View,
10-
} from "react-native";
2+
import { useCallback } from "react";
3+
import { Text, TouchableOpacity, View } from "react-native";
4+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
5+
import { useSafeAreaInsets } from "react-native-safe-area-context";
6+
import { ChatInput } from "../components/ChatInput";
117
import { MessagesList } from "../components/MessagesList";
8+
import { useGradualAnimation } from "../hooks/useGradualAnimation";
129
import { useMaxStore } from "../stores/maxStore";
1310

1411
export default function ChatScreen() {
15-
const [inputText, setInputText] = useState("");
16-
const { thread, streamingActive, askMax, stopGeneration } = useMaxStore();
12+
const insets = useSafeAreaInsets();
13+
const { thread, streamingActive, askMax, stopGeneration, resetThread } =
14+
useMaxStore();
1715

18-
const handleSend = async () => {
19-
const trimmed = inputText.trim();
20-
if (!trimmed) return;
21-
setInputText("");
22-
await askMax(trimmed);
23-
};
16+
const handleSend = useCallback(
17+
async (message: string) => {
18+
await askMax(message);
19+
},
20+
[askMax],
21+
);
22+
23+
const headerRight = useCallback(() => {
24+
if (streamingActive) {
25+
return (
26+
<TouchableOpacity onPress={stopGeneration} className="px-2">
27+
<Text className="font-medium text-red-500">Stop</Text>
28+
</TouchableOpacity>
29+
);
30+
}
31+
if (thread.length > 0) {
32+
return (
33+
<TouchableOpacity onPress={resetThread} className="px-2">
34+
<Text className="font-medium text-orange-500">New</Text>
35+
</TouchableOpacity>
36+
);
37+
}
38+
return null;
39+
}, [streamingActive, thread.length, stopGeneration, resetThread]);
40+
41+
const { height } = useGradualAnimation();
42+
43+
const contentPosition = useAnimatedStyle(() => {
44+
return {
45+
transform: [{ translateY: -height.value }],
46+
};
47+
}, []);
2448

2549
return (
2650
<>
2751
<Stack.Screen
2852
options={{
2953
headerShown: true,
3054
headerTitle: "Chat",
55+
headerBackTitle: "Back",
3156
headerStyle: { backgroundColor: "#09090b" },
3257
headerTintColor: "#fff",
33-
headerRight: streamingActive
34-
? () => (
35-
<TouchableOpacity onPress={stopGeneration}>
36-
<Text className="text-red-500">Stop</Text>
37-
</TouchableOpacity>
38-
)
39-
: undefined,
58+
headerTitleStyle: {
59+
fontWeight: "600",
60+
},
61+
headerRight,
4062
}}
4163
/>
42-
<KeyboardAvoidingView
43-
behavior={Platform.OS === "ios" ? "padding" : "height"}
44-
className="flex-1 bg-black"
45-
keyboardVerticalOffset={100}
46-
>
47-
<MessagesList messages={thread} isLoading={streamingActive} />
64+
<Animated.View className="flex-1 bg-dark-bg" style={[contentPosition]}>
65+
<MessagesList
66+
messages={thread}
67+
streamingActive={streamingActive}
68+
contentContainerStyle={{
69+
paddingTop: 80 + insets.bottom,
70+
paddingBottom: 16,
71+
flexGrow: thread.length === 0 ? 1 : undefined,
72+
}}
73+
/>
4874

49-
{/* Input */}
50-
<View className="flex-row items-center gap-2 border-gray-800 border-t p-4">
51-
<TextInput
52-
className="flex-1 rounded-lg bg-gray-800 px-4 py-3 text-white"
53-
placeholder="Type a message..."
54-
placeholderTextColor="#666"
55-
value={inputText}
56-
onChangeText={setInputText}
57-
onSubmitEditing={handleSend}
58-
editable={!streamingActive}
59-
/>
60-
<TouchableOpacity
61-
onPress={handleSend}
62-
disabled={!inputText.trim() || streamingActive}
63-
className={`rounded-lg px-4 py-3 ${
64-
inputText.trim() && !streamingActive
65-
? "bg-blue-600"
66-
: "bg-gray-700"
67-
}`}
68-
>
69-
<Text className="text-white">Send</Text>
70-
</TouchableOpacity>
75+
{/* Fixed input at bottom */}
76+
<View className="absolute inset-x-0 bottom-0">
77+
<ChatInput onSend={handleSend} disabled={streamingActive} />
7178
</View>
72-
</KeyboardAvoidingView>
79+
</Animated.View>
7380
</>
7481
);
7582
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { ActivityIndicator, Text, View } from "react-native";
2+
3+
interface AIMessageProps {
4+
content: string;
5+
isLoading?: boolean;
6+
thinkingText?: string;
7+
}
8+
9+
export function AIMessage({
10+
content,
11+
isLoading,
12+
thinkingText,
13+
}: AIMessageProps) {
14+
return (
15+
<View className="items-start px-4 py-2">
16+
<View className="max-w-[85%] rounded-2xl rounded-bl-md bg-dark-surface px-4 py-3">
17+
{isLoading && !content ? (
18+
<View className="flex-row items-center gap-2">
19+
<ActivityIndicator size="small" color="#a3a3a3" />
20+
<Text className="text-base text-dark-text-muted italic">
21+
{thinkingText || "Thinking..."}
22+
</Text>
23+
</View>
24+
) : (
25+
<Text className="text-base text-dark-text leading-6">{content}</Text>
26+
)}
27+
</View>
28+
</View>
29+
);
30+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useState } from "react";
2+
import { TextInput, TextStyle, TouchableOpacity, View } from "react-native";
3+
import { useSafeAreaInsets } from "react-native-safe-area-context";
4+
import { Text } from "./text";
5+
6+
const TEXT_INPUT_STYLE: TextStyle = {
7+
maxHeight: 120,
8+
};
9+
10+
interface ChatInputProps {
11+
onSend: (message: string) => void;
12+
disabled?: boolean;
13+
placeholder?: string;
14+
}
15+
16+
export function ChatInput({
17+
onSend,
18+
disabled = false,
19+
placeholder = "Message",
20+
}: ChatInputProps) {
21+
const insets = useSafeAreaInsets();
22+
const [message, setMessage] = useState("");
23+
24+
const handleSend = () => {
25+
const trimmed = message.trim();
26+
if (!trimmed || disabled) return;
27+
onSend(trimmed);
28+
setMessage("");
29+
};
30+
31+
const canSend = message.trim().length > 0 && !disabled;
32+
33+
return (
34+
<View
35+
style={{
36+
backgroundColor: "#0a0a0a",
37+
paddingBottom: insets.bottom,
38+
borderTopWidth: 0.5,
39+
borderTopColor: "rgba(255, 255, 255, 0.1)",
40+
}}
41+
>
42+
<View className="flex-row items-end gap-2 px-4 py-2">
43+
{/* Plus button */}
44+
<TouchableOpacity
45+
className="mb-0.5 h-9 w-9 items-center justify-center rounded-full bg-dark-surface"
46+
activeOpacity={0.7}
47+
>
48+
<Text className="text-dark-text-muted text-xl">+</Text>
49+
</TouchableOpacity>
50+
51+
{/* Text input */}
52+
<View className="relative flex-1">
53+
<TextInput
54+
placeholder={placeholder}
55+
style={TEXT_INPUT_STYLE}
56+
className="min-h-[36px] flex-1 rounded-[18px] border border-dark-border bg-dark-surface px-4 py-2 pr-10 text-base text-dark-text"
57+
placeholderTextColor="#6b6b6b"
58+
editable={!disabled}
59+
multiline
60+
onChangeText={setMessage}
61+
value={message}
62+
onSubmitEditing={handleSend}
63+
blurOnSubmit={false}
64+
/>
65+
66+
{/* Send / Mic button */}
67+
<View className="absolute right-1 bottom-1">
68+
{canSend ? (
69+
<TouchableOpacity
70+
onPress={handleSend}
71+
className="h-7 w-7 items-center justify-center rounded-full bg-orange-500"
72+
activeOpacity={0.7}
73+
>
74+
<Text className="font-bold text-sm text-white"></Text>
75+
</TouchableOpacity>
76+
) : (
77+
<TouchableOpacity
78+
className="h-7 w-7 items-center justify-center rounded-full opacity-50"
79+
activeOpacity={0.7}
80+
>
81+
<Text className="text-base text-dark-text-muted">🎤</Text>
82+
</TouchableOpacity>
83+
)}
84+
</View>
85+
</View>
86+
</View>
87+
</View>
88+
);
89+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Text, View } from "react-native";
2+
3+
interface FailureMessageProps {
4+
content?: string;
5+
}
6+
7+
export function FailureMessage({ content }: FailureMessageProps) {
8+
return (
9+
<View className="items-start px-4 py-2">
10+
<View className="max-w-[85%] rounded-2xl rounded-bl-md bg-red-900/30 px-4 py-3">
11+
<Text className="text-base text-red-300 leading-6">
12+
{content || "Something went wrong. Please try again."}
13+
</Text>
14+
</View>
15+
</View>
16+
);
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Text, View } from "react-native";
2+
3+
interface HumanMessageProps {
4+
content: string;
5+
}
6+
7+
export function HumanMessage({ content }: HumanMessageProps) {
8+
return (
9+
<View className="items-end px-4 py-2">
10+
<View className="max-w-[85%] rounded-2xl rounded-br-md bg-orange-500 px-4 py-3">
11+
<Text className="text-base text-white leading-6">{content}</Text>
12+
</View>
13+
</View>
14+
);
15+
}

0 commit comments

Comments
 (0)