@object-ui/collaboration 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/CommentThread.d.ts +61 -0
- package/dist/CommentThread.d.ts.map +1 -0
- package/dist/CommentThread.js +438 -0
- package/dist/LiveCursors.d.ts +33 -0
- package/dist/LiveCursors.d.ts.map +1 -0
- package/dist/LiveCursors.js +100 -0
- package/dist/PresenceAvatars.d.ts +29 -0
- package/dist/PresenceAvatars.d.ts.map +1 -0
- package/dist/PresenceAvatars.js +113 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/useConflictResolution.d.ts +72 -0
- package/dist/useConflictResolution.d.ts.map +1 -0
- package/dist/useConflictResolution.js +211 -0
- package/dist/usePresence.d.ts +82 -0
- package/dist/usePresence.d.ts.map +1 -0
- package/dist/usePresence.js +174 -0
- package/dist/useRealtimeSubscription.d.ts +59 -0
- package/dist/useRealtimeSubscription.d.ts.map +1 -0
- package/dist/useRealtimeSubscription.js +224 -0
- package/package.json +44 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
9
|
+
/** Deterministic color assignment based on user ID */
|
|
10
|
+
function userIdToColor(userId) {
|
|
11
|
+
const colors = [
|
|
12
|
+
'#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6',
|
|
13
|
+
'#1abc9c', '#e67e22', '#2980b9', '#27ae60', '#8e44ad',
|
|
14
|
+
'#d35400', '#c0392b', '#16a085', '#f1c40f', '#7f8c8d',
|
|
15
|
+
];
|
|
16
|
+
let hash = 0;
|
|
17
|
+
for (let i = 0; i < userId.length; i++) {
|
|
18
|
+
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
|
|
19
|
+
}
|
|
20
|
+
return colors[Math.abs(hash) % colors.length];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Hook for live cursors and presence tracking.
|
|
24
|
+
*
|
|
25
|
+
* Tracks cursor position with throttled updates, detects idle/away status
|
|
26
|
+
* through activity monitoring, and manages a set of remote users.
|
|
27
|
+
*
|
|
28
|
+
* @param sendPresence - Callback to broadcast presence updates to other users
|
|
29
|
+
* @param config - Presence configuration
|
|
30
|
+
*/
|
|
31
|
+
export function usePresence(sendPresence, config) {
|
|
32
|
+
const { user, throttleMs = 50, idleTimeout = 60000, awayTimeout = 300000, } = config;
|
|
33
|
+
const color = useMemo(() => userIdToColor(user.id), [user.id]);
|
|
34
|
+
const [remoteUsers, setRemoteUsers] = useState(new Map());
|
|
35
|
+
const [currentUser, setCurrentUser] = useState(() => ({
|
|
36
|
+
userId: user.id,
|
|
37
|
+
userName: user.name,
|
|
38
|
+
avatar: user.avatar,
|
|
39
|
+
color,
|
|
40
|
+
status: 'active',
|
|
41
|
+
lastActivity: new Date().toISOString(),
|
|
42
|
+
}));
|
|
43
|
+
const lastSendRef = useRef(0);
|
|
44
|
+
const activityTimerRef = useRef(null);
|
|
45
|
+
const awayTimerRef = useRef(null);
|
|
46
|
+
const currentUserRef = useRef(currentUser);
|
|
47
|
+
currentUserRef.current = currentUser;
|
|
48
|
+
const sendPresenceRef = useRef(sendPresence);
|
|
49
|
+
sendPresenceRef.current = sendPresence;
|
|
50
|
+
const throttledSend = useCallback((updated) => {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
if (now - lastSendRef.current >= throttleMs) {
|
|
53
|
+
lastSendRef.current = now;
|
|
54
|
+
sendPresenceRef.current(updated);
|
|
55
|
+
}
|
|
56
|
+
}, [throttleMs]);
|
|
57
|
+
const resetActivityTimers = useCallback(() => {
|
|
58
|
+
if (activityTimerRef.current !== null)
|
|
59
|
+
clearTimeout(activityTimerRef.current);
|
|
60
|
+
if (awayTimerRef.current !== null)
|
|
61
|
+
clearTimeout(awayTimerRef.current);
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
setCurrentUser(prev => {
|
|
64
|
+
if (prev.status !== 'active' || prev.lastActivity !== now) {
|
|
65
|
+
const updated = { ...prev, status: 'active', lastActivity: now };
|
|
66
|
+
throttledSend(updated);
|
|
67
|
+
return updated;
|
|
68
|
+
}
|
|
69
|
+
return prev;
|
|
70
|
+
});
|
|
71
|
+
activityTimerRef.current = setTimeout(() => {
|
|
72
|
+
setCurrentUser(prev => {
|
|
73
|
+
const updated = { ...prev, status: 'idle', lastActivity: new Date().toISOString() };
|
|
74
|
+
sendPresenceRef.current(updated);
|
|
75
|
+
return updated;
|
|
76
|
+
});
|
|
77
|
+
awayTimerRef.current = setTimeout(() => {
|
|
78
|
+
setCurrentUser(prev => {
|
|
79
|
+
const updated = { ...prev, status: 'away', lastActivity: new Date().toISOString() };
|
|
80
|
+
sendPresenceRef.current(updated);
|
|
81
|
+
return updated;
|
|
82
|
+
});
|
|
83
|
+
}, awayTimeout - idleTimeout);
|
|
84
|
+
}, idleTimeout);
|
|
85
|
+
}, [idleTimeout, awayTimeout, throttledSend]);
|
|
86
|
+
// Listen for user activity (mouse, keyboard, touch)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (typeof window === 'undefined')
|
|
89
|
+
return;
|
|
90
|
+
const handleActivity = () => resetActivityTimers();
|
|
91
|
+
const events = ['mousemove', 'keydown', 'touchstart', 'scroll', 'click'];
|
|
92
|
+
events.forEach(event => window.addEventListener(event, handleActivity, { passive: true }));
|
|
93
|
+
resetActivityTimers();
|
|
94
|
+
return () => {
|
|
95
|
+
events.forEach(event => window.removeEventListener(event, handleActivity));
|
|
96
|
+
if (activityTimerRef.current !== null)
|
|
97
|
+
clearTimeout(activityTimerRef.current);
|
|
98
|
+
if (awayTimerRef.current !== null)
|
|
99
|
+
clearTimeout(awayTimerRef.current);
|
|
100
|
+
};
|
|
101
|
+
}, [resetActivityTimers]);
|
|
102
|
+
const updateCursor = useCallback((position) => {
|
|
103
|
+
setCurrentUser(prev => {
|
|
104
|
+
const updated = {
|
|
105
|
+
...prev,
|
|
106
|
+
cursor: position,
|
|
107
|
+
lastActivity: new Date().toISOString(),
|
|
108
|
+
status: 'active',
|
|
109
|
+
};
|
|
110
|
+
throttledSend(updated);
|
|
111
|
+
return updated;
|
|
112
|
+
});
|
|
113
|
+
}, [throttledSend]);
|
|
114
|
+
const updateSelection = useCallback((selection) => {
|
|
115
|
+
setCurrentUser(prev => {
|
|
116
|
+
const updated = {
|
|
117
|
+
...prev,
|
|
118
|
+
selection,
|
|
119
|
+
lastActivity: new Date().toISOString(),
|
|
120
|
+
status: 'active',
|
|
121
|
+
};
|
|
122
|
+
throttledSend(updated);
|
|
123
|
+
return updated;
|
|
124
|
+
});
|
|
125
|
+
}, [throttledSend]);
|
|
126
|
+
// Keep currentUser in sync with config changes
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
setCurrentUser(prev => ({
|
|
129
|
+
...prev,
|
|
130
|
+
userId: user.id,
|
|
131
|
+
userName: user.name,
|
|
132
|
+
avatar: user.avatar,
|
|
133
|
+
color,
|
|
134
|
+
}));
|
|
135
|
+
}, [user.id, user.name, user.avatar, color]);
|
|
136
|
+
const users = useMemo(() => Array.from(remoteUsers.values()), [remoteUsers]);
|
|
137
|
+
return {
|
|
138
|
+
users,
|
|
139
|
+
userCount: users.length + 1,
|
|
140
|
+
updateCursor,
|
|
141
|
+
updateSelection,
|
|
142
|
+
currentUser,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Update remote user presence. Call this when receiving presence data
|
|
147
|
+
* from other users via the realtime channel.
|
|
148
|
+
*
|
|
149
|
+
* @returns A function to update or remove a remote user from the presence set.
|
|
150
|
+
*/
|
|
151
|
+
export function createPresenceUpdater() {
|
|
152
|
+
const users = new Map();
|
|
153
|
+
const listeners = new Set();
|
|
154
|
+
const notify = () => {
|
|
155
|
+
listeners.forEach(cb => cb(new Map(users)));
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
updateUser(user) {
|
|
159
|
+
users.set(user.userId, user);
|
|
160
|
+
notify();
|
|
161
|
+
},
|
|
162
|
+
removeUser(userId) {
|
|
163
|
+
users.delete(userId);
|
|
164
|
+
notify();
|
|
165
|
+
},
|
|
166
|
+
getUsers() {
|
|
167
|
+
return Array.from(users.values());
|
|
168
|
+
},
|
|
169
|
+
onUsersChange(callback) {
|
|
170
|
+
listeners.add(callback);
|
|
171
|
+
return () => { listeners.delete(callback); };
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/** WebSocket connection configuration */
|
|
9
|
+
export interface RealtimeConfig {
|
|
10
|
+
/** WebSocket URL */
|
|
11
|
+
url?: string;
|
|
12
|
+
/** Room/channel to subscribe to */
|
|
13
|
+
channel: string;
|
|
14
|
+
/** Auto-reconnect on disconnect */
|
|
15
|
+
autoReconnect?: boolean;
|
|
16
|
+
/** Reconnect interval in ms */
|
|
17
|
+
reconnectInterval?: number;
|
|
18
|
+
/** Max reconnect attempts */
|
|
19
|
+
maxReconnectAttempts?: number;
|
|
20
|
+
/** Auth token for WebSocket */
|
|
21
|
+
authToken?: string;
|
|
22
|
+
}
|
|
23
|
+
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'error';
|
|
24
|
+
export interface RealtimeMessage<T = unknown> {
|
|
25
|
+
type: string;
|
|
26
|
+
channel: string;
|
|
27
|
+
data: T;
|
|
28
|
+
sender?: string;
|
|
29
|
+
timestamp: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RealtimeResult<T = unknown> {
|
|
32
|
+
/** Current connection state */
|
|
33
|
+
connectionState: ConnectionState;
|
|
34
|
+
/** Whether connected */
|
|
35
|
+
isConnected: boolean;
|
|
36
|
+
/** Last received message */
|
|
37
|
+
lastMessage: RealtimeMessage<T> | null;
|
|
38
|
+
/** All messages received since subscription */
|
|
39
|
+
messages: RealtimeMessage<T>[];
|
|
40
|
+
/** Send a message to the channel */
|
|
41
|
+
send: (type: string, data: T) => void;
|
|
42
|
+
/** Subscribe to specific message types */
|
|
43
|
+
subscribe: (type: string, handler: (data: T) => void) => () => void;
|
|
44
|
+
/** Disconnect from the channel */
|
|
45
|
+
disconnect: () => void;
|
|
46
|
+
/** Reconnect to the channel */
|
|
47
|
+
reconnect: () => void;
|
|
48
|
+
/** Error if any */
|
|
49
|
+
error: Error | null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Hook for real-time data subscriptions via WebSocket.
|
|
53
|
+
* Aligned with @objectstack/client realtime API.
|
|
54
|
+
*
|
|
55
|
+
* Provides automatic reconnection with exponential backoff,
|
|
56
|
+
* message buffering, and typed message subscriptions.
|
|
57
|
+
*/
|
|
58
|
+
export declare function useRealtimeSubscription<T = unknown>(config: RealtimeConfig): RealtimeResult<T>;
|
|
59
|
+
//# sourceMappingURL=useRealtimeSubscription.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useRealtimeSubscription.d.ts","sourceRoot":"","sources":["../src/useRealtimeSubscription.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,yCAAyC;AACzC,MAAM,WAAW,cAAc;IAC7B,oBAAoB;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,mCAAmC;IACnC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,+BAA+B;IAC/B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6BAA6B;IAC7B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,+BAA+B;IAC/B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,cAAc,GAAG,OAAO,CAAC;AAErG,MAAM,WAAW,eAAe,CAAC,CAAC,GAAG,OAAO;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,CAAC,CAAC;IACR,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,OAAO;IACzC,+BAA+B;IAC/B,eAAe,EAAE,eAAe,CAAC;IACjC,wBAAwB;IACxB,WAAW,EAAE,OAAO,CAAC;IACrB,4BAA4B;IAC5B,WAAW,EAAE,eAAe,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IACvC,+CAA+C;IAC/C,QAAQ,EAAE,eAAe,CAAC,CAAC,CAAC,EAAE,CAAC;IAC/B,oCAAoC;IACpC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IACtC,0CAA0C;IAC1C,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IACpE,kCAAkC;IAClC,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,+BAA+B;IAC/B,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,mBAAmB;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,GAAG,OAAO,EAAE,MAAM,EAAE,cAAc,GAAG,cAAc,CAAC,CAAC,CAAC,CA0O9F"}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
9
|
+
/**
|
|
10
|
+
* Hook for real-time data subscriptions via WebSocket.
|
|
11
|
+
* Aligned with @objectstack/client realtime API.
|
|
12
|
+
*
|
|
13
|
+
* Provides automatic reconnection with exponential backoff,
|
|
14
|
+
* message buffering, and typed message subscriptions.
|
|
15
|
+
*/
|
|
16
|
+
export function useRealtimeSubscription(config) {
|
|
17
|
+
const { url, channel, autoReconnect = true, reconnectInterval = 1000, maxReconnectAttempts = 10, authToken, } = config;
|
|
18
|
+
const [connectionState, setConnectionState] = useState('disconnected');
|
|
19
|
+
const [lastMessage, setLastMessage] = useState(null);
|
|
20
|
+
const [messages, setMessages] = useState([]);
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
const wsRef = useRef(null);
|
|
23
|
+
const reconnectAttemptsRef = useRef(0);
|
|
24
|
+
const reconnectTimerRef = useRef(null);
|
|
25
|
+
const handlersRef = useRef(new Map());
|
|
26
|
+
const sendBufferRef = useRef([]);
|
|
27
|
+
const mountedRef = useRef(true);
|
|
28
|
+
const intentionalCloseRef = useRef(false);
|
|
29
|
+
const clearReconnectTimer = useCallback(() => {
|
|
30
|
+
if (reconnectTimerRef.current !== null) {
|
|
31
|
+
clearTimeout(reconnectTimerRef.current);
|
|
32
|
+
reconnectTimerRef.current = null;
|
|
33
|
+
}
|
|
34
|
+
}, []);
|
|
35
|
+
const flushSendBuffer = useCallback((ws) => {
|
|
36
|
+
while (sendBufferRef.current.length > 0) {
|
|
37
|
+
const msg = sendBufferRef.current.shift();
|
|
38
|
+
const payload = {
|
|
39
|
+
type: msg.type,
|
|
40
|
+
channel,
|
|
41
|
+
data: msg.data,
|
|
42
|
+
timestamp: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
ws.send(JSON.stringify(payload));
|
|
45
|
+
}
|
|
46
|
+
}, [channel]);
|
|
47
|
+
const connect = useCallback(() => {
|
|
48
|
+
if (typeof WebSocket === 'undefined') {
|
|
49
|
+
setError(new Error('WebSocket is not available in this environment'));
|
|
50
|
+
setConnectionState('error');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!url) {
|
|
54
|
+
setError(new Error('WebSocket URL is required'));
|
|
55
|
+
setConnectionState('error');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Validate WebSocket URL protocol (only ws: or wss: allowed)
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(url);
|
|
61
|
+
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
|
62
|
+
setError(new Error('WebSocket URL must use ws: or wss: protocol'));
|
|
63
|
+
setConnectionState('error');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
setError(new Error('Invalid WebSocket URL'));
|
|
69
|
+
setConnectionState('error');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Close existing connection
|
|
73
|
+
if (wsRef.current) {
|
|
74
|
+
intentionalCloseRef.current = true;
|
|
75
|
+
wsRef.current.close();
|
|
76
|
+
wsRef.current = null;
|
|
77
|
+
}
|
|
78
|
+
intentionalCloseRef.current = false;
|
|
79
|
+
setConnectionState('connecting');
|
|
80
|
+
setError(null);
|
|
81
|
+
const wsUrl = authToken ? `${url}?token=${encodeURIComponent(authToken)}&channel=${encodeURIComponent(channel)}` : `${url}?channel=${encodeURIComponent(channel)}`;
|
|
82
|
+
let ws;
|
|
83
|
+
try {
|
|
84
|
+
ws = new WebSocket(wsUrl);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (!mountedRef.current)
|
|
88
|
+
return;
|
|
89
|
+
const connectError = err instanceof Error ? err : new Error('Failed to create WebSocket connection');
|
|
90
|
+
setError(connectError);
|
|
91
|
+
setConnectionState('error');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
wsRef.current = ws;
|
|
95
|
+
ws.onopen = () => {
|
|
96
|
+
if (!mountedRef.current)
|
|
97
|
+
return;
|
|
98
|
+
setConnectionState('connected');
|
|
99
|
+
setError(null);
|
|
100
|
+
reconnectAttemptsRef.current = 0;
|
|
101
|
+
flushSendBuffer(ws);
|
|
102
|
+
};
|
|
103
|
+
ws.onmessage = (event) => {
|
|
104
|
+
if (!mountedRef.current)
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
const message = JSON.parse(String(event.data));
|
|
108
|
+
if (message.channel !== channel)
|
|
109
|
+
return;
|
|
110
|
+
setLastMessage(message);
|
|
111
|
+
setMessages(prev => [...prev, message]);
|
|
112
|
+
// Notify type-specific handlers
|
|
113
|
+
const typeHandlers = handlersRef.current.get(message.type);
|
|
114
|
+
if (typeHandlers) {
|
|
115
|
+
typeHandlers.forEach(handler => handler(message.data));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Ignore unparseable messages
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
ws.onerror = () => {
|
|
123
|
+
if (!mountedRef.current)
|
|
124
|
+
return;
|
|
125
|
+
setError(new Error('WebSocket connection error'));
|
|
126
|
+
setConnectionState('error');
|
|
127
|
+
};
|
|
128
|
+
ws.onclose = () => {
|
|
129
|
+
if (!mountedRef.current)
|
|
130
|
+
return;
|
|
131
|
+
wsRef.current = null;
|
|
132
|
+
if (intentionalCloseRef.current) {
|
|
133
|
+
setConnectionState('disconnected');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (autoReconnect && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
|
137
|
+
setConnectionState('reconnecting');
|
|
138
|
+
const backoff = Math.min(reconnectInterval * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
|
139
|
+
reconnectAttemptsRef.current += 1;
|
|
140
|
+
reconnectTimerRef.current = setTimeout(() => {
|
|
141
|
+
if (mountedRef.current) {
|
|
142
|
+
connect();
|
|
143
|
+
}
|
|
144
|
+
}, backoff);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
setConnectionState('disconnected');
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}, [url, channel, authToken, autoReconnect, reconnectInterval, maxReconnectAttempts, flushSendBuffer]);
|
|
151
|
+
const disconnect = useCallback(() => {
|
|
152
|
+
clearReconnectTimer();
|
|
153
|
+
intentionalCloseRef.current = true;
|
|
154
|
+
reconnectAttemptsRef.current = 0;
|
|
155
|
+
if (wsRef.current) {
|
|
156
|
+
wsRef.current.close();
|
|
157
|
+
wsRef.current = null;
|
|
158
|
+
}
|
|
159
|
+
setConnectionState('disconnected');
|
|
160
|
+
}, [clearReconnectTimer]);
|
|
161
|
+
const reconnectFn = useCallback(() => {
|
|
162
|
+
clearReconnectTimer();
|
|
163
|
+
reconnectAttemptsRef.current = 0;
|
|
164
|
+
connect();
|
|
165
|
+
}, [connect, clearReconnectTimer]);
|
|
166
|
+
const send = useCallback((type, data) => {
|
|
167
|
+
const ws = wsRef.current;
|
|
168
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
169
|
+
const payload = {
|
|
170
|
+
type,
|
|
171
|
+
channel,
|
|
172
|
+
data,
|
|
173
|
+
timestamp: new Date().toISOString(),
|
|
174
|
+
};
|
|
175
|
+
ws.send(JSON.stringify(payload));
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// Buffer message for when connection is restored
|
|
179
|
+
sendBufferRef.current.push({ type, data });
|
|
180
|
+
}
|
|
181
|
+
}, [channel]);
|
|
182
|
+
const subscribe = useCallback((type, handler) => {
|
|
183
|
+
if (!handlersRef.current.has(type)) {
|
|
184
|
+
handlersRef.current.set(type, new Set());
|
|
185
|
+
}
|
|
186
|
+
handlersRef.current.get(type).add(handler);
|
|
187
|
+
return () => {
|
|
188
|
+
const handlers = handlersRef.current.get(type);
|
|
189
|
+
if (handlers) {
|
|
190
|
+
handlers.delete(handler);
|
|
191
|
+
if (handlers.size === 0) {
|
|
192
|
+
handlersRef.current.delete(type);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}, []);
|
|
197
|
+
// Connect on mount, disconnect on unmount
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
mountedRef.current = true;
|
|
200
|
+
if (url) {
|
|
201
|
+
connect();
|
|
202
|
+
}
|
|
203
|
+
return () => {
|
|
204
|
+
mountedRef.current = false;
|
|
205
|
+
clearReconnectTimer();
|
|
206
|
+
intentionalCloseRef.current = true;
|
|
207
|
+
if (wsRef.current) {
|
|
208
|
+
wsRef.current.close();
|
|
209
|
+
wsRef.current = null;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}, [url, channel, authToken]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
213
|
+
return {
|
|
214
|
+
connectionState,
|
|
215
|
+
isConnected: connectionState === 'connected',
|
|
216
|
+
lastMessage,
|
|
217
|
+
messages,
|
|
218
|
+
send,
|
|
219
|
+
subscribe,
|
|
220
|
+
disconnect,
|
|
221
|
+
reconnect: reconnectFn,
|
|
222
|
+
error,
|
|
223
|
+
};
|
|
224
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@object-ui/collaboration",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Real-time collaboration for Object UI with presence tracking, live cursors, conflict resolution, and comment threads.",
|
|
7
|
+
"homepage": "https://www.objectui.org",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/objectstack-ai/objectui.git",
|
|
11
|
+
"directory": "packages/collaboration"
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"module": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@object-ui/types": "2.0.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/react": "^19.2.13",
|
|
33
|
+
"react": "^19.1.0",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vitest": "^4.0.18"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"clean": "rm -rf dist",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"type-check": "tsc --noEmit",
|
|
42
|
+
"lint": "eslint ."
|
|
43
|
+
}
|
|
44
|
+
}
|