Skip to content

Commit 91d6296

Browse files
Nakshatra SharmaNakshatra Sharma
authored andcommitted
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
1 parent 47b38d0 commit 91d6296

File tree

6 files changed

+105
-25
lines changed

6 files changed

+105
-25
lines changed

src/hooks/useRemoteConnection.ts

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { useState, useEffect, useCallback, useRef } from 'react';
2+
import type { RemoteMessage } from '@/types';
23

34
export const useRemoteConnection = () => {
45
const wsRef = useRef<WebSocket | null>(null);
56
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('disconnected');
7+
const [latency, setLatency] = useState<number | null>(null);
68

79
useEffect(() => {
810
let isMounted = true;
@@ -26,6 +28,9 @@ export const useRemoteConnection = () => {
2628
}
2729

2830
let reconnectTimer: NodeJS.Timeout;
31+
let heartbeatTimer: NodeJS.Timeout;
32+
let reconnectDelay = 1000;
33+
const MAX_RECONNECT_DELAY = 30000;
2934

3035
const connect = () => {
3136
if (!isMounted) return;
@@ -35,27 +40,64 @@ export const useRemoteConnection = () => {
3540
wsRef.current.onopen = null;
3641
wsRef.current.onclose = null;
3742
wsRef.current.onerror = null;
43+
wsRef.current.onmessage = null;
3844
wsRef.current.close();
3945
wsRef.current = null;
4046
}
4147

4248
setStatus('connecting');
4349
const socket = new WebSocket(wsUrl);
50+
wsRef.current = socket;
4451

4552
socket.onopen = () => {
46-
if (isMounted) setStatus('connected');
53+
if (!isMounted) return;
54+
setStatus('connected');
55+
reconnectDelay = 1000;
56+
57+
// Fire first ping right away so the UI updates instantly
58+
if (socket.readyState === WebSocket.OPEN) {
59+
socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
60+
}
61+
62+
clearInterval(heartbeatTimer);
63+
heartbeatTimer = setInterval(() => {
64+
if (socket.readyState === WebSocket.OPEN) {
65+
socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
66+
}
67+
}, 3000);
4768
};
48-
socket.onclose = () => {
49-
if (isMounted) {
50-
setStatus('disconnected');
51-
reconnectTimer = setTimeout(connect, 3000);
69+
70+
socket.onmessage = (event) => {
71+
try {
72+
const msg = JSON.parse(event.data);
73+
if (msg.type === 'pong') {
74+
const ts = Number(msg.timestamp);
75+
const rtt = Date.now() - ts;
76+
if (Number.isFinite(ts) && Number.isFinite(rtt) && rtt >= 0 && rtt < 60000) {
77+
setLatency(rtt);
78+
}
79+
}
80+
} catch {
81+
// ignore non-JSON or malformed server messages
5282
}
5383
};
54-
socket.onerror = () => {
55-
socket.close();
84+
85+
socket.onclose = () => {
86+
if (!isMounted) return;
87+
setStatus('disconnected');
88+
setLatency(null);
89+
wsRef.current = null;
90+
clearInterval(heartbeatTimer);
91+
92+
const delay = reconnectDelay;
93+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
94+
reconnectTimer = setTimeout(connect, delay);
5695
};
5796

58-
wsRef.current = socket;
97+
socket.onerror = (e) => {
98+
console.error('WS Error', e);
99+
socket.close();
100+
};
59101
};
60102

61103
// Defer to next tick so React Strict Mode's immediate unmount
@@ -66,31 +108,33 @@ export const useRemoteConnection = () => {
66108
isMounted = false;
67109
clearTimeout(initialTimer);
68110
clearTimeout(reconnectTimer);
111+
clearInterval(heartbeatTimer);
112+
69113
if (wsRef.current) {
70-
// Nullify handlers to prevent cascading error/close events
71114
wsRef.current.onopen = null;
72115
wsRef.current.onclose = null;
73116
wsRef.current.onerror = null;
117+
wsRef.current.onmessage = null;
74118
wsRef.current.close();
75119
wsRef.current = null;
76120
}
77121
};
78122
}, []);
79123

80-
const send = useCallback((msg: any) => {
124+
const send = useCallback((msg: RemoteMessage) => {
81125
if (wsRef.current?.readyState === WebSocket.OPEN) {
82126
wsRef.current.send(JSON.stringify(msg));
83127
}
84128
}, []);
85129

86-
const sendCombo = useCallback((msg: string[]) => {
130+
const sendCombo = useCallback((keys: string[]) => {
87131
if (wsRef.current?.readyState === WebSocket.OPEN) {
88132
wsRef.current.send(JSON.stringify({
89-
type: "combo",
90-
keys: msg,
133+
type: 'combo',
134+
keys,
91135
}));
92136
}
93137
}, []);
94138

95-
return { status, send, sendCombo };
139+
return { status, latency, send, sendCombo };
96140
};

src/hooks/useTrackpadGesture.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useRef, useState } from 'react';
22
import { TOUCH_MOVE_THRESHOLD, TOUCH_TIMEOUT, PINCH_THRESHOLD, calculateAccelerationMult } from '../utils/math';
3+
import type { RemoteMessage } from '@/types';
34

45
interface TrackedTouch {
56
identifier: number;
@@ -17,7 +18,7 @@ const getTouchDistance = (a: TrackedTouch, b: TrackedTouch): number => {
1718
};
1819

1920
export const useTrackpadGesture = (
20-
send: (msg: any) => void,
21+
send: (msg: RemoteMessage) => void,
2122
scrollMode: boolean,
2223
sensitivity: number = 1.5,
2324
invertScroll: boolean = false,

src/routes/__root.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function Navbar() {
6565
<div className="flex-1">
6666
<Link to="/trackpad" className="btn btn-ghost text-xl normal-case">Rein</Link>
6767
</div>
68-
<div className="flex-none flex gap-2">
68+
<div className="flex-none flex gap-2 items-center">
6969
<Link
7070
to="/trackpad"
7171
className="btn btn-ghost btn-sm"
@@ -80,6 +80,7 @@ function Navbar() {
8080
>
8181
Settings
8282
</Link>
83+
<div id="ping-indicator" className="flex items-center" />
8384
</div>
8485
</div>
8586
);

src/routes/trackpad.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createFileRoute } from '@tanstack/react-router'
22
import { useState, useRef } from 'react'
3+
import { createPortal } from 'react-dom'
34
import { useRemoteConnection } from '../hooks/useRemoteConnection';
45
import { useTrackpadGesture } from '../hooks/useTrackpadGesture';
56
import { ControlBar } from '../components/Trackpad/ControlBar';
@@ -19,21 +20,21 @@ function TrackpadPage() {
1920
const bufferText = buffer.join(" + ");
2021
const hiddenInputRef = useRef<HTMLInputElement>(null);
2122
const isComposingRef = useRef(false);
22-
23+
2324
// Load Client Settings
2425
const [sensitivity] = useState(() => {
2526
if (typeof window === 'undefined') return 1.0;
2627
const s = localStorage.getItem('rein_sensitivity');
2728
return s ? parseFloat(s) : 1.0;
2829
});
29-
30+
3031
const [invertScroll] = useState(() => {
3132
if (typeof window === 'undefined') return false;
3233
const s = localStorage.getItem('rein_invert');
3334
return s ? JSON.parse(s) : false;
3435
});
3536

36-
const { status, send, sendCombo } = useRemoteConnection();
37+
const { status, latency, send, sendCombo } = useRemoteConnection();
3738
// Pass sensitivity and invertScroll to the gesture hook
3839
const { isTracking, handlers } = useTrackpadGesture(send, scrollMode, sensitivity, invertScroll);
3940

@@ -49,7 +50,7 @@ function TrackpadPage() {
4950

5051
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
5152
const key = e.key.toLowerCase();
52-
53+
5354
if (modifier !== "Release") {
5455
if (key === 'backspace') {
5556
e.preventDefault();
@@ -76,7 +77,7 @@ function TrackpadPage() {
7677
};
7778

7879
const handleModifierState = () => {
79-
switch(modifier){
80+
switch (modifier) {
8081
case "Active":
8182
if (buffer.length > 0) {
8283
setModifier("Hold");
@@ -97,7 +98,7 @@ function TrackpadPage() {
9798

9899
const handleModifier = (key: string) => {
99100
console.log(`handleModifier called with key: ${key}, current modifier: ${modifier}, buffer:`, buffer);
100-
101+
101102
if (modifier === "Hold") {
102103
const comboKeys = [...buffer, key];
103104
console.log(`Sending combo:`, comboKeys);
@@ -139,7 +140,7 @@ function TrackpadPage() {
139140
// Don't send text during modifier mode
140141
if (modifier !== "Release") {
141142
handleModifier(val);
142-
}else{
143+
} else {
143144
sendText(val);
144145
}
145146
(e.target as HTMLInputElement).value = '';
@@ -153,11 +154,30 @@ function TrackpadPage() {
153154
}
154155
};
155156

157+
const getLatencyColor = (ms: number) => {
158+
if (ms < 50) return "text-success";
159+
if (ms < 150) return "text-warning";
160+
return "text-error";
161+
};
162+
163+
const PingIndicator = () => {
164+
if (typeof document === 'undefined') return null;
165+
const target = document.getElementById('ping-indicator');
166+
if (!target) return null;
167+
return createPortal(
168+
<span className={`text-xs font-mono ${latency !== null ? getLatencyColor(latency) : "opacity-50"}`}>
169+
{latency !== null ? `${latency}ms` : "---"}
170+
</span>,
171+
target
172+
);
173+
};
174+
156175
return (
157176
<div
158177
className="flex flex-col h-full overflow-hidden"
159178
onClick={handleContainerClick}
160179
>
180+
<PingIndicator />
161181
{/* Touch Surface */}
162182
<TouchArea
163183
isTracking={isTracking}

src/server/websocket.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function createWsServer(server: any) {
114114
const msg = JSON.parse(raw);
115115

116116
// PERFORMANCE: Only touch if it's an actual command (not ping/ip)
117-
if (token && msg.type !== 'get-ip' && msg.type !== 'generate-token') {
117+
if (token && msg.type !== 'get-ip' && msg.type !== 'ping' && msg.type !== 'generate-token') {
118118
touchToken(token);
119119
}
120120

@@ -123,6 +123,11 @@ export function createWsServer(server: any) {
123123
return;
124124
}
125125

126+
if (msg.type === 'ping') {
127+
ws.send(JSON.stringify({ type: 'pong', timestamp: msg.timestamp }));
128+
return;
129+
}
130+
126131
if (msg.type === 'generate-token') {
127132
if (!isLocal) {
128133
logger.warn('Token generation attempt from non-localhost');
@@ -169,7 +174,7 @@ export function createWsServer(server: any) {
169174
}
170175
});
171176

172-
ws.on('close', () => {
177+
ws.on('close', () => {
173178
logger.info('Client disconnected');
174179
});
175180

src/types.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
11
export type ModifierState = "Active" | "Release" | "Hold";
2+
3+
export type RemoteMessage =
4+
| { type: "move"; dx: number; dy: number }
5+
| { type: "scroll"; dx?: number; dy?: number }
6+
| { type: "click"; button: "left" | "right" | "middle"; press: boolean }
7+
| { type: "key"; key: string }
8+
| { type: "text"; text: string }
9+
| { type: "zoom"; delta: number }
10+
| { type: "combo"; keys: string[] };

0 commit comments

Comments
 (0)