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