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
77 changes: 72 additions & 5 deletions app/(protected)/videocall/(pages)/CallScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
FontAwesome6,
} from "@expo/vector-icons";
import { useSafeAreaControl } from "@/components/SafeArea";
import { useEffect } from "react";
import { useEffect, useRef } from "react";

const tCall = createT("videocall.call");

Expand All @@ -30,11 +30,15 @@ interface Props {
icebreakerLoading: boolean;
icebreakerIndex: number;
icebreakerOpacity: Animated.Value;
isLiked: boolean;
receivedLike: boolean;
mutualLike: boolean;
onToggleControls: () => void;
onToggleMute: () => void;
onFlipCamera: () => void;
onStop: () => void;
onLike: () => void;
onLikeBack: () => void;
onNextUser: () => void;
onReaction: () => void;
onIcebreaker: () => void;
Expand All @@ -54,15 +58,30 @@ export function CallScreen({
icebreakerLoading,
icebreakerIndex,
icebreakerOpacity,
isLiked,
receivedLike,
mutualLike,
onToggleControls,
onToggleMute,
onFlipCamera,
onStop,
onLike,
onLikeBack,
onNextUser,
onReaction,
onIcebreaker,
}: Props) {
const likeNotifOpacity = useRef(new Animated.Value(0)).current;
const showBanner = receivedLike || mutualLike;

useEffect(() => {
Animated.timing(likeNotifOpacity, {
toValue: showBanner ? 1 : 0,
duration: 300,
useNativeDriver: true,
}).start();
}, [showBanner, likeNotifOpacity]);

const { setDisableSafeArea } = useSafeAreaControl();

useEffect(() => {
Expand All @@ -73,6 +92,10 @@ export function CallScreen({
};
});

const likeButtonVariant = mutualLike || receivedLike ? "liked" : "success";
const likeButtonIcon = isLiked || mutualLike ? "heart" : "heart-outline";
const likeButtonPress = mutualLike ? undefined : receivedLike ? onLikeBack : onLike;

return (
<View style={s.container}>
<Pressable style={s.videoLayer} onPress={onToggleControls}>
Expand Down Expand Up @@ -135,11 +158,11 @@ export function CallScreen({
}
/>
<ControlButton
onPress={onLike}
variant="success"
onPress={likeButtonPress ?? (() => {})}
variant={likeButtonVariant}
icon={
<Ionicons
name="heart-outline"
name={likeButtonIcon}
size={22}
color="#fff"
/>
Expand Down Expand Up @@ -174,6 +197,23 @@ export function CallScreen({
</View>
</Animated.View>

<Animated.View
style={[s.likeNotification, { opacity: likeNotifOpacity }]}
pointerEvents={showBanner && !mutualLike ? "box-none" : "none"}
>
<Ionicons name="heart" size={16} color="#fff" />
{mutualLike ? (
<Text style={s.likeNotificationText}>{"It's a match! 💬"}</Text>
) : (
<>
<Text style={s.likeNotificationText}>You got liked!</Text>
<Pressable style={s.likeBackButton} onPress={onLikeBack}>
<Text style={s.likeBackText}>Like back</Text>
</Pressable>
</>
)}
</Animated.View>

{localUrl && (
<Animated.View
style={[
Expand Down Expand Up @@ -208,7 +248,7 @@ function ControlButton({
}: {
onPress: () => void;
icon: React.ReactNode;
variant?: "default" | "success" | "danger";
variant?: "default" | "success" | "danger" | "liked";
}) {
return (
<Pressable
Expand All @@ -217,6 +257,7 @@ function ControlButton({
s.controlButton,
variant === "success" && s.controlButtonSuccess,
variant === "danger" && s.controlButtonDanger,
variant === "liked" && s.controlButtonLiked,
]}
>
{icon}
Expand Down Expand Up @@ -281,6 +322,32 @@ const s = StyleSheet.create({
},
controlButtonSuccess: { backgroundColor: "#45c466", borderColor: "#45c466" },
controlButtonDanger: { backgroundColor: "#df1d1d", borderColor: "#df1d1d" },
controlButtonLiked: { backgroundColor: "#e91e8c", borderColor: "#e91e8c" },
likeNotification: {
position: "absolute",
top: 120,
alignSelf: "center",
backgroundColor: "#e91e8c",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
likeNotificationText: { color: "#fff", fontSize: 14, fontWeight: "600" as const },
likeBackButton: {
backgroundColor: "rgba(255,255,255,0.25)",
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
},
likeBackText: { color: "#fff", fontSize: 13, fontWeight: "700" as const },
icebreakerBubble: {
position: "absolute",
left: 14,
Expand Down
139 changes: 136 additions & 3 deletions app/(protected)/videocall/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,23 @@ export default function VideoCall() {
const [_matchedUserId, setMatchedUserId] = useState<string | null>(null);
const [gatewayRoomId, setGatewayRoomId] = useState<string | null>(null);

const [isLiked, setIsLiked] = useState(false);
const [receivedLike, setReceivedLike] = useState(false);
const [mutualLike, setMutualLike] = useState(false);
const chatRoomIdRef = useRef<string | null>(null);

useEffect(() => {
if (!receivedLike) return;
const t = setTimeout(() => setReceivedLike(false), 5000);
return () => clearTimeout(t);
}, [receivedLike]);

useEffect(() => {
if (!mutualLike) return;
const t = setTimeout(() => setMutualLike(false), 4000);
return () => clearTimeout(t);
}, [mutualLike]);

const matchmakingSocketRef = useRef<Socket | null>(null);
const stopTracksRef = useRef<() => void>(() => {});
const connectingIntentRef = useRef(false);
Expand Down Expand Up @@ -462,6 +479,7 @@ export default function VideoCall() {
await createRecvTransport(device);

const socket: Socket = io(BASE_URL, {
auth: { token },
query: { peerId: peerIdRef.current, roomId: currentRoomId },
transports: ["websocket"],
forceNew: true,
Expand All @@ -484,6 +502,20 @@ export default function VideoCall() {
},
);

socket.on("receive_like", () => {
setReceivedLike(true);
});

socket.on("mutual_like", ({ chatRoomId: id }: { chatRoomId: string }) => {
setMutualLike(true);
setReceivedLike(false);
chatRoomIdRef.current = id;
});

socket.on("peer_left", () => {
handleNextUserRef.current();
});
Comment on lines +453 to +455

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 | 🟠 Major | ⚡ Quick win

Guard handleNextUser against concurrent execution.

handleNextUser can be triggered automatically from Line 454 (peer_left) and manually via the Next button path, but there is no in-flight guard. Overlapping runs can duplicate decline/leave/deactivate/activate sequences and produce inconsistent call state.

Suggested fix
+const nextUserInFlightRef = useRef(false);

 async function handleNextUser() {
+    if (nextUserInFlightRef.current) return;
+    nextUserInFlightRef.current = true;
+    try {
         const roomId = roomIdRef.current;

         // Decline current match so these two users won't match again
         if (roomId) {
             try {
                 await api("/matchmaking/decline", {
                     method: "POST",
                     body: JSON.stringify({ roomId }),
                 });
             } catch {}
         }

         // Clean up video session without touching the matchmaking socket
         localStreamRef.current?.getTracks()?.forEach((t: any) => t.stop());
         localStreamRef.current = null;
         startingRef.current = false;
         ...
         try {
             await deactivateMatchmakingRef.current();
         } catch {}
         await activateMatchmaking();
+    } finally {
+        nextUserInFlightRef.current = false;
+    }
 }

Also applies to: 691-765

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(protected)/videocall/index.tsx around lines 453 - 455, The handler
handleNextUser (referenced via handleNextUserRef.current and invoked on the
"peer_left" event and from the Next button) must be guarded against concurrent
execution: add an in-flight or mutex flag (e.g., isHandlingNextUser) at the same
scope as the handleNextUser function, have both call sites (the socket
"peer_left" listener and the Next button path) check and return immediately if
the flag is true, set the flag true at start of handleNextUser, and ensure the
flag is cleared in a finally/finalization path so it always resets on success or
error; apply the same guard to the other code paths mentioned in the 691-765
region to prevent overlapping decline/leave/deactivate/activate sequences.


await new Promise<void>((resolve) => {
if (socket.connected) resolve();
else socket.once("connect", () => resolve());
Expand All @@ -504,6 +536,7 @@ export default function VideoCall() {
}
}, [
screen,
token,
api,
consumeProducer,
createRecvTransport,
Expand Down Expand Up @@ -556,6 +589,12 @@ export default function VideoCall() {
body: JSON.stringify({ peerId: peerIdRef.current }),
});
} catch {}
try {
await api("/matchmaking/decline", {
method: "POST",
body: JSON.stringify({ roomId: currentRoomId }),
});
} catch {}
}

try {
Expand Down Expand Up @@ -584,6 +623,10 @@ export default function VideoCall() {
setMatchedUserId(null);
setMatchState("idle");
setMatchmakingReady(false);
setIsLiked(false);
setReceivedLike(false);
setMutualLike(false);
chatRoomIdRef.current = null;
roomIdRef.current = null;

matchmakingSocketRef.current?.disconnect();
Expand Down Expand Up @@ -611,6 +654,16 @@ export default function VideoCall() {
[api, log],
);

const stopCallRef = useRef(stopCall);
useEffect(() => {
stopCallRef.current = stopCall;
}, [stopCall]);

const handleNextUserRef = useRef<() => Promise<void>>(async () => {});
useEffect(() => {
handleNextUserRef.current = handleNextUser;
});

const handleStartConnecting = useCallback(
async (_camera: string, _mic: string, _interests: string[]) => {
connectingIntentRef.current = true;
Expand Down Expand Up @@ -702,10 +755,90 @@ export default function VideoCall() {
}

function handleLike() {
Alert.alert("Liked", "User wurde geliked.");
if (isLiked) return;
socketRef.current?.emit("send_like");
setIsLiked(true);
}

function handleLikeBack() {
console.log("[LikeBack] User liked back in room:", roomIdRef.current);
socketRef.current?.emit("send_like_back");
}
Comment on lines +686 to +689

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 | 🟠 Major | ⚡ Quick win

Make handleLikeBack idempotent before emitting.

Unlike handleLike (Line 681), this path has no guard, so repeated taps can emit duplicate send_like_back events and race local UI state.

Suggested fix
 function handleLikeBack() {
-    console.log("[LikeBack] User liked back in room:", roomIdRef.current);
-    socketRef.current?.emit("send_like_back");
+    if (isLiked || mutualLike) return;
+    setIsLiked(true);
+    setReceivedLike(false);
+    socketRef.current?.emit("send_like_back");
 }
📝 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
function handleLikeBack() {
console.log("[LikeBack] User liked back in room:", roomIdRef.current);
socketRef.current?.emit("send_like_back");
}
function handleLikeBack() {
if (isLiked || mutualLike) return;
setIsLiked(true);
setReceivedLike(false);
socketRef.current?.emit("send_like_back");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(protected)/videocall/index.tsx around lines 686 - 689, The
handleLikeBack handler currently emits "send_like_back" unguarded; make it
idempotent by checking and setting a local flag/ref before emitting (similar to
handleLike). Add a boolean ref or state (e.g., hasLikedBackRef) checked at the
top of handleLikeBack (if true, return), then set it true immediately after the
check and only then call socketRef.current?.emit("send_like_back") (include
roomIdRef in logs as now). This prevents duplicate emits and local UI race
conditions.

function handleNextUser() {
Alert.alert("Next user", "Hier kannst du den nächsten Match laden.");

async function handleNextUser() {
const roomId = roomIdRef.current;

// Decline current match so these two users won't match again
if (roomId) {
try {
await api("/matchmaking/decline", {
method: "POST",
body: JSON.stringify({ roomId }),
});
} catch {}
}

// Clean up video session without touching the matchmaking socket
localStreamRef.current?.getTracks()?.forEach((t: any) => t.stop());
localStreamRef.current = null;
startingRef.current = false;

if (icebreakerTimeoutRef.current) clearTimeout(icebreakerTimeoutRef.current);
setIcebreakerVisible(false);
icebreakerOpacity.setValue(0);

socketRef.current?.disconnect();
socketRef.current = null;

if (roomId) {
try {
await api(`/video/room/${roomId}/leave`, {
method: "DELETE",
body: JSON.stringify({ peerId: peerIdRef.current }),
});
} catch {}
}

sendTransportRef.current?.close();
recvTransportRef.current?.close();
sendTransportRef.current = null;
recvTransportRef.current = null;
remoteStreamRef.current = new MediaStream();

consumersRef.current.forEach((c) => {
try {
c.close();
} catch {}
});
consumersRef.current.clear();
consumedProducerIdsRef.current.clear();
consumingProducerIdsRef.current.clear();
remoteVideoStreamRef.current = null;

iceRefreshTimerRef.current && clearTimeout(iceRefreshTimerRef.current);
iceRefreshTimerRef.current = null;

// Generate a fresh peerId for the new call
peerIdRef.current = `peer-${Math.random().toString(36).slice(2, 10)}`;

setLocalUrl(null);
setRemoteUrl(null);
setGatewayRoomId(null);
setMatchedUserId(null);
setMatchState("idle");
setIsLiked(false);
setReceivedLike(false);
setMutualLike(false);
chatRoomIdRef.current = null;
roomIdRef.current = null;

// Re-enter matchmaking — deactivate first to guarantee IDLE state
setScreen("connecting");
await connectMatchmakingGateway();
try {
await deactivateMatchmakingRef.current();
} catch {}
await activateMatchmaking();
}
function handleReaction() {
Alert.alert("Reaction", "Emoji Picker oder Quick Reaction öffnen.");
Expand Down