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
| .. | .. |
|---|
| 9 | 9 | "@react-navigation/native": "^7.1.31", |
|---|
| 10 | 10 | "expo": "~55.0.4", |
|---|
| 11 | 11 | "expo-audio": "^55.0.8", |
|---|
| 12 | + "expo-clipboard": "~55.0.8", |
|---|
| 12 | 13 | "expo-constants": "~55.0.7", |
|---|
| 13 | 14 | "expo-file-system": "~55.0.10", |
|---|
| 14 | 15 | "expo-haptics": "~55.0.8", |
|---|
| .. | .. |
|---|
| 671 | 672 | |
|---|
| 672 | 673 | "expo-audio": ["expo-audio@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-X61pQSikE2rsP2ZTMFUMThOmgGyYEHcmZpGVMrKJgcYtRCFKuctB/z69dFQPoumL+zTz8qlBoGohjkHVvA9P8A=="], |
|---|
| 673 | 674 | |
|---|
| 675 | + "expo-clipboard": ["expo-clipboard@55.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-s0Hkop+dc6m09LwzUAWweNI0gzLAaX5CgEGR8TMdOdSPKTPc2rCl8h8Ji/cUNM1wYoJQ4Wysa15E8If/Vlu7WA=="], |
|---|
| 676 | + |
|---|
| 674 | 677 | "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=="], |
|---|
| 675 | 678 | |
|---|
| 676 | 679 | "expo-file-system": ["expo-file-system@55.0.10", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw=="], |
|---|
| .. | .. |
|---|
| 10 | 10 | View, |
|---|
| 11 | 11 | } from "react-native"; |
|---|
| 12 | 12 | import { cacheDirectory, writeAsStringAsync } from "expo-file-system/legacy"; |
|---|
| 13 | +import * as Clipboard from "expo-clipboard"; |
|---|
| 13 | 14 | import * as Sharing from "expo-sharing"; |
|---|
| 14 | 15 | |
|---|
| 15 | 16 | /** Apple-style share icon (square with upward arrow) */ |
|---|
| .. | .. |
|---|
| 68 | 69 | export function ImageViewer({ visible, imageBase64, onClose }: ImageViewerProps) { |
|---|
| 69 | 70 | const { width, height } = Dimensions.get("window"); |
|---|
| 70 | 71 | |
|---|
| 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 | + |
|---|
| 71 | 80 | const handleShare = useCallback(async () => { |
|---|
| 72 | 81 | try { |
|---|
| 73 | 82 | const fileUri = `${cacheDirectory}pailot-screenshot-${Date.now()}.png`; |
|---|
| .. | .. |
|---|
| 105 | 114 | }} |
|---|
| 106 | 115 | > |
|---|
| 107 | 116 | <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 |
|---|
| 108 | 131 | onPress={handleShare} |
|---|
| 109 | 132 | hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} |
|---|
| 110 | 133 | style={{ |
|---|
| .. | .. |
|---|
| 1 | 1 | import React, { useCallback, useEffect, useState } from "react"; |
|---|
| 2 | 2 | import { ActionSheetIOS, Alert, Image, Platform, Pressable, Text, View } from "react-native"; |
|---|
| 3 | +import * as Clipboard from "expo-clipboard"; |
|---|
| 3 | 4 | import { Message } from "../../types"; |
|---|
| 4 | 5 | import { playSingle, stopPlayback, onPlayingChange } from "../../services/audio"; |
|---|
| 5 | 6 | import { ImageViewer } from "./ImageViewer"; |
|---|
| .. | .. |
|---|
| 11 | 12 | onPlayVoice?: (id: string) => void; |
|---|
| 12 | 13 | } |
|---|
| 13 | 14 | |
|---|
| 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; |
|---|
| 16 | 17 | const totalSeconds = Math.floor(ms / 1000); |
|---|
| 17 | 18 | const minutes = Math.floor(totalSeconds / 60); |
|---|
| 18 | 19 | const seconds = totalSeconds % 60; |
|---|
| .. | .. |
|---|
| 30 | 31 | const { colors, isDark } = useTheme(); |
|---|
| 31 | 32 | |
|---|
| 32 | 33 | const handleLongPress = useCallback(() => { |
|---|
| 33 | | - if (!onDelete) return; |
|---|
| 34 | + const hasText = !!message.content; |
|---|
| 34 | 35 | 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 | + |
|---|
| 35 | 41 | ActionSheetIOS.showActionSheetWithOptions( |
|---|
| 36 | 42 | { |
|---|
| 37 | | - options: ["Cancel", "Delete Message"], |
|---|
| 38 | | - destructiveButtonIndex: 1, |
|---|
| 43 | + options, |
|---|
| 44 | + destructiveButtonIndex: destructiveIndex, |
|---|
| 39 | 45 | cancelButtonIndex: 0, |
|---|
| 40 | 46 | }, |
|---|
| 41 | 47 | (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); |
|---|
| 43 | 51 | }, |
|---|
| 44 | 52 | ); |
|---|
| 45 | 53 | } 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); |
|---|
| 50 | 58 | } |
|---|
| 51 | | - }, [onDelete, message.id]); |
|---|
| 59 | + }, [onDelete, message.id, message.content]); |
|---|
| 52 | 60 | |
|---|
| 53 | 61 | // Track whether THIS bubble's audio is playing via the singleton URI |
|---|
| 54 | 62 | useEffect(() => { |
|---|
| .. | .. |
|---|
| 183 | 191 | ))} |
|---|
| 184 | 192 | </View> |
|---|
| 185 | 193 | |
|---|
| 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 | + )} |
|---|
| 194 | 204 | </Pressable> |
|---|
| 195 | 205 | {message.content ? ( |
|---|
| 196 | 206 | <Text |
|---|
| .. | .. |
|---|
| 13 | 13 | "@react-navigation/native": "^7.1.31", |
|---|
| 14 | 14 | "expo": "~55.0.4", |
|---|
| 15 | 15 | "expo-audio": "^55.0.8", |
|---|
| 16 | + "expo-clipboard": "~55.0.8", |
|---|
| 16 | 17 | "expo-constants": "~55.0.7", |
|---|
| 17 | 18 | "expo-file-system": "~55.0.10", |
|---|
| 18 | 19 | "expo-haptics": "~55.0.8", |
|---|
| .. | .. |
|---|
| 17 | 17 | const MAX_RECONNECT_DELAY = 30000; |
|---|
| 18 | 18 | const RECONNECT_MULTIPLIER = 2; |
|---|
| 19 | 19 | 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 |
|---|
| 21 | 21 | |
|---|
| 22 | 22 | export class WebSocketClient { |
|---|
| 23 | 23 | private ws: WebSocket | null = null; |
|---|
| .. | .. |
|---|
| 27 | 27 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 28 | 28 | private localTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 29 | 29 | private heartbeatTimer: ReturnType<typeof setInterval> | null = null; |
|---|
| 30 | | - private pongReceived: boolean = true; |
|---|
| 30 | + private lastMessageAt: number = 0; // timestamp of last received message (any type) |
|---|
| 31 | 31 | private shouldReconnect: boolean = false; |
|---|
| 32 | 32 | private connected: boolean = false; |
|---|
| 33 | 33 | private callbacks: WebSocketClientOptions = {}; |
|---|
| 34 | 34 | |
|---|
| 35 | 35 | constructor() { |
|---|
| 36 | | - // When app comes back to foreground, check if socket is still alive |
|---|
| 36 | + // When app comes back to foreground, verify the connection |
|---|
| 37 | 37 | AppState.addEventListener("change", (state: AppStateStatus) => { |
|---|
| 38 | 38 | 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 |
|---|
| 41 | 41 | this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 42 | 42 | this.urlIndex = 0; |
|---|
| 43 | 43 | 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(); |
|---|
| 46 | 48 | this.sendPing(); |
|---|
| 47 | 49 | } |
|---|
| 48 | 50 | } |
|---|
| .. | .. |
|---|
| 80 | 82 | |
|---|
| 81 | 83 | private startHeartbeat() { |
|---|
| 82 | 84 | this.stopHeartbeat(); |
|---|
| 83 | | - this.pongReceived = true; |
|---|
| 85 | + this.lastMessageAt = Date.now(); |
|---|
| 84 | 86 | 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 |
|---|
| 87 | 90 | this.connected = false; |
|---|
| 88 | 91 | this.callbacks.onClose?.(); |
|---|
| 89 | 92 | this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| .. | .. |
|---|
| 91 | 94 | this.tryUrl(); |
|---|
| 92 | 95 | return; |
|---|
| 93 | 96 | } |
|---|
| 97 | + // Send a ping so the server has something to respond to |
|---|
| 94 | 98 | this.sendPing(); |
|---|
| 95 | 99 | }, HEARTBEAT_INTERVAL); |
|---|
| 96 | 100 | } |
|---|
| .. | .. |
|---|
| 101 | 105 | |
|---|
| 102 | 106 | private sendPing() { |
|---|
| 103 | 107 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { |
|---|
| 104 | | - this.pongReceived = false; |
|---|
| 105 | 108 | try { |
|---|
| 106 | 109 | this.ws.send(JSON.stringify({ type: "ping" })); |
|---|
| 107 | 110 | } catch { |
|---|
| .. | .. |
|---|
| 147 | 150 | |
|---|
| 148 | 151 | ws.onmessage = (event) => { |
|---|
| 149 | 152 | if (ws !== this.ws) return; // stale |
|---|
| 153 | + this.lastMessageAt = Date.now(); // any message proves the connection is alive |
|---|
| 150 | 154 | try { |
|---|
| 151 | 155 | 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 |
|---|
| 157 | 157 | this.callbacks.onMessage?.(data); |
|---|
| 158 | 158 | } catch { |
|---|
| 159 | 159 | this.callbacks.onMessage?.({ type: "text", content: String(event.data) }); |
|---|