@byscott-io/where-is-waldo 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.
@@ -0,0 +1,895 @@
1
+ import { createConsumer } from '@rails/actioncable';
2
+ import React, { useState, useRef, useEffect, useCallback, createContext, useMemo, useContext } from 'react';
3
+
4
+ function _arrayLikeToArray(r, a) {
5
+ (null == a || a > r.length) && (a = r.length);
6
+ for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
7
+ return n;
8
+ }
9
+ function _arrayWithHoles(r) {
10
+ if (Array.isArray(r)) return r;
11
+ }
12
+ function _defineProperty(e, r, t) {
13
+ return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
14
+ value: t,
15
+ enumerable: true,
16
+ configurable: true,
17
+ writable: true
18
+ }) : e[r] = t, e;
19
+ }
20
+ function _iterableToArrayLimit(r, l) {
21
+ var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];
22
+ if (null != t) {
23
+ var e,
24
+ n,
25
+ i,
26
+ u,
27
+ a = [],
28
+ f = true,
29
+ o = false;
30
+ try {
31
+ if (i = (t = t.call(r)).next, 0 === l) ; else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0);
32
+ } catch (r) {
33
+ o = true, n = r;
34
+ } finally {
35
+ try {
36
+ if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return;
37
+ } finally {
38
+ if (o) throw n;
39
+ }
40
+ }
41
+ return a;
42
+ }
43
+ }
44
+ function _nonIterableRest() {
45
+ throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
46
+ }
47
+ function ownKeys(e, r) {
48
+ var t = Object.keys(e);
49
+ if (Object.getOwnPropertySymbols) {
50
+ var o = Object.getOwnPropertySymbols(e);
51
+ r && (o = o.filter(function (r) {
52
+ return Object.getOwnPropertyDescriptor(e, r).enumerable;
53
+ })), t.push.apply(t, o);
54
+ }
55
+ return t;
56
+ }
57
+ function _objectSpread2(e) {
58
+ for (var r = 1; r < arguments.length; r++) {
59
+ var t = null != arguments[r] ? arguments[r] : {};
60
+ r % 2 ? ownKeys(Object(t), true).forEach(function (r) {
61
+ _defineProperty(e, r, t[r]);
62
+ }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) {
63
+ Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));
64
+ });
65
+ }
66
+ return e;
67
+ }
68
+ function _slicedToArray(r, e) {
69
+ return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest();
70
+ }
71
+ function _toPrimitive(t, r) {
72
+ if ("object" != typeof t || !t) return t;
73
+ var e = t[Symbol.toPrimitive];
74
+ if (void 0 !== e) {
75
+ var i = e.call(t, r);
76
+ if ("object" != typeof i) return i;
77
+ throw new TypeError("@@toPrimitive must return a primitive value.");
78
+ }
79
+ return ("string" === r ? String : Number)(t);
80
+ }
81
+ function _toPropertyKey(t) {
82
+ var i = _toPrimitive(t, "string");
83
+ return "symbol" == typeof i ? i : i + "";
84
+ }
85
+ function _unsupportedIterableToArray(r, a) {
86
+ if (r) {
87
+ if ("string" == typeof r) return _arrayLikeToArray(r, a);
88
+ var t = {}.toString.call(r).slice(8, -1);
89
+ return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;
90
+ }
91
+ }
92
+
93
+ var consumer = null;
94
+ var config = {
95
+ url: '/cable',
96
+ getToken: null
97
+ };
98
+
99
+ // Subscribe-by-event-type registry: message type -> Set of listener fns.
100
+ // Components subscribe to a raw event type (see subscribeToEvent /
101
+ // useWaldoEvent) and decide for themselves whether they care about a given
102
+ // payload. handleMessage dispatches every incoming message to the listeners
103
+ // registered for its type.
104
+ var listeners = new Map();
105
+
106
+ /**
107
+ * Subscribe to a raw cable event type. Returns an unsubscribe function.
108
+ * Multiple subscribers per type are supported; each receives (data, message).
109
+ * @param {string} type - The message type to listen for
110
+ * @param {Function} callback - Called with (data, message) on each matching event
111
+ * @returns {Function} unsubscribe
112
+ */
113
+ function subscribeToEvent(type, callback) {
114
+ if (!type || typeof callback !== 'function') return function () {};
115
+ var set = listeners.get(type);
116
+ if (!set) {
117
+ set = new Set();
118
+ listeners.set(type, set);
119
+ }
120
+ set.add(callback);
121
+ return function unsubscribe() {
122
+ var current = listeners.get(type);
123
+ if (!current) return;
124
+ current["delete"](callback);
125
+ if (current.size === 0) listeners["delete"](type);
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Configure the ActionCable connection
131
+ * @param {Object} options
132
+ * @param {string} options.url - WebSocket URL (default: '/cable')
133
+ * @param {Function} options.getToken - Function that returns auth token
134
+ */
135
+ function configureCable() {
136
+ var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
137
+ config = _objectSpread2(_objectSpread2({}, config), options);
138
+ // Reset consumer when config changes
139
+ if (consumer) {
140
+ consumer.disconnect();
141
+ consumer = null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Get or create the ActionCable consumer
147
+ * @returns {Consumer}
148
+ */
149
+ function getConsumer() {
150
+ if (!consumer) {
151
+ var url = config.url;
152
+ if (config.getToken) {
153
+ var token = config.getToken();
154
+ if (token) {
155
+ var separator = url.includes('?') ? '&' : '?';
156
+ url = "".concat(url).concat(separator, "token=").concat(encodeURIComponent(token));
157
+ }
158
+ }
159
+ consumer = createConsumer(url);
160
+ }
161
+ return consumer;
162
+ }
163
+
164
+ /**
165
+ * Disconnect and reset the consumer
166
+ */
167
+ function disconnectCable() {
168
+ if (consumer) {
169
+ consumer.disconnect();
170
+ consumer = null;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Get current cable configuration
176
+ * @returns {Object}
177
+ */
178
+ function getCableConfig() {
179
+ return _objectSpread2({}, config);
180
+ }
181
+
182
+ /**
183
+ * Handle an incoming message by dispatching to every listener subscribed to
184
+ * its type.
185
+ * @param {Object} message - The message object with type and data
186
+ * @returns {boolean} - Whether at least one listener was invoked
187
+ */
188
+ function handleMessage(message) {
189
+ var type = message.type,
190
+ data = message.data;
191
+ var set = listeners.get(type);
192
+ if (!set || set.size === 0) return false;
193
+
194
+ // Iterate a snapshot so a listener that unsubscribes during dispatch is safe.
195
+ Array.from(set).forEach(function (cb) {
196
+ try {
197
+ cb(data, message);
198
+ } catch (err) {
199
+ // One bad listener must not block the others.
200
+ // eslint-disable-next-line no-console
201
+ console.error("[where-is-waldo] listener for \"".concat(type, "\" threw:"), err);
202
+ }
203
+ });
204
+ return true;
205
+ }
206
+
207
+ var DEFAULTS = {
208
+ channelName: 'WhereIsWaldo::PresenceChannel',
209
+ heartbeatInterval: 30000,
210
+ // ms
211
+ activityTimeout: 30000,
212
+ // ms of no activity -> subject_active=false
213
+ metadata: {},
214
+ debug: false
215
+ };
216
+ function createPresenceReporter() {
217
+ var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
218
+ var config = _objectSpread2(_objectSpread2({}, DEFAULTS), options);
219
+ var now = config.now || function () {
220
+ return Date.now();
221
+ };
222
+ var resolveConsumer = config.getConsumer || getConsumer;
223
+ var resolveCableConfig = config.getCableConfig || getCableConfig;
224
+ var dispatch = config.handleMessage || handleMessage;
225
+ var log = function log() {
226
+ var _console;
227
+ for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
228
+ args[_key] = arguments[_key];
229
+ }
230
+ // eslint-disable-next-line no-console
231
+ if (config.debug) (_console = console).log.apply(_console, ['[PresenceReporter]'].concat(args));
232
+ };
233
+ var subscription = null;
234
+ var heartbeatTimer = null;
235
+ var activityTimer = null;
236
+ var lastActivityAt = 0;
237
+
238
+ // visible = tab/app is foreground & visible; active = recent user activity.
239
+ var state = {
240
+ connected: false,
241
+ visible: true,
242
+ active: true,
243
+ sessionId: null
244
+ };
245
+ var emitChange = function emitChange() {
246
+ if (config.onChange) config.onChange(_objectSpread2({}, state));
247
+ };
248
+ var buildPayload = function buildPayload() {
249
+ var _overrides$tab_visibl, _overrides$subject_ac;
250
+ var overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
251
+ var payload = {
252
+ tab_visible: (_overrides$tab_visibl = overrides.tab_visible) !== null && _overrides$tab_visibl !== void 0 ? _overrides$tab_visibl : state.visible,
253
+ subject_active: (_overrides$subject_ac = overrides.subject_active) !== null && _overrides$subject_ac !== void 0 ? _overrides$subject_ac : state.active,
254
+ metadata: config.metadata || {}
255
+ };
256
+ if (lastActivityAt) payload.last_activity_at = lastActivityAt;
257
+ return payload;
258
+ };
259
+ var sendHeartbeat = function sendHeartbeat() {
260
+ var overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
261
+ if (!subscription) return;
262
+ log('heartbeat', overrides);
263
+ subscription.perform('heartbeat', buildPayload(overrides));
264
+ };
265
+
266
+ // (Re)arm the inactivity timer: after activityTimeout with no activity, flip
267
+ // to inactive and send an immediate heartbeat announcing it.
268
+ var scheduleInactivity = function scheduleInactivity() {
269
+ if (activityTimer) clearTimeout(activityTimer);
270
+ activityTimer = setTimeout(function () {
271
+ if (!state.active) return;
272
+ state.active = false;
273
+ emitChange();
274
+ sendHeartbeat({
275
+ subject_active: false
276
+ });
277
+ }, config.activityTimeout);
278
+ };
279
+ var reportActivity = function reportActivity() {
280
+ lastActivityAt = now();
281
+ if (!state.active) {
282
+ state.active = true;
283
+ emitChange();
284
+ sendHeartbeat({
285
+ subject_active: true
286
+ }); // immediate: inactive -> active
287
+ }
288
+ scheduleInactivity();
289
+ };
290
+ var setVisible = function setVisible(visible) {
291
+ if (visible === state.visible) return;
292
+ state.visible = visible;
293
+ emitChange();
294
+ sendHeartbeat(); // immediate: visibility change
295
+ if (visible) reportActivity();
296
+ };
297
+ var startHeartbeatLoop = function startHeartbeatLoop() {
298
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
299
+ heartbeatTimer = setInterval(function () {
300
+ return sendHeartbeat();
301
+ }, config.heartbeatInterval);
302
+ };
303
+ var start = function start() {
304
+ if (subscription) return subscription;
305
+ subscription = resolveConsumer().subscriptions.create({
306
+ channel: config.channelName,
307
+ metadata: config.metadata || {}
308
+ }, {
309
+ connected: function connected() {
310
+ state.connected = true;
311
+ var cable = resolveCableConfig();
312
+ if (cable && cable.sessionId) state.sessionId = cable.sessionId;
313
+ emitChange();
314
+ if (config.onConnected) config.onConnected();
315
+ startHeartbeatLoop();
316
+ },
317
+ disconnected: function disconnected() {
318
+ state.connected = false;
319
+ emitChange();
320
+ if (config.onDisconnected) config.onDisconnected();
321
+ },
322
+ received: function received(message) {
323
+ // Honor targeted (per-session) messages, then route to the event bus.
324
+ if (message && message._target_session && message._target_session !== state.sessionId) {
325
+ return;
326
+ }
327
+ dispatch(message);
328
+ }
329
+ });
330
+ scheduleInactivity();
331
+ return subscription;
332
+ };
333
+ var stop = function stop() {
334
+ if (heartbeatTimer) {
335
+ clearInterval(heartbeatTimer);
336
+ heartbeatTimer = null;
337
+ }
338
+ if (activityTimer) {
339
+ clearTimeout(activityTimer);
340
+ activityTimer = null;
341
+ }
342
+ if (subscription) {
343
+ subscription.unsubscribe();
344
+ subscription = null;
345
+ }
346
+ state.connected = false;
347
+ };
348
+ return {
349
+ start: start,
350
+ stop: stop,
351
+ setVisible: setVisible,
352
+ reportActivity: reportActivity,
353
+ sendHeartbeat: sendHeartbeat,
354
+ getState: function getState() {
355
+ return _objectSpread2({}, state);
356
+ }
357
+ };
358
+ }
359
+
360
+ var DEFAULT_OPTIONS = {
361
+ channelName: 'WhereIsWaldo::PresenceChannel',
362
+ heartbeatInterval: 30000,
363
+ activityTimeout: 30000,
364
+ trackActivity: true,
365
+ trackVisibility: true,
366
+ debug: false
367
+ };
368
+ var initialVisible = function initialVisible() {
369
+ return typeof document === 'undefined' ? true : document.visibilityState !== 'hidden';
370
+ };
371
+ var initialFocused = function initialFocused() {
372
+ return typeof document === 'undefined' ? true : document.hasFocus();
373
+ };
374
+
375
+ /**
376
+ * usePresence - browser presence tracking. This is now a thin DOM-sensor
377
+ * wrapper around the framework-agnostic core (../core/presenceReporter): it
378
+ * binds `document`/`window` events to the reporter's `setVisible` /
379
+ * `reportActivity` inputs and mirrors the reporter's state into React state.
380
+ * The heartbeat cadence, activity/visibility state machine, and cable
381
+ * subscription all live in the core, which a React Native reporter reuses.
382
+ *
383
+ * @param {Object} options - see DEFAULT_OPTIONS
384
+ * @returns {{ connected, sessionId, tabVisible, windowFocused, subjectActive, sendHeartbeat }}
385
+ */
386
+ function usePresence() {
387
+ var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
388
+ var config = _objectSpread2(_objectSpread2({}, DEFAULT_OPTIONS), options);
389
+ var _useState = useState(false),
390
+ _useState2 = _slicedToArray(_useState, 2),
391
+ connected = _useState2[0],
392
+ setConnected = _useState2[1];
393
+ var _useState3 = useState(null),
394
+ _useState4 = _slicedToArray(_useState3, 2),
395
+ sessionId = _useState4[0],
396
+ setSessionId = _useState4[1];
397
+ var _useState5 = useState(true),
398
+ _useState6 = _slicedToArray(_useState5, 2),
399
+ subjectActive = _useState6[0],
400
+ setSubjectActive = _useState6[1];
401
+ var _useState7 = useState(initialVisible),
402
+ _useState8 = _slicedToArray(_useState7, 2),
403
+ tabVisible = _useState8[0],
404
+ setTabVisible = _useState8[1];
405
+ var _useState9 = useState(initialFocused),
406
+ _useState0 = _slicedToArray(_useState9, 2),
407
+ windowFocused = _useState0[0],
408
+ setWindowFocused = _useState0[1];
409
+ var reporterRef = useRef(null);
410
+ // Latest sensor readings, read by the DOM handlers to compute foreground.
411
+ var sensorRef = useRef({
412
+ tab: initialVisible(),
413
+ focus: initialFocused()
414
+ });
415
+
416
+ // Create + start the reporter once (re-created only if the channel changes).
417
+ useEffect(function () {
418
+ var reporter = createPresenceReporter({
419
+ channelName: config.channelName,
420
+ heartbeatInterval: config.heartbeatInterval,
421
+ activityTimeout: config.activityTimeout,
422
+ metadata: config.metadata,
423
+ debug: config.debug,
424
+ onConnected: config.onConnected,
425
+ onDisconnected: config.onDisconnected,
426
+ onChange: function onChange(state) {
427
+ setConnected(state.connected);
428
+ setSubjectActive(state.active);
429
+ if (state.sessionId) setSessionId(state.sessionId);
430
+ }
431
+ });
432
+ reporterRef.current = reporter;
433
+ reporter.start();
434
+ // Seed foreground state from the current sensors.
435
+ reporter.setVisible(sensorRef.current.tab && sensorRef.current.focus);
436
+ return function () {
437
+ reporter.stop();
438
+ reporterRef.current = null;
439
+ };
440
+ }, [config.channelName]); // eslint-disable-line react-hooks/exhaustive-deps
441
+
442
+ // Activity sensors -> reporter.reportActivity()
443
+ useEffect(function () {
444
+ if (!config.trackActivity) return undefined;
445
+ var onActivity = function onActivity() {
446
+ var _reporterRef$current;
447
+ return (_reporterRef$current = reporterRef.current) === null || _reporterRef$current === void 0 ? void 0 : _reporterRef$current.reportActivity();
448
+ };
449
+ var events = ['mousemove', 'keydown', 'scroll', 'touchstart', 'click'];
450
+ events.forEach(function (event) {
451
+ return window.addEventListener(event, onActivity, {
452
+ passive: true
453
+ });
454
+ });
455
+ return function () {
456
+ return events.forEach(function (event) {
457
+ return window.removeEventListener(event, onActivity);
458
+ });
459
+ };
460
+ }, [config.trackActivity]);
461
+
462
+ // Visibility + focus sensors -> reporter.setVisible(foreground)
463
+ useEffect(function () {
464
+ if (!config.trackVisibility) return undefined;
465
+ var pushForeground = function pushForeground() {
466
+ var _reporterRef$current2;
467
+ (_reporterRef$current2 = reporterRef.current) === null || _reporterRef$current2 === void 0 || _reporterRef$current2.setVisible(sensorRef.current.tab && sensorRef.current.focus);
468
+ };
469
+ var onVisibility = function onVisibility() {
470
+ var visible = document.visibilityState !== 'hidden';
471
+ sensorRef.current.tab = visible;
472
+ setTabVisible(visible);
473
+ pushForeground();
474
+ };
475
+ var onFocus = function onFocus() {
476
+ sensorRef.current.focus = true;
477
+ setWindowFocused(true);
478
+ pushForeground();
479
+ };
480
+ var onBlur = function onBlur() {
481
+ sensorRef.current.focus = false;
482
+ setWindowFocused(false);
483
+ pushForeground();
484
+ };
485
+ document.addEventListener('visibilitychange', onVisibility);
486
+ window.addEventListener('focus', onFocus);
487
+ window.addEventListener('blur', onBlur);
488
+ return function () {
489
+ document.removeEventListener('visibilitychange', onVisibility);
490
+ window.removeEventListener('focus', onFocus);
491
+ window.removeEventListener('blur', onBlur);
492
+ };
493
+ }, [config.trackVisibility]);
494
+ var sendHeartbeat = useCallback(function (overrides) {
495
+ var _reporterRef$current3;
496
+ (_reporterRef$current3 = reporterRef.current) === null || _reporterRef$current3 === void 0 || _reporterRef$current3.sendHeartbeat(overrides);
497
+ }, []);
498
+ return {
499
+ connected: connected,
500
+ sessionId: sessionId,
501
+ tabVisible: tabVisible,
502
+ windowFocused: windowFocused,
503
+ subjectActive: subjectActive,
504
+ sendHeartbeat: sendHeartbeat
505
+ };
506
+ }
507
+
508
+ var PresenceContext = /*#__PURE__*/createContext(null);
509
+ var DEFAULT_CONFIG = {
510
+ channelName: 'WhereIsWaldo::PresenceChannel',
511
+ heartbeatInterval: 30000,
512
+ activityTimeout: 30000,
513
+ trackActivity: true,
514
+ trackVisibility: true,
515
+ debug: false
516
+ };
517
+
518
+ /**
519
+ * Get merged config from defaults, cable config, and overrides
520
+ */
521
+ function getMergedConfig(configOverrides) {
522
+ var cableConfig = getCableConfig();
523
+ var presenceConfig = cableConfig.presence || {};
524
+ return _objectSpread2(_objectSpread2(_objectSpread2({}, DEFAULT_CONFIG), presenceConfig), configOverrides);
525
+ }
526
+
527
+ /**
528
+ * PresenceProvider - Provides real-time presence tracking and message handling
529
+ *
530
+ * @param {Object} props
531
+ * @param {React.ReactNode} props.children
532
+ * @param {Object} props.metadata - Optional metadata to attach to presence
533
+ * @param {Object} props.config - Optional configuration overrides
534
+ * @param {Function} props.onConnected - Callback when connected
535
+ * @param {Function} props.onDisconnected - Callback when disconnected
536
+ */
537
+ function PresenceProvider(_ref) {
538
+ var children = _ref.children,
539
+ _ref$metadata = _ref.metadata,
540
+ metadata = _ref$metadata === void 0 ? {} : _ref$metadata,
541
+ _ref$config = _ref.config,
542
+ configOverrides = _ref$config === void 0 ? {} : _ref$config,
543
+ onConnected = _ref.onConnected,
544
+ onDisconnected = _ref.onDisconnected;
545
+ var config = useMemo(function () {
546
+ return getMergedConfig(configOverrides);
547
+ }, [configOverrides]);
548
+
549
+ // Use the shared usePresence hook for all presence tracking
550
+ var presence = usePresence(_objectSpread2(_objectSpread2({}, config), {}, {
551
+ metadata: metadata,
552
+ onConnected: onConnected,
553
+ onDisconnected: onDisconnected
554
+ }));
555
+ return /*#__PURE__*/React.createElement(PresenceContext.Provider, {
556
+ value: presence
557
+ }, children);
558
+ }
559
+
560
+ /**
561
+ * Hook to access presence context
562
+ * @returns {Object} Presence context value
563
+ */
564
+ function usePresenceContext() {
565
+ var context = useContext(PresenceContext);
566
+ if (!context) {
567
+ throw new Error('usePresenceContext must be used within a PresenceProvider');
568
+ }
569
+ return context;
570
+ }
571
+
572
+ /**
573
+ * useWaldoEvent - subscribe a component to one or more raw real-time event types.
574
+ *
575
+ * The component subscribes to a message `type` (or an array of types) and
576
+ * receives the payload on every matching cable event; it decides for itself
577
+ * whether it cares about a given record (e.g. `if (data.project_id === myId)
578
+ * ...`). The subscription is automatically torn down when the component
579
+ * unmounts ("out of view"), exactly like a useEffect cleanup.
580
+ *
581
+ * The handler is held in a ref so it always sees the latest props/state without
582
+ * re-subscribing on every render — callers do NOT need to memoize it. The
583
+ * subscription is re-created only when the type(s) / `enabled` change.
584
+ *
585
+ * This is a pure subscription primitive — it has no timing/debounce concern.
586
+ * To coalesce a burst of events, compose with a debounce at the call site, e.g.
587
+ * corebyscott's `useDebouncedCallback`:
588
+ *
589
+ * const refetch = useDebouncedCallback(loadAll, 250);
590
+ * useWaldoEvent(['issue_update', 'project_update'], refetch);
591
+ *
592
+ * @param {string|string[]} type - Cable message type(s), e.g. 'issue_update'
593
+ * @param {Function} handler - Called with (data, message) on each event
594
+ * @param {Object} [options]
595
+ * @param {boolean} [options.enabled=true] - When false, no subscription is made
596
+ *
597
+ * @example
598
+ * useWaldoEvent('issue_update', (data) => {
599
+ * if (data.id === issueId) setIssue((prev) => ({ ...prev, ...data }));
600
+ * });
601
+ */
602
+ function useWaldoEvent(type, handler) {
603
+ var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
604
+ var _options$enabled = options.enabled,
605
+ enabled = _options$enabled === void 0 ? true : _options$enabled;
606
+ var handlerRef = useRef(handler);
607
+
608
+ // Keep the ref pointing at the latest handler every render.
609
+ useEffect(function () {
610
+ handlerRef.current = handler;
611
+ });
612
+
613
+ // Stable, order-insensitive key so an inline array literal doesn't
614
+ // re-subscribe on every render.
615
+ var types = Array.isArray(type) ? type : [type];
616
+ var typesKey = types.filter(Boolean).join(' ');
617
+ useEffect(function () {
618
+ if (!enabled || !typesKey) return undefined;
619
+ var typeList = typesKey.split(' ');
620
+ var onEvent = function onEvent(data, message) {
621
+ if (handlerRef.current) handlerRef.current(data, message);
622
+ };
623
+ var unsubscribes = typeList.map(function (t) {
624
+ return subscribeToEvent(t, onEvent);
625
+ });
626
+ return function () {
627
+ unsubscribes.forEach(function (unsub) {
628
+ return unsub();
629
+ });
630
+ };
631
+ }, [typesKey, enabled]);
632
+ }
633
+
634
+ // Framework-agnostic roster store: pure JS, no DOM and no React Native APIs, so
635
+ // it is safe to import from the web package AND the mobile (React Native)
636
+ // package. It owns the "materialize full state from a snapshot + transition
637
+ // deltas" logic and the derivations over it (per-device roll-up, sorting,
638
+ // labels, colors) — everything except the sensors and the pixels, which are
639
+ // necessarily platform-specific.
640
+
641
+ // Cable message types exchanged on the roster stream.
642
+ var ROSTER_SNAPSHOT = 'roster_snapshot';
643
+ var ROSTER_DELTA = 'roster_delta';
644
+ // Content-free "re-poll now" trigger (:nudge mode). Carries no data.
645
+ var ROSTER_NUDGE = 'roster_nudge';
646
+
647
+ // Activity ranking, most-present first. Shared by sorting and by the "highest
648
+ // across devices" roll-up so web and mobile agree.
649
+ var STATUS_RANK = {
650
+ active: 3,
651
+ idle: 2,
652
+ background: 1,
653
+ offline: 0
654
+ };
655
+
656
+ // Status -> presence dot color. Platform UIs may theme these, but sharing a
657
+ // default keeps web and mobile visually consistent out of the box.
658
+ var STATUS_COLORS = {
659
+ active: '#22c55e',
660
+ // visible + working
661
+ idle: '#f59e0b',
662
+ // visible but not actively using
663
+ background: '#9ca3af',
664
+ // only backgrounded/hidden sessions
665
+ offline: '#d1d5db' // no live sessions
666
+ };
667
+ function presenceColor(status) {
668
+ return STATUS_COLORS[status] || STATUS_COLORS.offline;
669
+ }
670
+
671
+ /**
672
+ * Apply an incoming cable message to a members Map (keyed by subject id),
673
+ * returning a NEW Map (immutable update) so callers can trigger re-renders by
674
+ * identity. Unknown message types return the same Map unchanged.
675
+ *
676
+ * - snapshot: replaces the whole roster.
677
+ * - delta: upserts one member. Offline members are KEPT (status "offline") so
678
+ * the roster stays the full team with live dots rather than vanishing rows.
679
+ *
680
+ * @param {Map<any, Object>} map - current members map
681
+ * @param {Object} message - { type, members? , member? }
682
+ * @returns {Map<any, Object>}
683
+ */
684
+ function applyRosterMessage(map, message) {
685
+ if (!message || !message.type) return map;
686
+ if (message.type === ROSTER_SNAPSHOT) {
687
+ var next = new Map();
688
+ (message.members || []).forEach(function (m) {
689
+ return next.set(m.id, m);
690
+ });
691
+ return next;
692
+ }
693
+ if (message.type === ROSTER_DELTA && message.member) {
694
+ var _next = new Map(map);
695
+ if (message.member._removed) {
696
+ // Member left the viewer's visible scope (a visibility change) — drop it.
697
+ // Distinct from going offline, which arrives as status "offline".
698
+ _next["delete"](message.member.id);
699
+ } else {
700
+ _next.set(message.member.id, message.member);
701
+ }
702
+ return _next;
703
+ }
704
+ return map;
705
+ }
706
+
707
+ /**
708
+ * Modes that require the client to poll the server for deltas (vs. passively
709
+ * receiving pushed broadcasts). The server declares the mode in the snapshot.
710
+ */
711
+ function isPollMode(mode) {
712
+ return mode === 'poll' || mode === 'nudge';
713
+ }
714
+
715
+ /**
716
+ * Members Map -> array, sorted most-present first then by display label.
717
+ */
718
+ function sortedMembers(map) {
719
+ var list = Array.from(map.values());
720
+ list.sort(function (a, b) {
721
+ var _STATUS_RANK$b$status, _STATUS_RANK$a$status;
722
+ var rank = ((_STATUS_RANK$b$status = STATUS_RANK[b.status]) !== null && _STATUS_RANK$b$status !== void 0 ? _STATUS_RANK$b$status : 0) - ((_STATUS_RANK$a$status = STATUS_RANK[a.status]) !== null && _STATUS_RANK$a$status !== void 0 ? _STATUS_RANK$a$status : 0);
723
+ if (rank !== 0) return rank;
724
+ return String(memberLabel(a)).localeCompare(String(memberLabel(b)));
725
+ });
726
+ return list;
727
+ }
728
+
729
+ /**
730
+ * Only members with a live session (status !== "offline").
731
+ */
732
+ function onlineMembers(members) {
733
+ return members.filter(function (m) {
734
+ return m.status && m.status !== 'offline';
735
+ });
736
+ }
737
+
738
+ /**
739
+ * That member's status on a specific device/platform, e.g.
740
+ * deviceStatus(member, 'mobile') // => "idle"
741
+ * Answers "is the user active on mobile?" vs the overall member.status.
742
+ */
743
+ function deviceStatus(member, platform) {
744
+ return member && member.devices && member.devices[platform] || 'offline';
745
+ }
746
+
747
+ /**
748
+ * Best-effort display label from a member's subject data.
749
+ */
750
+ function memberLabel(member) {
751
+ if (!member) return '';
752
+ return member.name || [member.first_name, member.last_name].filter(Boolean).join(' ') || member.email || member.username || "#".concat(member.id);
753
+ }
754
+
755
+ var DEFAULT_CHANNEL = 'WhereIsWaldo::RosterChannel';
756
+ var DEFAULT_POLL_INTERVAL = 15; // seconds; overridden by the server's snapshot
757
+ var DEFAULT_NUDGE_JITTER = 0.5; // seconds; overridden by the server's snapshot
758
+
759
+ /**
760
+ * usePresenceRoster - live "who's around" roster as data (bring your own UI).
761
+ *
762
+ * The server picks the delivery mode per account and declares it in the initial
763
+ * snapshot. This hook adapts transparently — no client mode config:
764
+ * - broadcast/fanout : passively receive pushed deltas.
765
+ * - pull/nudge : poll the server (`perform('poll')`) on the interval the
766
+ * server specifies; the server replies with a filtered
767
+ * snapshot (resync) or diff.
768
+ * Either way the shared reducer (../core/rosterStore) keeps `members` as the
769
+ * full, live set (snapshot seeded, deltas patched, removals dropped).
770
+ *
771
+ * Each member: { id, status, devices: { web, mobile, ... }, ...subjectData }.
772
+ *
773
+ * @param {Object} [options]
774
+ * @param {string} [options.channelName] - override the roster channel name
775
+ * @param {boolean} [options.enabled=true] - when false, no subscription is made
776
+ * @param {Function} [options.filter] - (member) => boolean. Client-side filter
777
+ * applied to the returned members. COSMETIC ONLY — intended for `:broadcast`
778
+ * mode, where the full account roster reaches the client and you want to hide
779
+ * some rows in the UI. It is NOT an access-control boundary (the unfiltered
780
+ * data is already on the client). For enforced visibility use a server-side
781
+ * mode (`:poll`/`:nudge`/`:fanout`).
782
+ * @returns {{ members: Array, online: Array, onlineCount: number,
783
+ * byId: Object, connected: boolean, mode: string|null }}
784
+ */
785
+ function usePresenceRoster() {
786
+ var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
787
+ var _options$channelName = options.channelName,
788
+ channelName = _options$channelName === void 0 ? DEFAULT_CHANNEL : _options$channelName,
789
+ _options$enabled = options.enabled,
790
+ enabled = _options$enabled === void 0 ? true : _options$enabled,
791
+ filter = options.filter;
792
+ var _useState = useState([]),
793
+ _useState2 = _slicedToArray(_useState, 2),
794
+ members = _useState2[0],
795
+ setMembers = _useState2[1];
796
+ var _useState3 = useState(false),
797
+ _useState4 = _slicedToArray(_useState3, 2),
798
+ connected = _useState4[0],
799
+ setConnected = _useState4[1];
800
+ var _useState5 = useState(null),
801
+ _useState6 = _slicedToArray(_useState5, 2),
802
+ mode = _useState6[0],
803
+ setMode = _useState6[1];
804
+ var mapRef = useRef(new Map());
805
+ var pollTimerRef = useRef(null);
806
+ var nudgeTimerRef = useRef(null);
807
+ var nudgeJitterRef = useRef(DEFAULT_NUDGE_JITTER);
808
+ var flush = useCallback(function () {
809
+ setMembers(sortedMembers(mapRef.current));
810
+ }, []);
811
+ useEffect(function () {
812
+ if (!enabled) return undefined;
813
+ var consumer = getConsumer();
814
+ var startPolling = function startPolling(subscription, intervalSeconds) {
815
+ if (pollTimerRef.current) return; // already polling
816
+ var ms = (intervalSeconds || DEFAULT_POLL_INTERVAL) * 1000;
817
+ // Small random offset so clients don't poll in lockstep (herd control).
818
+ var jitter = function jitter() {
819
+ return 0.85 + Math.random() * 0.3;
820
+ };
821
+ var _tick = function tick() {
822
+ subscription.perform('poll');
823
+ pollTimerRef.current = setTimeout(_tick, ms * jitter());
824
+ };
825
+ pollTimerRef.current = setTimeout(_tick, ms * jitter());
826
+ };
827
+
828
+ // :nudge — on a content-free trigger, re-poll off-cycle after a random
829
+ // jitter (herd control), debounced to one pending re-poll at a time.
830
+ var scheduleNudgePoll = function scheduleNudgePoll(subscription) {
831
+ if (nudgeTimerRef.current) return;
832
+ var delay = Math.random() * nudgeJitterRef.current * 1000;
833
+ nudgeTimerRef.current = setTimeout(function () {
834
+ nudgeTimerRef.current = null;
835
+ subscription.perform('poll');
836
+ }, delay);
837
+ };
838
+ var subscription = consumer.subscriptions.create({
839
+ channel: channelName
840
+ }, {
841
+ connected: function connected() {
842
+ setConnected(true);
843
+ },
844
+ disconnected: function disconnected() {
845
+ setConnected(false);
846
+ },
847
+ received: function received(message) {
848
+ if (message && message.type === ROSTER_SNAPSHOT) {
849
+ setMode(message.mode || null);
850
+ if (message.nudge_jitter != null) nudgeJitterRef.current = message.nudge_jitter;
851
+ if (isPollMode(message.mode)) startPolling(subscription, message.poll_interval);
852
+ }
853
+ if (message && message.type === ROSTER_NUDGE) {
854
+ scheduleNudgePoll(subscription);
855
+ return; // content-free trigger; nothing to reduce
856
+ }
857
+ var next = applyRosterMessage(mapRef.current, message);
858
+ if (next !== mapRef.current) {
859
+ mapRef.current = next;
860
+ flush();
861
+ }
862
+ }
863
+ });
864
+ return function () {
865
+ if (pollTimerRef.current) {
866
+ clearTimeout(pollTimerRef.current);
867
+ pollTimerRef.current = null;
868
+ }
869
+ if (nudgeTimerRef.current) {
870
+ clearTimeout(nudgeTimerRef.current);
871
+ nudgeTimerRef.current = null;
872
+ }
873
+ subscription.unsubscribe();
874
+ };
875
+ }, [channelName, enabled, flush]);
876
+
877
+ // Client-side (cosmetic) filter — see options.filter.
878
+ var visibleMembers = filter ? members.filter(filter) : members;
879
+ var online = onlineMembers(visibleMembers);
880
+ var byId = {};
881
+ visibleMembers.forEach(function (m) {
882
+ byId[m.id] = m;
883
+ });
884
+ return {
885
+ members: visibleMembers,
886
+ online: online,
887
+ onlineCount: online.length,
888
+ byId: byId,
889
+ connected: connected,
890
+ mode: mode
891
+ };
892
+ }
893
+
894
+ export { PresenceContext, PresenceProvider, ROSTER_DELTA, ROSTER_SNAPSHOT, STATUS_COLORS, STATUS_RANK, applyRosterMessage, configureCable, createPresenceReporter, deviceStatus, disconnectCable, getCableConfig, getConsumer, handleMessage, memberLabel, onlineMembers, presenceColor, sortedMembers, subscribeToEvent, usePresence, usePresenceContext, usePresenceRoster, useWaldoEvent };
895
+ //# sourceMappingURL=index.esm.js.map