From 44fbc47212a89f8cde07f66293a1f07239977881 Mon Sep 17 00:00:00 2001 From: konsalex Date: Thu, 21 May 2026 10:09:58 +0200 Subject: [PATCH 1/8] fix: drop websocket with pings and new ping interval --- backend/Taskfile.yml | 8 +++---- .../internal/handlers/websocketHandlers.go | 21 ++++++++++++++++--- tauri/src/services/socket.ts | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/backend/Taskfile.yml b/backend/Taskfile.yml index 29912f31..55eee7b9 100644 --- a/backend/Taskfile.yml +++ b/backend/Taskfile.yml @@ -21,10 +21,10 @@ tasks: dev: desc: Start the development server cmds: - - task: web-app:build-dev - - task: web-app:inject-to-backend - vars: - SUFFIX: "-debug" + # - task: web-app:build-dev + # - task: web-app:inject-to-backend + # vars: + # SUFFIX: "-debug" - go run *.go env: ENV_STACK: local diff --git a/backend/internal/handlers/websocketHandlers.go b/backend/internal/handlers/websocketHandlers.go index 0dfc4423..b86323b2 100644 --- a/backend/internal/handlers/websocketHandlers.go +++ b/backend/internal/handlers/websocketHandlers.go @@ -38,6 +38,11 @@ func CreateWSHandler(server *common.ServerState) echo.HandlerFunc { } defer ws.Close() + // Kill the connections after 2 heartbeats (30 seconds for old apps) + // TODO: Modify after most users are upgraded to >1.0.15 + const wsReadTimeout = 65 * time.Second + _ = ws.SetReadDeadline(time.Now().Add(wsReadTimeout)) + // Get user from context email, err := server.JwtIssuer.GetUserEmail(c) if err != nil { @@ -107,7 +112,7 @@ func CreateWSHandler(server *common.ServerState) echo.HandlerFunc { if websocket.IsCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNoStatusReceived) { c.Logger().Debug("WebSocket connection closed normally") } else { - c.Logger().Error("WebSocket read error: ", err) + c.Logger().Errorf("WebSocket read error: %v (user: %s)", err, user.ID) } done <- struct{}{} return @@ -143,7 +148,9 @@ func CreateWSHandler(server *common.ServerState) echo.HandlerFunc { endCall(c, server, user.ID, *parsedMessage.CallEnd) case parsedMessage.Ping != nil: // Handle ping message - c.Logger().Debug("Received ping") + // Reset the read deadline + _ = ws.SetReadDeadline(time.Now().Add(wsReadTimeout)) + c.Logger().Debugf("Received ping from user: %s (email: %s)", user.ID, user.Email) pong := messages.NewPongMessage() pongJSON, err := json.Marshal(pong) if err != nil { @@ -206,10 +213,12 @@ func CreateWSHandler(server *common.ServerState) echo.HandlerFunc { switch { case parsedMessage.IncomingCall != nil: // Forward incoming call message to the callee + c.Logger().Debugf("Forwarding incoming call message") err = ws.WriteMessage(websocket.TextMessage, []byte(msg.Payload)) if err != nil { c.Logger().Error(err) } + c.Logger().Debugf("Forwarded") case parsedMessage.RejectCallMessage != nil: err = ws.WriteMessage(websocket.TextMessage, []byte(msg.Payload)) if err != nil { @@ -274,7 +283,8 @@ func initiateCall(ctx echo.Context, s *common.ServerState, ws *websocket.Conn, r // Dedupe: prevent duplicate/glare call requests within ringing window. // Symmetric key: pending A->B also blocks B->A. - const callDedupeTTL = 30 * time.Second + // const callDedupeTTL = 30 * time.Second + const callDedupeTTL = 5 * time.Second acquired, err := s.Redis.SetNX(rdbCtx, dedupeKey, "1", callDedupeTTL).Result() if err != nil { // Fail open — do not block legit calls on Redis hiccup. @@ -343,6 +353,11 @@ func initiateCall(ctx echo.Context, s *common.ServerState, ws *websocket.Conn, r return } + // User has established WS connection, the computer may + // though still be sleeping. + // We need to ACK the call request from the callee to the caller to avoid + // the caller from thinking the callee is actually online. + // User is online ping the callee // Publish a message to the callee channel msg := messages.NewIncomingCallMessage(callerId) diff --git a/tauri/src/services/socket.ts b/tauri/src/services/socket.ts index 559ffb18..6dcd9718 100644 --- a/tauri/src/services/socket.ts +++ b/tauri/src/services/socket.ts @@ -105,7 +105,7 @@ class SocketService { }, }); } - }, 30_000); // Send ping every 30 seconds + }, 15_000); // Send ping every 15 seconds } private stopHeartbeat() { From 572b5da852440063efaa4b1f59df1b86643bdd55 Mon Sep 17 00:00:00 2001 From: konsalex Date: Thu, 21 May 2026 10:19:01 +0200 Subject: [PATCH 2/8] revert: dedupe ttl --- backend/internal/handlers/websocketHandlers.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/internal/handlers/websocketHandlers.go b/backend/internal/handlers/websocketHandlers.go index b86323b2..04dd6349 100644 --- a/backend/internal/handlers/websocketHandlers.go +++ b/backend/internal/handlers/websocketHandlers.go @@ -283,8 +283,7 @@ func initiateCall(ctx echo.Context, s *common.ServerState, ws *websocket.Conn, r // Dedupe: prevent duplicate/glare call requests within ringing window. // Symmetric key: pending A->B also blocks B->A. - // const callDedupeTTL = 30 * time.Second - const callDedupeTTL = 5 * time.Second + const callDedupeTTL = 30 * time.Second acquired, err := s.Redis.SetNX(rdbCtx, dedupeKey, "1", callDedupeTTL).Result() if err != nil { // Fail open — do not block legit calls on Redis hiccup. From 7e5ac4c94f50116ef27750c60a69f4575292022d Mon Sep 17 00:00:00 2001 From: konsalex Date: Thu, 21 May 2026 10:32:32 +0200 Subject: [PATCH 3/8] fix: disable call button when receiving a call --- tauri/src/components/ui/call-banner.tsx | 8 +++++--- .../components/ui/participant-row-wo-livekit.tsx | 7 +++++-- tauri/src/store/store.ts | 8 ++++++++ tauri/src/windows/main-window/app.tsx | 13 ++++++++----- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/tauri/src/components/ui/call-banner.tsx b/tauri/src/components/ui/call-banner.tsx index 9659d96f..422f3bca 100644 --- a/tauri/src/components/ui/call-banner.tsx +++ b/tauri/src/components/ui/call-banner.tsx @@ -29,7 +29,7 @@ export const CallBanner = ({ callerId, toastId }: { callerId: string; toastId: s }; } - const { setCallTokens } = useStore(); + const { setCallTokens, setIncomingCallCallerId } = useStore(); const handleReject = useMemo( () => @@ -43,11 +43,12 @@ export const CallBanner = ({ callerId, toastId }: { callerId: string; toastId: s }, }); toast.dismiss(toastId); + setIncomingCallCallerId(null); }, ACTION_THROTTLE_MS, { leading: true, trailing: false }, ), - [callerId, toastId], + [callerId, toastId, setIncomingCallCallerId], ); const handleAnswer = useMemo( @@ -89,6 +90,7 @@ export const CallBanner = ({ callerId, toastId }: { callerId: string; toastId: s } toast.dismiss(toastId); + setIncomingCallCallerId(null); } }; @@ -116,7 +118,7 @@ export const CallBanner = ({ callerId, toastId }: { callerId: string; toastId: s ACTION_THROTTLE_MS, { leading: true, trailing: false }, ), - [callerId, toastId, setCallTokens, handleReject], + [callerId, toastId, setCallTokens, setIncomingCallCallerId, handleReject], ); useEffect(() => { diff --git a/tauri/src/components/ui/participant-row-wo-livekit.tsx b/tauri/src/components/ui/participant-row-wo-livekit.tsx index ad48b54a..e0eb8fa1 100644 --- a/tauri/src/components/ui/participant-row-wo-livekit.tsx +++ b/tauri/src/components/ui/participant-row-wo-livekit.tsx @@ -56,11 +56,14 @@ export const ParticipantRow = (props: { user: components["schemas"]["BaseUser"] const isCalling = useStore((state) => state.calling === props.user.id); const { setCalling, setCallTokens } = useStore((state) => state); const inACall = useStore((state) => state.callTokens !== null); + const hasIncomingCall = useStore((state) => state.incomingCallCallerId !== null); const callbackIdRef = useRef(`call-response-${props.user.id}`); const callResolvedRef = useRef(false); const callUser = useCallback(() => { + if (hasIncomingCall) return; + posthog.capture("user_call_request", { user_id: props.user.id, user_name: props.user.first_name, @@ -97,7 +100,7 @@ export const ParticipantRow = (props: { user: components["schemas"]["BaseUser"] callee_id: props.user.id, }, } as TCallRequestMessage); - }, [props.user]); + }, [props.user, hasIncomingCall]); // Add a useEffect to listen for call responses // that will unsubscribe from the socket when the component unmounts @@ -230,7 +233,7 @@ export const ParticipantRow = (props: { user: components["schemas"]["BaseUser"] callUser(); } }} - disabled={inACall} + disabled={inACall || hasIncomingCall} className={clsx( "px-2 w-auto h-7 flex flex-row items-center gap-1", !isCalling && "text-slate-600", diff --git a/tauri/src/store/store.ts b/tauri/src/store/store.ts index 73a25ee7..d9a82f38 100644 --- a/tauri/src/store/store.ts +++ b/tauri/src/store/store.ts @@ -43,6 +43,8 @@ type State = { teammates: components["schemas"]["BaseUser"][] | null; // The targeted user id (callee) calling: string | null; + // Caller id while incoming call banner is visible + incomingCallCallerId: string | null; // Call tokens for LiveKit callTokens: CallState | null; customServerUrl: string | null; @@ -60,6 +62,7 @@ type Actions = { getStoredToken: () => Promise; reset: () => void; setCalling: (calling: string | null) => void; + setIncomingCallCallerId: (callerId: string | null) => void; setCallTokens: (tokens: CallState | null) => void; // TODO(@konsalex): Rename `xxCallToken` as its not // representative anymore or the actual state it holds. @@ -77,6 +80,7 @@ const initialState: State = { user: null, teammates: null, calling: null, + incomingCallCallerId: null, callTokens: null, customServerUrl: null, livekitUrl: null, @@ -118,6 +122,10 @@ const useStore = create()( set((state) => { state.calling = calling; }), + setIncomingCallCallerId: (callerId) => + set((state) => { + state.incomingCallCallerId = callerId; + }), setCallTokens: (tokens) => set((state) => { state.callTokens = tokens ? { ...tokens, micLevel: tokens.micLevel ?? 0 } : null; diff --git a/tauri/src/windows/main-window/app.tsx b/tauri/src/windows/main-window/app.tsx index 9563cf91..f92b0a26 100644 --- a/tauri/src/windows/main-window/app.tsx +++ b/tauri/src/windows/main-window/app.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { checkForUpdates } from "@/update.ts"; @@ -40,6 +40,7 @@ function App() { setTeammates, setAuthToken, setLivekitUrl, + setIncomingCallCallerId, } = useStore(); const coreProcessCrashedRef = useRef(false); @@ -47,7 +48,6 @@ function App() { const { useQuery } = useAPI(); - const [incomingCallerId, setIncomingCallerId] = useState(null); const sentryMetadataRef = useRef(false); const { error: userError } = useQuery("get", "/api/auth/user", undefined, { @@ -168,17 +168,19 @@ function App() { }, []); const handleReject = (isInCall?: boolean) => { - if (!incomingCallerId && !isInCall) return; + const { incomingCallCallerId } = useStore.getState(); + if (!incomingCallCallerId && !isInCall) return; if (!isInCall) { sounds.incomingCall.stop(); } socketService.send({ type: "call_reject", payload: { - caller_id: incomingCallerId, + caller_id: incomingCallCallerId, reject_reason: "rejected", }, } as TRejectCallMessage); + setIncomingCallCallerId(null); }; const handleInCallRejection = (callerID: string) => { @@ -222,7 +224,7 @@ function App() { } if (data.type === "incoming_call") { - setIncomingCallerId(data.payload.caller_id); + setIncomingCallCallerId(data.payload.caller_id); /* Reject call if update in progress */ if (updateInProgress) { @@ -250,6 +252,7 @@ function App() { if (data.type === "call_end") { // Get call info before clearing tokens toast.dismiss("call-banner"); + setIncomingCallCallerId(null); const { callTokens: currentCallTokens, user } = useStore.getState(); const participantId = currentCallTokens?.participant || ""; From a9dc0006581d0ad2628f0698eb11a442bd310176 Mon Sep 17 00:00:00 2001 From: konsalex Date: Thu, 21 May 2026 10:49:23 +0200 Subject: [PATCH 4/8] fix: do not render call-center after 60s of an initiated call --- backend/internal/handlers/websocketHandlers.go | 2 +- backend/internal/messages/messages.go | 8 +++++--- tauri/src/payloads.ts | 5 ++++- tauri/src/windows/main-window/app.tsx | 13 +++++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/backend/internal/handlers/websocketHandlers.go b/backend/internal/handlers/websocketHandlers.go index 04dd6349..85041cbc 100644 --- a/backend/internal/handlers/websocketHandlers.go +++ b/backend/internal/handlers/websocketHandlers.go @@ -359,7 +359,7 @@ func initiateCall(ctx echo.Context, s *common.ServerState, ws *websocket.Conn, r // User is online ping the callee // Publish a message to the callee channel - msg := messages.NewIncomingCallMessage(callerId) + msg := messages.NewIncomingCallMessage(callerId, time.Now().Unix()) msgJSON, err := json.Marshal(msg) if err != nil { ctx.Logger().Error(err) diff --git a/backend/internal/messages/messages.go b/backend/internal/messages/messages.go index af1b0f29..0d8351f6 100644 --- a/backend/internal/messages/messages.go +++ b/backend/internal/messages/messages.go @@ -81,7 +81,8 @@ type CallEndMessage struct { // IncomingCallPayload represents the payload for an incoming call by another user type IncomingCallPayload struct { - CallerID string `json:"caller_id" validate:"required"` + CallerID string `json:"caller_id" validate:"required"` + InitiatedAt *int64 `json:"initiated_at,omitempty"` } // IncomingCallMessage is a complete call request message @@ -297,11 +298,12 @@ func NewCallEndMessage(callID string) CallEndMessage { } } -func NewIncomingCallMessage(callerID string) IncomingCallMessage { +func NewIncomingCallMessage(callerID string, initiatedAt int64) IncomingCallMessage { return IncomingCallMessage{ Type: MessageTypeIncomingCall, Payload: IncomingCallPayload{ - CallerID: callerID, + CallerID: callerID, + InitiatedAt: &initiatedAt, }, } } diff --git a/tauri/src/payloads.ts b/tauri/src/payloads.ts index d302ef64..1559ce78 100644 --- a/tauri/src/payloads.ts +++ b/tauri/src/payloads.ts @@ -213,7 +213,10 @@ export const PCallEndMessage = z.object({ export const PIncomingCallMessage = z.object({ type: z.literal("incoming_call"), - payload: z.object({ caller_id: z.string() }), + payload: z.object({ + caller_id: z.string(), + initiated_at: z.number().optional(), + }), }); export const PAcceptCallMessage = z.object({ diff --git a/tauri/src/windows/main-window/app.tsx b/tauri/src/windows/main-window/app.tsx index f92b0a26..131b6a0e 100644 --- a/tauri/src/windows/main-window/app.tsx +++ b/tauri/src/windows/main-window/app.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { differenceInSeconds, fromUnixTime } from "date-fns"; import { Button } from "@/components/ui/button"; import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { checkForUpdates } from "@/update.ts"; @@ -201,6 +202,18 @@ function App() { if (data.type !== "incoming_call") { return; } + + const MAX_CALL_AGE_S = 5; + const incomingMsg = data as TIncomingCallMessage; + const initiatedAt = incomingMsg.payload.initiated_at; + if (initiatedAt != null) { + const ageSeconds = differenceInSeconds(new Date(), fromUnixTime(initiatedAt)); + if (ageSeconds > MAX_CALL_AGE_S) { + console.warn(`Dropping stale incoming call (age: ${ageSeconds}s)`); + return; + } + } + // Check that there is no ongoing call // If there is, reject the call const { callTokens } = useStore.getState(); From 7673fa6547a9a157fb482d78c6767977eb72d994 Mon Sep 17 00:00:00 2001 From: konsalex Date: Thu, 21 May 2026 11:03:40 +0200 Subject: [PATCH 5/8] fix: add did not answer timeout when we are calling --- .../ui/participant-row-wo-livekit.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tauri/src/components/ui/participant-row-wo-livekit.tsx b/tauri/src/components/ui/participant-row-wo-livekit.tsx index e0eb8fa1..3e10ccd7 100644 --- a/tauri/src/components/ui/participant-row-wo-livekit.tsx +++ b/tauri/src/components/ui/participant-row-wo-livekit.tsx @@ -185,6 +185,24 @@ export const ParticipantRow = (props: { user: components["schemas"]["BaseUser"] } }); + const timeoutId = + isCalling ? + setTimeout( + () => { + callResolvedRef.current = true; + sounds.ringing.stop(); + setCalling(null); + socketService.send({ + type: "call_end", + payload: { participant_id: props.user.id }, + }); + toast.error(`${props.user.first_name} didn't answer`, { duration: 1500 }); + }, + // 5 secs more from auto-reject from callee's timeout + 65_000, + ) + : undefined; + return () => { if (!isCalling) return; console.debug("Unsubscribing from call response for user:", props.user.id); @@ -199,6 +217,7 @@ export const ParticipantRow = (props: { user: components["schemas"]["BaseUser"] payload: { participant_id: props.user.id }, }); } + if (timeoutId) clearTimeout(timeoutId); }; }, [isCalling]); From 6378804510005a388146d357e53c023598829fea Mon Sep 17 00:00:00 2001 From: konsalex Date: Thu, 21 May 2026 11:17:40 +0200 Subject: [PATCH 6/8] chore: bumb version --- tauri/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tauri/src-tauri/tauri.conf.json b/tauri/src-tauri/tauri.conf.json index de3615bd..e634bfce 100644 --- a/tauri/src-tauri/tauri.conf.json +++ b/tauri/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ }, "productName": "hopp", "mainBinaryName": "hopp", - "version": "1.0.14", + "version": "1.0.15", "identifier": "com.hopp.app", "plugins": { "updater": { From 27b4ac0a1020992bacac751254337920bc721b5f Mon Sep 17 00:00:00 2001 From: konsalex Date: Thu, 21 May 2026 11:26:03 +0200 Subject: [PATCH 7/8] remove: dead code --- backend/internal/handlers/websocketHandlers.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/internal/handlers/websocketHandlers.go b/backend/internal/handlers/websocketHandlers.go index 85041cbc..b989fd02 100644 --- a/backend/internal/handlers/websocketHandlers.go +++ b/backend/internal/handlers/websocketHandlers.go @@ -352,11 +352,6 @@ func initiateCall(ctx echo.Context, s *common.ServerState, ws *websocket.Conn, r return } - // User has established WS connection, the computer may - // though still be sleeping. - // We need to ACK the call request from the callee to the caller to avoid - // the caller from thinking the callee is actually online. - // User is online ping the callee // Publish a message to the callee channel msg := messages.NewIncomingCallMessage(callerId, time.Now().Unix()) From 79302d9a32c0433ffbf8a2cc67375286174444b8 Mon Sep 17 00:00:00 2001 From: konsalex Date: Thu, 21 May 2026 11:30:25 +0200 Subject: [PATCH 8/8] review: feedback --- backend/Taskfile.yml | 18 +++++++++++++----- backend/internal/handlers/websocketHandlers.go | 4 +--- tauri/src/windows/main-window/app.tsx | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/backend/Taskfile.yml b/backend/Taskfile.yml index 55eee7b9..080b9685 100644 --- a/backend/Taskfile.yml +++ b/backend/Taskfile.yml @@ -21,10 +21,10 @@ tasks: dev: desc: Start the development server cmds: - # - task: web-app:build-dev - # - task: web-app:inject-to-backend - # vars: - # SUFFIX: "-debug" + - task: web-app:build-dev + - task: web-app:inject-to-backend + vars: + SUFFIX: "-debug" - go run *.go env: ENV_STACK: local @@ -32,7 +32,7 @@ tasks: compose-up: desc: Start the development databases and related services (local Go server for faster dev) cmds: - - docker compose -f dev-compose.yml up -d + - docker compose -f docker-files/docker-compose.yml up -d build: cmds: @@ -51,6 +51,14 @@ tasks: cmds: - docker run -p 1926:1926 --env-file env-files/.env.local --network host --name hopp-backend hopp-backend + fullstack-up: + desc: Build and run the fullstack setup with Redis and backend + cmds: + # - task: build-image + - docker compose -f docker-files/fullstack-compose.yml up -d + env: + ENV_STACK: local + install-reload-tools: desc: Install required development tools (maybe you will need to install manually) cmds: diff --git a/backend/internal/handlers/websocketHandlers.go b/backend/internal/handlers/websocketHandlers.go index b989fd02..cfc0e08b 100644 --- a/backend/internal/handlers/websocketHandlers.go +++ b/backend/internal/handlers/websocketHandlers.go @@ -150,7 +150,7 @@ func CreateWSHandler(server *common.ServerState) echo.HandlerFunc { // Handle ping message // Reset the read deadline _ = ws.SetReadDeadline(time.Now().Add(wsReadTimeout)) - c.Logger().Debugf("Received ping from user: %s (email: %s)", user.ID, user.Email) + c.Logger().Debugf("Received ping from user: %s", user.ID) pong := messages.NewPongMessage() pongJSON, err := json.Marshal(pong) if err != nil { @@ -213,12 +213,10 @@ func CreateWSHandler(server *common.ServerState) echo.HandlerFunc { switch { case parsedMessage.IncomingCall != nil: // Forward incoming call message to the callee - c.Logger().Debugf("Forwarding incoming call message") err = ws.WriteMessage(websocket.TextMessage, []byte(msg.Payload)) if err != nil { c.Logger().Error(err) } - c.Logger().Debugf("Forwarded") case parsedMessage.RejectCallMessage != nil: err = ws.WriteMessage(websocket.TextMessage, []byte(msg.Payload)) if err != nil { diff --git a/tauri/src/windows/main-window/app.tsx b/tauri/src/windows/main-window/app.tsx index 131b6a0e..3b181de9 100644 --- a/tauri/src/windows/main-window/app.tsx +++ b/tauri/src/windows/main-window/app.tsx @@ -203,7 +203,7 @@ function App() { return; } - const MAX_CALL_AGE_S = 5; + const MAX_CALL_AGE_S = 60; const incomingMsg = data as TIncomingCallMessage; const initiatedAt = incomingMsg.payload.initiated_at; if (initiatedAt != null) {