forked from StellarSplit/StellarSplit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCollaborationProvider.tsx
More file actions
160 lines (131 loc) · 6.02 KB
/
Copy pathCollaborationProvider.tsx
File metadata and controls
160 lines (131 loc) · 6.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { createContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
import { io, Socket } from 'socket.io-client';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import type { PresenceUser, ActivityEvent, ConflictInfo, CollaborationState, SplitUpdate } from '../../types/collaboration';
import { BASE_API_URL } from '../../constants/api';
export interface CollaborationContextType extends CollaborationState {
joinSplit: (splitId: string, user: Partial<PresenceUser>) => void;
leaveSplit: () => void;
setTyping: (isTyping: boolean) => void;
sendUpdate: (update: Omit<SplitUpdate, 'timestamp'>) => void;
resolveConflict: (field: string, resolution: 'local' | 'remote' | 'merge') => void;
updateCursor: (x: number, y: number) => void;
}
export const CollaborationContext = createContext<CollaborationContextType | undefined>(undefined);
export function CollaborationProvider({ children }: { children: ReactNode }) {
const [socket, setSocket] = useState<Socket | null>(null);
const [connected, setConnected] = useState(false);
const [presence, setPresence] = useState<Record<string, PresenceUser>>({});
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const [conflicts, setConflicts] = useState<ConflictInfo[]>([]);
const currentSplitId = useRef<string | null>(null);
const currentUser = useRef<Partial<PresenceUser>>({});
// Yjs CRDT for Operational Transform & Cursor Sync
const ydoc = useRef(new Y.Doc());
const yprovider = useRef<WebsocketProvider | null>(null);
useEffect(() => {
// Extract domain from BASE_API_URL to construct socket URL
const url = new URL(BASE_API_URL.startsWith('http') ? BASE_API_URL : window.location.origin);
const socketUrl = `${url.protocol}//${url.host}`;
const newSocket = io(socketUrl, {
path: '/socket.io',
autoConnect: true, // Auto connect on provider mount
transports: ['websocket', 'polling']
});
newSocket.on('connect', () => {
setConnected(true);
if (currentSplitId.current) {
newSocket.emit('join-room', { roomId: currentSplitId.current, user: currentUser.current });
}
});
newSocket.on('disconnect', () => {
setConnected(false);
});
newSocket.on('presence-update', (users: Record<string, PresenceUser>) => {
setPresence(users);
});
newSocket.on('activity-new', (activity: ActivityEvent) => {
setActivities((prev) => [activity, ...prev].slice(0, 50));
});
newSocket.on('split-update', (update: SplitUpdate) => {
console.log('Received split update:', update);
// Simulate conflict detection if editing the same item, etc
});
setSocket(newSocket);
return () => {
newSocket.disconnect();
};
}, []);
const joinSplit = useCallback((splitId: string, user: Partial<PresenceUser>) => {
currentSplitId.current = splitId;
currentUser.current = user;
// Connect standard Socket.io presence events
if (socket && socket.connected) {
socket.emit('join-room', { roomId: splitId, user });
}
// Connect Yjs CRDT for Operational Transform
const url = new URL(BASE_API_URL.startsWith('http') ? BASE_API_URL : window.location.origin);
const wsUrl = `ws://${url.host}/yjs`;
if (yprovider.current) {
yprovider.current.disconnect();
}
yprovider.current = new WebsocketProvider(wsUrl, splitId, ydoc.current);
// Sync cursor and presence via Yjs Awareness
yprovider.current.awareness.setLocalStateField('user', user);
yprovider.current.awareness.on('change', () => {
const states = Array.from(yprovider.current!.awareness.getStates().values());
const yPresence: Record<string, PresenceUser> = {};
states.forEach((state: any) => {
if (state.user?.userId) {
yPresence[state.user.userId] = {
...state.user,
cursor: state.cursor
};
}
});
setPresence((prev) => ({ ...prev, ...yPresence }));
});
}, [socket]);
const leaveSplit = useCallback(() => {
if (socket && currentSplitId.current) {
socket.emit('leave-room', { roomId: currentSplitId.current, userId: currentUser.current?.userId });
}
currentSplitId.current = null;
setPresence({});
setActivities([]);
}, [socket]);
const setTyping = useCallback((isTyping: boolean) => {
if (socket && currentSplitId.current) {
socket.emit('typing-status', { roomId: currentSplitId.current, userId: currentUser.current?.userId, isTyping });
}
}, [socket]);
const sendUpdate = useCallback((update: Omit<SplitUpdate, 'timestamp'>) => {
if (socket && currentSplitId.current) {
const fullUpdate: SplitUpdate = { ...update, timestamp: new Date() };
socket.emit('split-update', { roomId: currentSplitId.current, update: fullUpdate });
}
}, [socket]);
const resolveConflict = useCallback((field: string, resolution: 'local' | 'remote' | 'merge') => {
setConflicts((prev) => prev.filter(c => c.field !== field));
console.log(`Resolved conflict for ${field} with ${resolution}`);
}, []);
const updateCursor = useCallback((x: number, y: number) => {
if (yprovider.current) {
yprovider.current.awareness.setLocalStateField('cursor', { x, y });
}
}, []);
const value: CollaborationContextType = {
connected,
presence,
activities,
conflicts,
joinSplit,
leaveSplit,
setTyping,
sendUpdate,
resolveConflict,
updateCursor,
};
return <CollaborationContext.Provider value={value}>{children}</CollaborationContext.Provider>;
}