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
This commit is contained in:
parent
9c54fb3386
commit
c08627a561
2 changed files with 666 additions and 0 deletions
139
src/lib/collaboration/types.ts
Normal file
139
src/lib/collaboration/types.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
527
src/stores/collaboration-store.ts
Normal file
527
src/stores/collaboration-store.ts
Normal file
|
|
@ -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<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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue