- 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
527 lines
13 KiB
TypeScript
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,
|
|
},
|
|
],
|
|
});
|
|
}
|