/** * Real-time Collaboration Store * Manages collaboration state, sessions, and real-time updates */ import { create } from 'zustand'; import { io, Socket } from 'socket.io-client'; import { CollaboratorInfo, CollaborationSession, ChatMessage, CursorPosition, SelectionRange, CodeChange, ConnectionState, SessionSettings, DEFAULT_SESSION_SETTINGS, getCollaboratorColor, PermissionLevel, } from '@/lib/collaboration/types'; interface CollaborationState { // Connection socket: Socket | null; connectionState: ConnectionState; reconnectAttempts: number; // Session currentSession: CollaborationSession | null; collaborators: CollaboratorInfo[]; myInfo: CollaboratorInfo | null; myPermission: PermissionLevel; // Chat chatMessages: ChatMessage[]; unreadChatCount: number; // Settings settings: SessionSettings; // UI State isChatOpen: boolean; isCollaboratorsPanelOpen: boolean; // Actions connect: (serverUrl?: string) => void; disconnect: () => void; createSession: (name: string, fileId: string, fileName: string, isPublic: boolean) => Promise; joinSession: (sessionId: string, name: string, avatarUrl?: string) => Promise; leaveSession: () => void; // Real-time updates updateCursor: (position: CursorPosition) => void; updateSelection: (selection: SelectionRange | null) => void; sendCodeChange: (change: CodeChange) => void; setTyping: (isTyping: boolean) => void; // Chat sendChatMessage: (content: string) => void; markChatAsRead: () => void; // Settings updateSettings: (settings: Partial) => void; setFollowUser: (userId: string | null) => void; // UI toggleChat: () => void; toggleCollaboratorsPanel: () => void; // Permissions updateCollaboratorPermission: (collaboratorId: string, permission: PermissionLevel) => void; kickCollaborator: (collaboratorId: string) => void; } // Generate unique ID const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; export const useCollaborationStore = create((set, get) => ({ // Initial state socket: null, connectionState: 'disconnected', reconnectAttempts: 0, currentSession: null, collaborators: [], myInfo: null, myPermission: 'viewer', chatMessages: [], unreadChatCount: 0, settings: DEFAULT_SESSION_SETTINGS, isChatOpen: false, isCollaboratorsPanelOpen: true, // Connection connect: (serverUrl = 'ws://localhost:3001') => { const { socket, connectionState } = get(); if (socket || connectionState === 'connecting') return; set({ connectionState: 'connecting' }); const newSocket = io(serverUrl, { transports: ['websocket'], reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000, }); newSocket.on('connect', () => { set({ connectionState: 'connected', reconnectAttempts: 0, }); }); newSocket.on('disconnect', () => { set({ connectionState: 'disconnected' }); }); newSocket.on('reconnect_attempt', (attempt) => { set({ connectionState: 'reconnecting', reconnectAttempts: attempt, }); }); newSocket.on('connect_error', () => { set({ connectionState: 'error' }); }); // Session events newSocket.on('session_joined', (data: { session: CollaborationSession; collaborators: CollaboratorInfo[] }) => { set({ currentSession: data.session, collaborators: data.collaborators, }); }); newSocket.on('collaborator_joined', (collaborator: CollaboratorInfo) => { set((state) => ({ collaborators: [...state.collaborators, collaborator], })); }); newSocket.on('collaborator_left', (collaboratorId: string) => { set((state) => ({ collaborators: state.collaborators.filter((c) => c.id !== collaboratorId), })); }); newSocket.on('cursor_updated', (data: { id: string; cursor: CursorPosition }) => { set((state) => ({ collaborators: state.collaborators.map((c) => c.id === data.id ? { ...c, cursor: data.cursor, lastActive: Date.now() } : c ), })); }); newSocket.on('selection_updated', (data: { id: string; selection: SelectionRange | null }) => { set((state) => ({ collaborators: state.collaborators.map((c) => c.id === data.id ? { ...c, selection: data.selection ?? undefined, lastActive: Date.now() } : c ), })); }); newSocket.on('typing_changed', (data: { id: string; isTyping: boolean }) => { set((state) => ({ collaborators: state.collaborators.map((c) => c.id === data.id ? { ...c, isTyping: data.isTyping } : c ), })); }); newSocket.on('chat_message', (message: ChatMessage) => { set((state) => ({ chatMessages: [...state.chatMessages, message], unreadChatCount: state.isChatOpen ? 0 : state.unreadChatCount + 1, })); }); newSocket.on('code_changed', (change: CodeChange & { senderId: string }) => { // This would be handled by the editor integration // The editor should listen to this event and apply the change }); set({ socket: newSocket }); }, disconnect: () => { const { socket } = get(); if (socket) { socket.disconnect(); } set({ socket: null, connectionState: 'disconnected', currentSession: null, collaborators: [], myInfo: null, chatMessages: [], }); }, createSession: async (name: string, fileId: string, fileName: string, isPublic: boolean): Promise => { const { socket, myInfo } = get(); if (!socket || !myInfo) { throw new Error('Not connected'); } return new Promise((resolve, reject) => { const sessionId = generateId(); socket.emit('create_session', { sessionId, name, fileId, fileName, isPublic, ownerId: myInfo.id, ownerName: myInfo.name, }); socket.once('session_created', (data: { sessionId: string }) => { const session: CollaborationSession = { id: data.sessionId, name, ownerId: myInfo.id, ownerName: myInfo.name, createdAt: Date.now(), collaborators: [myInfo], fileId, fileName, isPublic, maxCollaborators: 10, }; set({ currentSession: session, collaborators: [myInfo], myPermission: 'owner', }); resolve(data.sessionId); }); socket.once('session_error', (error: string) => { reject(new Error(error)); }); // Timeout setTimeout(() => reject(new Error('Session creation timed out')), 10000); }); }, joinSession: async (sessionId: string, name: string, avatarUrl?: string): Promise => { const { socket } = get(); if (!socket) { throw new Error('Not connected'); } const myId = generateId(); const colorIndex = Math.floor(Math.random() * 10); const myInfo: CollaboratorInfo = { id: myId, name, color: getCollaboratorColor(colorIndex), avatarUrl, lastActive: Date.now(), isTyping: false, }; set({ myInfo }); return new Promise((resolve, reject) => { socket.emit('join_session', { sessionId, collaborator: myInfo, }); socket.once('session_joined', () => { set({ myPermission: 'editor' }); resolve(true); }); socket.once('session_error', (error: string) => { reject(new Error(error)); }); setTimeout(() => reject(new Error('Join timed out')), 10000); }); }, leaveSession: () => { const { socket, currentSession, myInfo } = get(); if (socket && currentSession && myInfo) { socket.emit('leave_session', { sessionId: currentSession.id, collaboratorId: myInfo.id, }); } set({ currentSession: null, collaborators: [], myPermission: 'viewer', chatMessages: [], }); }, updateCursor: (position: CursorPosition) => { const { socket, currentSession, myInfo, settings } = get(); if (!socket || !currentSession || !myInfo || !settings.showCursors) return; socket.emit('cursor_update', { sessionId: currentSession.id, collaboratorId: myInfo.id, cursor: position, }); }, updateSelection: (selection: SelectionRange | null) => { const { socket, currentSession, myInfo, settings } = get(); if (!socket || !currentSession || !myInfo || !settings.showSelections) return; socket.emit('selection_update', { sessionId: currentSession.id, collaboratorId: myInfo.id, selection, }); }, sendCodeChange: (change: CodeChange) => { const { socket, currentSession, myInfo, myPermission } = get(); if (!socket || !currentSession || !myInfo) return; if (!['editor', 'admin', 'owner'].includes(myPermission)) return; socket.emit('code_change', { sessionId: currentSession.id, collaboratorId: myInfo.id, change, }); }, setTyping: (isTyping: boolean) => { const { socket, currentSession, myInfo } = get(); if (!socket || !currentSession || !myInfo) return; socket.emit('typing_change', { sessionId: currentSession.id, collaboratorId: myInfo.id, isTyping, }); set((state) => ({ myInfo: state.myInfo ? { ...state.myInfo, isTyping } : null, })); }, sendChatMessage: (content: string) => { const { socket, currentSession, myInfo, settings } = get(); if (!socket || !currentSession || !myInfo || !settings.allowChat) return; if (!content.trim()) return; const message: ChatMessage = { id: generateId(), senderId: myInfo.id, senderName: myInfo.name, senderColor: myInfo.color, content: content.trim(), timestamp: Date.now(), }; socket.emit('chat_message', { sessionId: currentSession.id, message, }); // Add to local state immediately set((state) => ({ chatMessages: [...state.chatMessages, message], })); }, markChatAsRead: () => { set({ unreadChatCount: 0 }); }, updateSettings: (newSettings: Partial) => { set((state) => ({ settings: { ...state.settings, ...newSettings }, })); }, setFollowUser: (userId: string | null) => { set((state) => ({ settings: { ...state.settings, followMode: userId !== null, followUserId: userId ?? undefined, }, })); }, toggleChat: () => { set((state) => ({ isChatOpen: !state.isChatOpen, unreadChatCount: !state.isChatOpen ? 0 : state.unreadChatCount, })); }, toggleCollaboratorsPanel: () => { set((state) => ({ isCollaboratorsPanelOpen: !state.isCollaboratorsPanelOpen, })); }, updateCollaboratorPermission: (collaboratorId: string, permission: PermissionLevel) => { const { socket, currentSession, myPermission } = get(); if (!socket || !currentSession) return; if (myPermission !== 'owner' && myPermission !== 'admin') return; socket.emit('update_permission', { sessionId: currentSession.id, collaboratorId, permission, }); }, kickCollaborator: (collaboratorId: string) => { const { socket, currentSession, myPermission } = get(); if (!socket || !currentSession) return; if (myPermission !== 'owner' && myPermission !== 'admin') return; socket.emit('kick_collaborator', { sessionId: currentSession.id, collaboratorId, }); }, })); // Demo/mock mode for when no server is available export function enableMockMode() { const store = useCollaborationStore.getState(); // Create mock session const mockMyInfo: CollaboratorInfo = { id: 'mock-user-1', name: 'You', color: getCollaboratorColor(0), lastActive: Date.now(), isTyping: false, }; const mockCollaborators: CollaboratorInfo[] = [ mockMyInfo, { id: 'mock-user-2', name: 'Alice', color: getCollaboratorColor(1), cursor: { lineNumber: 5, column: 10 }, lastActive: Date.now(), isTyping: false, }, { id: 'mock-user-3', name: 'Bob', color: getCollaboratorColor(2), cursor: { lineNumber: 12, column: 25 }, lastActive: Date.now() - 30000, isTyping: true, }, ]; const mockSession: CollaborationSession = { id: 'mock-session', name: 'Demo Collaboration', ownerId: mockMyInfo.id, ownerName: mockMyInfo.name, createdAt: Date.now(), collaborators: mockCollaborators, fileId: 'file-1', fileName: 'script.lua', isPublic: true, maxCollaborators: 10, }; useCollaborationStore.setState({ connectionState: 'connected', currentSession: mockSession, collaborators: mockCollaborators, myInfo: mockMyInfo, myPermission: 'owner', chatMessages: [ { id: '1', senderId: 'mock-user-2', senderName: 'Alice', senderColor: getCollaboratorColor(1), content: 'Hey, I just pushed some changes to the combat system!', timestamp: Date.now() - 120000, }, { id: '2', senderId: 'mock-user-3', senderName: 'Bob', senderColor: getCollaboratorColor(2), content: 'Nice! Let me take a look at the damage calculation.', timestamp: Date.now() - 60000, }, ], }); }