Matthias Nott
2026-03-07 c23dfe16e95713e7058137308bdbc28419609a39
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
changed files
app/chat.tsx patch | view | blame | history
components/SessionDrawer.tsx patch | view | blame | history
components/chat/MessageBubble.tsx patch | view | blame | history
components/chat/MessageList.tsx patch | view | blame | history
components/chat/TypingIndicator.tsx patch | view | blame | history
contexts/ChatContext.tsx patch | view | blame | history
services/audio.ts patch | view | blame | history
types/index.ts patch | view | blame | history
app/chat.tsx
....@@ -11,7 +11,7 @@
1111 import { ImageCaptionModal } from "../components/chat/ImageCaptionModal";
1212 import { StatusDot } from "../components/ui/StatusDot";
1313 import { SessionDrawer } from "../components/SessionDrawer";
14
-import { playSingle, stopPlayback, isPlaying, onPlayingChange } from "../services/audio";
14
+import { playAudio, stopPlayback, isPlaying, onPlayingChange } from "../services/audio";
1515
1616 interface StagedImage {
1717 base64: string;
....@@ -20,7 +20,7 @@
2020 }
2121
2222 export default function ChatScreen() {
23
- const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, clearMessages, requestScreenshot, sessions } =
23
+ const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions } =
2424 useChat();
2525 const { status } = useConnection();
2626 const { colors, mode, cycleMode } = useTheme();
....@@ -130,17 +130,37 @@
130130 [stagedImage, sendImageMessage],
131131 );
132132
133
- const handleReplay = useCallback(() => {
133
+ const handleReplay = useCallback(async () => {
134134 if (isPlaying()) {
135135 stopPlayback();
136136 return;
137137 }
138
+ // Find the last assistant voice message, then walk back to the first chunk in that group
139
+ let lastIdx = -1;
138140 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;
143144 }
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);
144164 }
145165 }, [messages]);
146166
....@@ -267,7 +287,7 @@
267287 </View>
268288 </View>
269289 ) : (
270
- <MessageList messages={messages} />
290
+ <MessageList messages={messages} isTyping={isTyping} onDeleteMessage={deleteMessage} />
271291 )}
272292 </View>
273293
components/SessionDrawer.tsx
....@@ -482,8 +482,7 @@
482482 >
483483 Sessions
484484 </Text>
485
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
486
- <Pressable
485
+ <Pressable
487486 onPress={() => requestSessions()}
488487 hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
489488 style={({ pressed }) => ({
....@@ -497,23 +496,6 @@
497496 Refresh
498497 </Text>
499498 </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>
517499 </View>
518500 </View>
519501
....@@ -537,10 +519,29 @@
537519 />
538520 )}
539521
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
+
540541 {/* Footer */}
541542 <View
542543 style={{
543
- paddingVertical: 12,
544
+ paddingVertical: 8,
544545 paddingHorizontal: 20,
545546 borderTopWidth: 1,
546547 borderTopColor: colors.border,
components/chat/MessageBubble.tsx
....@@ -1,5 +1,5 @@
11 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";
33 import { Message } from "../../types";
44 import { playSingle, stopPlayback, onPlayingChange } from "../../services/audio";
55 import { ImageViewer } from "./ImageViewer";
....@@ -7,6 +7,8 @@
77
88 interface MessageBubbleProps {
99 message: Message;
10
+ onDelete?: (id: string) => void;
11
+ onPlayVoice?: (id: string) => void;
1012 }
1113
1214 function formatDuration(ms?: number): string {
....@@ -22,10 +24,31 @@
2224 return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2325 }
2426
25
-export function MessageBubble({ message }: MessageBubbleProps) {
27
+export function MessageBubble({ message, onDelete, onPlayVoice }: MessageBubbleProps) {
2628 const [isPlaying, setIsPlaying] = useState(false);
2729 const [showViewer, setShowViewer] = useState(false);
2830 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]);
2952
3053 // Track whether THIS bubble's audio is playing via the singleton URI
3154 useEffect(() => {
....@@ -41,13 +64,14 @@
4164 if (!message.audioUri) return;
4265
4366 if (isPlaying) {
44
- // This bubble is playing — stop it
4567 await stopPlayback();
68
+ } else if (onPlayVoice) {
69
+ // Let parent handle chain playback (plays this + subsequent chunks)
70
+ onPlayVoice(message.id);
4671 } else {
47
- // Play this bubble (stops anything else automatically)
4872 await playSingle(message.audioUri, () => {});
4973 }
50
- }, [isPlaying, message.audioUri]);
74
+ }, [isPlaying, message.audioUri, onPlayVoice, message.id]);
5175
5276 if (isSystem) {
5377 return (
....@@ -65,7 +89,9 @@
6589 : { borderTopLeftRadius: 4 };
6690
6791 return (
68
- <View
92
+ <Pressable
93
+ onLongPress={handleLongPress}
94
+ delayLongPress={500}
6995 style={{
7096 flexDirection: "row",
7197 marginVertical: 4,
....@@ -213,6 +239,6 @@
213239 )}
214240 </View>
215241 </View>
216
- </View>
242
+ </Pressable>
217243 );
218244 }
components/chat/MessageList.tsx
....@@ -1,36 +1,76 @@
1
-import React, { useEffect, useRef } from "react";
1
+import React, { useCallback, useEffect, useRef } from "react";
22 import { FlatList, View } from "react-native";
33 import { Message } from "../../types";
44 import { MessageBubble } from "./MessageBubble";
5
+import { TypingIndicator } from "./TypingIndicator";
6
+import { stopPlayback, playAudio } from "../../services/audio";
57
68 interface MessageListProps {
79 messages: Message[];
10
+ isTyping?: boolean;
11
+ onDeleteMessage?: (id: string) => void;
812 }
913
10
-export function MessageList({ messages }: MessageListProps) {
14
+export function MessageList({ messages, isTyping, onDeleteMessage }: MessageListProps) {
1115 const listRef = useRef<FlatList<Message>>(null);
1216
1317 useEffect(() => {
1418 if (messages.length > 0) {
15
- // Small delay to allow layout to complete
1619 setTimeout(() => {
1720 listRef.current?.scrollToEnd({ animated: true });
1821 }, 50);
1922 }
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]);
2150
2251 return (
2352 <FlatList
2453 ref={listRef}
2554 data={messages}
2655 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
+ )}
2863 contentContainerStyle={{ paddingVertical: 12 }}
2964 onContentSizeChange={() => {
3065 listRef.current?.scrollToEnd({ animated: false });
3166 }}
3267 showsVerticalScrollIndicator={false}
33
- ListFooterComponent={<View style={{ height: 4 }} />}
68
+ ListFooterComponent={
69
+ <>
70
+ {isTyping && <TypingIndicator />}
71
+ <View style={{ height: 4 }} />
72
+ </>
73
+ }
3474 />
3575 );
3676 }
components/chat/TypingIndicator.tsx
....@@ -0,0 +1,78 @@
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
+}
contexts/ChatContext.tsx
....@@ -8,7 +8,7 @@
88 } from "react";
99 import { Message, WsIncoming, WsSession } from "../types";
1010 import { useConnection } from "./ConnectionContext";
11
-import { playAudio, encodeAudioToBase64, saveBase64Audio } from "../services/audio";
11
+import { playAudio, encodeAudioToBase64, saveBase64Audio, canAutoplay } from "../services/audio";
1212 import { requestNotificationPermissions, notifyIncomingMessage } from "../services/notifications";
1313
1414 function generateId(): string {
....@@ -119,7 +119,9 @@
119119 sendTextMessage: (text: string) => void;
120120 sendVoiceMessage: (audioUri: string, durationMs?: number) => void;
121121 sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => void;
122
+ deleteMessage: (id: string) => void;
122123 clearMessages: () => void;
124
+ isTyping: boolean;
123125 sessions: WsSession[];
124126 activeSessionId: string | null;
125127 requestSessions: () => void;
....@@ -147,6 +149,8 @@
147149 const [messages, setMessages] = useState<Message[]>([]);
148150 // Unread counts for non-active sessions
149151 const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
152
+ // Typing indicator from server
153
+ const [isTyping, setIsTyping] = useState(false);
150154
151155 const {
152156 status,
....@@ -197,6 +201,8 @@
197201 if (status === "connected") {
198202 needsSync.current = true;
199203 sendCommand("sync", activeSessionId ? { activeSessionId } : undefined);
204
+ } else if (status === "disconnected") {
205
+ setIsTyping(false);
200206 }
201207 // eslint-disable-next-line react-hooks/exhaustive-deps — only fire on status change
202208 }, [status, sendCommand]);
....@@ -270,6 +276,7 @@
270276 onMessageReceived.current = async (data: WsIncoming) => {
271277 switch (data.type) {
272278 case "text": {
279
+ setIsTyping(false);
273280 const msg: Message = {
274281 id: generateId(),
275282 role: "assistant",
....@@ -283,6 +290,7 @@
283290 break;
284291 }
285292 case "voice": {
293
+ setIsTyping(false);
286294 let audioUri: string | undefined;
287295 if (data.audioBase64) {
288296 try {
....@@ -302,7 +310,7 @@
302310 };
303311 addMessageToActive(msg);
304312 notifyIncomingMessage("PAILot", data.content ?? "Voice message");
305
- if (msg.audioUri) {
313
+ if (msg.audioUri && canAutoplay()) {
306314 playAudio(msg.audioUri).catch(() => {});
307315 }
308316 break;
....@@ -356,6 +364,14 @@
356364 case "transcript": {
357365 // Voice → text reflection: replace voice bubble with transcribed text
358366 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
359375 break;
360376 }
361377 case "error": {
....@@ -440,6 +456,20 @@
440456 [wsImageSend, addMessageToActive, updateMessageStatus]
441457 );
442458
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
+
443473 const clearMessages = useCallback(() => {
444474 setMessages([]);
445475 setActiveSessionId((id) => {
....@@ -515,7 +545,9 @@
515545 sendTextMessage,
516546 sendVoiceMessage,
517547 sendImageMessage,
548
+ deleteMessage,
518549 clearMessages,
550
+ isTyping,
519551 sessions,
520552 activeSessionId,
521553 requestSessions,
services/audio.ts
....@@ -4,10 +4,32 @@
44 setAudioModeAsync,
55 } from "expo-audio";
66 import * as LegacyFileSystem from "expo-file-system/legacy";
7
+import { AppState } from "react-native";
78
89 export interface RecordingResult {
910 uri: string;
1011 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;
1133 }
1234
1335 // --- Singleton audio player ---
....@@ -94,13 +116,13 @@
94116
95117 while (audioQueue.length > 0) {
96118 const item = audioQueue.shift()!;
97
- await playOneAudio(item.uri, item.onFinish);
119
+ await playOneAudio(item.uri, item.onFinish, false);
98120 }
99121
100122 processingQueue = false;
101123 }
102124
103
-function playOneAudio(uri: string, onFinish?: () => void): Promise<void> {
125
+function playOneAudio(uri: string, onFinish?: () => void, cancelPrevious = true): Promise<void> {
104126 return new Promise<void>(async (resolve) => {
105127 let settled = false;
106128 const finish = () => {
....@@ -118,8 +140,8 @@
118140 resolve();
119141 };
120142
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) {
123145 cancelCurrent();
124146 }
125147
....@@ -138,9 +160,17 @@
138160 notifyListeners(uri);
139161
140162 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);
144174 }
145175 });
146176
types/index.ts
....@@ -102,6 +102,11 @@
102102 content: string;
103103 }
104104
105
+export interface WsIncomingTyping {
106
+ type: "typing";
107
+ typing: boolean;
108
+}
109
+
105110 export interface WsIncomingError {
106111 type: "error";
107112 message: string;
....@@ -120,5 +125,6 @@
120125 | WsIncomingSessionSwitched
121126 | WsIncomingSessionRenamed
122127 | WsIncomingTranscript
128
+ | WsIncomingTyping
123129 | WsIncomingError
124130 | WsIncomingStatus;