Matthias Nott
2026-03-07 af1543135d42adc2e97dc5243aeef7418cd3b00d
app/chat.tsx
....@@ -1,30 +1,42 @@
1
-import React, { useCallback, useState } from "react";
2
-import { Pressable, Text, View } from "react-native";
1
+import React, { useCallback, useEffect, useRef, useState } from "react";
2
+import { ActionSheetIOS, Alert, KeyboardAvoidingView, Platform, Pressable, Text, View } from "react-native";
33 import { SafeAreaView } from "react-native-safe-area-context";
44 import { router } from "expo-router";
55 import { useChat } from "../contexts/ChatContext";
66 import { useConnection } from "../contexts/ConnectionContext";
7
+import { useTheme } from "../contexts/ThemeContext";
78 import { MessageList } from "../components/chat/MessageList";
89 import { InputBar } from "../components/chat/InputBar";
910 import { CommandBar, TextModeCommandBar } from "../components/chat/CommandBar";
11
+import { ImageCaptionModal } from "../components/chat/ImageCaptionModal";
1012 import { StatusDot } from "../components/ui/StatusDot";
11
-import { SessionPicker } from "../components/SessionPicker";
12
-import { playAudio } from "../services/audio";
13
+import { SessionDrawer } from "../components/SessionDrawer";
14
+import { playAudio, stopPlayback, isPlaying, onPlayingChange } from "../services/audio";
15
+
16
+interface StagedImage {
17
+ base64: string;
18
+ uri: string;
19
+ mimeType: string;
20
+}
1321
1422 export default function ChatScreen() {
15
- const { messages, sendTextMessage, clearMessages, requestScreenshot } =
23
+ const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, clearMessages, requestScreenshot, sessions } =
1624 useChat();
1725 const { status } = useConnection();
26
+ const { colors, mode, cycleMode } = useTheme();
27
+ const themeIcon = mode === "dark" ? "🌙" : mode === "light" ? "☀️" : "📱";
28
+ const activeSessionName = sessions.find((s) => s.isActive)?.name ?? "PAILot";
1829 const [isTextMode, setIsTextMode] = useState(false);
1930 const [showSessions, setShowSessions] = useState(false);
31
+ const [audioPlaying, setAudioPlaying] = useState(false);
32
+ const [stagedImage, setStagedImage] = useState<StagedImage | null>(null);
2033
21
- const handleSessions = useCallback(() => {
22
- setShowSessions(true);
34
+ useEffect(() => {
35
+ return onPlayingChange(setAudioPlaying);
2336 }, []);
2437
2538 const handleScreenshot = useCallback(() => {
2639 requestScreenshot();
27
- router.push("/navigate");
2840 }, [requestScreenshot]);
2941
3042 const handleHelp = useCallback(() => {
....@@ -39,7 +51,90 @@
3951 clearMessages();
4052 }, [clearMessages]);
4153
54
+ // Resolve a picked asset into a StagedImage
55
+ const stageAsset = useCallback(async (asset: { base64?: string | null; uri: string; mimeType?: string | null }) => {
56
+ const mimeType = asset.mimeType ?? (asset.uri.endsWith(".png") ? "image/png" : "image/jpeg");
57
+ let base64 = asset.base64 ?? "";
58
+ if (!base64 && asset.uri) {
59
+ const { readAsStringAsync } = await import("expo-file-system/legacy");
60
+ base64 = await readAsStringAsync(asset.uri, { encoding: "base64" });
61
+ }
62
+ if (base64) {
63
+ setStagedImage({ base64, uri: asset.uri, mimeType });
64
+ }
65
+ }, []);
66
+
67
+ const pickFromLibrary = useCallback(async () => {
68
+ try {
69
+ const ImagePicker = await import("expo-image-picker");
70
+ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
71
+ if (status !== "granted") {
72
+ Alert.alert("Permission needed", "Please allow photo library access in Settings.");
73
+ return;
74
+ }
75
+ const result = await ImagePicker.launchImageLibraryAsync({
76
+ mediaTypes: ["images"],
77
+ quality: 0.7,
78
+ base64: true,
79
+ });
80
+ if (result.canceled || !result.assets?.[0]) return;
81
+ await stageAsset(result.assets[0]);
82
+ } catch (err: any) {
83
+ Alert.alert("Image Error", err?.message ?? String(err));
84
+ }
85
+ }, [stageAsset]);
86
+
87
+ const pickFromCamera = useCallback(async () => {
88
+ try {
89
+ const ImagePicker = await import("expo-image-picker");
90
+ const { status } = await ImagePicker.requestCameraPermissionsAsync();
91
+ if (status !== "granted") {
92
+ Alert.alert("Permission needed", "Please allow camera access in Settings.");
93
+ return;
94
+ }
95
+ const result = await ImagePicker.launchCameraAsync({
96
+ quality: 0.7,
97
+ base64: true,
98
+ });
99
+ if (result.canceled || !result.assets?.[0]) return;
100
+ await stageAsset(result.assets[0]);
101
+ } catch (err: any) {
102
+ Alert.alert("Camera Error", err?.message ?? String(err));
103
+ }
104
+ }, [stageAsset]);
105
+
106
+ const handlePickImage = useCallback(() => {
107
+ if (Platform.OS === "ios") {
108
+ ActionSheetIOS.showActionSheetWithOptions(
109
+ {
110
+ options: ["Cancel", "Take Photo", "Choose from Library"],
111
+ cancelButtonIndex: 0,
112
+ },
113
+ (index) => {
114
+ if (index === 1) pickFromCamera();
115
+ else if (index === 2) pickFromLibrary();
116
+ },
117
+ );
118
+ } else {
119
+ // Android: just open library (camera is accessible from there)
120
+ pickFromLibrary();
121
+ }
122
+ }, [pickFromCamera, pickFromLibrary]);
123
+
124
+ const handleImageSend = useCallback(
125
+ (caption: string) => {
126
+ if (!stagedImage) return;
127
+ sendImageMessage(stagedImage.base64, caption, stagedImage.mimeType);
128
+ setStagedImage(null);
129
+ },
130
+ [stagedImage, sendImageMessage],
131
+ );
132
+
42133 const handleReplay = useCallback(() => {
134
+ if (isPlaying()) {
135
+ stopPlayback();
136
+ return;
137
+ }
43138 for (let i = messages.length - 1; i >= 0; i--) {
44139 const msg = messages[i];
45140 if (msg.role === "assistant") {
....@@ -52,7 +147,12 @@
52147 }, [messages]);
53148
54149 return (
55
- <SafeAreaView style={{ flex: 1, backgroundColor: "#0A0A0F" }} edges={["top", "bottom"]}>
150
+ <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={["top", "bottom"]}>
151
+ <KeyboardAvoidingView
152
+ style={{ flex: 1 }}
153
+ behavior={Platform.OS === "ios" ? "padding" : undefined}
154
+ keyboardVerticalOffset={0}
155
+ >
56156 {/* Header */}
57157 <View
58158 style={{
....@@ -62,37 +162,75 @@
62162 paddingHorizontal: 16,
63163 paddingVertical: 12,
64164 borderBottomWidth: 1,
65
- borderBottomColor: "#2E2E45",
165
+ borderBottomColor: colors.border,
66166 }}
67167 >
68
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
69
- <Text
70
- style={{
71
- color: "#E8E8F0",
72
- fontSize: 22,
73
- fontWeight: "800",
74
- letterSpacing: -0.5,
75
- }}
168
+ <View style={{ flexDirection: "row", alignItems: "center", flex: 1, gap: 10 }}>
169
+ <Pressable
170
+ onPress={() => setShowSessions(true)}
171
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
172
+ style={({ pressed }) => ({
173
+ width: 36,
174
+ height: 36,
175
+ alignItems: "center",
176
+ justifyContent: "center",
177
+ borderRadius: 18,
178
+ backgroundColor: pressed ? colors.bgTertiary : colors.bgTertiary + "80",
179
+ })}
76180 >
77
- PAILot
78
- </Text>
79
- <StatusDot status={status} size={8} />
181
+ <Text style={{ color: colors.textSecondary, fontSize: 18 }}>☰</Text>
182
+ </Pressable>
183
+ <Pressable
184
+ onPress={() => setShowSessions(true)}
185
+ style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}
186
+ hitSlop={{ top: 6, bottom: 6, left: 0, right: 6 }}
187
+ >
188
+ <Text
189
+ style={{
190
+ color: colors.text,
191
+ fontSize: 22,
192
+ fontWeight: "800",
193
+ letterSpacing: -0.5,
194
+ flexShrink: 1,
195
+ }}
196
+ numberOfLines={1}
197
+ >
198
+ {activeSessionName}
199
+ </Text>
200
+ <StatusDot status={status} size={8} />
201
+ </Pressable>
80202 </View>
81203
82
- <Pressable
83
- onPress={() => router.push("/settings")}
84
- hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
85
- style={{
86
- width: 36,
87
- height: 36,
88
- alignItems: "center",
89
- justifyContent: "center",
90
- borderRadius: 18,
91
- backgroundColor: "#1E1E2E",
92
- }}
93
- >
94
- <Text style={{ fontSize: 15 }}>⚙️</Text>
95
- </Pressable>
204
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
205
+ <Pressable
206
+ onPress={cycleMode}
207
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
208
+ style={({ pressed }) => ({
209
+ width: 36,
210
+ height: 36,
211
+ alignItems: "center",
212
+ justifyContent: "center",
213
+ borderRadius: 18,
214
+ backgroundColor: pressed ? colors.bgTertiary : colors.bgTertiary + "80",
215
+ })}
216
+ >
217
+ <Text style={{ fontSize: 15 }}>{themeIcon}</Text>
218
+ </Pressable>
219
+ <Pressable
220
+ onPress={() => router.push("/settings")}
221
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
222
+ style={{
223
+ width: 36,
224
+ height: 36,
225
+ alignItems: "center",
226
+ justifyContent: "center",
227
+ borderRadius: 18,
228
+ backgroundColor: colors.bgTertiary,
229
+ }}
230
+ >
231
+ <Text style={{ fontSize: 15 }}>⚙️</Text>
232
+ </Pressable>
233
+ </View>
96234 </View>
97235
98236 {/* Message list */}
....@@ -104,22 +242,22 @@
104242 width: 80,
105243 height: 80,
106244 borderRadius: 40,
107
- backgroundColor: "#1E1E2E",
245
+ backgroundColor: colors.bgTertiary,
108246 alignItems: "center",
109247 justifyContent: "center",
110248 borderWidth: 1,
111
- borderColor: "#2E2E45",
249
+ borderColor: colors.border,
112250 }}
113251 >
114252 <Text style={{ fontSize: 36 }}>🛩</Text>
115253 </View>
116254 <View style={{ alignItems: "center", gap: 6 }}>
117
- <Text style={{ color: "#E8E8F0", fontSize: 20, fontWeight: "700" }}>
255
+ <Text style={{ color: colors.text, fontSize: 20, fontWeight: "700" }}>
118256 PAILot
119257 </Text>
120258 <Text
121259 style={{
122
- color: "#5A5A78",
260
+ color: colors.textMuted,
123261 fontSize: 14,
124262 textAlign: "center",
125263 paddingHorizontal: 40,
....@@ -138,32 +276,46 @@
138276 {/* Command bar */}
139277 {isTextMode ? (
140278 <TextModeCommandBar
141
- onSessions={handleSessions}
142279 onScreenshot={handleScreenshot}
143280 onNavigate={handleNavigate}
281
+ onPhoto={handlePickImage}
282
+ onHelp={handleHelp}
144283 onClear={handleClear}
145284 />
146285 ) : (
147286 <CommandBar
148
- onSessions={handleSessions}
149287 onScreenshot={handleScreenshot}
150
- onHelp={handleHelp}
288
+ onNavigate={handleNavigate}
289
+ onPhoto={handlePickImage}
290
+ onClear={handleClear}
151291 />
152292 )}
153293
154294 {/* Input bar */}
155295 <InputBar
156296 onSendText={sendTextMessage}
297
+ onVoiceRecorded={sendVoiceMessage}
157298 onReplay={handleReplay}
158299 isTextMode={isTextMode}
159300 onToggleMode={() => setIsTextMode((v) => !v)}
301
+ audioPlaying={audioPlaying}
160302 />
161303
162
- {/* Session picker modal */}
163
- <SessionPicker
164
- visible={showSessions}
165
- onClose={() => setShowSessions(false)}
166
- />
304
+ </KeyboardAvoidingView>
305
+
306
+ {/* Image caption modal — WhatsApp-style full-screen preview */}
307
+ <ImageCaptionModal
308
+ visible={!!stagedImage}
309
+ imageUri={stagedImage ? `data:${stagedImage.mimeType};base64,${stagedImage.base64}` : ""}
310
+ onSend={handleImageSend}
311
+ onCancel={() => setStagedImage(null)}
312
+ />
313
+
314
+ {/* Session drawer — absolute overlay outside KAV */}
315
+ <SessionDrawer
316
+ visible={showSessions}
317
+ onClose={() => setShowSessions(false)}
318
+ />
167319 </SafeAreaView>
168320 );
169321 }