From c08627a5619413ed67fca8e006e1c5b505bf1884 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 02:57:50 +0000 Subject: [PATCH] feat: Add Real-time Collaboration infrastructure - Create collaboration types (CollaboratorInfo, Session, Chat, Permissions) - Build collaboration store with Zustand and socket.io integration - Add cursor/selection sharing, chat messaging, and typing indicators - Include permission system (viewer, editor, admin, owner) - Add mock mode for demo/testing without server - Support follow mode and session settings --- src/lib/collaboration/types.ts | 139 ++++++++ src/stores/collaboration-store.ts | 527 ++++++++++++++++++++++++++++++ 2 files changed, 666 insertions(+) create mode 100644 src/lib/collaboration/types.ts create mode 100644 src/stores/collaboration-store.ts diff --git a/src/lib/collaboration/types.ts b/src/lib/collaboration/types.ts new file mode 100644 index 0000000..aa41f9d --- /dev/null +++ b/src/lib/collaboration/types.ts @@ -0,0 +1,139 @@ +/** + * Real-time Collaboration Types + */ + +export interface CollaboratorInfo { + id: string; + name: string; + color: string; + avatarUrl?: string; + cursor?: CursorPosition; + selection?: SelectionRange; + lastActive: number; + isTyping: boolean; +} + +export interface CursorPosition { + lineNumber: number; + column: number; +} + +export interface SelectionRange { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; +} + +export interface CollaborationSession { + id: string; + name: string; + ownerId: string; + ownerName: string; + createdAt: number; + collaborators: CollaboratorInfo[]; + fileId: string; + fileName: string; + isPublic: boolean; + maxCollaborators: number; +} + +export interface CollaborationMessage { + type: CollaborationMessageType; + sessionId: string; + senderId: string; + timestamp: number; + payload: any; +} + +export type CollaborationMessageType = + | 'join' + | 'leave' + | 'cursor_update' + | 'selection_update' + | 'code_change' + | 'chat' + | 'typing_start' + | 'typing_stop' + | 'file_change' + | 'request_sync' + | 'sync_response'; + +export interface ChatMessage { + id: string; + senderId: string; + senderName: string; + senderColor: string; + content: string; + timestamp: number; +} + +export interface CodeChange { + range: SelectionRange; + text: string; + rangeLength: number; +} + +// Collaboration colors for users +export const COLLABORATOR_COLORS = [ + '#ef4444', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#14b8a6', // teal + '#3b82f6', // blue + '#8b5cf6', // violet + '#ec4899', // pink + '#6366f1', // indigo + '#06b6d4', // cyan +]; + +export function getCollaboratorColor(index: number): string { + return COLLABORATOR_COLORS[index % COLLABORATOR_COLORS.length]; +} + +// Session settings +export interface SessionSettings { + allowChat: boolean; + allowVoice: boolean; + showCursors: boolean; + showSelections: boolean; + autoSync: boolean; + followMode: boolean; + followUserId?: string; +} + +export const DEFAULT_SESSION_SETTINGS: SessionSettings = { + allowChat: true, + allowVoice: false, + showCursors: true, + showSelections: true, + autoSync: true, + followMode: false, +}; + +// Connection states +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'; + +// Permission levels +export type PermissionLevel = 'viewer' | 'editor' | 'admin' | 'owner'; + +export interface CollaboratorPermission { + collaboratorId: string; + level: PermissionLevel; + canEdit: boolean; + canInvite: boolean; + canChat: boolean; +} + +export function canEdit(permission: PermissionLevel): boolean { + return ['editor', 'admin', 'owner'].includes(permission); +} + +export function canInvite(permission: PermissionLevel): boolean { + return ['admin', 'owner'].includes(permission); +} + +export function canManage(permission: PermissionLevel): boolean { + return permission === 'owner'; +} diff --git a/src/stores/collaboration-store.ts b/src/stores/collaboration-store.ts new file mode 100644 index 0000000..3cd8dcd --- /dev/null +++ b/src/stores/collaboration-store.ts @@ -0,0 +1,527 @@ +/** + * 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, + }, + ], + }); +}