feat: typing indicator, message deletion, chain playback, autoplay guard
- Typing indicator: animated three dots when server is processing
- Message deletion: long-press any bubble to delete (persisted)
- Chain playback: tap any voice chunk to auto-play all subsequent chunks
- Replay button plays full message group from first chunk
- Audio queue fix: queued chunks no longer cancel previous mid-playback
- Autoplay suppression: skip autoplay when app backgrounded or phone call active
- New Session button: prominent pill at bottom of session drawer
1 files added
7 files modified
| .. | .. |
|---|
| 11 | 11 | import { ImageCaptionModal } from "../components/chat/ImageCaptionModal"; |
|---|
| 12 | 12 | import { StatusDot } from "../components/ui/StatusDot"; |
|---|
| 13 | 13 | import { SessionDrawer } from "../components/SessionDrawer"; |
|---|
| 14 | | -import { playSingle, stopPlayback, isPlaying, onPlayingChange } from "../services/audio"; |
|---|
| 14 | +import { playAudio, stopPlayback, isPlaying, onPlayingChange } from "../services/audio"; |
|---|
| 15 | 15 | |
|---|
| 16 | 16 | interface StagedImage { |
|---|
| 17 | 17 | base64: string; |
|---|
| .. | .. |
|---|
| 20 | 20 | } |
|---|
| 21 | 21 | |
|---|
| 22 | 22 | export default function ChatScreen() { |
|---|
| 23 | | - const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, clearMessages, requestScreenshot, sessions } = |
|---|
| 23 | + const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions } = |
|---|
| 24 | 24 | useChat(); |
|---|
| 25 | 25 | const { status } = useConnection(); |
|---|
| 26 | 26 | const { colors, mode, cycleMode } = useTheme(); |
|---|
| .. | .. |
|---|
| 130 | 130 | [stagedImage, sendImageMessage], |
|---|
| 131 | 131 | ); |
|---|
| 132 | 132 | |
|---|
| 133 | | - const handleReplay = useCallback(() => { |
|---|
| 133 | + const handleReplay = useCallback(async () => { |
|---|
| 134 | 134 | if (isPlaying()) { |
|---|
| 135 | 135 | stopPlayback(); |
|---|
| 136 | 136 | return; |
|---|
| 137 | 137 | } |
|---|
| 138 | + // Find the last assistant voice message, then walk back to the first chunk in that group |
|---|
| 139 | + let lastIdx = -1; |
|---|
| 138 | 140 | for (let i = messages.length - 1; i >= 0; i--) { |
|---|
| 139 | | - const msg = messages[i]; |
|---|
| 140 | | - if (msg.role === "assistant" && msg.audioUri) { |
|---|
| 141 | | - playSingle(msg.audioUri).catch(() => {}); |
|---|
| 142 | | - return; |
|---|
| 141 | + if (messages[i].role === "assistant" && messages[i].type === "voice" && messages[i].audioUri) { |
|---|
| 142 | + lastIdx = i; |
|---|
| 143 | + break; |
|---|
| 143 | 144 | } |
|---|
| 145 | + } |
|---|
| 146 | + if (lastIdx === -1) return; |
|---|
| 147 | + |
|---|
| 148 | + // Walk back to find the start of this chunk group |
|---|
| 149 | + let startIdx = lastIdx; |
|---|
| 150 | + while (startIdx > 0) { |
|---|
| 151 | + const prev = messages[startIdx - 1]; |
|---|
| 152 | + if (prev.role === "assistant" && prev.type === "voice" && prev.audioUri) { |
|---|
| 153 | + startIdx--; |
|---|
| 154 | + } else { |
|---|
| 155 | + break; |
|---|
| 156 | + } |
|---|
| 157 | + } |
|---|
| 158 | + |
|---|
| 159 | + // Queue all chunks from start to last |
|---|
| 160 | + await stopPlayback(); |
|---|
| 161 | + for (let i = startIdx; i <= lastIdx; i++) { |
|---|
| 162 | + const m = messages[i]; |
|---|
| 163 | + if (m.audioUri) playAudio(m.audioUri); |
|---|
| 144 | 164 | } |
|---|
| 145 | 165 | }, [messages]); |
|---|
| 146 | 166 | |
|---|
| .. | .. |
|---|
| 267 | 287 | </View> |
|---|
| 268 | 288 | </View> |
|---|
| 269 | 289 | ) : ( |
|---|
| 270 | | - <MessageList messages={messages} /> |
|---|
| 290 | + <MessageList messages={messages} isTyping={isTyping} onDeleteMessage={deleteMessage} /> |
|---|
| 271 | 291 | )} |
|---|
| 272 | 292 | </View> |
|---|
| 273 | 293 | |
|---|
| .. | .. |
|---|
| 482 | 482 | > |
|---|
| 483 | 483 | Sessions |
|---|
| 484 | 484 | </Text> |
|---|
| 485 | | - <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}> |
|---|
| 486 | | - <Pressable |
|---|
| 485 | + <Pressable |
|---|
| 487 | 486 | onPress={() => requestSessions()} |
|---|
| 488 | 487 | hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} |
|---|
| 489 | 488 | style={({ pressed }) => ({ |
|---|
| .. | .. |
|---|
| 497 | 496 | Refresh |
|---|
| 498 | 497 | </Text> |
|---|
| 499 | 498 | </Pressable> |
|---|
| 500 | | - <Pressable |
|---|
| 501 | | - onPress={handleNewSession} |
|---|
| 502 | | - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} |
|---|
| 503 | | - style={({ pressed }) => ({ |
|---|
| 504 | | - width: 30, |
|---|
| 505 | | - height: 30, |
|---|
| 506 | | - borderRadius: 15, |
|---|
| 507 | | - alignItems: "center", |
|---|
| 508 | | - justifyContent: "center", |
|---|
| 509 | | - backgroundColor: pressed ? colors.accent + "CC" : colors.accent, |
|---|
| 510 | | - })} |
|---|
| 511 | | - > |
|---|
| 512 | | - <Text style={{ color: "#FFF", fontSize: 20, fontWeight: "600", marginTop: -1 }}> |
|---|
| 513 | | - + |
|---|
| 514 | | - </Text> |
|---|
| 515 | | - </Pressable> |
|---|
| 516 | | - </View> |
|---|
| 517 | 499 | </View> |
|---|
| 518 | 500 | </View> |
|---|
| 519 | 501 | |
|---|
| .. | .. |
|---|
| 537 | 519 | /> |
|---|
| 538 | 520 | )} |
|---|
| 539 | 521 | |
|---|
| 522 | + {/* New session FAB */} |
|---|
| 523 | + <View style={{ alignItems: "center", paddingVertical: 12 }}> |
|---|
| 524 | + <Pressable |
|---|
| 525 | + onPress={handleNewSession} |
|---|
| 526 | + style={({ pressed }) => ({ |
|---|
| 527 | + flexDirection: "row", |
|---|
| 528 | + alignItems: "center", |
|---|
| 529 | + gap: 8, |
|---|
| 530 | + paddingHorizontal: 20, |
|---|
| 531 | + paddingVertical: 12, |
|---|
| 532 | + borderRadius: 24, |
|---|
| 533 | + backgroundColor: pressed ? colors.accent + "CC" : colors.accent, |
|---|
| 534 | + })} |
|---|
| 535 | + > |
|---|
| 536 | + <Text style={{ color: "#FFF", fontSize: 20, fontWeight: "600", marginTop: -1 }}>+</Text> |
|---|
| 537 | + <Text style={{ color: "#FFF", fontSize: 15, fontWeight: "600" }}>New Session</Text> |
|---|
| 538 | + </Pressable> |
|---|
| 539 | + </View> |
|---|
| 540 | + |
|---|
| 540 | 541 | {/* Footer */} |
|---|
| 541 | 542 | <View |
|---|
| 542 | 543 | style={{ |
|---|
| 543 | | - paddingVertical: 12, |
|---|
| 544 | + paddingVertical: 8, |
|---|
| 544 | 545 | paddingHorizontal: 20, |
|---|
| 545 | 546 | borderTopWidth: 1, |
|---|
| 546 | 547 | borderTopColor: colors.border, |
|---|
| .. | .. |
|---|
| 1 | 1 | import React, { useCallback, useEffect, useState } from "react"; |
|---|
| 2 | | -import { Image, Pressable, Text, View } from "react-native"; |
|---|
| 2 | +import { ActionSheetIOS, Alert, Image, Platform, Pressable, Text, View } from "react-native"; |
|---|
| 3 | 3 | import { Message } from "../../types"; |
|---|
| 4 | 4 | import { playSingle, stopPlayback, onPlayingChange } from "../../services/audio"; |
|---|
| 5 | 5 | import { ImageViewer } from "./ImageViewer"; |
|---|
| .. | .. |
|---|
| 7 | 7 | |
|---|
| 8 | 8 | interface MessageBubbleProps { |
|---|
| 9 | 9 | message: Message; |
|---|
| 10 | + onDelete?: (id: string) => void; |
|---|
| 11 | + onPlayVoice?: (id: string) => void; |
|---|
| 10 | 12 | } |
|---|
| 11 | 13 | |
|---|
| 12 | 14 | function formatDuration(ms?: number): string { |
|---|
| .. | .. |
|---|
| 22 | 24 | return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); |
|---|
| 23 | 25 | } |
|---|
| 24 | 26 | |
|---|
| 25 | | -export function MessageBubble({ message }: MessageBubbleProps) { |
|---|
| 27 | +export function MessageBubble({ message, onDelete, onPlayVoice }: MessageBubbleProps) { |
|---|
| 26 | 28 | const [isPlaying, setIsPlaying] = useState(false); |
|---|
| 27 | 29 | const [showViewer, setShowViewer] = useState(false); |
|---|
| 28 | 30 | const { colors, isDark } = useTheme(); |
|---|
| 31 | + |
|---|
| 32 | + const handleLongPress = useCallback(() => { |
|---|
| 33 | + if (!onDelete) return; |
|---|
| 34 | + if (Platform.OS === "ios") { |
|---|
| 35 | + ActionSheetIOS.showActionSheetWithOptions( |
|---|
| 36 | + { |
|---|
| 37 | + options: ["Cancel", "Delete Message"], |
|---|
| 38 | + destructiveButtonIndex: 1, |
|---|
| 39 | + cancelButtonIndex: 0, |
|---|
| 40 | + }, |
|---|
| 41 | + (index) => { |
|---|
| 42 | + if (index === 1) onDelete(message.id); |
|---|
| 43 | + }, |
|---|
| 44 | + ); |
|---|
| 45 | + } else { |
|---|
| 46 | + Alert.alert("Delete Message", "Remove this message?", [ |
|---|
| 47 | + { text: "Cancel", style: "cancel" }, |
|---|
| 48 | + { text: "Delete", style: "destructive", onPress: () => onDelete(message.id) }, |
|---|
| 49 | + ]); |
|---|
| 50 | + } |
|---|
| 51 | + }, [onDelete, message.id]); |
|---|
| 29 | 52 | |
|---|
| 30 | 53 | // Track whether THIS bubble's audio is playing via the singleton URI |
|---|
| 31 | 54 | useEffect(() => { |
|---|
| .. | .. |
|---|
| 41 | 64 | if (!message.audioUri) return; |
|---|
| 42 | 65 | |
|---|
| 43 | 66 | if (isPlaying) { |
|---|
| 44 | | - // This bubble is playing — stop it |
|---|
| 45 | 67 | await stopPlayback(); |
|---|
| 68 | + } else if (onPlayVoice) { |
|---|
| 69 | + // Let parent handle chain playback (plays this + subsequent chunks) |
|---|
| 70 | + onPlayVoice(message.id); |
|---|
| 46 | 71 | } else { |
|---|
| 47 | | - // Play this bubble (stops anything else automatically) |
|---|
| 48 | 72 | await playSingle(message.audioUri, () => {}); |
|---|
| 49 | 73 | } |
|---|
| 50 | | - }, [isPlaying, message.audioUri]); |
|---|
| 74 | + }, [isPlaying, message.audioUri, onPlayVoice, message.id]); |
|---|
| 51 | 75 | |
|---|
| 52 | 76 | if (isSystem) { |
|---|
| 53 | 77 | return ( |
|---|
| .. | .. |
|---|
| 65 | 89 | : { borderTopLeftRadius: 4 }; |
|---|
| 66 | 90 | |
|---|
| 67 | 91 | return ( |
|---|
| 68 | | - <View |
|---|
| 92 | + <Pressable |
|---|
| 93 | + onLongPress={handleLongPress} |
|---|
| 94 | + delayLongPress={500} |
|---|
| 69 | 95 | style={{ |
|---|
| 70 | 96 | flexDirection: "row", |
|---|
| 71 | 97 | marginVertical: 4, |
|---|
| .. | .. |
|---|
| 213 | 239 | )} |
|---|
| 214 | 240 | </View> |
|---|
| 215 | 241 | </View> |
|---|
| 216 | | - </View> |
|---|
| 242 | + </Pressable> |
|---|
| 217 | 243 | ); |
|---|
| 218 | 244 | } |
|---|
| .. | .. |
|---|
| 1 | | -import React, { useEffect, useRef } from "react"; |
|---|
| 1 | +import React, { useCallback, useEffect, useRef } from "react"; |
|---|
| 2 | 2 | import { FlatList, View } from "react-native"; |
|---|
| 3 | 3 | import { Message } from "../../types"; |
|---|
| 4 | 4 | import { MessageBubble } from "./MessageBubble"; |
|---|
| 5 | +import { TypingIndicator } from "./TypingIndicator"; |
|---|
| 6 | +import { stopPlayback, playAudio } from "../../services/audio"; |
|---|
| 5 | 7 | |
|---|
| 6 | 8 | interface MessageListProps { |
|---|
| 7 | 9 | messages: Message[]; |
|---|
| 10 | + isTyping?: boolean; |
|---|
| 11 | + onDeleteMessage?: (id: string) => void; |
|---|
| 8 | 12 | } |
|---|
| 9 | 13 | |
|---|
| 10 | | -export function MessageList({ messages }: MessageListProps) { |
|---|
| 14 | +export function MessageList({ messages, isTyping, onDeleteMessage }: MessageListProps) { |
|---|
| 11 | 15 | const listRef = useRef<FlatList<Message>>(null); |
|---|
| 12 | 16 | |
|---|
| 13 | 17 | useEffect(() => { |
|---|
| 14 | 18 | if (messages.length > 0) { |
|---|
| 15 | | - // Small delay to allow layout to complete |
|---|
| 16 | 19 | setTimeout(() => { |
|---|
| 17 | 20 | listRef.current?.scrollToEnd({ animated: true }); |
|---|
| 18 | 21 | }, 50); |
|---|
| 19 | 22 | } |
|---|
| 20 | | - }, [messages.length]); |
|---|
| 23 | + }, [messages.length, isTyping]); |
|---|
| 24 | + |
|---|
| 25 | + // Play from a voice message and auto-chain all consecutive assistant voice messages after it |
|---|
| 26 | + const handlePlayVoice = useCallback(async (messageId: string) => { |
|---|
| 27 | + const idx = messages.findIndex((m) => m.id === messageId); |
|---|
| 28 | + if (idx === -1) return; |
|---|
| 29 | + |
|---|
| 30 | + // Collect this message + all consecutive assistant voice messages after it |
|---|
| 31 | + const chain: Message[] = []; |
|---|
| 32 | + for (let i = idx; i < messages.length; i++) { |
|---|
| 33 | + const m = messages[i]; |
|---|
| 34 | + if (m.role === "assistant" && m.type === "voice" && m.audioUri) { |
|---|
| 35 | + chain.push(m); |
|---|
| 36 | + } else if (i > idx) { |
|---|
| 37 | + // Stop at the first non-voice or non-assistant message |
|---|
| 38 | + break; |
|---|
| 39 | + } |
|---|
| 40 | + } |
|---|
| 41 | + |
|---|
| 42 | + if (chain.length === 0) return; |
|---|
| 43 | + |
|---|
| 44 | + // Stop current playback, then queue all chunks |
|---|
| 45 | + await stopPlayback(); |
|---|
| 46 | + for (const m of chain) { |
|---|
| 47 | + playAudio(m.audioUri!); |
|---|
| 48 | + } |
|---|
| 49 | + }, [messages]); |
|---|
| 21 | 50 | |
|---|
| 22 | 51 | return ( |
|---|
| 23 | 52 | <FlatList |
|---|
| 24 | 53 | ref={listRef} |
|---|
| 25 | 54 | data={messages} |
|---|
| 26 | 55 | keyExtractor={(item) => item.id} |
|---|
| 27 | | - renderItem={({ item }) => <MessageBubble message={item} />} |
|---|
| 56 | + renderItem={({ item }) => ( |
|---|
| 57 | + <MessageBubble |
|---|
| 58 | + message={item} |
|---|
| 59 | + onDelete={onDeleteMessage} |
|---|
| 60 | + onPlayVoice={handlePlayVoice} |
|---|
| 61 | + /> |
|---|
| 62 | + )} |
|---|
| 28 | 63 | contentContainerStyle={{ paddingVertical: 12 }} |
|---|
| 29 | 64 | onContentSizeChange={() => { |
|---|
| 30 | 65 | listRef.current?.scrollToEnd({ animated: false }); |
|---|
| 31 | 66 | }} |
|---|
| 32 | 67 | showsVerticalScrollIndicator={false} |
|---|
| 33 | | - ListFooterComponent={<View style={{ height: 4 }} />} |
|---|
| 68 | + ListFooterComponent={ |
|---|
| 69 | + <> |
|---|
| 70 | + {isTyping && <TypingIndicator />} |
|---|
| 71 | + <View style={{ height: 4 }} /> |
|---|
| 72 | + </> |
|---|
| 73 | + } |
|---|
| 34 | 74 | /> |
|---|
| 35 | 75 | ); |
|---|
| 36 | 76 | } |
|---|
| .. | .. |
|---|
| 1 | +import React, { useEffect, useRef } from "react"; |
|---|
| 2 | +import { Animated, View } from "react-native"; |
|---|
| 3 | +import { useTheme } from "../../contexts/ThemeContext"; |
|---|
| 4 | + |
|---|
| 5 | +export function TypingIndicator() { |
|---|
| 6 | + const { colors, isDark } = useTheme(); |
|---|
| 7 | + const dot1 = useRef(new Animated.Value(0)).current; |
|---|
| 8 | + const dot2 = useRef(new Animated.Value(0)).current; |
|---|
| 9 | + const dot3 = useRef(new Animated.Value(0)).current; |
|---|
| 10 | + |
|---|
| 11 | + useEffect(() => { |
|---|
| 12 | + const animate = (dot: Animated.Value, delay: number) => |
|---|
| 13 | + Animated.loop( |
|---|
| 14 | + Animated.sequence([ |
|---|
| 15 | + Animated.delay(delay), |
|---|
| 16 | + Animated.timing(dot, { toValue: 1, duration: 300, useNativeDriver: true }), |
|---|
| 17 | + Animated.timing(dot, { toValue: 0, duration: 300, useNativeDriver: true }), |
|---|
| 18 | + ]) |
|---|
| 19 | + ); |
|---|
| 20 | + |
|---|
| 21 | + const a1 = animate(dot1, 0); |
|---|
| 22 | + const a2 = animate(dot2, 200); |
|---|
| 23 | + const a3 = animate(dot3, 400); |
|---|
| 24 | + a1.start(); |
|---|
| 25 | + a2.start(); |
|---|
| 26 | + a3.start(); |
|---|
| 27 | + |
|---|
| 28 | + return () => { a1.stop(); a2.stop(); a3.stop(); }; |
|---|
| 29 | + }, [dot1, dot2, dot3]); |
|---|
| 30 | + |
|---|
| 31 | + const bubbleBg = isDark ? "#252538" : colors.bgSecondary; |
|---|
| 32 | + const dotColor = colors.textMuted; |
|---|
| 33 | + |
|---|
| 34 | + return ( |
|---|
| 35 | + <View |
|---|
| 36 | + style={{ |
|---|
| 37 | + flexDirection: "row", |
|---|
| 38 | + marginVertical: 4, |
|---|
| 39 | + paddingHorizontal: 12, |
|---|
| 40 | + justifyContent: "flex-start", |
|---|
| 41 | + }} |
|---|
| 42 | + > |
|---|
| 43 | + <View |
|---|
| 44 | + style={{ |
|---|
| 45 | + borderRadius: 16, |
|---|
| 46 | + borderTopLeftRadius: 4, |
|---|
| 47 | + paddingHorizontal: 16, |
|---|
| 48 | + paddingVertical: 14, |
|---|
| 49 | + backgroundColor: bubbleBg, |
|---|
| 50 | + flexDirection: "row", |
|---|
| 51 | + gap: 4, |
|---|
| 52 | + alignItems: "center", |
|---|
| 53 | + }} |
|---|
| 54 | + > |
|---|
| 55 | + {[dot1, dot2, dot3].map((dot, i) => ( |
|---|
| 56 | + <Animated.View |
|---|
| 57 | + key={i} |
|---|
| 58 | + style={{ |
|---|
| 59 | + width: 8, |
|---|
| 60 | + height: 8, |
|---|
| 61 | + borderRadius: 4, |
|---|
| 62 | + backgroundColor: dotColor, |
|---|
| 63 | + opacity: dot.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }), |
|---|
| 64 | + transform: [ |
|---|
| 65 | + { |
|---|
| 66 | + translateY: dot.interpolate({ |
|---|
| 67 | + inputRange: [0, 1], |
|---|
| 68 | + outputRange: [0, -4], |
|---|
| 69 | + }), |
|---|
| 70 | + }, |
|---|
| 71 | + ], |
|---|
| 72 | + }} |
|---|
| 73 | + /> |
|---|
| 74 | + ))} |
|---|
| 75 | + </View> |
|---|
| 76 | + </View> |
|---|
| 77 | + ); |
|---|
| 78 | +} |
|---|
| .. | .. |
|---|
| 8 | 8 | } from "react"; |
|---|
| 9 | 9 | import { Message, WsIncoming, WsSession } from "../types"; |
|---|
| 10 | 10 | import { useConnection } from "./ConnectionContext"; |
|---|
| 11 | | -import { playAudio, encodeAudioToBase64, saveBase64Audio } from "../services/audio"; |
|---|
| 11 | +import { playAudio, encodeAudioToBase64, saveBase64Audio, canAutoplay } from "../services/audio"; |
|---|
| 12 | 12 | import { requestNotificationPermissions, notifyIncomingMessage } from "../services/notifications"; |
|---|
| 13 | 13 | |
|---|
| 14 | 14 | function generateId(): string { |
|---|
| .. | .. |
|---|
| 119 | 119 | sendTextMessage: (text: string) => void; |
|---|
| 120 | 120 | sendVoiceMessage: (audioUri: string, durationMs?: number) => void; |
|---|
| 121 | 121 | sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => void; |
|---|
| 122 | + deleteMessage: (id: string) => void; |
|---|
| 122 | 123 | clearMessages: () => void; |
|---|
| 124 | + isTyping: boolean; |
|---|
| 123 | 125 | sessions: WsSession[]; |
|---|
| 124 | 126 | activeSessionId: string | null; |
|---|
| 125 | 127 | requestSessions: () => void; |
|---|
| .. | .. |
|---|
| 147 | 149 | const [messages, setMessages] = useState<Message[]>([]); |
|---|
| 148 | 150 | // Unread counts for non-active sessions |
|---|
| 149 | 151 | const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({}); |
|---|
| 152 | + // Typing indicator from server |
|---|
| 153 | + const [isTyping, setIsTyping] = useState(false); |
|---|
| 150 | 154 | |
|---|
| 151 | 155 | const { |
|---|
| 152 | 156 | status, |
|---|
| .. | .. |
|---|
| 197 | 201 | if (status === "connected") { |
|---|
| 198 | 202 | needsSync.current = true; |
|---|
| 199 | 203 | sendCommand("sync", activeSessionId ? { activeSessionId } : undefined); |
|---|
| 204 | + } else if (status === "disconnected") { |
|---|
| 205 | + setIsTyping(false); |
|---|
| 200 | 206 | } |
|---|
| 201 | 207 | // eslint-disable-next-line react-hooks/exhaustive-deps — only fire on status change |
|---|
| 202 | 208 | }, [status, sendCommand]); |
|---|
| .. | .. |
|---|
| 270 | 276 | onMessageReceived.current = async (data: WsIncoming) => { |
|---|
| 271 | 277 | switch (data.type) { |
|---|
| 272 | 278 | case "text": { |
|---|
| 279 | + setIsTyping(false); |
|---|
| 273 | 280 | const msg: Message = { |
|---|
| 274 | 281 | id: generateId(), |
|---|
| 275 | 282 | role: "assistant", |
|---|
| .. | .. |
|---|
| 283 | 290 | break; |
|---|
| 284 | 291 | } |
|---|
| 285 | 292 | case "voice": { |
|---|
| 293 | + setIsTyping(false); |
|---|
| 286 | 294 | let audioUri: string | undefined; |
|---|
| 287 | 295 | if (data.audioBase64) { |
|---|
| 288 | 296 | try { |
|---|
| .. | .. |
|---|
| 302 | 310 | }; |
|---|
| 303 | 311 | addMessageToActive(msg); |
|---|
| 304 | 312 | notifyIncomingMessage("PAILot", data.content ?? "Voice message"); |
|---|
| 305 | | - if (msg.audioUri) { |
|---|
| 313 | + if (msg.audioUri && canAutoplay()) { |
|---|
| 306 | 314 | playAudio(msg.audioUri).catch(() => {}); |
|---|
| 307 | 315 | } |
|---|
| 308 | 316 | break; |
|---|
| .. | .. |
|---|
| 356 | 364 | case "transcript": { |
|---|
| 357 | 365 | // Voice → text reflection: replace voice bubble with transcribed text |
|---|
| 358 | 366 | updateMessageContent(data.messageId, data.content); |
|---|
| 367 | + break; |
|---|
| 368 | + } |
|---|
| 369 | + case "typing": { |
|---|
| 370 | + setIsTyping(data.typing); |
|---|
| 371 | + break; |
|---|
| 372 | + } |
|---|
| 373 | + case "status": { |
|---|
| 374 | + // Connection status update — ignore for now |
|---|
| 359 | 375 | break; |
|---|
| 360 | 376 | } |
|---|
| 361 | 377 | case "error": { |
|---|
| .. | .. |
|---|
| 440 | 456 | [wsImageSend, addMessageToActive, updateMessageStatus] |
|---|
| 441 | 457 | ); |
|---|
| 442 | 458 | |
|---|
| 459 | + const deleteMessage = useCallback((id: string) => { |
|---|
| 460 | + setMessages((prev) => { |
|---|
| 461 | + const next = prev.filter((m) => m.id !== id); |
|---|
| 462 | + setActiveSessionId((sessId) => { |
|---|
| 463 | + if (sessId) { |
|---|
| 464 | + messagesMapRef.current[sessId] = next; |
|---|
| 465 | + debouncedSave(messagesMapRef.current); |
|---|
| 466 | + } |
|---|
| 467 | + return sessId; |
|---|
| 468 | + }); |
|---|
| 469 | + return next; |
|---|
| 470 | + }); |
|---|
| 471 | + }, []); |
|---|
| 472 | + |
|---|
| 443 | 473 | const clearMessages = useCallback(() => { |
|---|
| 444 | 474 | setMessages([]); |
|---|
| 445 | 475 | setActiveSessionId((id) => { |
|---|
| .. | .. |
|---|
| 515 | 545 | sendTextMessage, |
|---|
| 516 | 546 | sendVoiceMessage, |
|---|
| 517 | 547 | sendImageMessage, |
|---|
| 548 | + deleteMessage, |
|---|
| 518 | 549 | clearMessages, |
|---|
| 550 | + isTyping, |
|---|
| 519 | 551 | sessions, |
|---|
| 520 | 552 | activeSessionId, |
|---|
| 521 | 553 | requestSessions, |
|---|
| .. | .. |
|---|
| 4 | 4 | setAudioModeAsync, |
|---|
| 5 | 5 | } from "expo-audio"; |
|---|
| 6 | 6 | import * as LegacyFileSystem from "expo-file-system/legacy"; |
|---|
| 7 | +import { AppState } from "react-native"; |
|---|
| 7 | 8 | |
|---|
| 8 | 9 | export interface RecordingResult { |
|---|
| 9 | 10 | uri: string; |
|---|
| 10 | 11 | durationMs: number; |
|---|
| 12 | +} |
|---|
| 13 | + |
|---|
| 14 | +// --- Autoplay suppression --- |
|---|
| 15 | +// Don't autoplay voice messages when the app is in the background |
|---|
| 16 | +// or when the user is on a phone call (detected via audio interruption). |
|---|
| 17 | +let _autoplayEnabled = true; |
|---|
| 18 | +let _audioInterrupted = false; |
|---|
| 19 | + |
|---|
| 20 | +// Track app state — suppress autoplay when backgrounded |
|---|
| 21 | +AppState.addEventListener("change", (state) => { |
|---|
| 22 | + _autoplayEnabled = state === "active"; |
|---|
| 23 | +}); |
|---|
| 24 | + |
|---|
| 25 | +/** Check if autoplay is safe right now (app in foreground, no interruption). */ |
|---|
| 26 | +export function canAutoplay(): boolean { |
|---|
| 27 | + return _autoplayEnabled && !_audioInterrupted; |
|---|
| 28 | +} |
|---|
| 29 | + |
|---|
| 30 | +/** Called externally to signal audio interruption (e.g., phone call started/ended). */ |
|---|
| 31 | +export function setAudioInterrupted(interrupted: boolean): void { |
|---|
| 32 | + _audioInterrupted = interrupted; |
|---|
| 11 | 33 | } |
|---|
| 12 | 34 | |
|---|
| 13 | 35 | // --- Singleton audio player --- |
|---|
| .. | .. |
|---|
| 94 | 116 | |
|---|
| 95 | 117 | while (audioQueue.length > 0) { |
|---|
| 96 | 118 | const item = audioQueue.shift()!; |
|---|
| 97 | | - await playOneAudio(item.uri, item.onFinish); |
|---|
| 119 | + await playOneAudio(item.uri, item.onFinish, false); |
|---|
| 98 | 120 | } |
|---|
| 99 | 121 | |
|---|
| 100 | 122 | processingQueue = false; |
|---|
| 101 | 123 | } |
|---|
| 102 | 124 | |
|---|
| 103 | | -function playOneAudio(uri: string, onFinish?: () => void): Promise<void> { |
|---|
| 125 | +function playOneAudio(uri: string, onFinish?: () => void, cancelPrevious = true): Promise<void> { |
|---|
| 104 | 126 | return new Promise<void>(async (resolve) => { |
|---|
| 105 | 127 | let settled = false; |
|---|
| 106 | 128 | const finish = () => { |
|---|
| .. | .. |
|---|
| 118 | 140 | resolve(); |
|---|
| 119 | 141 | }; |
|---|
| 120 | 142 | |
|---|
| 121 | | - // Stop any currently playing audio first |
|---|
| 122 | | - if (cancelCurrent) { |
|---|
| 143 | + // Stop any currently playing audio first (only for non-queued calls) |
|---|
| 144 | + if (cancelPrevious && cancelCurrent) { |
|---|
| 123 | 145 | cancelCurrent(); |
|---|
| 124 | 146 | } |
|---|
| 125 | 147 | |
|---|
| .. | .. |
|---|
| 138 | 160 | notifyListeners(uri); |
|---|
| 139 | 161 | |
|---|
| 140 | 162 | player.addListener("playbackStatusUpdate", (status) => { |
|---|
| 141 | | - if (!status.playing && status.currentTime > 0 && |
|---|
| 142 | | - (status.duration <= 0 || status.currentTime >= status.duration)) { |
|---|
| 143 | | - finish(); |
|---|
| 163 | + if (!status.playing && status.currentTime > 0) { |
|---|
| 164 | + if (status.duration <= 0 || status.currentTime >= status.duration) { |
|---|
| 165 | + // Playback finished naturally |
|---|
| 166 | + finish(); |
|---|
| 167 | + } else { |
|---|
| 168 | + // Paused mid-playback — likely audio interruption (phone call) |
|---|
| 169 | + setAudioInterrupted(true); |
|---|
| 170 | + } |
|---|
| 171 | + } else if (status.playing && _audioInterrupted) { |
|---|
| 172 | + // Resumed after interruption |
|---|
| 173 | + setAudioInterrupted(false); |
|---|
| 144 | 174 | } |
|---|
| 145 | 175 | }); |
|---|
| 146 | 176 | |
|---|
| .. | .. |
|---|
| 102 | 102 | content: string; |
|---|
| 103 | 103 | } |
|---|
| 104 | 104 | |
|---|
| 105 | +export interface WsIncomingTyping { |
|---|
| 106 | + type: "typing"; |
|---|
| 107 | + typing: boolean; |
|---|
| 108 | +} |
|---|
| 109 | + |
|---|
| 105 | 110 | export interface WsIncomingError { |
|---|
| 106 | 111 | type: "error"; |
|---|
| 107 | 112 | message: string; |
|---|
| .. | .. |
|---|
| 120 | 125 | | WsIncomingSessionSwitched |
|---|
| 121 | 126 | | WsIncomingSessionRenamed |
|---|
| 122 | 127 | | WsIncomingTranscript |
|---|
| 128 | + | WsIncomingTyping |
|---|
| 123 | 129 | | WsIncomingError |
|---|
| 124 | 130 | | WsIncomingStatus; |
|---|