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