fea:Add an elegant exit room feature for the recipient

This commit is contained in:
david_bai
2025-08-10 23:16:59 +08:00
parent 3e6c9b46e8
commit 12cda8c030
8 changed files with 162 additions and 26 deletions
+41 -1
View File
@@ -29,6 +29,11 @@ interface CheckRoomRequest {
roomId: string;
}
interface LeaveRoomRequest {
roomId: string;
socketId: string;
}
// Route handler for creating a room
const createRoomHandler: RequestHandler<{}, any, CreateRoomRequest> = async (
req,
@@ -110,7 +115,7 @@ const setTrackHandler: RequestHandler<{}, any, ReferrerTrack> = async (
// Use MULTI to ensure atomicity of hincrby and expire
await redis
.multi()
.hincrby(dailyKey, ref, 1) // \"referrers:daily:2024-01-20\" : { \"producthunt\": \"5\", \"twitter\": \"3\" }
.hincrby(dailyKey, ref, 1) // "referrers:daily:2024-01-20" : { "producthunt": "5", "twitter": "3" }
.expire(dailyKey, thirtyDaysInSeconds) // Set a 30-day expiration
.exec();
@@ -136,11 +141,46 @@ const logsDebugHandler: RequestHandler<{}, any, LogMessage> = async (
}
};
// Route handler for leaving a room
const leaveRoomHandler: RequestHandler<{}, any, LeaveRoomRequest> = async (
req,
res
) => {
const { roomId, socketId } = req.body;
if (!roomId || !socketId) {
res.status(400).json({ error: "Room ID and Socket ID are required" });
return;
}
try {
const roomExists = await roomService.isRoomExist(roomId);
if (!roomExists) {
res.json({ success: true, message: "Room does not exist." });
return;
}
await roomService.unbindSocketFromRoom(socketId, roomId);
if (await roomService.isRoomEmpty(roomId)) {
await roomService.refreshRoom(roomId, 900); // 15 minutes
console.log(
`Room ${roomId} is empty after leave and will be deleted in 15 min.`
);
}
res.json({ success: true, message: "Successfully left the room" });
} catch (error) {
console.error("Error leaving room:", error);
res.status(500).json({ error: "Internal server error" });
}
};
// Register routes
router.post("/api/create_room", createRoomHandler);
router.get("/api/get_room", getRoomHandler);
router.post("/api/check_room", checkRoomHandler);
router.post("/api/set_track", setTrackHandler);
router.post("/api/logs_debug", logsDebugHandler);
router.post("/api/leave_room", leaveRoomHandler);
export default router;
+4 -4
View File
@@ -115,16 +115,16 @@ export function setupSocketHandlers(io: Server): void {
console.log("Disconnected:", socket.id);
const roomId = await roomService.getRoomBySocketId(socket.id);
if (roomId) {
// Notify other users in the room that this peer has left
socket.to(roomId).emit("peer-disconnected", { peerId: socket.id });
await roomService.unbindSocketFromRoom(socket.id, roomId);
if (await roomService.isRoomEmpty(roomId)) {
// await deleteRoom(roomId);
await roomService.refreshRoom(roomId, 3600);
await roomService.refreshRoom(roomId, 900);
console.log(
`Room ${roomId} is empty and will be deleted in 1 hour due to disconnect.`
`Room ${roomId} is empty and will be deleted in 15 min due to disconnect.`
);
}
// Notify other users in the room that this peer has left
// io.to(roomId).emit('peer-disconnected', { peerId: socket.id });
}
});
});
+17
View File
@@ -6,6 +6,7 @@ export const API_ROUTES = {
get_room: `${API_URL}/api/get_room`,
check_room: `${API_URL}/api/check_room`,
create_room: `${API_URL}/api/create_room`,
leave_room: `${API_URL}/api/leave_room`,
set_track: `${API_URL}/api/set_track`,
logs_debug: `${API_URL}/api/logs_debug`,
};
@@ -95,3 +96,19 @@ export const postLogInDebug = async (message: string) => {
});
return apiCall<void>(API_ROUTES.logs_debug, options);
};
// Leave a room
export const leaveRoom = async (
roomId: string,
socketId: string
): Promise<boolean> => {
const options = getFetchOptions({
method: "POST",
body: JSON.stringify({ roomId, socketId }),
});
const data = await apiCall<{ success: boolean }>(
API_ROUTES.leave_room,
options
);
return data?.success ?? false;
};
+12 -1
View File
@@ -72,6 +72,7 @@ const ClipboardApp = () => {
requestFolder,
setReceiverDirectoryHandle,
getReceiverSaveType,
senderDisconnected,
} = useWebRTCConnection({
shareContent,
sendFiles,
@@ -83,6 +84,12 @@ const ClipboardApp = () => {
onFileReceived: onFileFullyReceived,
});
const resetAppState = useCallback(() => {
// This function will reset the state of the application
// For now, it just reloads the page, a more granular state reset can be implemented later.
window.location.reload();
}, []);
// Initialize Room Manager Hook
const {
shareRoomId,
@@ -93,6 +100,7 @@ const ClipboardApp = () => {
processRoomIdInput,
joinRoom,
generateShareLinkAndBroadcast,
handleLeaveRoom,
} = useRoomManager({
messages,
putMessageInMs,
@@ -101,9 +109,10 @@ const ClipboardApp = () => {
activeTab,
sharePeerCount,
retrievePeerCount,
// Pass the actual broadcast function from useWebRTCConnection
senderDisconnected,
broadcastDataToPeers: () =>
broadcastDataToAllPeers(shareContent, sendFiles),
resetApp: resetAppState,
});
const handleFileDrop = useCallback(
@@ -267,6 +276,8 @@ const ClipboardApp = () => {
setReceiverDirectoryHandle={setReceiverDirectoryHandle}
getReceiverSaveType={getReceiverSaveType}
retrieveMessage={retrieveMessage}
senderDisconnected={senderDisconnected}
handleLeaveRoom={handleLeaveRoom}
/>
)}
</CardContent>
@@ -40,6 +40,8 @@ interface RetrieveTabPanelProps {
) => Promise<void>;
getReceiverSaveType: () => { [fileId: string]: boolean } | undefined;
retrieveMessage: string;
senderDisconnected: boolean;
handleLeaveRoom: () => void;
}
export function RetrieveTabPanel({
@@ -62,6 +64,8 @@ export function RetrieveTabPanel({
setReceiverDirectoryHandle,
getReceiverSaveType,
retrieveMessage,
senderDisconnected,
handleLeaveRoom,
}: RetrieveTabPanelProps) {
const onLocationPick = useCallback(async (): Promise<boolean> => {
if (!messages) return false; // Should not happen if panel is rendered
@@ -145,6 +149,17 @@ export function RetrieveTabPanel({
{messages.text.ClipboardApp.html.joinRoom_dis}
</Button>
</div>
{senderDisconnected && (
<div className="mb-3">
<Button
className="w-full"
variant="destructive"
onClick={handleLeaveRoom}
>
{messages.text.ClipboardApp.html.leaveRoom_dis || "Leave Room"}
</Button>
</div>
)}
{retrievedContent && (
<div className="my-3 p-2 border rounded bg-gray-50">
<RichTextEditor
+47 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { fetchRoom, createRoom, checkRoom } from "@/app/config/api";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { fetchRoom, createRoom, checkRoom, leaveRoom } from "@/app/config/api";
import { debounce } from "lodash";
import type { Messages } from "@/types/messages";
import type WebRTC_Initiator from "@/lib/webrtc_Initiator";
@@ -21,7 +21,9 @@ interface UseRoomManagerProps {
activeTab: "send" | "retrieve";
sharePeerCount: number;
retrievePeerCount: number;
senderDisconnected: boolean;
broadcastDataToPeers: () => Promise<boolean>;
resetApp: () => void; // Add a reset function prop
}
export function useRoomManager({
@@ -32,13 +34,34 @@ export function useRoomManager({
activeTab,
sharePeerCount,
retrievePeerCount,
senderDisconnected,
broadcastDataToPeers,
resetApp,
}: UseRoomManagerProps) {
const [shareRoomId, setShareRoomId] = useState(""); // Represents the validated or initially fetched room ID
const [initShareRoomId, setInitShareRoomId] = useState(""); // Stores the initially fetched room ID for comparison
const [shareLink, setShareLink] = useState("");
const [shareRoomStatusText, setShareRoomStatusText] = useState("");
const [retrieveRoomStatusText, setRetrieveRoomStatusText] = useState("");
const autoLeaveTimer = useRef<NodeJS.Timeout | null>(null);
const handleLeaveRoom = useCallback(async () => {
if (!receiver || !receiver.roomId || !receiver.peerId) return;
try {
await leaveRoom(receiver.roomId, receiver.peerId);
putMessageInMs("You have left the room.", false);
} catch (error) {
console.error("Error leaving room:", error);
putMessageInMs("Failed to leave the room.", false);
} finally {
// Reset application state
resetApp();
if (autoLeaveTimer.current) {
clearTimeout(autoLeaveTimer.current);
autoLeaveTimer.current = null;
}
}
}, [receiver, putMessageInMs, resetApp]);
// Initialize shareRoomId on mount
useEffect(() => {
@@ -278,8 +301,29 @@ export function useRoomManager({
sender,
receiver,
messages,
senderDisconnected,
]);
useEffect(() => {
if (activeTab === "retrieve" && senderDisconnected) {
setRetrieveRoomStatusText(
messages?.text.ClipboardApp.roomStatus.senderDisconnected_dis ||
"The sender has disconnected. The room will close in 15 minutes."
);
// Set a timer to automatically leave the room
autoLeaveTimer.current = setTimeout(() => {
handleLeaveRoom();
}, 900000); // 15 minutes
}
return () => {
// Cleanup timer if the component unmounts or dependencies change
if (autoLeaveTimer.current) {
clearTimeout(autoLeaveTimer.current);
}
};
}, [senderDisconnected, activeTab, messages, handleLeaveRoom]);
return {
shareRoomId, // This is the validated or initial room ID
initShareRoomId, // Exposed for UI comparison or reset logic
@@ -289,5 +333,6 @@ export function useRoomManager({
processRoomIdInput, // New input processing function
joinRoom,
generateShareLinkAndBroadcast,
handleLeaveRoom,
};
}
+16
View File
@@ -58,6 +58,7 @@ export function useWebRTCConnection({
const [sendProgress, setSendProgress] = useState<ProgressState>({});
const [receiveProgress, setReceiveProgress] = useState<ProgressState>({});
const [senderDisconnected, setSenderDisconnected] = useState(false);
// Calculate isAnyFileTransferring internally based on progress states
const isAnyFileTransferring = useMemo(() => {
@@ -222,6 +223,20 @@ export function useWebRTCConnection({
onFileReceived(file, peerId || "unknown_peer");
};
receiver.onPeerDisconnected = (peerId) => {
if (developmentEnv)
console.log(`Receiver peer ${peerId} disconnected.`);
// On the receiver side, any peer is a sender.
setSenderDisconnected(true);
};
receiver.onConnectionEstablished = (peerId) => {
if (developmentEnv)
console.log(`Receiver connection established with ${peerId}.`);
// If a connection is re-established, assume sender is back.
setSenderDisconnected(false);
};
receiver.onError = (error) => {
console.error("Receiver Error:", error.message, error.context);
putMessageInMs(`Connection error: ${error.message}`, false);
@@ -317,5 +332,6 @@ export function useWebRTCConnection({
requestFolder,
setReceiverDirectoryHandle,
getReceiverSaveType,
senderDisconnected,
};
}
+10 -18
View File
@@ -26,6 +26,7 @@ interface CallbackTypes {
state: RTCPeerConnectionState,
peerId: string
) => void;
onPeerDisconnected?: (peerId: string) => void;
onError?: (error: WebRTCError) => void;
}
@@ -50,6 +51,7 @@ export default class BaseWebRTC {
public onConnectionStateChange:
| CallbackTypes["onConnectionStateChange"]
| null;
public onPeerDisconnected: CallbackTypes["onPeerDisconnected"] | null;
public onError: CallbackTypes["onError"] | null;
protected iceCandidatesQueue: Map<string, RTCIceCandidateInit[]>;
@@ -74,6 +76,7 @@ export default class BaseWebRTC {
this.onDataReceived = null; // Callback for receiving data
this.onConnectionEstablished = null; // Triggered when the WebRTC connection is established
this.onConnectionStateChange = null; // Monitors and responds to connection state changes
this.onPeerDisconnected = null;
this.onError = null;
this.iceCandidatesQueue = new Map(); // Stores ICE candidates for each peer
@@ -133,6 +136,13 @@ export default class BaseWebRTC {
// The disconnect code executes upon returning, so reconnect directly here; send a new signal to start reconnection.
this.attemptReconnection();
});
this.socket.on("peer-disconnected", ({ peerId }) => {
this.log("log", `Peer ${peerId} has disconnected.`);
this.onPeerDisconnected?.(peerId);
// We can also clean up the connection here if needed
this.cleanupExistingConnection(peerId);
});
}
protected async attemptReconnection(): Promise<void> {
if (this.reconnectionInProgress) return;
@@ -211,10 +221,6 @@ export default class BaseWebRTC {
const candidates = this.iceCandidatesQueue.get(peerId);
const peerConnection = this.peerConnections.get(peerId);
// this.log('log',`Attempting to add ${candidates?.length || 0} queued candidates for ${peerId}`);
// this.log('log',`Connection state: ${peerConnection?.connectionState}`);
// this.log('log',`Signaling state: ${peerConnection?.signalingState}`);
if (!peerConnection || !candidates?.length) {
return;
}
@@ -262,15 +268,6 @@ export default class BaseWebRTC {
iceServers: this.iceServers,
});
// // Add more detailed connection state monitoring
// newPeerConnection.oniceconnectionstatechange = () => {
// this.log('log',`ICE Connection State (${peerId}):`, newPeerConnection.iceConnectionState);
// };
// newPeerConnection.onsignalingstatechange = () => {
// this.log('log',`Signaling State (${peerId}):`, newPeerConnection.signalingState);
// };
newPeerConnection.onconnectionstatechange = () => {
// const state = newPeerConnection.connectionState;
// this.log('log',`Connection State (${peerId}):`, state);
@@ -288,11 +285,6 @@ export default class BaseWebRTC {
}
};
// // Add ICE gathering state monitoring
// newPeerConnection.onicegatheringstatechange = () => {
// this.log('log',`ICE Gathering State (${peerId}):`, newPeerConnection.iceGatheringState);
// };
this.peerConnections.set(peerId, newPeerConnection);
// this.log('log','New peer connection created for:', peerId);
return Promise.resolve(newPeerConnection);