Matthias Nott
2026-03-07 5db84bd89c8808b0895c7206e8a6a58043f9f8dc
feat: heartbeat fix, copy messages, copy/share images, hide unknown duration

- WebSocket: timestamp-based heartbeat liveness (replaces boolean flag race condition)
- MessageBubble: add Copy option to long-press menu, suppress 0:00 when duration unknown
- ImageViewer: add Copy button alongside Share
- Add expo-clipboard dependency
5 files modified
changed files
bun.lock patch | view | blame | history
components/chat/ImageViewer.tsx patch | view | blame | history
components/chat/MessageBubble.tsx patch | view | blame | history
package.json patch | view | blame | history
services/websocket.ts patch | view | blame | history
bun.lock
....@@ -9,6 +9,7 @@
99 "@react-navigation/native": "^7.1.31",
1010 "expo": "~55.0.4",
1111 "expo-audio": "^55.0.8",
12
+ "expo-clipboard": "~55.0.8",
1213 "expo-constants": "~55.0.7",
1314 "expo-file-system": "~55.0.10",
1415 "expo-haptics": "~55.0.8",
....@@ -671,6 +672,8 @@
671672
672673 "expo-audio": ["expo-audio@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-X61pQSikE2rsP2ZTMFUMThOmgGyYEHcmZpGVMrKJgcYtRCFKuctB/z69dFQPoumL+zTz8qlBoGohjkHVvA9P8A=="],
673674
675
+ "expo-clipboard": ["expo-clipboard@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-s0Hkop+dc6m09LwzUAWweNI0gzLAaX5CgEGR8TMdOdSPKTPc2rCl8h8Ji/cUNM1wYoJQ4Wysa15E8If/Vlu7WA=="],
676
+
674677 "expo-constants": ["expo-constants@55.0.7", "", { "dependencies": { "@expo/config": "~55.0.8", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ=="],
675678
676679 "expo-file-system": ["expo-file-system@55.0.10", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw=="],
components/chat/ImageViewer.tsx
....@@ -10,6 +10,7 @@
1010 View,
1111 } from "react-native";
1212 import { cacheDirectory, writeAsStringAsync } from "expo-file-system/legacy";
13
+import * as Clipboard from "expo-clipboard";
1314 import * as Sharing from "expo-sharing";
1415
1516 /** Apple-style share icon (square with upward arrow) */
....@@ -68,6 +69,14 @@
6869 export function ImageViewer({ visible, imageBase64, onClose }: ImageViewerProps) {
6970 const { width, height } = Dimensions.get("window");
7071
72
+ const handleCopy = useCallback(async () => {
73
+ try {
74
+ await Clipboard.setImageAsync(`data:image/png;base64,${imageBase64}`);
75
+ } catch (err: any) {
76
+ Alert.alert("Copy Error", err?.message ?? String(err));
77
+ }
78
+ }, [imageBase64]);
79
+
7180 const handleShare = useCallback(async () => {
7281 try {
7382 const fileUri = `${cacheDirectory}pailot-screenshot-${Date.now()}.png`;
....@@ -105,6 +114,20 @@
105114 }}
106115 >
107116 <Pressable
117
+ onPress={handleCopy}
118
+ hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
119
+ style={{
120
+ width: 40,
121
+ height: 40,
122
+ borderRadius: 20,
123
+ backgroundColor: "rgba(255,255,255,0.15)",
124
+ alignItems: "center",
125
+ justifyContent: "center",
126
+ }}
127
+ >
128
+ <Text style={{ color: "#fff", fontSize: 18 }}>📋</Text>
129
+ </Pressable>
130
+ <Pressable
108131 onPress={handleShare}
109132 hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
110133 style={{
components/chat/MessageBubble.tsx
....@@ -1,5 +1,6 @@
11 import React, { useCallback, useEffect, useState } from "react";
22 import { ActionSheetIOS, Alert, Image, Platform, Pressable, Text, View } from "react-native";
3
+import * as Clipboard from "expo-clipboard";
34 import { Message } from "../../types";
45 import { playSingle, stopPlayback, onPlayingChange } from "../../services/audio";
56 import { ImageViewer } from "./ImageViewer";
....@@ -11,8 +12,8 @@
1112 onPlayVoice?: (id: string) => void;
1213 }
1314
14
-function formatDuration(ms?: number): string {
15
- if (!ms) return "0:00";
15
+function formatDuration(ms?: number): string | null {
16
+ if (!ms || ms <= 0) return null;
1617 const totalSeconds = Math.floor(ms / 1000);
1718 const minutes = Math.floor(totalSeconds / 60);
1819 const seconds = totalSeconds % 60;
....@@ -30,25 +31,32 @@
3031 const { colors, isDark } = useTheme();
3132
3233 const handleLongPress = useCallback(() => {
33
- if (!onDelete) return;
34
+ const hasText = !!message.content;
3435 if (Platform.OS === "ios") {
36
+ const options = ["Cancel"];
37
+ if (hasText) options.push("Copy");
38
+ if (onDelete) options.push("Delete Message");
39
+ const destructiveIndex = onDelete ? options.indexOf("Delete Message") : undefined;
40
+
3541 ActionSheetIOS.showActionSheetWithOptions(
3642 {
37
- options: ["Cancel", "Delete Message"],
38
- destructiveButtonIndex: 1,
43
+ options,
44
+ destructiveButtonIndex: destructiveIndex,
3945 cancelButtonIndex: 0,
4046 },
4147 (index) => {
42
- if (index === 1) onDelete(message.id);
48
+ const selected = options[index];
49
+ if (selected === "Copy") Clipboard.setStringAsync(message.content ?? "");
50
+ else if (selected === "Delete Message") onDelete?.(message.id);
4351 },
4452 );
4553 } else {
46
- Alert.alert("Delete Message", "Remove this message?", [
47
- { text: "Cancel", style: "cancel" },
48
- { text: "Delete", style: "destructive", onPress: () => onDelete(message.id) },
49
- ]);
54
+ const buttons: any[] = [{ text: "Cancel", style: "cancel" }];
55
+ if (hasText) buttons.push({ text: "Copy", onPress: () => Clipboard.setStringAsync(message.content ?? "") });
56
+ if (onDelete) buttons.push({ text: "Delete", style: "destructive", onPress: () => onDelete(message.id) });
57
+ Alert.alert("Message", undefined, buttons);
5058 }
51
- }, [onDelete, message.id]);
59
+ }, [onDelete, message.id, message.content]);
5260
5361 // Track whether THIS bubble's audio is playing via the singleton URI
5462 useEffect(() => {
....@@ -183,14 +191,16 @@
183191 ))}
184192 </View>
185193
186
- <Text
187
- style={{
188
- fontSize: 11,
189
- color: isUser ? "rgba(255,255,255,0.8)" : colors.textSecondary,
190
- }}
191
- >
192
- {formatDuration(message.duration)}
193
- </Text>
194
+ {formatDuration(message.duration) && (
195
+ <Text
196
+ style={{
197
+ fontSize: 11,
198
+ color: isUser ? "rgba(255,255,255,0.8)" : colors.textSecondary,
199
+ }}
200
+ >
201
+ {formatDuration(message.duration)}
202
+ </Text>
203
+ )}
194204 </Pressable>
195205 {message.content ? (
196206 <Text
package.json
....@@ -13,6 +13,7 @@
1313 "@react-navigation/native": "^7.1.31",
1414 "expo": "~55.0.4",
1515 "expo-audio": "^55.0.8",
16
+ "expo-clipboard": "~55.0.8",
1617 "expo-constants": "~55.0.7",
1718 "expo-file-system": "~55.0.10",
1819 "expo-haptics": "~55.0.8",
services/websocket.ts
....@@ -17,7 +17,7 @@
1717 const MAX_RECONNECT_DELAY = 30000;
1818 const RECONNECT_MULTIPLIER = 2;
1919 const LOCAL_TIMEOUT = 2500;
20
-const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets
20
+const HEARTBEAT_INTERVAL = 30000; // 30s ping to detect zombie sockets
2121
2222 export class WebSocketClient {
2323 private ws: WebSocket | null = null;
....@@ -27,22 +27,24 @@
2727 private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2828 private localTimer: ReturnType<typeof setTimeout> | null = null;
2929 private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
30
- private pongReceived: boolean = true;
30
+ private lastMessageAt: number = 0; // timestamp of last received message (any type)
3131 private shouldReconnect: boolean = false;
3232 private connected: boolean = false;
3333 private callbacks: WebSocketClientOptions = {};
3434
3535 constructor() {
36
- // When app comes back to foreground, check if socket is still alive
36
+ // When app comes back to foreground, verify the connection
3737 AppState.addEventListener("change", (state: AppStateStatus) => {
3838 if (state === "active" && this.shouldReconnect) {
39
- if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
40
- // Socket is dead — force immediate reconnect
39
+ if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
40
+ // Socket is definitively dead — force immediate reconnect
4141 this.reconnectDelay = INITIAL_RECONNECT_DELAY;
4242 this.urlIndex = 0;
4343 this.tryUrl();
44
- } else {
45
- // Socket looks open but might be zombie — send a ping to verify
44
+ } else if (this.connected) {
45
+ // Socket might be alive — send a ping and restart heartbeat
46
+ // to give it a fresh 2-interval window to prove liveness
47
+ this.startHeartbeat();
4648 this.sendPing();
4749 }
4850 }
....@@ -80,10 +82,11 @@
8082
8183 private startHeartbeat() {
8284 this.stopHeartbeat();
83
- this.pongReceived = true;
85
+ this.lastMessageAt = Date.now();
8486 this.heartbeatTimer = setInterval(() => {
85
- if (!this.pongReceived) {
86
- // No pong since last ping — socket is zombie, force reconnect
87
+ const silentMs = Date.now() - this.lastMessageAt;
88
+ if (silentMs > HEARTBEAT_INTERVAL * 2) {
89
+ // No message (including pong) for 2 full intervals — zombie socket
8790 this.connected = false;
8891 this.callbacks.onClose?.();
8992 this.reconnectDelay = INITIAL_RECONNECT_DELAY;
....@@ -91,6 +94,7 @@
9194 this.tryUrl();
9295 return;
9396 }
97
+ // Send a ping so the server has something to respond to
9498 this.sendPing();
9599 }, HEARTBEAT_INTERVAL);
96100 }
....@@ -101,7 +105,6 @@
101105
102106 private sendPing() {
103107 if (this.ws && this.ws.readyState === WebSocket.OPEN) {
104
- this.pongReceived = false;
105108 try {
106109 this.ws.send(JSON.stringify({ type: "ping" }));
107110 } catch {
....@@ -147,13 +150,10 @@
147150
148151 ws.onmessage = (event) => {
149152 if (ws !== this.ws) return; // stale
153
+ this.lastMessageAt = Date.now(); // any message proves the connection is alive
150154 try {
151155 const data = JSON.parse(event.data) as WebSocketMessage;
152
- // Handle pong responses from heartbeat
153
- if (data.type === "pong") {
154
- this.pongReceived = true;
155
- return;
156
- }
156
+ if (data.type === "pong") return; // heartbeat response, don't forward
157157 this.callbacks.onMessage?.(data);
158158 } catch {
159159 this.callbacks.onMessage?.({ type: "text", content: String(event.data) });