From 5db84bd89c8808b0895c7206e8a6a58043f9f8dc Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 18:04:16 +0100
Subject: [PATCH] feat: heartbeat fix, copy messages, copy/share images, hide unknown duration
---
bun.lock | 3 +
services/websocket.ts | 32 ++++++++--------
components/chat/ImageViewer.tsx | 23 +++++++++++
package.json | 1
components/chat/MessageBubble.tsx | 48 ++++++++++++++---------
5 files changed, 72 insertions(+), 35 deletions(-)
diff --git a/bun.lock b/bun.lock
index 7118831..1dabeb7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,6 +9,7 @@
"@react-navigation/native": "^7.1.31",
"expo": "~55.0.4",
"expo-audio": "^55.0.8",
+ "expo-clipboard": "~55.0.8",
"expo-constants": "~55.0.7",
"expo-file-system": "~55.0.10",
"expo-haptics": "~55.0.8",
@@ -671,6 +672,8 @@
"expo-audio": ["expo-audio@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-X61pQSikE2rsP2ZTMFUMThOmgGyYEHcmZpGVMrKJgcYtRCFKuctB/z69dFQPoumL+zTz8qlBoGohjkHVvA9P8A=="],
+ "expo-clipboard": ["expo-clipboard@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-s0Hkop+dc6m09LwzUAWweNI0gzLAaX5CgEGR8TMdOdSPKTPc2rCl8h8Ji/cUNM1wYoJQ4Wysa15E8If/Vlu7WA=="],
+
"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=="],
"expo-file-system": ["expo-file-system@55.0.10", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw=="],
diff --git a/components/chat/ImageViewer.tsx b/components/chat/ImageViewer.tsx
index e10aa5f..0bd0188 100644
--- a/components/chat/ImageViewer.tsx
+++ b/components/chat/ImageViewer.tsx
@@ -10,6 +10,7 @@
View,
} from "react-native";
import { cacheDirectory, writeAsStringAsync } from "expo-file-system/legacy";
+import * as Clipboard from "expo-clipboard";
import * as Sharing from "expo-sharing";
/** Apple-style share icon (square with upward arrow) */
@@ -68,6 +69,14 @@
export function ImageViewer({ visible, imageBase64, onClose }: ImageViewerProps) {
const { width, height } = Dimensions.get("window");
+ const handleCopy = useCallback(async () => {
+ try {
+ await Clipboard.setImageAsync(`data:image/png;base64,${imageBase64}`);
+ } catch (err: any) {
+ Alert.alert("Copy Error", err?.message ?? String(err));
+ }
+ }, [imageBase64]);
+
const handleShare = useCallback(async () => {
try {
const fileUri = `${cacheDirectory}pailot-screenshot-${Date.now()}.png`;
@@ -105,6 +114,20 @@
}}
>
<Pressable
+ onPress={handleCopy}
+ hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
+ style={{
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: "rgba(255,255,255,0.15)",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <Text style={{ color: "#fff", fontSize: 18 }}>📋</Text>
+ </Pressable>
+ <Pressable
onPress={handleShare}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
style={{
diff --git a/components/chat/MessageBubble.tsx b/components/chat/MessageBubble.tsx
index e7ad9fb..ae83792 100644
--- a/components/chat/MessageBubble.tsx
+++ b/components/chat/MessageBubble.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { ActionSheetIOS, Alert, Image, Platform, Pressable, Text, View } from "react-native";
+import * as Clipboard from "expo-clipboard";
import { Message } from "../../types";
import { playSingle, stopPlayback, onPlayingChange } from "../../services/audio";
import { ImageViewer } from "./ImageViewer";
@@ -11,8 +12,8 @@
onPlayVoice?: (id: string) => void;
}
-function formatDuration(ms?: number): string {
- if (!ms) return "0:00";
+function formatDuration(ms?: number): string | null {
+ if (!ms || ms <= 0) return null;
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
@@ -30,25 +31,32 @@
const { colors, isDark } = useTheme();
const handleLongPress = useCallback(() => {
- if (!onDelete) return;
+ const hasText = !!message.content;
if (Platform.OS === "ios") {
+ const options = ["Cancel"];
+ if (hasText) options.push("Copy");
+ if (onDelete) options.push("Delete Message");
+ const destructiveIndex = onDelete ? options.indexOf("Delete Message") : undefined;
+
ActionSheetIOS.showActionSheetWithOptions(
{
- options: ["Cancel", "Delete Message"],
- destructiveButtonIndex: 1,
+ options,
+ destructiveButtonIndex: destructiveIndex,
cancelButtonIndex: 0,
},
(index) => {
- if (index === 1) onDelete(message.id);
+ const selected = options[index];
+ if (selected === "Copy") Clipboard.setStringAsync(message.content ?? "");
+ else if (selected === "Delete Message") onDelete?.(message.id);
},
);
} else {
- Alert.alert("Delete Message", "Remove this message?", [
- { text: "Cancel", style: "cancel" },
- { text: "Delete", style: "destructive", onPress: () => onDelete(message.id) },
- ]);
+ const buttons: any[] = [{ text: "Cancel", style: "cancel" }];
+ if (hasText) buttons.push({ text: "Copy", onPress: () => Clipboard.setStringAsync(message.content ?? "") });
+ if (onDelete) buttons.push({ text: "Delete", style: "destructive", onPress: () => onDelete(message.id) });
+ Alert.alert("Message", undefined, buttons);
}
- }, [onDelete, message.id]);
+ }, [onDelete, message.id, message.content]);
// Track whether THIS bubble's audio is playing via the singleton URI
useEffect(() => {
@@ -183,14 +191,16 @@
))}
</View>
- <Text
- style={{
- fontSize: 11,
- color: isUser ? "rgba(255,255,255,0.8)" : colors.textSecondary,
- }}
- >
- {formatDuration(message.duration)}
- </Text>
+ {formatDuration(message.duration) && (
+ <Text
+ style={{
+ fontSize: 11,
+ color: isUser ? "rgba(255,255,255,0.8)" : colors.textSecondary,
+ }}
+ >
+ {formatDuration(message.duration)}
+ </Text>
+ )}
</Pressable>
{message.content ? (
<Text
diff --git a/package.json b/package.json
index a1ab2fc..b391c75 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"@react-navigation/native": "^7.1.31",
"expo": "~55.0.4",
"expo-audio": "^55.0.8",
+ "expo-clipboard": "~55.0.8",
"expo-constants": "~55.0.7",
"expo-file-system": "~55.0.10",
"expo-haptics": "~55.0.8",
diff --git a/services/websocket.ts b/services/websocket.ts
index 4c74e56..895d995 100644
--- a/services/websocket.ts
+++ b/services/websocket.ts
@@ -17,7 +17,7 @@
const MAX_RECONNECT_DELAY = 30000;
const RECONNECT_MULTIPLIER = 2;
const LOCAL_TIMEOUT = 2500;
-const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets
+const HEARTBEAT_INTERVAL = 30000; // 30s ping to detect zombie sockets
export class WebSocketClient {
private ws: WebSocket | null = null;
@@ -27,22 +27,24 @@
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private localTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
- private pongReceived: boolean = true;
+ private lastMessageAt: number = 0; // timestamp of last received message (any type)
private shouldReconnect: boolean = false;
private connected: boolean = false;
private callbacks: WebSocketClientOptions = {};
constructor() {
- // When app comes back to foreground, check if socket is still alive
+ // When app comes back to foreground, verify the connection
AppState.addEventListener("change", (state: AppStateStatus) => {
if (state === "active" && this.shouldReconnect) {
- if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
- // Socket is dead — force immediate reconnect
+ if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
+ // Socket is definitively dead — force immediate reconnect
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
this.urlIndex = 0;
this.tryUrl();
- } else {
- // Socket looks open but might be zombie — send a ping to verify
+ } else if (this.connected) {
+ // Socket might be alive — send a ping and restart heartbeat
+ // to give it a fresh 2-interval window to prove liveness
+ this.startHeartbeat();
this.sendPing();
}
}
@@ -80,10 +82,11 @@
private startHeartbeat() {
this.stopHeartbeat();
- this.pongReceived = true;
+ this.lastMessageAt = Date.now();
this.heartbeatTimer = setInterval(() => {
- if (!this.pongReceived) {
- // No pong since last ping — socket is zombie, force reconnect
+ const silentMs = Date.now() - this.lastMessageAt;
+ if (silentMs > HEARTBEAT_INTERVAL * 2) {
+ // No message (including pong) for 2 full intervals — zombie socket
this.connected = false;
this.callbacks.onClose?.();
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
@@ -91,6 +94,7 @@
this.tryUrl();
return;
}
+ // Send a ping so the server has something to respond to
this.sendPing();
}, HEARTBEAT_INTERVAL);
}
@@ -101,7 +105,6 @@
private sendPing() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
- this.pongReceived = false;
try {
this.ws.send(JSON.stringify({ type: "ping" }));
} catch {
@@ -147,13 +150,10 @@
ws.onmessage = (event) => {
if (ws !== this.ws) return; // stale
+ this.lastMessageAt = Date.now(); // any message proves the connection is alive
try {
const data = JSON.parse(event.data) as WebSocketMessage;
- // Handle pong responses from heartbeat
- if (data.type === "pong") {
- this.pongReceived = true;
- return;
- }
+ if (data.type === "pong") return; // heartbeat response, don't forward
this.callbacks.onMessage?.(data);
} catch {
this.callbacks.onMessage?.({ type: "text", content: String(event.data) });
--
Gitblit v1.3.1