187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
|
|
"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<Socket | null>(null);
|
||
|
|
const { selectedRoomId, notificationEnabled, isOpen, openMessenger, mutedRooms } = useMessengerContext();
|
||
|
|
const currentUserIdRef = useRef<string | null>(null);
|
||
|
|
const selectedRoomIdRef = useRef(selectedRoomId);
|
||
|
|
const notificationEnabledRef = useRef(notificationEnabled);
|
||
|
|
const isOpenRef = useRef(isOpen);
|
||
|
|
const mutedRoomsRef = useRef(mutedRooms);
|
||
|
|
|
||
|
|
const qc = useQueryClient();
|
||
|
|
const [userStatuses, setUserStatuses] = useState<Map<string, UserStatus>>(new Map());
|
||
|
|
const [typingUsers, setTypingUsers] = useState<Map<string, string[]>>(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<string, string>) => {
|
||
|
|
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(
|
||
|
|
<div className="flex items-center gap-2 cursor-pointer" onClick={() => openMessenger(roomIdStr)}>
|
||
|
|
{photoSrc ? (
|
||
|
|
<img src={photoSrc} alt="" className="w-6 h-6 rounded-full object-cover shrink-0" />
|
||
|
|
) : (
|
||
|
|
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium shrink-0">
|
||
|
|
{(data.sender_name || "?").charAt(0).toUpperCase()}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="min-w-0">
|
||
|
|
<div className="font-medium text-sm">{data.sender_name || "새 메시지"}</div>
|
||
|
|
<div className="text-xs text-muted-foreground truncate">{data.content?.slice(0, 60) || ""}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// 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 };
|
||
|
|
}
|