Files
vexplor_dev/frontend/hooks/useMessengerSocket.tsx
syc0123 403e5cae40 [RAPID] 메신저 기능 구현 및 UI/UX 개선
- 사용자 온라인 상태 표시 (온라인/자리비움/오프라인) 디스코드 스타일
- 채팅방별 알림 ON/OFF 토글 (Bell 아이콘, localStorage 저장)
- 파일 업로드 실시간 소켓 브로드캐스트 및 한글 파일명 깨짐 수정
- FAB 읽지않음 배지 버그 수정 (메신저 닫혀있을 때 markAsRead 차단)
- 타이핑 도트 애니메이션, 날짜 오늘/어제 표시
- 입력창 bordered box, DM 편집 버튼 숨김
- 메신저 설정 버튼 제거, 새 대화 시작하기 Empty state CTA
- useMessengerSocket 소켓 중복 생성 방지 (MessengerModal로 이동)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

[RAPID-micro] 추적 파일 정리 및 메신저 소소한 변경

- .omc/state/ 파일 git 추적 제거 (.gitignore 이미 설정됨)
- db/checkpoints/ gitignore 추가
- globals.css: 메신저 메시지 시간 폰트 스타일 추가
- useMessenger.ts: fileMimeType 필드 및 API_BASE_URL import 추가

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:20:43 +09:00

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 };
}