@byscott-io/where-is-waldo 0.1.1 → 0.1.2

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.esm.js CHANGED
@@ -1,336 +1,171 @@
1
1
  import { createConsumer } from '@rails/actioncable';
2
2
  import React, { useState, useRef, useEffect, useCallback, createContext, useMemo, useContext } from 'react';
3
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',
4
+ let consumer = null;
5
+ let config = {
6
+ url: "/cable",
96
7
  getToken: null
97
8
  };
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
- */
9
+ const listeners = /* @__PURE__ */ new Map();
113
10
  function subscribeToEvent(type, callback) {
114
- if (!type || typeof callback !== 'function') return function () {};
115
- var set = listeners.get(type);
11
+ if (!type || typeof callback !== "function") return () => {
12
+ };
13
+ let set = listeners.get(type);
116
14
  if (!set) {
117
- set = new Set();
15
+ set = /* @__PURE__ */ new Set();
118
16
  listeners.set(type, set);
119
17
  }
120
18
  set.add(callback);
121
19
  return function unsubscribe() {
122
- var current = listeners.get(type);
20
+ const current = listeners.get(type);
123
21
  if (!current) return;
124
- current["delete"](callback);
125
- if (current.size === 0) listeners["delete"](type);
22
+ current.delete(callback);
23
+ if (current.size === 0) listeners.delete(type);
126
24
  };
127
25
  }
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
26
+ function configureCable(options = {}) {
27
+ config = { ...config, ...options };
139
28
  if (consumer) {
140
29
  consumer.disconnect();
141
30
  consumer = null;
142
31
  }
143
32
  }
144
-
145
- /**
146
- * Get or create the ActionCable consumer
147
- * @returns {Consumer}
148
- */
149
33
  function getConsumer() {
150
34
  if (!consumer) {
151
- var url = config.url;
35
+ let url = config.url;
152
36
  if (config.getToken) {
153
- var token = config.getToken();
37
+ const token = config.getToken();
154
38
  if (token) {
155
- var separator = url.includes('?') ? '&' : '?';
156
- url = "".concat(url).concat(separator, "token=").concat(encodeURIComponent(token));
39
+ const separator = url.includes("?") ? "&" : "?";
40
+ url = `${url}${separator}token=${encodeURIComponent(token)}`;
157
41
  }
158
42
  }
159
43
  consumer = createConsumer(url);
160
44
  }
161
45
  return consumer;
162
46
  }
163
-
164
- /**
165
- * Disconnect and reset the consumer
166
- */
167
47
  function disconnectCable() {
168
48
  if (consumer) {
169
49
  consumer.disconnect();
170
50
  consumer = null;
171
51
  }
172
52
  }
173
-
174
- /**
175
- * Get current cable configuration
176
- * @returns {Object}
177
- */
178
53
  function getCableConfig() {
179
- return _objectSpread2({}, config);
54
+ return { ...config };
180
55
  }
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
56
  function handleMessage(message) {
189
- var type = message.type,
190
- data = message.data;
191
- var set = listeners.get(type);
57
+ const { type, data } = message;
58
+ const set = listeners.get(type);
192
59
  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) {
60
+ Array.from(set).forEach((cb) => {
196
61
  try {
197
62
  cb(data, message);
198
63
  } 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);
64
+ console.error(`[where-is-waldo] listener for "${type}" threw:`, err);
202
65
  }
203
66
  });
204
67
  return true;
205
68
  }
206
69
 
207
- var DEFAULTS = {
208
- channelName: 'WhereIsWaldo::PresenceChannel',
209
- heartbeatInterval: 30000,
70
+ const DEFAULTS = {
71
+ channelName: "WhereIsWaldo::PresenceChannel",
72
+ heartbeatInterval: 3e4,
210
73
  // ms
211
- activityTimeout: 30000,
74
+ activityTimeout: 3e4,
212
75
  // ms of no activity -> subject_active=false
213
76
  metadata: {},
214
77
  debug: false
215
78
  };
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();
79
+ function createPresenceReporter(options = {}) {
80
+ const config = { ...DEFAULTS, ...options };
81
+ const now = config.now || (() => Date.now());
82
+ const resolveConsumer = config.getConsumer || getConsumer;
83
+ const resolveCableConfig = config.getCableConfig || getCableConfig;
84
+ const dispatch = config.handleMessage || handleMessage;
85
+ const log = (...args) => {
86
+ if (config.debug) console.log("[PresenceReporter]", ...args);
221
87
  };
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
88
+ let subscription = null;
89
+ let heartbeatTimer = null;
90
+ let activityTimer = null;
91
+ let lastActivityAt = 0;
92
+ const state = { connected: false, visible: true, active: true, sessionId: null };
93
+ const emitChange = () => {
94
+ if (config.onChange) config.onChange({ ...state });
244
95
  };
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,
96
+ const buildPayload = (overrides = {}) => {
97
+ const payload = {
98
+ tab_visible: overrides.tab_visible ?? state.visible,
99
+ subject_active: overrides.subject_active ?? state.active,
254
100
  metadata: config.metadata || {}
255
101
  };
256
102
  if (lastActivityAt) payload.last_activity_at = lastActivityAt;
257
103
  return payload;
258
104
  };
259
- var sendHeartbeat = function sendHeartbeat() {
260
- var overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
105
+ const sendHeartbeat = (overrides = {}) => {
261
106
  if (!subscription) return;
262
- log('heartbeat', overrides);
263
- subscription.perform('heartbeat', buildPayload(overrides));
107
+ log("heartbeat", overrides);
108
+ subscription.perform("heartbeat", buildPayload(overrides));
264
109
  };
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() {
110
+ const scheduleInactivity = () => {
269
111
  if (activityTimer) clearTimeout(activityTimer);
270
- activityTimer = setTimeout(function () {
112
+ activityTimer = setTimeout(() => {
271
113
  if (!state.active) return;
272
114
  state.active = false;
273
115
  emitChange();
274
- sendHeartbeat({
275
- subject_active: false
276
- });
116
+ sendHeartbeat({ subject_active: false });
277
117
  }, config.activityTimeout);
278
118
  };
279
- var reportActivity = function reportActivity() {
119
+ const reportActivity = () => {
280
120
  lastActivityAt = now();
281
121
  if (!state.active) {
282
122
  state.active = true;
283
123
  emitChange();
284
- sendHeartbeat({
285
- subject_active: true
286
- }); // immediate: inactive -> active
124
+ sendHeartbeat({ subject_active: true });
287
125
  }
288
126
  scheduleInactivity();
289
127
  };
290
- var setVisible = function setVisible(visible) {
128
+ const setVisible = (visible) => {
291
129
  if (visible === state.visible) return;
292
130
  state.visible = visible;
293
131
  emitChange();
294
- sendHeartbeat(); // immediate: visibility change
132
+ sendHeartbeat();
295
133
  if (visible) reportActivity();
296
134
  };
297
- var startHeartbeatLoop = function startHeartbeatLoop() {
135
+ const startHeartbeatLoop = () => {
298
136
  if (heartbeatTimer) clearInterval(heartbeatTimer);
299
- heartbeatTimer = setInterval(function () {
300
- return sendHeartbeat();
301
- }, config.heartbeatInterval);
137
+ heartbeatTimer = setInterval(() => sendHeartbeat(), config.heartbeatInterval);
302
138
  };
303
- var start = function start() {
139
+ const start = () => {
304
140
  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;
141
+ subscription = resolveConsumer().subscriptions.create(
142
+ { channel: config.channelName, metadata: config.metadata || {} },
143
+ {
144
+ connected() {
145
+ state.connected = true;
146
+ const cable = resolveCableConfig();
147
+ if (cable && cable.sessionId) state.sessionId = cable.sessionId;
148
+ emitChange();
149
+ if (config.onConnected) config.onConnected();
150
+ startHeartbeatLoop();
151
+ },
152
+ disconnected() {
153
+ state.connected = false;
154
+ emitChange();
155
+ if (config.onDisconnected) config.onDisconnected();
156
+ },
157
+ received(message) {
158
+ if (message && message._target_session && message._target_session !== state.sessionId) {
159
+ return;
160
+ }
161
+ dispatch(message);
326
162
  }
327
- dispatch(message);
328
163
  }
329
- });
164
+ );
330
165
  scheduleInactivity();
331
166
  return subscription;
332
167
  };
333
- var stop = function stop() {
168
+ const stop = () => {
334
169
  if (heartbeatTimer) {
335
170
  clearInterval(heartbeatTimer);
336
171
  heartbeatTimer = null;
@@ -346,76 +181,36 @@ function createPresenceReporter() {
346
181
  state.connected = false;
347
182
  };
348
183
  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
- }
184
+ start,
185
+ stop,
186
+ setVisible,
187
+ reportActivity,
188
+ sendHeartbeat,
189
+ getState: () => ({ ...state })
357
190
  };
358
191
  }
359
192
 
360
- var DEFAULT_OPTIONS = {
361
- channelName: 'WhereIsWaldo::PresenceChannel',
362
- heartbeatInterval: 30000,
363
- activityTimeout: 30000,
193
+ const DEFAULT_OPTIONS = {
194
+ channelName: "WhereIsWaldo::PresenceChannel",
195
+ heartbeatInterval: 3e4,
196
+ activityTimeout: 3e4,
364
197
  trackActivity: true,
365
198
  trackVisibility: true,
366
199
  debug: false
367
200
  };
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({
201
+ const initialVisible = () => typeof document === "undefined" ? true : document.visibilityState !== "hidden";
202
+ const initialFocused = () => typeof document === "undefined" ? true : document.hasFocus();
203
+ function usePresence(options = {}) {
204
+ const config = { ...DEFAULT_OPTIONS, ...options };
205
+ const [connected, setConnected] = useState(false);
206
+ const [sessionId, setSessionId] = useState(null);
207
+ const [subjectActive, setSubjectActive] = useState(true);
208
+ const [tabVisible, setTabVisible] = useState(initialVisible);
209
+ const [windowFocused, setWindowFocused] = useState(initialFocused);
210
+ const reporterRef = useRef(null);
211
+ const sensorRef = useRef({ tab: initialVisible(), focus: initialFocused() });
212
+ useEffect(() => {
213
+ const reporter = createPresenceReporter({
419
214
  channelName: config.channelName,
420
215
  heartbeatInterval: config.heartbeatInterval,
421
216
  activityTimeout: config.activityTimeout,
@@ -423,7 +218,7 @@ function usePresence() {
423
218
  debug: config.debug,
424
219
  onConnected: config.onConnected,
425
220
  onDisconnected: config.onDisconnected,
426
- onChange: function onChange(state) {
221
+ onChange: (state) => {
427
222
  setConnected(state.connected);
428
223
  setSubjectActive(state.active);
429
224
  if (state.sessionId) setSessionId(state.sessionId);
@@ -431,437 +226,236 @@ function usePresence() {
431
226
  });
432
227
  reporterRef.current = reporter;
433
228
  reporter.start();
434
- // Seed foreground state from the current sensors.
435
229
  reporter.setVisible(sensorRef.current.tab && sensorRef.current.focus);
436
- return function () {
230
+ return () => {
437
231
  reporter.stop();
438
232
  reporterRef.current = null;
439
233
  };
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
- };
234
+ }, [config.channelName]);
235
+ useEffect(() => {
236
+ if (!config.trackActivity) return void 0;
237
+ const onActivity = () => reporterRef.current?.reportActivity();
238
+ const events = ["mousemove", "keydown", "scroll", "touchstart", "click"];
239
+ events.forEach((event) => window.addEventListener(event, onActivity, { passive: true }));
240
+ return () => events.forEach((event) => window.removeEventListener(event, onActivity));
460
241
  }, [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);
242
+ useEffect(() => {
243
+ if (!config.trackVisibility) return void 0;
244
+ const pushForeground = () => {
245
+ reporterRef.current?.setVisible(sensorRef.current.tab && sensorRef.current.focus);
468
246
  };
469
- var onVisibility = function onVisibility() {
470
- var visible = document.visibilityState !== 'hidden';
247
+ const onVisibility = () => {
248
+ const visible = document.visibilityState !== "hidden";
471
249
  sensorRef.current.tab = visible;
472
250
  setTabVisible(visible);
473
251
  pushForeground();
474
252
  };
475
- var onFocus = function onFocus() {
253
+ const onFocus = () => {
476
254
  sensorRef.current.focus = true;
477
255
  setWindowFocused(true);
478
256
  pushForeground();
479
257
  };
480
- var onBlur = function onBlur() {
258
+ const onBlur = () => {
481
259
  sensorRef.current.focus = false;
482
260
  setWindowFocused(false);
483
261
  pushForeground();
484
262
  };
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);
263
+ document.addEventListener("visibilitychange", onVisibility);
264
+ window.addEventListener("focus", onFocus);
265
+ window.addEventListener("blur", onBlur);
266
+ return () => {
267
+ document.removeEventListener("visibilitychange", onVisibility);
268
+ window.removeEventListener("focus", onFocus);
269
+ window.removeEventListener("blur", onBlur);
492
270
  };
493
271
  }, [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);
272
+ const sendHeartbeat = useCallback((overrides) => {
273
+ reporterRef.current?.sendHeartbeat(overrides);
497
274
  }, []);
498
- return {
499
- connected: connected,
500
- sessionId: sessionId,
501
- tabVisible: tabVisible,
502
- windowFocused: windowFocused,
503
- subjectActive: subjectActive,
504
- sendHeartbeat: sendHeartbeat
505
- };
275
+ return { connected, sessionId, tabVisible, windowFocused, subjectActive, sendHeartbeat };
506
276
  }
507
277
 
508
- var PresenceContext = /*#__PURE__*/createContext(null);
509
- var DEFAULT_CONFIG = {
510
- channelName: 'WhereIsWaldo::PresenceChannel',
511
- heartbeatInterval: 30000,
512
- activityTimeout: 30000,
278
+ const PresenceContext = createContext(null);
279
+ const DEFAULT_CONFIG = {
280
+ channelName: "WhereIsWaldo::PresenceChannel",
281
+ heartbeatInterval: 3e4,
282
+ activityTimeout: 3e4,
513
283
  trackActivity: true,
514
284
  trackVisibility: true,
515
285
  debug: false
516
286
  };
517
-
518
- /**
519
- * Get merged config from defaults, cable config, and overrides
520
- */
521
287
  function getMergedConfig(configOverrides) {
522
- var cableConfig = getCableConfig();
523
- var presenceConfig = cableConfig.presence || {};
524
- return _objectSpread2(_objectSpread2(_objectSpread2({}, DEFAULT_CONFIG), presenceConfig), configOverrides);
288
+ const cableConfig = getCableConfig();
289
+ const presenceConfig = cableConfig.presence || {};
290
+ return { ...DEFAULT_CONFIG, ...presenceConfig, ...configOverrides };
525
291
  }
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);
292
+ function PresenceProvider({
293
+ children,
294
+ metadata = {},
295
+ config: configOverrides = {},
296
+ onConnected,
297
+ onDisconnected
298
+ }) {
299
+ const config = useMemo(() => getMergedConfig(configOverrides), [configOverrides]);
300
+ const presence = usePresence({
301
+ ...config,
302
+ metadata,
303
+ onConnected,
304
+ onDisconnected
305
+ });
306
+ return /* @__PURE__ */ React.createElement(PresenceContext.Provider, { value: presence }, children);
558
307
  }
559
-
560
- /**
561
- * Hook to access presence context
562
- * @returns {Object} Presence context value
563
- */
564
308
  function usePresenceContext() {
565
- var context = useContext(PresenceContext);
309
+ const context = useContext(PresenceContext);
566
310
  if (!context) {
567
- throw new Error('usePresenceContext must be used within a PresenceProvider');
311
+ throw new Error("usePresenceContext must be used within a PresenceProvider");
568
312
  }
569
313
  return context;
570
314
  }
571
315
 
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 () {
316
+ function useWaldoEvent(type, handler, options = {}) {
317
+ const { enabled = true } = options;
318
+ const handlerRef = useRef(handler);
319
+ useEffect(() => {
610
320
  handlerRef.current = handler;
611
321
  });
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) {
322
+ const types = Array.isArray(type) ? type : [type];
323
+ const typesKey = types.filter(Boolean).join(" ");
324
+ useEffect(() => {
325
+ if (!enabled || !typesKey) return void 0;
326
+ const typeList = typesKey.split(" ");
327
+ const onEvent = (data, message) => {
621
328
  if (handlerRef.current) handlerRef.current(data, message);
622
329
  };
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
- });
330
+ const unsubscribes = typeList.map((t) => subscribeToEvent(t, onEvent));
331
+ return () => {
332
+ unsubscribes.forEach((unsub) => unsub());
630
333
  };
631
334
  }, [typesKey, enabled]);
632
335
  }
633
336
 
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',
337
+ const ROSTER_SNAPSHOT = "roster_snapshot";
338
+ const ROSTER_DELTA = "roster_delta";
339
+ const ROSTER_NUDGE = "roster_nudge";
340
+ const STATUS_RANK = { active: 3, idle: 2, background: 1, offline: 0 };
341
+ const STATUS_COLORS = {
342
+ active: "#22c55e",
660
343
  // visible + working
661
- idle: '#f59e0b',
344
+ idle: "#f59e0b",
662
345
  // visible but not actively using
663
- background: '#9ca3af',
346
+ background: "#9ca3af",
664
347
  // only backgrounded/hidden sessions
665
- offline: '#d1d5db' // no live sessions
348
+ offline: "#d1d5db"
349
+ // no live sessions
666
350
  };
667
351
  function presenceColor(status) {
668
352
  return STATUS_COLORS[status] || STATUS_COLORS.offline;
669
353
  }
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
354
  function applyRosterMessage(map, message) {
685
355
  if (!message || !message.type) return map;
686
356
  if (message.type === ROSTER_SNAPSHOT) {
687
- var next = new Map();
688
- (message.members || []).forEach(function (m) {
689
- return next.set(m.id, m);
690
- });
357
+ const next = /* @__PURE__ */ new Map();
358
+ (message.members || []).forEach((m) => next.set(m.id, m));
691
359
  return next;
692
360
  }
693
361
  if (message.type === ROSTER_DELTA && message.member) {
694
- var _next = new Map(map);
362
+ const next = new Map(map);
695
363
  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);
364
+ next.delete(message.member.id);
699
365
  } else {
700
- _next.set(message.member.id, message.member);
366
+ next.set(message.member.id, message.member);
701
367
  }
702
- return _next;
368
+ return next;
703
369
  }
704
370
  return map;
705
371
  }
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
372
  function isPollMode(mode) {
712
- return mode === 'poll' || mode === 'nudge';
373
+ return mode === "poll" || mode === "nudge";
713
374
  }
714
-
715
- /**
716
- * Members Map -> array, sorted most-present first then by display label.
717
- */
718
375
  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);
376
+ const list = Array.from(map.values());
377
+ list.sort((a, b) => {
378
+ const rank = (STATUS_RANK[b.status] ?? 0) - (STATUS_RANK[a.status] ?? 0);
723
379
  if (rank !== 0) return rank;
724
380
  return String(memberLabel(a)).localeCompare(String(memberLabel(b)));
725
381
  });
726
382
  return list;
727
383
  }
728
-
729
- /**
730
- * Only members with a live session (status !== "offline").
731
- */
732
384
  function onlineMembers(members) {
733
- return members.filter(function (m) {
734
- return m.status && m.status !== 'offline';
735
- });
385
+ return members.filter((m) => m.status && m.status !== "offline");
736
386
  }
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
387
  function deviceStatus(member, platform) {
744
- return member && member.devices && member.devices[platform] || 'offline';
388
+ return member && member.devices && member.devices[platform] || "offline";
745
389
  }
746
-
747
- /**
748
- * Best-effort display label from a member's subject data.
749
- */
750
390
  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);
391
+ if (!member) return "";
392
+ return member.name || [member.first_name, member.last_name].filter(Boolean).join(" ") || member.email || member.username || `#${member.id}`;
753
393
  }
754
394
 
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 () {
395
+ const DEFAULT_CHANNEL = "WhereIsWaldo::RosterChannel";
396
+ const DEFAULT_POLL_INTERVAL = 15;
397
+ const DEFAULT_NUDGE_JITTER = 0.5;
398
+ function usePresenceRoster(options = {}) {
399
+ const { channelName = DEFAULT_CHANNEL, enabled = true, filter } = options;
400
+ const [members, setMembers] = useState([]);
401
+ const [connected, setConnected] = useState(false);
402
+ const [mode, setMode] = useState(null);
403
+ const mapRef = useRef(/* @__PURE__ */ new Map());
404
+ const pollTimerRef = useRef(null);
405
+ const nudgeTimerRef = useRef(null);
406
+ const nudgeJitterRef = useRef(DEFAULT_NUDGE_JITTER);
407
+ const flush = useCallback(() => {
809
408
  setMembers(sortedMembers(mapRef.current));
810
409
  }, []);
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());
410
+ useEffect(() => {
411
+ if (!enabled) return void 0;
412
+ const consumer = getConsumer();
413
+ const startPolling = (subscription2, intervalSeconds) => {
414
+ if (pollTimerRef.current) return;
415
+ const ms = (intervalSeconds || DEFAULT_POLL_INTERVAL) * 1e3;
416
+ const jitter = () => 0.85 + Math.random() * 0.3;
417
+ const tick = () => {
418
+ subscription2.perform("poll");
419
+ pollTimerRef.current = setTimeout(tick, ms * jitter());
824
420
  };
825
- pollTimerRef.current = setTimeout(_tick, ms * jitter());
421
+ pollTimerRef.current = setTimeout(tick, ms * jitter());
826
422
  };
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) {
423
+ const scheduleNudgePoll = (subscription2) => {
831
424
  if (nudgeTimerRef.current) return;
832
- var delay = Math.random() * nudgeJitterRef.current * 1000;
833
- nudgeTimerRef.current = setTimeout(function () {
425
+ const delay = Math.random() * nudgeJitterRef.current * 1e3;
426
+ nudgeTimerRef.current = setTimeout(() => {
834
427
  nudgeTimerRef.current = null;
835
- subscription.perform('poll');
428
+ subscription2.perform("poll");
836
429
  }, delay);
837
430
  };
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();
431
+ const subscription = consumer.subscriptions.create(
432
+ { channel: channelName },
433
+ {
434
+ connected() {
435
+ setConnected(true);
436
+ },
437
+ disconnected() {
438
+ setConnected(false);
439
+ },
440
+ received(message) {
441
+ if (message && message.type === ROSTER_SNAPSHOT) {
442
+ setMode(message.mode || null);
443
+ if (message.nudge_jitter != null) nudgeJitterRef.current = message.nudge_jitter;
444
+ if (isPollMode(message.mode)) startPolling(subscription, message.poll_interval);
445
+ }
446
+ if (message && message.type === ROSTER_NUDGE) {
447
+ scheduleNudgePoll(subscription);
448
+ return;
449
+ }
450
+ const next = applyRosterMessage(mapRef.current, message);
451
+ if (next !== mapRef.current) {
452
+ mapRef.current = next;
453
+ flush();
454
+ }
861
455
  }
862
456
  }
863
- });
864
- return function () {
457
+ );
458
+ return () => {
865
459
  if (pollTimerRef.current) {
866
460
  clearTimeout(pollTimerRef.current);
867
461
  pollTimerRef.current = null;
@@ -873,22 +467,13 @@ function usePresenceRoster() {
873
467
  subscription.unsubscribe();
874
468
  };
875
469
  }, [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) {
470
+ const visibleMembers = filter ? members.filter(filter) : members;
471
+ const online = onlineMembers(visibleMembers);
472
+ const byId = {};
473
+ visibleMembers.forEach((m) => {
882
474
  byId[m.id] = m;
883
475
  });
884
- return {
885
- members: visibleMembers,
886
- online: online,
887
- onlineCount: online.length,
888
- byId: byId,
889
- connected: connected,
890
- mode: mode
891
- };
476
+ return { members: visibleMembers, online, onlineCount: online.length, byId, connected, mode };
892
477
  }
893
478
 
894
479
  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 };