Spaces:
Running
Running
| import { io } from "socket.io-client"; | |
| const SOCKET_URL = import.meta.env.VITE_SOCKET_URL || "http://localhost:3000"; | |
| class SocketService { | |
| constructor() { | |
| this.socket = null; | |
| this.listeners = new Map(); | |
| this._connected = false; | |
| this._activeDMRooms = new Set(); | |
| this._activeRooms = new Set(); | |
| this._joinedRooms = new Set(); // Rooms that have received 'joinedRoom' ack | |
| } | |
| // ==================== Connection ==================== | |
| connect() { | |
| if (this.socket?.connected) return; | |
| const token = localStorage.getItem("access_token"); | |
| if (!token) { | |
| return; | |
| } | |
| this.socket = io(`${SOCKET_URL}/chat`, { | |
| // Use auth callback so socket.io reads fresh token on every connect/reconnect | |
| auth: (cb) => { | |
| cb({ token: localStorage.getItem("access_token") }); | |
| }, | |
| transports: ["websocket", "polling"], | |
| reconnection: true, | |
| reconnectionDelay: 1000, | |
| reconnectionAttempts: 5, | |
| }); | |
| this.socket.on("connect", () => { | |
| console.log("[Socket] Connected:", this.socket.id); | |
| this._connected = true; | |
| }); | |
| this.socket.on("disconnect", (reason) => { | |
| console.log("[Socket] Disconnected:", reason); | |
| this._connected = false; | |
| }); | |
| this.socket.on("connect_error", (error) => { | |
| console.error("[Socket] Connect error:", error.message); | |
| // If auth failed due to expired token, try to trigger a token refresh | |
| // by making a dummy API call. The axios interceptor will handle 401. | |
| if (error.message?.includes("jwt expired") || error.message?.includes("auth")) { | |
| this._handleAuthError(); | |
| } | |
| }); | |
| this.socket.on("reconnect", async (attemptNumber) => { | |
| console.log("[Socket] Reconnected after", attemptNumber, "attempts"); | |
| // Re-join all active rooms with fresh token | |
| this._activeDMRooms.forEach((conversationId) => { | |
| this.joinDM(conversationId); | |
| }); | |
| // Await room joins to ensure server is ready before sending | |
| const roomJoinPromises = Array.from(this._activeRooms).map((roomId) => | |
| this.joinRoom(roomId).catch((err) => { | |
| console.warn("[Socket] Failed to rejoin room:", roomId, err); | |
| }), | |
| ); | |
| await Promise.all(roomJoinPromises); | |
| }); | |
| this.socket.on("reconnect_error", (error) => { | |
| console.error("[Socket] Reconnect error:", error.message); | |
| }); | |
| this.socket.on("error", (error) => { | |
| console.error("[Socket] Error:", error); | |
| }); | |
| this.socket.on("connected", (data) => { | |
| console.log("[Socket] Server ack:", data); | |
| }); | |
| } | |
| // Trigger a token refresh by making a lightweight API call. | |
| // The axios response interceptor will handle 401 and refresh the token. | |
| _handleAuthError() { | |
| console.log("[Socket] Token may be expired, triggering refresh via API..."); | |
| // Use a lightweight endpoint to trigger the refresh flow | |
| // The access token will be updated in localStorage by the interceptor | |
| import("./api").then(({ default: api }) => { | |
| api.get("/users/me").catch(() => { | |
| // Expected to fail or succeed; either way, token may have been refreshed | |
| }); | |
| }); | |
| } | |
| // Reconnect with fresh token. Call this after token refresh succeeds. | |
| reconnect() { | |
| if (this.socket) { | |
| this.socket.disconnect(); | |
| this.socket = null; | |
| } | |
| this.connect(); | |
| } | |
| disconnect() { | |
| if (this.socket) { | |
| this.socket.disconnect(); | |
| this.socket = null; | |
| } | |
| this._connected = false; | |
| this._activeDMRooms.clear(); | |
| this._activeRooms.clear(); | |
| } | |
| isConnected() { | |
| return this.socket?.connected || false; | |
| } | |
| getId() { | |
| return this.socket?.id || null; | |
| } | |
| // ==================== DM Room Events ==================== | |
| joinDM(conversationId) { | |
| if (!conversationId) return; | |
| this._activeDMRooms.add(conversationId); | |
| this.socket?.emit("joinDM", { conversationId }); | |
| } | |
| leaveDM(conversationId) { | |
| if (!conversationId) return; | |
| this._activeDMRooms.delete(conversationId); | |
| this.socket?.emit("leaveDM", { conversationId }); | |
| } | |
| sendDM(conversationId, content, tempId) { | |
| if (!conversationId) return; | |
| const clientSentAt = Date.now(); | |
| const payload = { conversationId, content, clientSentAt }; | |
| if (tempId) payload.tempId = tempId; | |
| this.socket?.emit("sendDM", payload); | |
| } | |
| dmTyping(conversationId, isTyping) { | |
| this.socket?.emit("dmTyping", { conversationId, isTyping }); | |
| } | |
| markDMRead(conversationId) { | |
| this.socket?.emit("markDMRead", { conversationId }); | |
| } | |
| // ==================== Status Events ==================== | |
| setStatus(status) { | |
| this.socket?.emit("setStatus", { status }); | |
| } | |
| getOnlineUsers() { | |
| this.socket?.emit("getOnlineUsers"); | |
| } | |
| // ==================== Notification Events ==================== | |
| markNotificationRead(notificationId) { | |
| this.socket?.emit("markNotificationRead", { notificationId }); | |
| } | |
| getUnreadCount() { | |
| this.socket?.emit("getUnreadCount"); | |
| } | |
| // ==================== Room/Space Events (legacy) ==================== | |
| joinRoom(roomId) { | |
| if (!roomId) return Promise.resolve(); | |
| this._activeRooms.add(roomId); | |
| return new Promise((resolve) => { | |
| // Fast timeout — don't block UI if server is slow to ack | |
| const timeout = setTimeout(() => { | |
| this.socket?.off("joinedRoom", onJoined); | |
| this._joinedRooms.add(roomId); // Allow sending anyway | |
| console.warn("[Socket] joinRoom timeout, allowing sends for:", roomId); | |
| resolve({ roomId, timeout: true }); | |
| }, 1500); | |
| const onJoined = (data) => { | |
| if (data?.roomId === roomId) { | |
| clearTimeout(timeout); | |
| this.socket?.off("joinedRoom", onJoined); | |
| this._joinedRooms.add(roomId); | |
| console.log("[Socket] Joined room:", roomId); | |
| resolve(data); | |
| } | |
| }; | |
| this.socket?.on("joinedRoom", onJoined); | |
| this.socket?.emit("joinRoom", { roomId }); | |
| }); | |
| } | |
| leaveRoom(roomId) { | |
| if (!roomId) return; | |
| this._activeRooms.delete(roomId); | |
| this._joinedRooms.delete(roomId); | |
| this.socket?.emit("leaveRoom", { roomId }); | |
| } | |
| sendMessage(data) { | |
| const { roomId } = data; | |
| // Auto-join if not yet acked but is active room (don't block send) | |
| if (roomId && !this._joinedRooms.has(roomId)) { | |
| if (this._activeRooms.has(roomId)) { | |
| console.warn("[Socket] Room not yet acked, allowing send anyway:", roomId); | |
| } else { | |
| console.warn("[Socket] Cannot send message - not in active rooms:", roomId); | |
| return; | |
| } | |
| } | |
| this.socket?.emit("sendMessage", data); | |
| } | |
| updateStatus(status) { | |
| this.socket?.emit("updateStatus", status); | |
| } | |
| emitTyping(roomId) { | |
| if (roomId && !this._activeRooms.has(roomId)) { | |
| console.warn("[Socket] Cannot emit typing - not active room:", roomId); | |
| return; | |
| } | |
| this.socket?.emit("typing", { roomId }); | |
| } | |
| emitStopTyping(roomId) { | |
| if (roomId && !this._activeRooms.has(roomId)) { | |
| console.warn("[Socket] Cannot emit stopTyping - not active room:", roomId); | |
| return; | |
| } | |
| this.socket?.emit("stopTyping", { roomId }); | |
| } | |
| // ==================== Listener Management ==================== | |
| on(event, callback) { | |
| this.socket?.on(event, callback); | |
| } | |
| off(event, callback) { | |
| this.socket?.off(event, callback); | |
| } | |
| offEvent(event) { | |
| this.socket?.off(event); | |
| } | |
| removeAllListeners() { | |
| this.socket?.removeAllListeners(); | |
| } | |
| // ==================== DM-specific Listeners ==================== | |
| onJoinedDM(callback) { | |
| this.socket?.on("joinedDM", callback); | |
| } | |
| onLeftDM(callback) { | |
| this.socket?.on("leftDM", callback); | |
| } | |
| onNewDM(callback) { | |
| this.socket?.on("newDM", callback); | |
| } | |
| onDmSent(callback) { | |
| this.socket?.on("dmSent", callback); | |
| } | |
| onDmTyping(callback) { | |
| this.socket?.on("dmTyping", callback); | |
| } | |
| onDmRead(callback) { | |
| this.socket?.on("dmRead", callback); | |
| } | |
| onDmMarkedRead(callback) { | |
| this.socket?.on("dmMarkedRead", callback); | |
| } | |
| // ==================== User Status Listeners ==================== | |
| onUserStatusChanged(callback) { | |
| this.socket?.on("userStatusChanged", callback); | |
| } | |
| onStatusSet(callback) { | |
| this.socket?.on("statusSet", callback); | |
| } | |
| onOnlineUsers(callback) { | |
| this.socket?.on("onlineUsers", callback); | |
| } | |
| onConnected(callback) { | |
| this.socket?.on("connected", callback); | |
| } | |
| // ==================== Notification Listeners ==================== | |
| onNewNotification(callback) { | |
| this.socket?.on("newNotification", callback); | |
| } | |
| onNotificationsMarkedRead(callback) { | |
| this.socket?.on("notificationsMarkedRead", callback); | |
| } | |
| onUnreadCountUpdate(callback) { | |
| this.socket?.on("unreadCount", callback); | |
| } | |
| // ==================== Legacy Listeners ==================== | |
| onNewMessage(callback) { | |
| this.socket?.on("newMessage", callback); | |
| } | |
| onMessageSent(callback) { | |
| this.socket?.on("messageSent", callback); | |
| } | |
| onMessageDeleted(callback) { | |
| this.socket?.on("messageDeleted", callback); | |
| } | |
| onMessageUpdated(callback) { | |
| this.socket?.on("messageUpdated", callback); | |
| } | |
| onMessagePinned(callback) { | |
| this.socket?.on("messagePinned", callback); | |
| } | |
| onMessageUnpinned(callback) { | |
| this.socket?.on("messageUnpinned", callback); | |
| } | |
| onReactionAdded(callback) { | |
| this.socket?.on("reactionAdded", callback); | |
| } | |
| onReactionRemoved(callback) { | |
| this.socket?.on("reactionRemoved", callback); | |
| } | |
| onTyping(callback) { | |
| this.socket?.on("typing", callback); | |
| } | |
| onStopTyping(callback) { | |
| this.socket?.on("stopTyping", callback); | |
| } | |
| onUserJoined(callback) { | |
| this.socket?.on("userJoined", callback); | |
| } | |
| onUserLeft(callback) { | |
| this.socket?.on("userLeft", callback); | |
| } | |
| onMemberJoinedSpace(callback) { | |
| this.socket?.on("memberJoinedSpace", callback); | |
| } | |
| onMemberLeftSpace(callback) { | |
| this.socket?.on("memberLeftSpace", callback); | |
| } | |
| onRoomCreated(callback) { | |
| this.socket?.on("roomCreated", callback); | |
| } | |
| onRoomUpdated(callback) { | |
| this.socket?.on("roomUpdated", callback); | |
| } | |
| onRoomDeleted(callback) { | |
| this.socket?.on("roomDeleted", callback); | |
| } | |
| onUserProfileUpdated(callback) { | |
| this.socket?.on("userProfileUpdated", callback); | |
| } | |
| onNotification(callback) { | |
| this.socket?.on("notification", callback); | |
| } | |
| onFileUploadProgress(callback) { | |
| this.socket?.on("fileUploadProgress", callback); | |
| } | |
| onFileUploadComplete(callback) { | |
| this.socket?.on("fileUploadComplete", callback); | |
| } | |
| onFileUploadError(callback) { | |
| this.socket?.on("fileUploadError", callback); | |
| } | |
| } | |
| export default new SocketService(); | |