From 8cdf33e27c633ac30e8851c4617f6063c141660d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 17:53:05 +0100
Subject: [PATCH] fix: audio routing, WebSocket reconnection, inverted chat list

---
 services/websocket.ts |   65 ++++++++++++++++++++++++++++++++
 1 files changed, 65 insertions(+), 0 deletions(-)

diff --git a/services/websocket.ts b/services/websocket.ts
index 03f1a59..4c74e56 100644
--- a/services/websocket.ts
+++ b/services/websocket.ts
@@ -1,3 +1,4 @@
+import { AppState, AppStateStatus } from "react-native";
 import { WsOutgoing } from "../types";
 
 type WebSocketMessage = Record<string, unknown>;
@@ -16,6 +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
 
 export class WebSocketClient {
   private ws: WebSocket | null = null;
@@ -24,9 +26,28 @@
   private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
   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 shouldReconnect: boolean = false;
   private connected: boolean = false;
   private callbacks: WebSocketClientOptions = {};
+
+  constructor() {
+    // When app comes back to foreground, check if socket is still alive
+    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
+          this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+          this.urlIndex = 0;
+          this.tryUrl();
+        } else {
+          // Socket looks open but might be zombie — send a ping to verify
+          this.sendPing();
+        }
+      }
+    });
+  }
 
   setCallbacks(callbacks: WebSocketClientOptions) {
     this.callbacks = callbacks;
@@ -45,6 +66,7 @@
   private cleanup() {
     if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
     if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
+    this.stopHeartbeat();
     if (this.ws) {
       const old = this.ws;
       this.ws = null;
@@ -53,6 +75,43 @@
       old.onerror = null;
       old.onmessage = null;
       try { old.close(); } catch { /* ignore */ }
+    }
+  }
+
+  private startHeartbeat() {
+    this.stopHeartbeat();
+    this.pongReceived = true;
+    this.heartbeatTimer = setInterval(() => {
+      if (!this.pongReceived) {
+        // No pong since last ping — socket is zombie, force reconnect
+        this.connected = false;
+        this.callbacks.onClose?.();
+        this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+        this.urlIndex = 0;
+        this.tryUrl();
+        return;
+      }
+      this.sendPing();
+    }, HEARTBEAT_INTERVAL);
+  }
+
+  private stopHeartbeat() {
+    if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
+  }
+
+  private sendPing() {
+    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+      this.pongReceived = false;
+      try {
+        this.ws.send(JSON.stringify({ type: "ping" }));
+      } catch {
+        // Send failed — socket is dead
+        this.connected = false;
+        this.callbacks.onClose?.();
+        this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+        this.urlIndex = 0;
+        this.tryUrl();
+      }
     }
   }
 
@@ -82,6 +141,7 @@
       this.connected = true;
       if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
       this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+      this.startHeartbeat();
       this.callbacks.onOpen?.();
     };
 
@@ -89,6 +149,11 @@
       if (ws !== this.ws) return; // stale
       try {
         const data = JSON.parse(event.data) as WebSocketMessage;
+        // Handle pong responses from heartbeat
+        if (data.type === "pong") {
+          this.pongReceived = true;
+          return;
+        }
         this.callbacks.onMessage?.(data);
       } catch {
         this.callbacks.onMessage?.({ type: "text", content: String(event.data) });

--
Gitblit v1.3.1