From 7120debe3b393dc322d3f419a7712b5e67ecee7e Mon Sep 17 00:00:00 2001 From: Nakshatra Sharma Date: Tue, 17 Feb 2026 17:58:47 +0530 Subject: [PATCH] feat: add real-time latency indicator and websocket heartbeat - Add ping/pong heartbeat with 3s interval and immediate first ping - Show color-coded latency in ControlBar (green/yellow/red) - Use wsRef for reliable cleanup on unmount - Exponential backoff for reconnection (1s-30s) - Null all handlers (incl. onmessage) before closing sockets - Add RemoteMessage type, type send() and useTrackpadGesture - Add type="button" to all buttons, disable placeholder Copy/Paste - Use grid-cols-6 to fit all visible buttons Closes #83 --- src/hooks/useRemoteConnection.ts | 71 +++++++++++++++++++++++++------- src/hooks/useTrackpadGesture.ts | 3 +- src/routes/__root.tsx | 3 +- src/routes/trackpad.tsx | 59 ++++++++++++++++++++++---- src/server/websocket.ts | 5 ++- src/types.tsx | 9 ++++ 6 files changed, 126 insertions(+), 24 deletions(-) diff --git a/src/hooks/useRemoteConnection.ts b/src/hooks/useRemoteConnection.ts index 9adfbfd..a9c00d4 100644 --- a/src/hooks/useRemoteConnection.ts +++ b/src/hooks/useRemoteConnection.ts @@ -1,8 +1,10 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import type { RemoteMessage } from '@/types'; export const useRemoteConnection = () => { const wsRef = useRef(null); const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('disconnected'); + const [latency, setLatency] = useState(null); useEffect(() => { let isMounted = true; @@ -11,6 +13,9 @@ export const useRemoteConnection = () => { const wsUrl = `${protocol}//${host}/ws`; let reconnectTimer: NodeJS.Timeout; + let heartbeatTimer: NodeJS.Timeout; + let reconnectDelay = 1000; + const MAX_RECONNECT_DELAY = 30000; const connect = () => { if (!isMounted) return; @@ -20,6 +25,7 @@ export const useRemoteConnection = () => { wsRef.current.onopen = null; wsRef.current.onclose = null; wsRef.current.onerror = null; + wsRef.current.onmessage = null; wsRef.current.close(); wsRef.current = null; } @@ -27,22 +33,57 @@ export const useRemoteConnection = () => { console.log(`Connecting to ${wsUrl}`); setStatus('connecting'); const socket = new WebSocket(wsUrl); + wsRef.current = socket; socket.onopen = () => { - if (isMounted) setStatus('connected'); + if (!isMounted) return; + setStatus('connected'); + reconnectDelay = 1000; + + // Fire first ping right away so the UI updates instantly + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } + + clearInterval(heartbeatTimer); + heartbeatTimer = setInterval(() => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } + }, 3000); }; - socket.onclose = () => { - if (isMounted) { - setStatus('disconnected'); - reconnectTimer = setTimeout(connect, 3000); + + socket.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'pong') { + const ts = Number(msg.timestamp); + const rtt = Date.now() - ts; + if (Number.isFinite(ts) && Number.isFinite(rtt) && rtt >= 0 && rtt < 60000) { + setLatency(rtt); + } + } + } catch { + // ignore non-JSON or malformed server messages } }; + + socket.onclose = () => { + if (!isMounted) return; + setStatus('disconnected'); + setLatency(null); + wsRef.current = null; + clearInterval(heartbeatTimer); + + const delay = reconnectDelay; + reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); + reconnectTimer = setTimeout(connect, delay); + }; + socket.onerror = (e) => { - console.error("WS Error", e); + console.error('WS Error', e); socket.close(); }; - - wsRef.current = socket; }; // Defer to next tick so React Strict Mode's immediate unmount @@ -53,31 +94,33 @@ export const useRemoteConnection = () => { isMounted = false; clearTimeout(initialTimer); clearTimeout(reconnectTimer); + clearInterval(heartbeatTimer); + if (wsRef.current) { - // Nullify handlers to prevent cascading error/close events wsRef.current.onopen = null; wsRef.current.onclose = null; wsRef.current.onerror = null; + wsRef.current.onmessage = null; wsRef.current.close(); wsRef.current = null; } }; }, []); - const send = useCallback((msg: any) => { + const send = useCallback((msg: RemoteMessage) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(msg)); } }, []); - const sendCombo = useCallback((msg: string[]) => { + const sendCombo = useCallback((keys: string[]) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ - type: "combo", - keys: msg, + type: 'combo', + keys, })); } }, []); - return { status, send, sendCombo }; + return { status, latency, send, sendCombo }; }; diff --git a/src/hooks/useTrackpadGesture.ts b/src/hooks/useTrackpadGesture.ts index a178773..7d6c4bd 100644 --- a/src/hooks/useTrackpadGesture.ts +++ b/src/hooks/useTrackpadGesture.ts @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; import { TOUCH_MOVE_THRESHOLD, TOUCH_TIMEOUT, PINCH_THRESHOLD, calculateAccelerationMult } from '../utils/math'; +import type { RemoteMessage } from '@/types'; interface TrackedTouch { identifier: number; @@ -17,7 +18,7 @@ const getTouchDistance = (a: TrackedTouch, b: TrackedTouch): number => { }; export const useTrackpadGesture = ( - send: (msg: any) => void, + send: (msg: RemoteMessage) => void, scrollMode: boolean, sensitivity: number = 1.5, invertScroll: boolean = false, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index ab35248..bd87a7c 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -65,7 +65,7 @@ function Navbar() {
Rein
-
+
Settings +
); diff --git a/src/routes/trackpad.tsx b/src/routes/trackpad.tsx index cf21e0b..1a539dd 100644 --- a/src/routes/trackpad.tsx +++ b/src/routes/trackpad.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useState, useRef } from 'react' +import { createPortal } from 'react-dom' import { useRemoteConnection } from '../hooks/useRemoteConnection'; import { useTrackpadGesture } from '../hooks/useTrackpadGesture'; import { ControlBar } from '../components/Trackpad/ControlBar'; @@ -19,21 +20,21 @@ function TrackpadPage() { const bufferText = buffer.join(" + "); const hiddenInputRef = useRef(null); const isComposingRef = useRef(false); - + // Load Client Settings const [sensitivity] = useState(() => { if (typeof window === 'undefined') return 1.0; const s = localStorage.getItem('rein_sensitivity'); return s ? parseFloat(s) : 1.0; }); - + const [invertScroll] = useState(() => { if (typeof window === 'undefined') return false; const s = localStorage.getItem('rein_invert'); return s ? JSON.parse(s) : false; }); - const { status, send, sendCombo } = useRemoteConnection(); + const { status, latency, send, sendCombo } = useRemoteConnection(); // Pass sensitivity and invertScroll to the gesture hook const { isTracking, handlers } = useTrackpadGesture(send, scrollMode, sensitivity, invertScroll); @@ -49,7 +50,7 @@ function TrackpadPage() { const handleKeyDown = (e: React.KeyboardEvent) => { const key = e.key.toLowerCase(); - + if (modifier !== "Release") { if (key === 'backspace') { e.preventDefault(); @@ -76,7 +77,7 @@ function TrackpadPage() { }; const handleModifierState = () => { - switch(modifier){ + switch (modifier) { case "Active": if (buffer.length > 0) { setModifier("Hold"); @@ -97,7 +98,7 @@ function TrackpadPage() { const handleModifier = (key: string) => { console.log(`handleModifier called with key: ${key}, current modifier: ${modifier}, buffer:`, buffer); - + if (modifier === "Hold") { const comboKeys = [...buffer, key]; console.log(`Sending combo:`, comboKeys); @@ -139,7 +140,7 @@ function TrackpadPage() { // Don't send text during modifier mode if (modifier !== "Release") { handleModifier(val); - }else{ + } else { sendText(val); } (e.target as HTMLInputElement).value = ''; @@ -153,11 +154,55 @@ function TrackpadPage() { } }; + const getLatencyColor = (ms: number) => { + if (ms < 50) return { css: "text-success", fill: "#22c55e", bars: 3 }; + if (ms < 150) return { css: "text-warning", fill: "#eab308", bars: 2 }; + return { css: "text-error", fill: "#ef4444", bars: 1 }; + }; + + const GREY = "#6b7280"; + + const PingIndicator = () => { + if (typeof document === 'undefined') return null; + const target = document.getElementById('ping-indicator'); + if (!target) return null; + return createPortal( +
+ {latency !== null ? (() => { + const { css, fill, bars } = getLatencyColor(latency); + return ( + + + + = 1 ? fill : GREY} strokeWidth="2.2" strokeLinecap="round" fill="none" /> + = 2 ? fill : GREY} strokeWidth="2.2" strokeLinecap="round" fill="none" /> + = 3 ? fill : GREY} strokeWidth="2.2" strokeLinecap="round" fill="none" /> + + {latency}ms + + ); + })() : ( + + + + + + + + --- + + )} +
, + target + ); + }; + return (
+ {/* Touch Surface */}