From af1543135d42adc2e97dc5243aeef7418cd3b00d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 08:39:26 +0100
Subject: [PATCH] feat: dual address auto-switch, custom icon, notifications, image support
---
app/chat.tsx | 246 +++++++++++++++++++++++++++++++++++++++---------
1 files changed, 199 insertions(+), 47 deletions(-)
diff --git a/app/chat.tsx b/app/chat.tsx
index d773e98..49f93bc 100644
--- a/app/chat.tsx
+++ b/app/chat.tsx
@@ -1,30 +1,42 @@
-import React, { useCallback, useState } from "react";
-import { Pressable, Text, View } from "react-native";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { ActionSheetIOS, Alert, KeyboardAvoidingView, Platform, Pressable, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
import { useChat } from "../contexts/ChatContext";
import { useConnection } from "../contexts/ConnectionContext";
+import { useTheme } from "../contexts/ThemeContext";
import { MessageList } from "../components/chat/MessageList";
import { InputBar } from "../components/chat/InputBar";
import { CommandBar, TextModeCommandBar } from "../components/chat/CommandBar";
+import { ImageCaptionModal } from "../components/chat/ImageCaptionModal";
import { StatusDot } from "../components/ui/StatusDot";
-import { SessionPicker } from "../components/SessionPicker";
-import { playAudio } from "../services/audio";
+import { SessionDrawer } from "../components/SessionDrawer";
+import { playAudio, stopPlayback, isPlaying, onPlayingChange } from "../services/audio";
+
+interface StagedImage {
+ base64: string;
+ uri: string;
+ mimeType: string;
+}
export default function ChatScreen() {
- const { messages, sendTextMessage, clearMessages, requestScreenshot } =
+ const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, clearMessages, requestScreenshot, sessions } =
useChat();
const { status } = useConnection();
+ const { colors, mode, cycleMode } = useTheme();
+ const themeIcon = mode === "dark" ? "🌙" : mode === "light" ? "☀️" : "📱";
+ const activeSessionName = sessions.find((s) => s.isActive)?.name ?? "PAILot";
const [isTextMode, setIsTextMode] = useState(false);
const [showSessions, setShowSessions] = useState(false);
+ const [audioPlaying, setAudioPlaying] = useState(false);
+ const [stagedImage, setStagedImage] = useState<StagedImage | null>(null);
- const handleSessions = useCallback(() => {
- setShowSessions(true);
+ useEffect(() => {
+ return onPlayingChange(setAudioPlaying);
}, []);
const handleScreenshot = useCallback(() => {
requestScreenshot();
- router.push("/navigate");
}, [requestScreenshot]);
const handleHelp = useCallback(() => {
@@ -39,7 +51,90 @@
clearMessages();
}, [clearMessages]);
+ // Resolve a picked asset into a StagedImage
+ const stageAsset = useCallback(async (asset: { base64?: string | null; uri: string; mimeType?: string | null }) => {
+ const mimeType = asset.mimeType ?? (asset.uri.endsWith(".png") ? "image/png" : "image/jpeg");
+ let base64 = asset.base64 ?? "";
+ if (!base64 && asset.uri) {
+ const { readAsStringAsync } = await import("expo-file-system/legacy");
+ base64 = await readAsStringAsync(asset.uri, { encoding: "base64" });
+ }
+ if (base64) {
+ setStagedImage({ base64, uri: asset.uri, mimeType });
+ }
+ }, []);
+
+ const pickFromLibrary = useCallback(async () => {
+ try {
+ const ImagePicker = await import("expo-image-picker");
+ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
+ if (status !== "granted") {
+ Alert.alert("Permission needed", "Please allow photo library access in Settings.");
+ return;
+ }
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ["images"],
+ quality: 0.7,
+ base64: true,
+ });
+ if (result.canceled || !result.assets?.[0]) return;
+ await stageAsset(result.assets[0]);
+ } catch (err: any) {
+ Alert.alert("Image Error", err?.message ?? String(err));
+ }
+ }, [stageAsset]);
+
+ const pickFromCamera = useCallback(async () => {
+ try {
+ const ImagePicker = await import("expo-image-picker");
+ const { status } = await ImagePicker.requestCameraPermissionsAsync();
+ if (status !== "granted") {
+ Alert.alert("Permission needed", "Please allow camera access in Settings.");
+ return;
+ }
+ const result = await ImagePicker.launchCameraAsync({
+ quality: 0.7,
+ base64: true,
+ });
+ if (result.canceled || !result.assets?.[0]) return;
+ await stageAsset(result.assets[0]);
+ } catch (err: any) {
+ Alert.alert("Camera Error", err?.message ?? String(err));
+ }
+ }, [stageAsset]);
+
+ const handlePickImage = useCallback(() => {
+ if (Platform.OS === "ios") {
+ ActionSheetIOS.showActionSheetWithOptions(
+ {
+ options: ["Cancel", "Take Photo", "Choose from Library"],
+ cancelButtonIndex: 0,
+ },
+ (index) => {
+ if (index === 1) pickFromCamera();
+ else if (index === 2) pickFromLibrary();
+ },
+ );
+ } else {
+ // Android: just open library (camera is accessible from there)
+ pickFromLibrary();
+ }
+ }, [pickFromCamera, pickFromLibrary]);
+
+ const handleImageSend = useCallback(
+ (caption: string) => {
+ if (!stagedImage) return;
+ sendImageMessage(stagedImage.base64, caption, stagedImage.mimeType);
+ setStagedImage(null);
+ },
+ [stagedImage, sendImageMessage],
+ );
+
const handleReplay = useCallback(() => {
+ if (isPlaying()) {
+ stopPlayback();
+ return;
+ }
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant") {
@@ -52,7 +147,12 @@
}, [messages]);
return (
- <SafeAreaView style={{ flex: 1, backgroundColor: "#0A0A0F" }} edges={["top", "bottom"]}>
+ <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={["top", "bottom"]}>
+ <KeyboardAvoidingView
+ style={{ flex: 1 }}
+ behavior={Platform.OS === "ios" ? "padding" : undefined}
+ keyboardVerticalOffset={0}
+ >
{/* Header */}
<View
style={{
@@ -62,37 +162,75 @@
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
- borderBottomColor: "#2E2E45",
+ borderBottomColor: colors.border,
}}
>
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
- <Text
- style={{
- color: "#E8E8F0",
- fontSize: 22,
- fontWeight: "800",
- letterSpacing: -0.5,
- }}
+ <View style={{ flexDirection: "row", alignItems: "center", flex: 1, gap: 10 }}>
+ <Pressable
+ onPress={() => setShowSessions(true)}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ style={({ pressed }) => ({
+ width: 36,
+ height: 36,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: 18,
+ backgroundColor: pressed ? colors.bgTertiary : colors.bgTertiary + "80",
+ })}
>
- PAILot
- </Text>
- <StatusDot status={status} size={8} />
+ <Text style={{ color: colors.textSecondary, fontSize: 18 }}>☰</Text>
+ </Pressable>
+ <Pressable
+ onPress={() => setShowSessions(true)}
+ style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}
+ hitSlop={{ top: 6, bottom: 6, left: 0, right: 6 }}
+ >
+ <Text
+ style={{
+ color: colors.text,
+ fontSize: 22,
+ fontWeight: "800",
+ letterSpacing: -0.5,
+ flexShrink: 1,
+ }}
+ numberOfLines={1}
+ >
+ {activeSessionName}
+ </Text>
+ <StatusDot status={status} size={8} />
+ </Pressable>
</View>
- <Pressable
- onPress={() => router.push("/settings")}
- hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
- style={{
- width: 36,
- height: 36,
- alignItems: "center",
- justifyContent: "center",
- borderRadius: 18,
- backgroundColor: "#1E1E2E",
- }}
- >
- <Text style={{ fontSize: 15 }}>⚙️</Text>
- </Pressable>
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
+ <Pressable
+ onPress={cycleMode}
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
+ style={({ pressed }) => ({
+ width: 36,
+ height: 36,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: 18,
+ backgroundColor: pressed ? colors.bgTertiary : colors.bgTertiary + "80",
+ })}
+ >
+ <Text style={{ fontSize: 15 }}>{themeIcon}</Text>
+ </Pressable>
+ <Pressable
+ onPress={() => router.push("/settings")}
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
+ style={{
+ width: 36,
+ height: 36,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: 18,
+ backgroundColor: colors.bgTertiary,
+ }}
+ >
+ <Text style={{ fontSize: 15 }}>⚙️</Text>
+ </Pressable>
+ </View>
</View>
{/* Message list */}
@@ -104,22 +242,22 @@
width: 80,
height: 80,
borderRadius: 40,
- backgroundColor: "#1E1E2E",
+ backgroundColor: colors.bgTertiary,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
- borderColor: "#2E2E45",
+ borderColor: colors.border,
}}
>
<Text style={{ fontSize: 36 }}>🛩</Text>
</View>
<View style={{ alignItems: "center", gap: 6 }}>
- <Text style={{ color: "#E8E8F0", fontSize: 20, fontWeight: "700" }}>
+ <Text style={{ color: colors.text, fontSize: 20, fontWeight: "700" }}>
PAILot
</Text>
<Text
style={{
- color: "#5A5A78",
+ color: colors.textMuted,
fontSize: 14,
textAlign: "center",
paddingHorizontal: 40,
@@ -138,32 +276,46 @@
{/* Command bar */}
{isTextMode ? (
<TextModeCommandBar
- onSessions={handleSessions}
onScreenshot={handleScreenshot}
onNavigate={handleNavigate}
+ onPhoto={handlePickImage}
+ onHelp={handleHelp}
onClear={handleClear}
/>
) : (
<CommandBar
- onSessions={handleSessions}
onScreenshot={handleScreenshot}
- onHelp={handleHelp}
+ onNavigate={handleNavigate}
+ onPhoto={handlePickImage}
+ onClear={handleClear}
/>
)}
{/* Input bar */}
<InputBar
onSendText={sendTextMessage}
+ onVoiceRecorded={sendVoiceMessage}
onReplay={handleReplay}
isTextMode={isTextMode}
onToggleMode={() => setIsTextMode((v) => !v)}
+ audioPlaying={audioPlaying}
/>
- {/* Session picker modal */}
- <SessionPicker
- visible={showSessions}
- onClose={() => setShowSessions(false)}
- />
+ </KeyboardAvoidingView>
+
+ {/* Image caption modal — WhatsApp-style full-screen preview */}
+ <ImageCaptionModal
+ visible={!!stagedImage}
+ imageUri={stagedImage ? `data:${stagedImage.mimeType};base64,${stagedImage.base64}` : ""}
+ onSend={handleImageSend}
+ onCancel={() => setStagedImage(null)}
+ />
+
+ {/* Session drawer — absolute overlay outside KAV */}
+ <SessionDrawer
+ visible={showSessions}
+ onClose={() => setShowSessions(false)}
+ />
</SafeAreaView>
);
}
--
Gitblit v1.3.1