aethex-studio/src/stores/collaboration-store.ts
Claude c08627a561
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
2026-01-24 02:57:50 +00:00

527 lines
13 KiB
TypeScript

/**
* 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<string>;
joinSession: (sessionId: string, name: string, avatarUrl?: string) => Promise<boolean>;
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<SessionSettings>) => 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<CollaborationState>((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<string> => {
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<boolean> => {
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<SessionSettings>) => {
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,
},
],
});
}