Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion backend/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Comment on lines +54 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Description doesn't match implementation.

The task description claims "Build and run" but the build-image dependency on line 57 is commented out, so no build step actually executes.

Either uncomment the build task or update the description to remove "Build and".

📝 Proposed fix options

Option 1: Update description to match current behavior

-    desc: Build and run the fullstack setup with Redis and backend
+    desc: Run the fullstack setup with Redis and backend

Option 2: Uncomment the build step to match description

     cmds:
-      # - task: build-image
+      - task: build-docker
       - docker compose -f docker-files/fullstack-compose.yml up -d
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
fullstack-up:
desc: 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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/Taskfile.yml` around lines 54 - 61, The Taskfile entry for the task
fullstack-up has a misleading desc "Build and run" while the build-image
dependency is commented out; either uncomment the commented task reference "-
task: build-image" under cmds so fullstack-up actually performs the build
(restore the dependency in the cmds list) or change the desc to remove "Build
and" so it accurately reads e.g. "Run the fullstack setup with Redis and
backend"; update the single symbol fullstack-up and the commented line "- task:
build-image" accordingly.

install-reload-tools:
desc: Install required development tools (maybe you will need to install manually)
cmds:
Expand Down
13 changes: 10 additions & 3 deletions backend/internal/handlers/websocketHandlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", user.ID)
pong := messages.NewPongMessage()
pongJSON, err := json.Marshal(pong)
if err != nil {
Expand Down Expand Up @@ -345,7 +352,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)
Expand Down
8 changes: 5 additions & 3 deletions backend/internal/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
}
}
Expand Down
2 changes: 1 addition & 1 deletion tauri/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"productName": "hopp",
"mainBinaryName": "hopp",
"version": "1.0.14",
"version": "1.0.15",
"identifier": "com.hopp.app",
"plugins": {
"updater": {
Expand Down
8 changes: 5 additions & 3 deletions tauri/src/components/ui/call-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const CallBanner = ({ callerId, toastId }: { callerId: string; toastId: s
};
}

const { setCallTokens } = useStore();
const { setCallTokens, setIncomingCallCallerId } = useStore();

const handleReject = useMemo(
() =>
Expand All @@ -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(
Expand Down Expand Up @@ -89,6 +90,7 @@ export const CallBanner = ({ callerId, toastId }: { callerId: string; toastId: s
}

toast.dismiss(toastId);
setIncomingCallCallerId(null);
}
};

Expand Down Expand Up @@ -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(() => {
Expand Down
26 changes: 24 additions & 2 deletions tauri/src/components/ui/participant-row-wo-livekit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(`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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -182,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);
Expand All @@ -196,6 +217,7 @@ export const ParticipantRow = (props: { user: components["schemas"]["BaseUser"]
payload: { participant_id: props.user.id },
});
}
if (timeoutId) clearTimeout(timeoutId);
};
}, [isCalling]);

Expand Down Expand Up @@ -230,7 +252,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",
Expand Down
5 changes: 4 additions & 1 deletion tauri/src/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion tauri/src/services/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ class SocketService {
},
});
}
}, 30_000); // Send ping every 30 seconds
}, 15_000); // Send ping every 15 seconds
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify backend websocket read deadline timeout is compatible with 15s heartbeat.
# Expected: Backend timeout should be at least 30-45 seconds to accommodate 15s heartbeat.

# Search for read deadline configuration in websocket handler
rg -n -C5 'SetReadDeadline|ReadDeadline|pongWait|readWait' backend/internal/handlers/websocketHandlers.go

# Also check for any timeout constants or configurations
rg -n 'time\.(Second|Minute|Duration).*[0-9]+' backend/internal/handlers/websocketHandlers.go

Repository: gethopp/hopp

Length of output: 932


Backend timeout already accommodates the 15s heartbeat

  • Backend sets wsReadTimeout = 65 * time.Second via ws.SetReadDeadline(...) and resets it on each incoming Ping, so 15s heartbeats are well within the read-deadline window.
  • Minor: the comment “Kill the connections after 2 heartbeats (30 seconds for old apps)” no longer matches the 65s timeout; update it.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tauri/src/services/socket.ts` at line 108, The comment next to the ping
interval (the setInterval that sends a ping every 15_000 ms) is stale: backend
uses a 65s read timeout, so keep the 15s heartbeat but update the comment to
reflect the backend wsReadTimeout (≈65s) instead of "2 heartbeats (30 seconds)";
e.g. note that connections will be considered dead after the backend's ~65s read
timeout (~4 heartbeats), or simply reference the backend read-deadline, and
leave the setInterval/ping logic (sendPing / setInterval) unchanged.

}

private stopHeartbeat() {
Expand Down
8 changes: 8 additions & 0 deletions tauri/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -60,6 +62,7 @@ type Actions = {
getStoredToken: () => Promise<string | null>;
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.
Expand All @@ -77,6 +80,7 @@ const initialState: State = {
user: null,
teammates: null,
calling: null,
incomingCallCallerId: null,
callTokens: null,
customServerUrl: null,
livekitUrl: null,
Expand Down Expand Up @@ -118,6 +122,10 @@ const useStore = create<State & Actions>()(
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;
Expand Down
26 changes: 21 additions & 5 deletions tauri/src/windows/main-window/app.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
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";
Expand Down Expand Up @@ -40,14 +41,14 @@ function App() {
setTeammates,
setAuthToken,
setLivekitUrl,
setIncomingCallCallerId,
} = useStore();

const coreProcessCrashedRef = useRef(false);
useDisableNativeContextMenu();

const { useQuery } = useAPI();

const [incomingCallerId, setIncomingCallerId] = useState<string | null>(null);
const sentryMetadataRef = useRef<boolean>(false);

const { error: userError } = useQuery("get", "/api/auth/user", undefined, {
Expand Down Expand Up @@ -168,17 +169,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) => {
Expand All @@ -199,6 +202,18 @@ function App() {
if (data.type !== "incoming_call") {
return;
}

const MAX_CALL_AGE_S = 60;
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();
Expand All @@ -222,7 +237,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) {
Expand Down Expand Up @@ -250,6 +265,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 || "";
Expand Down
Loading