@cloudsignal/collaborate 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1153 @@
1
+ import { createContext, useState, useRef, useEffect, useMemo, useCallback, useContext } from 'react';
2
+ import CloudSignalClient from '@cloudsignal/mqtt-client';
3
+ import { jsx, jsxs } from 'react/jsx-runtime';
4
+
5
+ /**
6
+ * @cloudsignal/collaborate v0.1.0
7
+ * Real-time collaboration primitives for React
8
+ * https://cloudsignal.io
9
+ * MIT License
10
+ */
11
+
12
+ var SpaceContext = createContext(null);
13
+
14
+ // src/utils/colors.ts
15
+ var CURSOR_COLORS = [
16
+ "#3B82F6",
17
+ // Blue
18
+ "#EF4444",
19
+ // Red
20
+ "#22C55E",
21
+ // Green
22
+ "#A855F7",
23
+ // Purple
24
+ "#F97316",
25
+ // Orange
26
+ "#EC4899",
27
+ // Pink
28
+ "#14B8A6",
29
+ // Teal
30
+ "#EAB308",
31
+ // Yellow
32
+ "#6366F1",
33
+ // Indigo
34
+ "#F43F5E"
35
+ // Rose
36
+ ];
37
+ function getColorForUser(userId) {
38
+ let hash = 0;
39
+ for (let i = 0; i < userId.length; i++) {
40
+ hash = (hash << 5) - hash + userId.charCodeAt(i) | 0;
41
+ }
42
+ return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
43
+ }
44
+ function getInitials(name) {
45
+ return name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
46
+ }
47
+
48
+ // src/constants.ts
49
+ var TOPIC_PREFIX = "$spaces";
50
+ var TOPICS = {
51
+ PRESENCE: "presence",
52
+ CURSORS: "cursors",
53
+ LOCKS: "locks",
54
+ TYPING: "typing",
55
+ REACTIONS: "reactions",
56
+ BROADCAST: "broadcast",
57
+ STATE: "state"
58
+ };
59
+ var DEFAULTS = {
60
+ /** Presence heartbeat interval (ms) */
61
+ PRESENCE_HEARTBEAT_MS: 1e4,
62
+ /** Time before a user is considered offline (ms) */
63
+ PRESENCE_TIMEOUT_MS: 3e4,
64
+ /** Cursor publish throttle (ms) — ~33Hz */
65
+ CURSOR_THROTTLE_MS: 30,
66
+ /** Time before a cursor fades out (ms) */
67
+ CURSOR_STALE_MS: 3e3,
68
+ /** Typing indicator auto-reset (ms) */
69
+ TYPING_TIMEOUT_MS: 3e3,
70
+ /** Minimum interval between typing publishes (ms) */
71
+ TYPING_THROTTLE_MS: 2e3,
72
+ /** Time before a typing user is cleaned from the list (ms) */
73
+ TYPING_STALE_MS: 4e3,
74
+ /** How long a reaction stays visible (ms) */
75
+ REACTION_DURATION_MS: 3e3,
76
+ /** Max visible reactions at once */
77
+ REACTION_MAX_VISIBLE: 20,
78
+ /** Stats sync interval (ms) — for cursor count etc. */
79
+ STATS_TICK_MS: 1e3,
80
+ /** Opacity fade-out sync interval (ms) */
81
+ FADE_TICK_MS: 200
82
+ };
83
+ function spaceTopic(spaceId, segment) {
84
+ return `${TOPIC_PREFIX}/${spaceId}/${segment}`;
85
+ }
86
+ function spaceWildcard(spaceId) {
87
+ return `${TOPIC_PREFIX}/${spaceId}/#`;
88
+ }
89
+ function Space({
90
+ id,
91
+ connection,
92
+ userName,
93
+ userColor,
94
+ userAvatar,
95
+ userData,
96
+ debug = false,
97
+ presenceHeartbeatMs = DEFAULTS.PRESENCE_HEARTBEAT_MS,
98
+ presenceTimeoutMs = DEFAULTS.PRESENCE_TIMEOUT_MS,
99
+ onConnectionChange,
100
+ children
101
+ }) {
102
+ const [isConnected, setIsConnected] = useState(false);
103
+ const [isConnecting, setIsConnecting] = useState(false);
104
+ const [error, setError] = useState(null);
105
+ const clientRef = useRef(null);
106
+ const connectingRef = useRef(false);
107
+ const mountedRef = useRef(true);
108
+ const handlersRef = useRef(/* @__PURE__ */ new Map());
109
+ const messageHandlerRef = useRef(null);
110
+ const onConnectionChangeRef = useRef(onConnectionChange);
111
+ useEffect(() => {
112
+ onConnectionChangeRef.current = onConnectionChange;
113
+ }, [onConnectionChange]);
114
+ const resolvedColor = userColor || getColorForUser(userName);
115
+ const self = useMemo(() => ({
116
+ userId: connection.username || userName,
117
+ name: userName,
118
+ color: resolvedColor,
119
+ avatar: userAvatar,
120
+ data: userData,
121
+ joinedAt: Date.now(),
122
+ lastSeen: Date.now()
123
+ }), [connection.username, userName, resolvedColor, userAvatar, userData]);
124
+ const selfRef = useRef(self);
125
+ useEffect(() => {
126
+ selfRef.current = self;
127
+ }, [self]);
128
+ const log = useCallback((...args) => {
129
+ if (debug) console.log("[CloudSignal:Space]", ...args);
130
+ }, [debug]);
131
+ const addTopicHandler = useCallback((segment, handler) => {
132
+ if (!handlersRef.current.has(segment)) {
133
+ handlersRef.current.set(segment, /* @__PURE__ */ new Set());
134
+ }
135
+ handlersRef.current.get(segment).add(handler);
136
+ return () => {
137
+ handlersRef.current.get(segment)?.delete(handler);
138
+ };
139
+ }, []);
140
+ const routeMessage = useCallback((topic, messageStr) => {
141
+ const prefix = `${TOPIC_PREFIX}/${id}/`;
142
+ if (!topic.startsWith(prefix)) return;
143
+ const subtopic = topic.slice(prefix.length);
144
+ const segment = subtopic.split("/")[0];
145
+ let payload;
146
+ try {
147
+ payload = JSON.parse(messageStr);
148
+ } catch {
149
+ payload = messageStr;
150
+ }
151
+ log("\u2190", segment, subtopic);
152
+ const handlers = handlersRef.current.get(segment);
153
+ if (handlers) {
154
+ for (const handler of handlers) {
155
+ handler(subtopic, payload);
156
+ }
157
+ }
158
+ }, [id, log]);
159
+ const publish = useCallback((subtopic, payload, options) => {
160
+ if (!clientRef.current) {
161
+ log("Cannot publish: not connected");
162
+ return;
163
+ }
164
+ const topic = spaceTopic(id, subtopic);
165
+ const message = typeof payload === "string" ? payload : JSON.stringify(payload);
166
+ clientRef.current.transmit(topic, message, options);
167
+ log("\u2192", subtopic);
168
+ }, [id, log]);
169
+ useEffect(() => {
170
+ if (!isConnected) return;
171
+ const sendHeartbeat = () => {
172
+ publish(TOPICS.PRESENCE, {
173
+ userId: selfRef.current.userId,
174
+ name: selfRef.current.name,
175
+ color: selfRef.current.color,
176
+ avatar: selfRef.current.avatar,
177
+ data: selfRef.current.data,
178
+ ts: Date.now()
179
+ });
180
+ };
181
+ sendHeartbeat();
182
+ const interval = setInterval(sendHeartbeat, presenceHeartbeatMs);
183
+ return () => clearInterval(interval);
184
+ }, [isConnected, publish, presenceHeartbeatMs]);
185
+ useEffect(() => {
186
+ if (!isConnected) return;
187
+ const handleUnload = () => {
188
+ publish(TOPICS.PRESENCE, {
189
+ userId: selfRef.current.userId,
190
+ type: "leave",
191
+ ts: Date.now()
192
+ });
193
+ };
194
+ window.addEventListener("beforeunload", handleUnload);
195
+ return () => window.removeEventListener("beforeunload", handleUnload);
196
+ }, [isConnected, publish]);
197
+ useEffect(() => {
198
+ mountedRef.current = true;
199
+ const doConnect = async () => {
200
+ if (connectingRef.current || clientRef.current) return;
201
+ connectingRef.current = true;
202
+ setIsConnecting(true);
203
+ setError(null);
204
+ try {
205
+ const clientOptions = {
206
+ debug,
207
+ preset: "desktop"
208
+ };
209
+ if (connection.tokenServiceUrl) {
210
+ clientOptions.tokenServiceUrl = connection.tokenServiceUrl;
211
+ }
212
+ const client = new CloudSignalClient(clientOptions);
213
+ client.onConnectionStatusChange = (connected) => {
214
+ log("Connection:", connected);
215
+ if (mountedRef.current) {
216
+ setIsConnected(connected);
217
+ onConnectionChangeRef.current?.(connected);
218
+ }
219
+ };
220
+ client.onReconnecting = (attempt) => {
221
+ log("Reconnecting, attempt:", attempt);
222
+ };
223
+ client.onAuthError = (err) => {
224
+ log("Auth error:", err.message);
225
+ if (mountedRef.current) {
226
+ setError(err);
227
+ setIsConnected(false);
228
+ }
229
+ clientRef.current = null;
230
+ };
231
+ const handler = (topic, message) => routeMessage(topic, message);
232
+ messageHandlerRef.current = handler;
233
+ client.onMessage(handler);
234
+ const willPayload = JSON.stringify({
235
+ userId: selfRef.current.userId,
236
+ type: "leave",
237
+ ts: Date.now()
238
+ });
239
+ if (connection.secretKey || connection.externalToken) {
240
+ await client.connectWithToken({
241
+ host: connection.host,
242
+ organizationId: connection.organizationId,
243
+ secretKey: connection.secretKey,
244
+ externalToken: connection.externalToken,
245
+ willTopic: spaceTopic(id, TOPICS.PRESENCE),
246
+ willMessage: willPayload,
247
+ willQos: 0
248
+ });
249
+ } else {
250
+ await client.connect({
251
+ host: connection.host,
252
+ username: connection.username,
253
+ password: connection.password,
254
+ willTopic: spaceTopic(id, TOPICS.PRESENCE),
255
+ willMessage: willPayload,
256
+ willQos: 0
257
+ });
258
+ }
259
+ if (!mountedRef.current) {
260
+ client.destroy();
261
+ return;
262
+ }
263
+ clientRef.current = client;
264
+ await client.subscribe(spaceWildcard(id), 0);
265
+ log("Subscribed to", spaceWildcard(id));
266
+ } catch (err) {
267
+ log("Connection failed:", err);
268
+ if (mountedRef.current) {
269
+ setError(err instanceof Error ? err : new Error(String(err)));
270
+ }
271
+ } finally {
272
+ connectingRef.current = false;
273
+ if (mountedRef.current) setIsConnecting(false);
274
+ }
275
+ };
276
+ doConnect();
277
+ return () => {
278
+ mountedRef.current = false;
279
+ if (clientRef.current) {
280
+ try {
281
+ const leaveTopic = spaceTopic(id, TOPICS.PRESENCE);
282
+ const leavePayload = JSON.stringify({
283
+ userId: selfRef.current.userId,
284
+ type: "leave",
285
+ ts: Date.now()
286
+ });
287
+ clientRef.current.transmit(leaveTopic, leavePayload, { qos: 0 });
288
+ } catch {
289
+ }
290
+ clientRef.current.destroy();
291
+ clientRef.current = null;
292
+ }
293
+ };
294
+ }, [id, connection.host, connection.username, connection.password, connection.secretKey, connection.externalToken, connection.organizationId]);
295
+ const contextValue = useMemo(() => ({
296
+ spaceId: id,
297
+ self,
298
+ isConnected,
299
+ isConnecting,
300
+ error,
301
+ presenceTimeoutMs,
302
+ publish,
303
+ addTopicHandler
304
+ }), [id, self, isConnected, isConnecting, error, presenceTimeoutMs, publish, addTopicHandler]);
305
+ return /* @__PURE__ */ jsx(SpaceContext.Provider, { value: contextValue, children });
306
+ }
307
+ function useSpace() {
308
+ const ctx = useContext(SpaceContext);
309
+ if (!ctx) {
310
+ throw new Error(
311
+ 'useSpace() must be used within a <Space> provider. Wrap your component tree with <Space id="..." connection={...}>'
312
+ );
313
+ }
314
+ return ctx;
315
+ }
316
+ function usePresence() {
317
+ const { self, addTopicHandler, presenceTimeoutMs } = useSpace();
318
+ const [members, setMembers] = useState([self]);
319
+ const membersMapRef = useRef(/* @__PURE__ */ new Map());
320
+ const joinCallbacksRef = useRef(/* @__PURE__ */ new Set());
321
+ const leaveCallbacksRef = useRef(/* @__PURE__ */ new Set());
322
+ useEffect(() => {
323
+ membersMapRef.current.set(self.userId, self);
324
+ setMembers([self]);
325
+ }, [self]);
326
+ useEffect(() => {
327
+ const unsubscribe = addTopicHandler(TOPICS.PRESENCE, (_subtopic, payload) => {
328
+ if (typeof payload !== "object" || payload === null) return;
329
+ const data = payload;
330
+ const userId = data.userId;
331
+ if (!userId) return;
332
+ if (data.type === "leave") {
333
+ const user2 = membersMapRef.current.get(userId);
334
+ if (user2) {
335
+ membersMapRef.current.delete(userId);
336
+ setMembers(Array.from(membersMapRef.current.values()));
337
+ for (const cb of leaveCallbacksRef.current) cb(user2);
338
+ }
339
+ return;
340
+ }
341
+ const isNew = !membersMapRef.current.has(userId);
342
+ const user = {
343
+ userId,
344
+ name: data.name || userId,
345
+ color: data.color || "#3B82F6",
346
+ avatar: data.avatar,
347
+ data: data.data,
348
+ joinedAt: isNew ? Date.now() : membersMapRef.current.get(userId)?.joinedAt ?? Date.now(),
349
+ lastSeen: Date.now()
350
+ };
351
+ membersMapRef.current.set(userId, user);
352
+ setMembers(Array.from(membersMapRef.current.values()));
353
+ if (isNew) {
354
+ for (const cb of joinCallbacksRef.current) cb(user);
355
+ }
356
+ });
357
+ return unsubscribe;
358
+ }, [addTopicHandler]);
359
+ useEffect(() => {
360
+ const interval = setInterval(() => {
361
+ const now = Date.now();
362
+ let changed = false;
363
+ for (const [userId, user] of membersMapRef.current) {
364
+ if (userId === self.userId) continue;
365
+ if (now - user.lastSeen > presenceTimeoutMs) {
366
+ membersMapRef.current.delete(userId);
367
+ changed = true;
368
+ for (const cb of leaveCallbacksRef.current) cb(user);
369
+ }
370
+ }
371
+ if (changed) {
372
+ setMembers(Array.from(membersMapRef.current.values()));
373
+ }
374
+ }, 5e3);
375
+ return () => clearInterval(interval);
376
+ }, [self.userId, presenceTimeoutMs]);
377
+ const onJoin = useCallback((callback) => {
378
+ joinCallbacksRef.current.add(callback);
379
+ return () => {
380
+ joinCallbacksRef.current.delete(callback);
381
+ };
382
+ }, []);
383
+ const onLeave = useCallback((callback) => {
384
+ leaveCallbacksRef.current.add(callback);
385
+ return () => {
386
+ leaveCallbacksRef.current.delete(callback);
387
+ };
388
+ }, []);
389
+ return {
390
+ members,
391
+ count: members.length,
392
+ self,
393
+ onJoin,
394
+ onLeave
395
+ };
396
+ }
397
+ function useCursors(options = {}) {
398
+ const {
399
+ throttleMs = DEFAULTS.CURSOR_THROTTLE_MS,
400
+ staleMs = DEFAULTS.CURSOR_STALE_MS
401
+ } = options;
402
+ const { self, publish, addTopicHandler } = useSpace();
403
+ const cursorsRef = useRef(/* @__PURE__ */ new Map());
404
+ const onUpdateRef = useRef(null);
405
+ const lastPublishRef = useRef(0);
406
+ const throttleMsRef = useRef(throttleMs);
407
+ const [cursors, setCursors] = useState([]);
408
+ useEffect(() => {
409
+ throttleMsRef.current = throttleMs;
410
+ }, [throttleMs]);
411
+ useEffect(() => {
412
+ const unsubscribe = addTopicHandler(TOPICS.CURSORS, (_subtopic, payload) => {
413
+ if (typeof payload !== "object" || payload === null) return;
414
+ const data = payload;
415
+ const userId = data.userId;
416
+ if (!userId || userId === self.userId) return;
417
+ const now = Date.now();
418
+ cursorsRef.current.set(userId, {
419
+ userId,
420
+ name: data.name || userId,
421
+ x: data.x || 0,
422
+ y: data.y || 0,
423
+ color: data.color || "#3B82F6",
424
+ ts: data.ts || now,
425
+ lastSeen: now
426
+ });
427
+ onUpdateRef.current?.();
428
+ });
429
+ return unsubscribe;
430
+ }, [addTopicHandler, self.userId]);
431
+ const publishCursor = useCallback((x, y) => {
432
+ const now = Date.now();
433
+ if (now - lastPublishRef.current < throttleMsRef.current) return;
434
+ lastPublishRef.current = now;
435
+ publish(TOPICS.CURSORS, {
436
+ userId: self.userId,
437
+ name: self.name,
438
+ x,
439
+ y,
440
+ color: self.color,
441
+ ts: now
442
+ }, { qos: 0 });
443
+ }, [publish, self.userId, self.name, self.color]);
444
+ useEffect(() => {
445
+ const interval = setInterval(() => {
446
+ const now = Date.now();
447
+ let changed = false;
448
+ for (const [id, cursor] of cursorsRef.current) {
449
+ if (now - cursor.lastSeen > staleMs) {
450
+ cursorsRef.current.delete(id);
451
+ changed = true;
452
+ }
453
+ }
454
+ if (changed) {
455
+ onUpdateRef.current?.();
456
+ }
457
+ setCursors(Array.from(cursorsRef.current.values()));
458
+ }, DEFAULTS.STATS_TICK_MS);
459
+ return () => clearInterval(interval);
460
+ }, [staleMs]);
461
+ return {
462
+ cursorsRef,
463
+ onUpdateRef,
464
+ cursors,
465
+ publishCursor
466
+ };
467
+ }
468
+ function useLock(componentId) {
469
+ const { self, publish, addTopicHandler } = useSpace();
470
+ const [lockedBy, setLockedBy] = useState(null);
471
+ const lockedByRef = useRef(null);
472
+ const isLockedByMeRef = useRef(false);
473
+ const publishRef = useRef(publish);
474
+ const selfUserIdRef = useRef(self.userId);
475
+ useEffect(() => {
476
+ publishRef.current = publish;
477
+ }, [publish]);
478
+ useEffect(() => {
479
+ selfUserIdRef.current = self.userId;
480
+ }, [self.userId]);
481
+ const isLocked = lockedBy !== null;
482
+ const isLockedByMe = lockedBy?.userId === self.userId;
483
+ useEffect(() => {
484
+ const unsubscribe = addTopicHandler(TOPICS.LOCKS, (_subtopic, payload) => {
485
+ if (typeof payload !== "object" || payload === null) return;
486
+ const data = payload;
487
+ if (data.componentId !== componentId) return;
488
+ const action = data.action;
489
+ if (action === "lock") {
490
+ const user = {
491
+ userId: data.userId,
492
+ name: data.name || data.userId,
493
+ color: data.color || "#3B82F6",
494
+ joinedAt: Date.now(),
495
+ lastSeen: Date.now()
496
+ };
497
+ lockedByRef.current = user;
498
+ isLockedByMeRef.current = user.userId === self.userId;
499
+ setLockedBy(user);
500
+ } else if (action === "unlock") {
501
+ lockedByRef.current = null;
502
+ isLockedByMeRef.current = false;
503
+ setLockedBy(null);
504
+ }
505
+ });
506
+ return unsubscribe;
507
+ }, [addTopicHandler, componentId, self.userId]);
508
+ const lock = useCallback(() => {
509
+ if (lockedByRef.current && lockedByRef.current.userId !== self.userId) return;
510
+ publish(TOPICS.LOCKS, {
511
+ userId: self.userId,
512
+ name: self.name,
513
+ color: self.color,
514
+ componentId,
515
+ action: "lock",
516
+ ts: Date.now()
517
+ }, { qos: 1 });
518
+ }, [publish, self, componentId]);
519
+ const unlock = useCallback(() => {
520
+ if (!isLockedByMeRef.current) return;
521
+ publish(TOPICS.LOCKS, {
522
+ userId: self.userId,
523
+ componentId,
524
+ action: "unlock",
525
+ ts: Date.now()
526
+ }, { qos: 1 });
527
+ }, [publish, self.userId, componentId]);
528
+ useEffect(() => {
529
+ return () => {
530
+ if (isLockedByMeRef.current) {
531
+ publishRef.current(TOPICS.LOCKS, {
532
+ userId: selfUserIdRef.current,
533
+ componentId,
534
+ action: "unlock",
535
+ ts: Date.now()
536
+ }, { qos: 1 });
537
+ }
538
+ };
539
+ }, [componentId]);
540
+ return { isLocked, lockedBy, isLockedByMe, lock, unlock };
541
+ }
542
+ function useTypingIndicator(inputId) {
543
+ const { self, publish, addTopicHandler } = useSpace();
544
+ const [typingUsers, setTypingUsers] = useState([]);
545
+ const [isTyping, setIsTyping] = useState(false);
546
+ const typingMapRef = useRef(/* @__PURE__ */ new Map());
547
+ const lastPublishRef = useRef(0);
548
+ const stopTimerRef = useRef(null);
549
+ useEffect(() => {
550
+ const unsubscribe = addTopicHandler(TOPICS.TYPING, (_subtopic, payload) => {
551
+ if (typeof payload !== "object" || payload === null) return;
552
+ const data = payload;
553
+ const userId = data.userId;
554
+ if (!userId || userId === self.userId) return;
555
+ if (inputId && data.inputId !== inputId) return;
556
+ const isCurrentlyTyping = data.isTyping;
557
+ if (isCurrentlyTyping) {
558
+ typingMapRef.current.set(userId, {
559
+ userId,
560
+ name: data.name || userId,
561
+ color: data.color || "#3B82F6",
562
+ joinedAt: Date.now(),
563
+ lastSeen: Date.now()
564
+ });
565
+ } else {
566
+ typingMapRef.current.delete(userId);
567
+ }
568
+ setTypingUsers(Array.from(typingMapRef.current.values()));
569
+ });
570
+ return unsubscribe;
571
+ }, [addTopicHandler, self.userId, inputId]);
572
+ useEffect(() => {
573
+ const interval = setInterval(() => {
574
+ const now = Date.now();
575
+ let changed = false;
576
+ for (const [userId, user] of typingMapRef.current) {
577
+ if (now - user.lastSeen > DEFAULTS.TYPING_STALE_MS) {
578
+ typingMapRef.current.delete(userId);
579
+ changed = true;
580
+ }
581
+ }
582
+ if (changed) {
583
+ setTypingUsers(Array.from(typingMapRef.current.values()));
584
+ }
585
+ }, 2e3);
586
+ return () => clearInterval(interval);
587
+ }, []);
588
+ const publishTyping = useCallback((typing) => {
589
+ publish(TOPICS.TYPING, {
590
+ userId: self.userId,
591
+ name: self.name,
592
+ color: self.color,
593
+ inputId,
594
+ isTyping: typing,
595
+ ts: Date.now()
596
+ }, { qos: 0 });
597
+ }, [publish, self, inputId]);
598
+ const startTyping = useCallback(() => {
599
+ const now = Date.now();
600
+ if (now - lastPublishRef.current >= DEFAULTS.TYPING_THROTTLE_MS) {
601
+ lastPublishRef.current = now;
602
+ publishTyping(true);
603
+ }
604
+ setIsTyping(true);
605
+ if (stopTimerRef.current) clearTimeout(stopTimerRef.current);
606
+ stopTimerRef.current = setTimeout(() => {
607
+ publishTyping(false);
608
+ setIsTyping(false);
609
+ }, DEFAULTS.TYPING_TIMEOUT_MS);
610
+ }, [publishTyping]);
611
+ const stopTyping = useCallback(() => {
612
+ if (stopTimerRef.current) {
613
+ clearTimeout(stopTimerRef.current);
614
+ stopTimerRef.current = null;
615
+ }
616
+ publishTyping(false);
617
+ setIsTyping(false);
618
+ }, [publishTyping]);
619
+ const publishTypingRef = useRef(publishTyping);
620
+ useEffect(() => {
621
+ publishTypingRef.current = publishTyping;
622
+ }, [publishTyping]);
623
+ useEffect(() => {
624
+ return () => {
625
+ if (stopTimerRef.current) {
626
+ clearTimeout(stopTimerRef.current);
627
+ publishTypingRef.current(false);
628
+ }
629
+ };
630
+ }, []);
631
+ return { typingUsers, startTyping, stopTyping, isTyping };
632
+ }
633
+
634
+ // src/utils/uid.ts
635
+ function shortId() {
636
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
637
+ return crypto.randomUUID().slice(0, 8);
638
+ }
639
+ return Math.random().toString(36).slice(2, 10);
640
+ }
641
+
642
+ // src/hooks/useReactions.ts
643
+ function useReactions(options = {}) {
644
+ const {
645
+ maxVisible = DEFAULTS.REACTION_MAX_VISIBLE,
646
+ durationMs = DEFAULTS.REACTION_DURATION_MS
647
+ } = options;
648
+ const { self, publish, addTopicHandler } = useSpace();
649
+ const [reactions, setReactions] = useState([]);
650
+ const timersRef = useRef(/* @__PURE__ */ new Map());
651
+ const addReaction = useCallback((reaction) => {
652
+ const positioned = reaction.x != null ? reaction : { ...reaction, x: Math.random() * 80 + 10 };
653
+ setReactions((prev) => {
654
+ const next = [...prev, positioned];
655
+ return next.length > maxVisible ? next.slice(-maxVisible) : next;
656
+ });
657
+ const timer = setTimeout(() => {
658
+ setReactions((prev) => prev.filter((r) => r.id !== reaction.id));
659
+ timersRef.current.delete(reaction.id);
660
+ }, durationMs);
661
+ timersRef.current.set(reaction.id, timer);
662
+ }, [maxVisible, durationMs]);
663
+ useEffect(() => {
664
+ const unsubscribe = addTopicHandler(TOPICS.REACTIONS, (_subtopic, payload) => {
665
+ if (typeof payload !== "object" || payload === null) return;
666
+ const data = payload;
667
+ if (!data.emoji) return;
668
+ if (data.userId === self.userId) return;
669
+ addReaction({
670
+ id: data.id || shortId(),
671
+ userId: data.userId || "",
672
+ name: data.name || "",
673
+ emoji: data.emoji,
674
+ color: data.color || "#3B82F6",
675
+ ts: data.ts || Date.now()
676
+ });
677
+ });
678
+ return unsubscribe;
679
+ }, [addTopicHandler, addReaction, self.userId]);
680
+ const sendReaction = useCallback((emoji) => {
681
+ const reaction = {
682
+ id: shortId(),
683
+ userId: self.userId,
684
+ name: self.name,
685
+ emoji,
686
+ color: self.color,
687
+ ts: Date.now()
688
+ };
689
+ publish(TOPICS.REACTIONS, reaction, { qos: 0 });
690
+ addReaction(reaction);
691
+ }, [publish, self, addReaction]);
692
+ useEffect(() => {
693
+ return () => {
694
+ for (const timer of timersRef.current.values()) {
695
+ clearTimeout(timer);
696
+ }
697
+ };
698
+ }, []);
699
+ return { reactions, sendReaction };
700
+ }
701
+ function useBroadcast(event) {
702
+ const { publish, addTopicHandler } = useSpace();
703
+ const [lastMessage, setLastMessage] = useState(null);
704
+ const callbacksRef = useRef(/* @__PURE__ */ new Set());
705
+ useEffect(() => {
706
+ const unsubscribe = addTopicHandler(TOPICS.BROADCAST, (subtopic, payload) => {
707
+ const eventName = subtopic.includes("/") ? subtopic.split("/").slice(1).join("/") : void 0;
708
+ if (event && eventName !== event) return;
709
+ const data = payload;
710
+ setLastMessage(data);
711
+ for (const cb of callbacksRef.current) {
712
+ cb(data);
713
+ }
714
+ });
715
+ return unsubscribe;
716
+ }, [addTopicHandler, event]);
717
+ const broadcast = useCallback((data) => {
718
+ const subtopic = event ? `${TOPICS.BROADCAST}/${event}` : TOPICS.BROADCAST;
719
+ publish(subtopic, data, { qos: 0 });
720
+ }, [publish, event]);
721
+ const onMessage = useCallback((callback) => {
722
+ callbacksRef.current.add(callback);
723
+ return () => {
724
+ callbacksRef.current.delete(callback);
725
+ };
726
+ }, []);
727
+ return { broadcast, lastMessage, onMessage };
728
+ }
729
+
730
+ // src/utils/lww.ts
731
+ function lwwMerge(current, incoming) {
732
+ return incoming.ts > current.ts ? incoming : current;
733
+ }
734
+
735
+ // src/hooks/useSharedState.ts
736
+ function useSharedState(key, initialValue) {
737
+ const { self, publish, addTopicHandler } = useSpace();
738
+ const [value, setValue] = useState(initialValue);
739
+ const entryRef = useRef({
740
+ value: initialValue,
741
+ ts: 0,
742
+ userId: self.userId
743
+ });
744
+ useEffect(() => {
745
+ `${TOPICS.STATE}/${key}`;
746
+ const unsubscribe = addTopicHandler(TOPICS.STATE, (incomingSubtopic, payload) => {
747
+ const incomingKey = incomingSubtopic.includes("/") ? incomingSubtopic.split("/").slice(1).join("/") : void 0;
748
+ if (incomingKey !== key) return;
749
+ if (typeof payload !== "object" || payload === null) return;
750
+ const data = payload;
751
+ const incoming = {
752
+ value: data.value,
753
+ ts: data.ts || 0,
754
+ userId: data.userId || ""
755
+ };
756
+ const merged = lwwMerge(entryRef.current, incoming);
757
+ if (merged !== entryRef.current) {
758
+ entryRef.current = merged;
759
+ setValue(merged.value);
760
+ }
761
+ });
762
+ return unsubscribe;
763
+ }, [addTopicHandler, key]);
764
+ const update = useCallback((newValue) => {
765
+ const ts = Date.now();
766
+ const entry = { value: newValue, ts, userId: self.userId };
767
+ entryRef.current = entry;
768
+ setValue(newValue);
769
+ publish(`${TOPICS.STATE}/${key}`, {
770
+ value: newValue,
771
+ ts,
772
+ userId: self.userId
773
+ }, { qos: 1, retain: true });
774
+ }, [publish, key, self.userId]);
775
+ return [value, update];
776
+ }
777
+ function AvatarStack({ max = 5, size = 32, className = "" }) {
778
+ const { members } = usePresence();
779
+ const visible = members.slice(0, max);
780
+ const overflow = members.length - max;
781
+ return /* @__PURE__ */ jsx(
782
+ "div",
783
+ {
784
+ className,
785
+ style: { display: "flex", alignItems: "center" },
786
+ children: /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "row-reverse" }, children: [
787
+ overflow > 0 && /* @__PURE__ */ jsxs(
788
+ "div",
789
+ {
790
+ style: {
791
+ width: size,
792
+ height: size,
793
+ borderRadius: "50%",
794
+ backgroundColor: "#6B7280",
795
+ color: "white",
796
+ display: "flex",
797
+ alignItems: "center",
798
+ justifyContent: "center",
799
+ fontSize: size * 0.35,
800
+ fontWeight: 600,
801
+ border: "2px solid white",
802
+ marginLeft: -size * 0.25,
803
+ position: "relative",
804
+ zIndex: 0
805
+ },
806
+ children: [
807
+ "+",
808
+ overflow
809
+ ]
810
+ }
811
+ ),
812
+ visible.map((user, i) => /* @__PURE__ */ jsx(
813
+ "div",
814
+ {
815
+ title: user.name,
816
+ style: {
817
+ width: size,
818
+ height: size,
819
+ borderRadius: "50%",
820
+ backgroundColor: user.color,
821
+ color: "white",
822
+ display: "flex",
823
+ alignItems: "center",
824
+ justifyContent: "center",
825
+ fontSize: size * 0.35,
826
+ fontWeight: 600,
827
+ border: "2px solid white",
828
+ marginLeft: i === visible.length - 1 ? 0 : -size * 0.25,
829
+ position: "relative",
830
+ zIndex: visible.length - i,
831
+ overflow: "hidden"
832
+ },
833
+ children: user.avatar ? /* @__PURE__ */ jsx(
834
+ "img",
835
+ {
836
+ src: user.avatar,
837
+ alt: user.name,
838
+ style: { width: "100%", height: "100%", objectFit: "cover" }
839
+ }
840
+ ) : getInitials(user.name)
841
+ },
842
+ user.userId
843
+ ))
844
+ ] })
845
+ }
846
+ );
847
+ }
848
+ function CursorOverlay({
849
+ throttleMs,
850
+ staleMs = DEFAULTS.CURSOR_STALE_MS,
851
+ className = "",
852
+ children
853
+ }) {
854
+ const { cursorsRef, onUpdateRef, publishCursor } = useCursors({ throttleMs, staleMs });
855
+ const containerRef = useRef(null);
856
+ const wrapperRef = useRef(null);
857
+ const nodesRef = useRef(/* @__PURE__ */ new Map());
858
+ const syncDOM = useCallback(() => {
859
+ const container = containerRef.current;
860
+ const wrapper = wrapperRef.current;
861
+ if (!container || !wrapper) return;
862
+ const w = container.clientWidth;
863
+ const h = container.clientHeight;
864
+ const now = Date.now();
865
+ const cursors = cursorsRef.current;
866
+ for (const [id, node] of nodesRef.current) {
867
+ if (!cursors.has(id)) {
868
+ node.remove();
869
+ nodesRef.current.delete(id);
870
+ }
871
+ }
872
+ for (const [id, cursor] of cursors) {
873
+ const age = now - cursor.lastSeen;
874
+ const opacity = age > 1e3 ? Math.max(0, 1 - (age - 1e3) / (staleMs - 1e3)) : 1;
875
+ let node = nodesRef.current.get(id);
876
+ if (!node) {
877
+ node = createCursorNode(cursor);
878
+ wrapper.appendChild(node);
879
+ nodesRef.current.set(id, node);
880
+ }
881
+ node.style.transform = `translate(${cursor.x * w}px, ${cursor.y * h}px)`;
882
+ node.style.opacity = String(opacity);
883
+ }
884
+ }, [cursorsRef, staleMs]);
885
+ useEffect(() => {
886
+ onUpdateRef.current = syncDOM;
887
+ return () => {
888
+ onUpdateRef.current = null;
889
+ };
890
+ }, [onUpdateRef, syncDOM]);
891
+ useEffect(() => {
892
+ const container = containerRef.current;
893
+ if (!container) return;
894
+ const observer = new ResizeObserver(() => syncDOM());
895
+ observer.observe(container);
896
+ return () => observer.disconnect();
897
+ }, [syncDOM]);
898
+ useEffect(() => {
899
+ const interval = setInterval(syncDOM, DEFAULTS.FADE_TICK_MS);
900
+ return () => clearInterval(interval);
901
+ }, [syncDOM]);
902
+ const handleMouseMove = useCallback((e) => {
903
+ const container = containerRef.current;
904
+ if (!container) return;
905
+ const rect = container.getBoundingClientRect();
906
+ const x = (e.clientX - rect.left) / rect.width;
907
+ const y = (e.clientY - rect.top) / rect.height;
908
+ publishCursor(x, y);
909
+ }, [publishCursor]);
910
+ return /* @__PURE__ */ jsxs(
911
+ "div",
912
+ {
913
+ ref: containerRef,
914
+ className,
915
+ style: { position: "relative", overflow: "hidden" },
916
+ onMouseMove: handleMouseMove,
917
+ children: [
918
+ children,
919
+ /* @__PURE__ */ jsx(
920
+ "div",
921
+ {
922
+ ref: wrapperRef,
923
+ style: {
924
+ position: "absolute",
925
+ inset: 0,
926
+ pointerEvents: "none",
927
+ zIndex: 50
928
+ }
929
+ }
930
+ )
931
+ ]
932
+ }
933
+ );
934
+ }
935
+ function createCursorNode(cursor) {
936
+ const node = document.createElement("div");
937
+ node.style.position = "absolute";
938
+ node.style.left = "0";
939
+ node.style.top = "0";
940
+ node.style.pointerEvents = "none";
941
+ node.style.transition = "transform 30ms linear";
942
+ node.innerHTML = `
943
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" style="transform: translate(-2px, -2px)">
944
+ <path d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z" fill="${cursor.color}" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
945
+ </svg>
946
+ <div style="margin-left: 16px; margin-top: 4px; white-space: nowrap; border-radius: 4px; padding: 2px 6px; font-size: 12px; font-weight: 500; color: white; background-color: ${cursor.color}; box-shadow: 0 1px 2px rgba(0,0,0,0.1);">${escapeHtml(cursor.name)}</div>
947
+ `;
948
+ return node;
949
+ }
950
+ function escapeHtml(str) {
951
+ const div = document.createElement("div");
952
+ div.textContent = str;
953
+ return div.innerHTML;
954
+ }
955
+ function TypingIndicator({ inputId, className = "" }) {
956
+ const { typingUsers } = useTypingIndicator(inputId);
957
+ if (typingUsers.length === 0) {
958
+ return /* @__PURE__ */ jsx("div", { className, style: { minHeight: "1.25rem" } });
959
+ }
960
+ let text;
961
+ if (typingUsers.length === 1) {
962
+ text = `${typingUsers[0].name} is typing...`;
963
+ } else if (typingUsers.length === 2) {
964
+ text = `${typingUsers[0].name} and ${typingUsers[1].name} are typing...`;
965
+ } else {
966
+ text = `${typingUsers.length} people are typing...`;
967
+ }
968
+ return /* @__PURE__ */ jsx("div", { className, style: { minHeight: "1.25rem" }, children: /* @__PURE__ */ jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: "4px" }, children: [
969
+ /* @__PURE__ */ jsx(TypingDots, {}),
970
+ text
971
+ ] }) });
972
+ }
973
+ function TypingDots() {
974
+ return /* @__PURE__ */ jsxs("span", { style: { display: "inline-flex", gap: "2px", alignItems: "center" }, children: [
975
+ [0, 1, 2].map((i) => /* @__PURE__ */ jsx(
976
+ "span",
977
+ {
978
+ style: {
979
+ width: 4,
980
+ height: 4,
981
+ borderRadius: "50%",
982
+ backgroundColor: "currentColor",
983
+ opacity: 0.6,
984
+ animation: `cs-typing-dot 1.4s infinite`,
985
+ animationDelay: `${i * 0.2}s`
986
+ }
987
+ },
988
+ i
989
+ )),
990
+ /* @__PURE__ */ jsx("style", { children: `
991
+ @keyframes cs-typing-dot {
992
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
993
+ 40% { opacity: 1; transform: scale(1); }
994
+ }
995
+ ` })
996
+ ] });
997
+ }
998
+ function LockIndicator({ componentId, className = "", children }) {
999
+ const { isLocked, lockedBy, isLockedByMe } = useLock(componentId);
1000
+ const borderColor = isLocked ? isLockedByMe ? lockedBy?.color || "#3B82F6" : lockedBy?.color || "#EF4444" : "transparent";
1001
+ return /* @__PURE__ */ jsxs(
1002
+ "div",
1003
+ {
1004
+ className,
1005
+ style: {
1006
+ position: "relative",
1007
+ borderRadius: "6px",
1008
+ border: `2px solid ${borderColor}`,
1009
+ transition: "border-color 200ms ease"
1010
+ },
1011
+ children: [
1012
+ children,
1013
+ isLocked && !isLockedByMe && lockedBy && /* @__PURE__ */ jsx(
1014
+ "div",
1015
+ {
1016
+ style: {
1017
+ position: "absolute",
1018
+ top: -10,
1019
+ right: 8,
1020
+ padding: "1px 6px",
1021
+ borderRadius: "4px",
1022
+ fontSize: "11px",
1023
+ fontWeight: 500,
1024
+ color: "white",
1025
+ backgroundColor: lockedBy.color,
1026
+ whiteSpace: "nowrap",
1027
+ zIndex: 10
1028
+ },
1029
+ children: lockedBy.name
1030
+ }
1031
+ )
1032
+ ]
1033
+ }
1034
+ );
1035
+ }
1036
+ var DEFAULT_EMOJIS = ["\u{1F44D}", "\u2764\uFE0F", "\u{1F602}", "\u{1F389}", "\u{1F525}", "\u{1F440}", "\u{1F680}", "\u{1F4AF}"];
1037
+ function ReactionBar({ emojis = DEFAULT_EMOJIS, className = "" }) {
1038
+ const { reactions, sendReaction } = useReactions();
1039
+ return /* @__PURE__ */ jsxs("div", { className, style: { position: "relative" }, children: [
1040
+ /* @__PURE__ */ jsx(
1041
+ "div",
1042
+ {
1043
+ style: {
1044
+ position: "absolute",
1045
+ bottom: "100%",
1046
+ left: 0,
1047
+ right: 0,
1048
+ height: 120,
1049
+ pointerEvents: "none",
1050
+ overflow: "hidden"
1051
+ },
1052
+ children: reactions.map((reaction) => /* @__PURE__ */ jsx(
1053
+ "span",
1054
+ {
1055
+ style: {
1056
+ position: "absolute",
1057
+ bottom: 0,
1058
+ left: `${reaction.x ?? 50}%`,
1059
+ fontSize: "24px",
1060
+ animation: "cs-reaction-float 3s ease-out forwards",
1061
+ pointerEvents: "none"
1062
+ },
1063
+ children: reaction.emoji
1064
+ },
1065
+ reaction.id
1066
+ ))
1067
+ }
1068
+ ),
1069
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: "4px", flexWrap: "wrap" }, children: emojis.map((emoji) => /* @__PURE__ */ jsx(
1070
+ "button",
1071
+ {
1072
+ onClick: () => sendReaction(emoji),
1073
+ style: {
1074
+ padding: "4px 8px",
1075
+ fontSize: "18px",
1076
+ border: "1px solid #E5E7EB",
1077
+ borderRadius: "8px",
1078
+ backgroundColor: "white",
1079
+ cursor: "pointer",
1080
+ transition: "transform 100ms ease, background-color 100ms ease"
1081
+ },
1082
+ onMouseEnter: (e) => {
1083
+ e.currentTarget.style.transform = "scale(1.15)";
1084
+ e.currentTarget.style.backgroundColor = "#F3F4F6";
1085
+ },
1086
+ onMouseLeave: (e) => {
1087
+ e.currentTarget.style.transform = "scale(1)";
1088
+ e.currentTarget.style.backgroundColor = "white";
1089
+ },
1090
+ children: emoji
1091
+ },
1092
+ emoji
1093
+ )) }),
1094
+ /* @__PURE__ */ jsx("style", { children: `
1095
+ @keyframes cs-reaction-float {
1096
+ 0% { opacity: 1; transform: translateY(0) scale(1); }
1097
+ 100% { opacity: 0; transform: translateY(-100px) scale(1.5); }
1098
+ }
1099
+ ` })
1100
+ ] });
1101
+ }
1102
+ function PresenceBorder({
1103
+ componentId,
1104
+ borderWidth = 2,
1105
+ className = "",
1106
+ children
1107
+ }) {
1108
+ const { isLocked, lockedBy, isLockedByMe, lock, unlock } = useLock(componentId);
1109
+ const borderColor = isLocked ? lockedBy?.color || "#3B82F6" : "transparent";
1110
+ return /* @__PURE__ */ jsxs(
1111
+ "div",
1112
+ {
1113
+ className,
1114
+ style: {
1115
+ position: "relative",
1116
+ borderRadius: "6px",
1117
+ border: `${borderWidth}px solid ${borderColor}`,
1118
+ transition: "border-color 200ms ease"
1119
+ },
1120
+ onFocusCapture: () => lock(),
1121
+ onBlurCapture: () => unlock(),
1122
+ children: [
1123
+ children,
1124
+ isLocked && lockedBy && /* @__PURE__ */ jsx(
1125
+ "div",
1126
+ {
1127
+ style: {
1128
+ position: "absolute",
1129
+ top: -(borderWidth + 8),
1130
+ left: 8,
1131
+ padding: "0px 6px",
1132
+ borderRadius: "4px 4px 0 0",
1133
+ fontSize: "11px",
1134
+ fontWeight: 500,
1135
+ color: "white",
1136
+ backgroundColor: lockedBy.color,
1137
+ whiteSpace: "nowrap",
1138
+ lineHeight: "16px"
1139
+ },
1140
+ children: isLockedByMe ? "You" : lockedBy.name
1141
+ }
1142
+ )
1143
+ ]
1144
+ }
1145
+ );
1146
+ }
1147
+
1148
+ // src/index.ts
1149
+ var VERSION = "0.1.0";
1150
+
1151
+ export { AvatarStack, Space as CloudSignalSpace, CursorOverlay, DEFAULTS, LockIndicator, PresenceBorder, ReactionBar, Space, TOPICS, TOPIC_PREFIX, TypingIndicator, VERSION, getColorForUser, getInitials, spaceTopic, spaceWildcard, useBroadcast, useCursors, useLock, usePresence, useReactions, useSharedState, useSpace, useTypingIndicator };
1152
+ //# sourceMappingURL=index.js.map
1153
+ //# sourceMappingURL=index.js.map