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