"use client"; import { useEffect, useRef, useCallback, useState } from "react"; import { io, Socket } from "socket.io-client"; import { useQueryClient } from "@tanstack/react-query"; import { useMessengerContext } from "@/contexts/MessengerContext"; import { toast } from "sonner"; const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8080"; export type UserStatus = "online" | "away" | "offline"; interface NewMessageEvent { room_id: number; sender_name: string; sender_id: string; sender_photo?: string | null; content: string; } interface TypingEvent { room_id: number; user_id: string; user_name: string; } export function useMessengerSocket() { const socketRef = useRef(null); const { selectedRoomId, notificationEnabled, isOpen, openMessenger, mutedRooms } = useMessengerContext(); const currentUserIdRef = useRef(null); const selectedRoomIdRef = useRef(selectedRoomId); const notificationEnabledRef = useRef(notificationEnabled); const isOpenRef = useRef(isOpen); const mutedRoomsRef = useRef(mutedRooms); const qc = useQueryClient(); const [userStatuses, setUserStatuses] = useState>(new Map()); const [typingUsers, setTypingUsers] = useState>(new Map()); // Keep refs in sync so socket handlers use latest values useEffect(() => { selectedRoomIdRef.current = selectedRoomId; }, [selectedRoomId]); useEffect(() => { notificationEnabledRef.current = notificationEnabled; }, [notificationEnabled]); useEffect(() => { isOpenRef.current = isOpen; }, [isOpen]); useEffect(() => { mutedRoomsRef.current = mutedRooms; }, [mutedRooms]); useEffect(() => { const token = localStorage.getItem("authToken"); if (!token) return; try { const payload = JSON.parse(atob(token.split(".")[1])); currentUserIdRef.current = payload.userId ?? payload.user_id ?? null; } catch {} const socket = io(BACKEND_URL, { path: "/socket.io", auth: { token }, transports: ["websocket", "polling"], }); socketRef.current = socket; socket.on("connect", () => { socket.emit("join_rooms"); }); // Receive full presence list on connect socket.on("presence_list", (data: Record) => { setUserStatuses((prev) => { const next = new Map(prev); for (const [uid, status] of Object.entries(data)) { next.set(uid, status as UserStatus); } return next; }); }); // Receive individual status updates socket.on("user_status", (data: { userId: string; status: UserStatus }) => { setUserStatuses((prev) => { const next = new Map(prev); if (data.status === "offline") { next.delete(data.userId); } else { next.set(data.userId, data.status); } return next; }); }); // Tab visibility → away/online const handleVisibilityChange = () => { const status = document.hidden ? "away" : "online"; socket.emit("set_status", { status }); }; document.addEventListener("visibilitychange", handleVisibilityChange); // BUG-5 & BUG-8: Handle new_message with cache invalidation and toast socket.on("new_message", (data: NewMessageEvent) => { const roomIdStr = String(data.room_id); qc.invalidateQueries({ queryKey: ["messenger", "messages", roomIdStr] }); qc.invalidateQueries({ queryKey: ["messenger", "rooms"] }); qc.invalidateQueries({ queryKey: ["messenger", "unread"] }); const isOwnMessage = data.sender_id === currentUserIdRef.current; const isRoomMuted = mutedRoomsRef.current.has(roomIdStr); if (notificationEnabledRef.current && !isOwnMessage && !isRoomMuted && (!isOpenRef.current || roomIdStr !== selectedRoomIdRef.current)) { const photoSrc = data.sender_photo ? `data:image/jpeg;base64,${data.sender_photo}` : null; toast(
openMessenger(roomIdStr)}> {photoSrc ? ( ) : (
{(data.sender_name || "?").charAt(0).toUpperCase()}
)}
{data.sender_name || "새 메시지"}
{data.content?.slice(0, 60) || ""}
); } }); // BUG-7: Backend emits "user_typing" / "user_stop_typing" socket.on("user_typing", (data: TypingEvent) => { const roomIdStr = String(data.room_id); setTypingUsers((prev) => { const next = new Map(prev); const users = next.get(roomIdStr) || []; if (!users.includes(data.user_name)) { next.set(roomIdStr, [...users, data.user_name]); } return next; }); }); socket.on("user_stop_typing", (data: TypingEvent) => { const roomIdStr = String(data.room_id); setTypingUsers((prev) => { const next = new Map(prev); const users = next.get(roomIdStr) || []; next.set(roomIdStr, users.filter((u) => u !== data.user_name)); return next; }); }); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); socket.disconnect(); socketRef.current = null; }; }, [toast, qc]); const emitTypingStart = useCallback( (roomId: string) => { socketRef.current?.emit("typing_start", { room_id: Number(roomId) }); }, [] ); const emitTypingStop = useCallback( (roomId: string) => { socketRef.current?.emit("typing_stop", { room_id: Number(roomId) }); }, [] ); return { socket: socketRef, userStatuses, typingUsers, emitTypingStart, emitTypingStop }; }