@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/LICENSE +21 -0
- package/README.md +466 -0
- package/dist/index.esm.js +895 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +919 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/cable/index.js +120 -0
- package/src/context/PresenceContext.jsx +71 -0
- package/src/core/presenceReporter.js +163 -0
- package/src/core/rosterStore.js +117 -0
- package/src/hooks/usePresence.js +122 -0
- package/src/hooks/useRoster.js +138 -0
- package/src/hooks/useWaldoEvent.js +63 -0
- package/src/index.js +41 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Framework-agnostic roster store: pure JS, no DOM and no React Native APIs, so
|
|
2
|
+
// it is safe to import from the web package AND the mobile (React Native)
|
|
3
|
+
// package. It owns the "materialize full state from a snapshot + transition
|
|
4
|
+
// deltas" logic and the derivations over it (per-device roll-up, sorting,
|
|
5
|
+
// labels, colors) — everything except the sensors and the pixels, which are
|
|
6
|
+
// necessarily platform-specific.
|
|
7
|
+
|
|
8
|
+
// Cable message types exchanged on the roster stream.
|
|
9
|
+
export const ROSTER_SNAPSHOT = 'roster_snapshot';
|
|
10
|
+
export const ROSTER_DELTA = 'roster_delta';
|
|
11
|
+
// Content-free "re-poll now" trigger (:nudge mode). Carries no data.
|
|
12
|
+
export const ROSTER_NUDGE = 'roster_nudge';
|
|
13
|
+
|
|
14
|
+
// Activity ranking, most-present first. Shared by sorting and by the "highest
|
|
15
|
+
// across devices" roll-up so web and mobile agree.
|
|
16
|
+
export const STATUS_RANK = { active: 3, idle: 2, background: 1, offline: 0 };
|
|
17
|
+
|
|
18
|
+
// Status -> presence dot color. Platform UIs may theme these, but sharing a
|
|
19
|
+
// default keeps web and mobile visually consistent out of the box.
|
|
20
|
+
export const STATUS_COLORS = {
|
|
21
|
+
active: '#22c55e', // visible + working
|
|
22
|
+
idle: '#f59e0b', // visible but not actively using
|
|
23
|
+
background: '#9ca3af', // only backgrounded/hidden sessions
|
|
24
|
+
offline: '#d1d5db', // no live sessions
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function presenceColor(status) {
|
|
28
|
+
return STATUS_COLORS[status] || STATUS_COLORS.offline;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Apply an incoming cable message to a members Map (keyed by subject id),
|
|
33
|
+
* returning a NEW Map (immutable update) so callers can trigger re-renders by
|
|
34
|
+
* identity. Unknown message types return the same Map unchanged.
|
|
35
|
+
*
|
|
36
|
+
* - snapshot: replaces the whole roster.
|
|
37
|
+
* - delta: upserts one member. Offline members are KEPT (status "offline") so
|
|
38
|
+
* the roster stays the full team with live dots rather than vanishing rows.
|
|
39
|
+
*
|
|
40
|
+
* @param {Map<any, Object>} map - current members map
|
|
41
|
+
* @param {Object} message - { type, members? , member? }
|
|
42
|
+
* @returns {Map<any, Object>}
|
|
43
|
+
*/
|
|
44
|
+
export function applyRosterMessage(map, message) {
|
|
45
|
+
if (!message || !message.type) return map;
|
|
46
|
+
|
|
47
|
+
if (message.type === ROSTER_SNAPSHOT) {
|
|
48
|
+
const next = new Map();
|
|
49
|
+
(message.members || []).forEach((m) => next.set(m.id, m));
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (message.type === ROSTER_DELTA && message.member) {
|
|
54
|
+
const next = new Map(map);
|
|
55
|
+
if (message.member._removed) {
|
|
56
|
+
// Member left the viewer's visible scope (a visibility change) — drop it.
|
|
57
|
+
// Distinct from going offline, which arrives as status "offline".
|
|
58
|
+
next.delete(message.member.id);
|
|
59
|
+
} else {
|
|
60
|
+
next.set(message.member.id, message.member);
|
|
61
|
+
}
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return map;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Modes that require the client to poll the server for deltas (vs. passively
|
|
70
|
+
* receiving pushed broadcasts). The server declares the mode in the snapshot.
|
|
71
|
+
*/
|
|
72
|
+
export function isPollMode(mode) {
|
|
73
|
+
return mode === 'poll' || mode === 'nudge';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Members Map -> array, sorted most-present first then by display label.
|
|
78
|
+
*/
|
|
79
|
+
export function sortedMembers(map) {
|
|
80
|
+
const list = Array.from(map.values());
|
|
81
|
+
list.sort((a, b) => {
|
|
82
|
+
const rank = (STATUS_RANK[b.status] ?? 0) - (STATUS_RANK[a.status] ?? 0);
|
|
83
|
+
if (rank !== 0) return rank;
|
|
84
|
+
return String(memberLabel(a)).localeCompare(String(memberLabel(b)));
|
|
85
|
+
});
|
|
86
|
+
return list;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Only members with a live session (status !== "offline").
|
|
91
|
+
*/
|
|
92
|
+
export function onlineMembers(members) {
|
|
93
|
+
return members.filter((m) => m.status && m.status !== 'offline');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* That member's status on a specific device/platform, e.g.
|
|
98
|
+
* deviceStatus(member, 'mobile') // => "idle"
|
|
99
|
+
* Answers "is the user active on mobile?" vs the overall member.status.
|
|
100
|
+
*/
|
|
101
|
+
export function deviceStatus(member, platform) {
|
|
102
|
+
return (member && member.devices && member.devices[platform]) || 'offline';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Best-effort display label from a member's subject data.
|
|
107
|
+
*/
|
|
108
|
+
export function memberLabel(member) {
|
|
109
|
+
if (!member) return '';
|
|
110
|
+
return (
|
|
111
|
+
member.name ||
|
|
112
|
+
[member.first_name, member.last_name].filter(Boolean).join(' ') ||
|
|
113
|
+
member.email ||
|
|
114
|
+
member.username ||
|
|
115
|
+
`#${member.id}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { createPresenceReporter } from '../core/presenceReporter';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_OPTIONS = {
|
|
5
|
+
channelName: 'WhereIsWaldo::PresenceChannel',
|
|
6
|
+
heartbeatInterval: 30000,
|
|
7
|
+
activityTimeout: 30000,
|
|
8
|
+
trackActivity: true,
|
|
9
|
+
trackVisibility: true,
|
|
10
|
+
debug: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const initialVisible = () =>
|
|
14
|
+
typeof document === 'undefined' ? true : document.visibilityState !== 'hidden';
|
|
15
|
+
const initialFocused = () =>
|
|
16
|
+
typeof document === 'undefined' ? true : document.hasFocus();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* usePresence - browser presence tracking. This is now a thin DOM-sensor
|
|
20
|
+
* wrapper around the framework-agnostic core (../core/presenceReporter): it
|
|
21
|
+
* binds `document`/`window` events to the reporter's `setVisible` /
|
|
22
|
+
* `reportActivity` inputs and mirrors the reporter's state into React state.
|
|
23
|
+
* The heartbeat cadence, activity/visibility state machine, and cable
|
|
24
|
+
* subscription all live in the core, which a React Native reporter reuses.
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} options - see DEFAULT_OPTIONS
|
|
27
|
+
* @returns {{ connected, sessionId, tabVisible, windowFocused, subjectActive, sendHeartbeat }}
|
|
28
|
+
*/
|
|
29
|
+
export function usePresence(options = {}) {
|
|
30
|
+
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
31
|
+
|
|
32
|
+
const [connected, setConnected] = useState(false);
|
|
33
|
+
const [sessionId, setSessionId] = useState(null);
|
|
34
|
+
const [subjectActive, setSubjectActive] = useState(true);
|
|
35
|
+
const [tabVisible, setTabVisible] = useState(initialVisible);
|
|
36
|
+
const [windowFocused, setWindowFocused] = useState(initialFocused);
|
|
37
|
+
|
|
38
|
+
const reporterRef = useRef(null);
|
|
39
|
+
// Latest sensor readings, read by the DOM handlers to compute foreground.
|
|
40
|
+
const sensorRef = useRef({ tab: initialVisible(), focus: initialFocused() });
|
|
41
|
+
|
|
42
|
+
// Create + start the reporter once (re-created only if the channel changes).
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const reporter = createPresenceReporter({
|
|
45
|
+
channelName: config.channelName,
|
|
46
|
+
heartbeatInterval: config.heartbeatInterval,
|
|
47
|
+
activityTimeout: config.activityTimeout,
|
|
48
|
+
metadata: config.metadata,
|
|
49
|
+
debug: config.debug,
|
|
50
|
+
onConnected: config.onConnected,
|
|
51
|
+
onDisconnected: config.onDisconnected,
|
|
52
|
+
onChange: (state) => {
|
|
53
|
+
setConnected(state.connected);
|
|
54
|
+
setSubjectActive(state.active);
|
|
55
|
+
if (state.sessionId) setSessionId(state.sessionId);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
reporterRef.current = reporter;
|
|
59
|
+
reporter.start();
|
|
60
|
+
// Seed foreground state from the current sensors.
|
|
61
|
+
reporter.setVisible(sensorRef.current.tab && sensorRef.current.focus);
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
reporter.stop();
|
|
65
|
+
reporterRef.current = null;
|
|
66
|
+
};
|
|
67
|
+
}, [config.channelName]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
68
|
+
|
|
69
|
+
// Activity sensors -> reporter.reportActivity()
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!config.trackActivity) return undefined;
|
|
72
|
+
|
|
73
|
+
const onActivity = () => reporterRef.current?.reportActivity();
|
|
74
|
+
const events = ['mousemove', 'keydown', 'scroll', 'touchstart', 'click'];
|
|
75
|
+
events.forEach((event) => window.addEventListener(event, onActivity, { passive: true }));
|
|
76
|
+
|
|
77
|
+
return () => events.forEach((event) => window.removeEventListener(event, onActivity));
|
|
78
|
+
}, [config.trackActivity]);
|
|
79
|
+
|
|
80
|
+
// Visibility + focus sensors -> reporter.setVisible(foreground)
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!config.trackVisibility) return undefined;
|
|
83
|
+
|
|
84
|
+
const pushForeground = () => {
|
|
85
|
+
reporterRef.current?.setVisible(sensorRef.current.tab && sensorRef.current.focus);
|
|
86
|
+
};
|
|
87
|
+
const onVisibility = () => {
|
|
88
|
+
const visible = document.visibilityState !== 'hidden';
|
|
89
|
+
sensorRef.current.tab = visible;
|
|
90
|
+
setTabVisible(visible);
|
|
91
|
+
pushForeground();
|
|
92
|
+
};
|
|
93
|
+
const onFocus = () => {
|
|
94
|
+
sensorRef.current.focus = true;
|
|
95
|
+
setWindowFocused(true);
|
|
96
|
+
pushForeground();
|
|
97
|
+
};
|
|
98
|
+
const onBlur = () => {
|
|
99
|
+
sensorRef.current.focus = false;
|
|
100
|
+
setWindowFocused(false);
|
|
101
|
+
pushForeground();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
105
|
+
window.addEventListener('focus', onFocus);
|
|
106
|
+
window.addEventListener('blur', onBlur);
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
110
|
+
window.removeEventListener('focus', onFocus);
|
|
111
|
+
window.removeEventListener('blur', onBlur);
|
|
112
|
+
};
|
|
113
|
+
}, [config.trackVisibility]);
|
|
114
|
+
|
|
115
|
+
const sendHeartbeat = useCallback((overrides) => {
|
|
116
|
+
reporterRef.current?.sendHeartbeat(overrides);
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
return { connected, sessionId, tabVisible, windowFocused, subjectActive, sendHeartbeat };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default usePresence;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { getConsumer } from '../cable';
|
|
3
|
+
import {
|
|
4
|
+
applyRosterMessage,
|
|
5
|
+
sortedMembers,
|
|
6
|
+
onlineMembers,
|
|
7
|
+
isPollMode,
|
|
8
|
+
ROSTER_SNAPSHOT,
|
|
9
|
+
ROSTER_NUDGE,
|
|
10
|
+
} from '../core/rosterStore';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CHANNEL = 'WhereIsWaldo::RosterChannel';
|
|
13
|
+
const DEFAULT_POLL_INTERVAL = 15; // seconds; overridden by the server's snapshot
|
|
14
|
+
const DEFAULT_NUDGE_JITTER = 0.5; // seconds; overridden by the server's snapshot
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* usePresenceRoster - live "who's around" roster as data (bring your own UI).
|
|
18
|
+
*
|
|
19
|
+
* The server picks the delivery mode per account and declares it in the initial
|
|
20
|
+
* snapshot. This hook adapts transparently — no client mode config:
|
|
21
|
+
* - broadcast/fanout : passively receive pushed deltas.
|
|
22
|
+
* - pull/nudge : poll the server (`perform('poll')`) on the interval the
|
|
23
|
+
* server specifies; the server replies with a filtered
|
|
24
|
+
* snapshot (resync) or diff.
|
|
25
|
+
* Either way the shared reducer (../core/rosterStore) keeps `members` as the
|
|
26
|
+
* full, live set (snapshot seeded, deltas patched, removals dropped).
|
|
27
|
+
*
|
|
28
|
+
* Each member: { id, status, devices: { web, mobile, ... }, ...subjectData }.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} [options]
|
|
31
|
+
* @param {string} [options.channelName] - override the roster channel name
|
|
32
|
+
* @param {boolean} [options.enabled=true] - when false, no subscription is made
|
|
33
|
+
* @param {Function} [options.filter] - (member) => boolean. Client-side filter
|
|
34
|
+
* applied to the returned members. COSMETIC ONLY — intended for `:broadcast`
|
|
35
|
+
* mode, where the full account roster reaches the client and you want to hide
|
|
36
|
+
* some rows in the UI. It is NOT an access-control boundary (the unfiltered
|
|
37
|
+
* data is already on the client). For enforced visibility use a server-side
|
|
38
|
+
* mode (`:poll`/`:nudge`/`:fanout`).
|
|
39
|
+
* @returns {{ members: Array, online: Array, onlineCount: number,
|
|
40
|
+
* byId: Object, connected: boolean, mode: string|null }}
|
|
41
|
+
*/
|
|
42
|
+
export function usePresenceRoster(options = {}) {
|
|
43
|
+
const { channelName = DEFAULT_CHANNEL, enabled = true, filter } = options;
|
|
44
|
+
|
|
45
|
+
const [members, setMembers] = useState([]);
|
|
46
|
+
const [connected, setConnected] = useState(false);
|
|
47
|
+
const [mode, setMode] = useState(null);
|
|
48
|
+
|
|
49
|
+
const mapRef = useRef(new Map());
|
|
50
|
+
const pollTimerRef = useRef(null);
|
|
51
|
+
const nudgeTimerRef = useRef(null);
|
|
52
|
+
const nudgeJitterRef = useRef(DEFAULT_NUDGE_JITTER);
|
|
53
|
+
|
|
54
|
+
const flush = useCallback(() => {
|
|
55
|
+
setMembers(sortedMembers(mapRef.current));
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!enabled) return undefined;
|
|
60
|
+
|
|
61
|
+
const consumer = getConsumer();
|
|
62
|
+
|
|
63
|
+
const startPolling = (subscription, intervalSeconds) => {
|
|
64
|
+
if (pollTimerRef.current) return; // already polling
|
|
65
|
+
const ms = (intervalSeconds || DEFAULT_POLL_INTERVAL) * 1000;
|
|
66
|
+
// Small random offset so clients don't poll in lockstep (herd control).
|
|
67
|
+
const jitter = () => 0.85 + Math.random() * 0.3;
|
|
68
|
+
const tick = () => {
|
|
69
|
+
subscription.perform('poll');
|
|
70
|
+
pollTimerRef.current = setTimeout(tick, ms * jitter());
|
|
71
|
+
};
|
|
72
|
+
pollTimerRef.current = setTimeout(tick, ms * jitter());
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// :nudge — on a content-free trigger, re-poll off-cycle after a random
|
|
76
|
+
// jitter (herd control), debounced to one pending re-poll at a time.
|
|
77
|
+
const scheduleNudgePoll = (subscription) => {
|
|
78
|
+
if (nudgeTimerRef.current) return;
|
|
79
|
+
const delay = Math.random() * nudgeJitterRef.current * 1000;
|
|
80
|
+
nudgeTimerRef.current = setTimeout(() => {
|
|
81
|
+
nudgeTimerRef.current = null;
|
|
82
|
+
subscription.perform('poll');
|
|
83
|
+
}, delay);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const subscription = consumer.subscriptions.create(
|
|
87
|
+
{ channel: channelName },
|
|
88
|
+
{
|
|
89
|
+
connected() {
|
|
90
|
+
setConnected(true);
|
|
91
|
+
},
|
|
92
|
+
disconnected() {
|
|
93
|
+
setConnected(false);
|
|
94
|
+
},
|
|
95
|
+
received(message) {
|
|
96
|
+
if (message && message.type === ROSTER_SNAPSHOT) {
|
|
97
|
+
setMode(message.mode || null);
|
|
98
|
+
if (message.nudge_jitter != null) nudgeJitterRef.current = message.nudge_jitter;
|
|
99
|
+
if (isPollMode(message.mode)) startPolling(subscription, message.poll_interval);
|
|
100
|
+
}
|
|
101
|
+
if (message && message.type === ROSTER_NUDGE) {
|
|
102
|
+
scheduleNudgePoll(subscription);
|
|
103
|
+
return; // content-free trigger; nothing to reduce
|
|
104
|
+
}
|
|
105
|
+
const next = applyRosterMessage(mapRef.current, message);
|
|
106
|
+
if (next !== mapRef.current) {
|
|
107
|
+
mapRef.current = next;
|
|
108
|
+
flush();
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
if (pollTimerRef.current) {
|
|
116
|
+
clearTimeout(pollTimerRef.current);
|
|
117
|
+
pollTimerRef.current = null;
|
|
118
|
+
}
|
|
119
|
+
if (nudgeTimerRef.current) {
|
|
120
|
+
clearTimeout(nudgeTimerRef.current);
|
|
121
|
+
nudgeTimerRef.current = null;
|
|
122
|
+
}
|
|
123
|
+
subscription.unsubscribe();
|
|
124
|
+
};
|
|
125
|
+
}, [channelName, enabled, flush]);
|
|
126
|
+
|
|
127
|
+
// Client-side (cosmetic) filter — see options.filter.
|
|
128
|
+
const visibleMembers = filter ? members.filter(filter) : members;
|
|
129
|
+
const online = onlineMembers(visibleMembers);
|
|
130
|
+
const byId = {};
|
|
131
|
+
visibleMembers.forEach((m) => {
|
|
132
|
+
byId[m.id] = m;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return { members: visibleMembers, online, onlineCount: online.length, byId, connected, mode };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default usePresenceRoster;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { subscribeToEvent } from '../cable';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* useWaldoEvent - subscribe a component to one or more raw real-time event types.
|
|
6
|
+
*
|
|
7
|
+
* The component subscribes to a message `type` (or an array of types) and
|
|
8
|
+
* receives the payload on every matching cable event; it decides for itself
|
|
9
|
+
* whether it cares about a given record (e.g. `if (data.project_id === myId)
|
|
10
|
+
* ...`). The subscription is automatically torn down when the component
|
|
11
|
+
* unmounts ("out of view"), exactly like a useEffect cleanup.
|
|
12
|
+
*
|
|
13
|
+
* The handler is held in a ref so it always sees the latest props/state without
|
|
14
|
+
* re-subscribing on every render — callers do NOT need to memoize it. The
|
|
15
|
+
* subscription is re-created only when the type(s) / `enabled` change.
|
|
16
|
+
*
|
|
17
|
+
* This is a pure subscription primitive — it has no timing/debounce concern.
|
|
18
|
+
* To coalesce a burst of events, compose with a debounce at the call site, e.g.
|
|
19
|
+
* corebyscott's `useDebouncedCallback`:
|
|
20
|
+
*
|
|
21
|
+
* const refetch = useDebouncedCallback(loadAll, 250);
|
|
22
|
+
* useWaldoEvent(['issue_update', 'project_update'], refetch);
|
|
23
|
+
*
|
|
24
|
+
* @param {string|string[]} type - Cable message type(s), e.g. 'issue_update'
|
|
25
|
+
* @param {Function} handler - Called with (data, message) on each event
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {boolean} [options.enabled=true] - When false, no subscription is made
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* useWaldoEvent('issue_update', (data) => {
|
|
31
|
+
* if (data.id === issueId) setIssue((prev) => ({ ...prev, ...data }));
|
|
32
|
+
* });
|
|
33
|
+
*/
|
|
34
|
+
export function useWaldoEvent(type, handler, options = {}) {
|
|
35
|
+
const { enabled = true } = options;
|
|
36
|
+
const handlerRef = useRef(handler);
|
|
37
|
+
|
|
38
|
+
// Keep the ref pointing at the latest handler every render.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
handlerRef.current = handler;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Stable, order-insensitive key so an inline array literal doesn't
|
|
44
|
+
// re-subscribe on every render.
|
|
45
|
+
const types = Array.isArray(type) ? type : [type];
|
|
46
|
+
const typesKey = types.filter(Boolean).join(' ');
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!enabled || !typesKey) return undefined;
|
|
50
|
+
const typeList = typesKey.split(' ');
|
|
51
|
+
|
|
52
|
+
const onEvent = (data, message) => {
|
|
53
|
+
if (handlerRef.current) handlerRef.current(data, message);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const unsubscribes = typeList.map((t) => subscribeToEvent(t, onEvent));
|
|
57
|
+
return () => {
|
|
58
|
+
unsubscribes.forEach((unsub) => unsub());
|
|
59
|
+
};
|
|
60
|
+
}, [typesKey, enabled]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default useWaldoEvent;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Cable management
|
|
2
|
+
export {
|
|
3
|
+
configureCable,
|
|
4
|
+
getConsumer,
|
|
5
|
+
disconnectCable,
|
|
6
|
+
getCableConfig,
|
|
7
|
+
handleMessage,
|
|
8
|
+
subscribeToEvent,
|
|
9
|
+
} from './cable';
|
|
10
|
+
|
|
11
|
+
// Context and Provider
|
|
12
|
+
export {
|
|
13
|
+
PresenceProvider,
|
|
14
|
+
usePresenceContext,
|
|
15
|
+
default as PresenceContext,
|
|
16
|
+
} from './context/PresenceContext';
|
|
17
|
+
|
|
18
|
+
// Hooks
|
|
19
|
+
export { usePresence } from './hooks/usePresence';
|
|
20
|
+
export { useWaldoEvent } from './hooks/useWaldoEvent';
|
|
21
|
+
export { usePresenceRoster } from './hooks/useRoster';
|
|
22
|
+
|
|
23
|
+
// Framework-agnostic presence reporter core (no DOM) — the shared session-state
|
|
24
|
+
// -> heartbeat state machine that usePresence wraps and a React Native reporter
|
|
25
|
+
// reuses. See docs for mobile wiring.
|
|
26
|
+
export { createPresenceReporter } from './core/presenceReporter';
|
|
27
|
+
|
|
28
|
+
// Roster core helpers (pure JS, no DOM — shared with the mobile package). Build
|
|
29
|
+
// your own UI with these; nothing here renders.
|
|
30
|
+
export {
|
|
31
|
+
applyRosterMessage,
|
|
32
|
+
sortedMembers,
|
|
33
|
+
onlineMembers,
|
|
34
|
+
deviceStatus,
|
|
35
|
+
memberLabel,
|
|
36
|
+
presenceColor,
|
|
37
|
+
STATUS_COLORS,
|
|
38
|
+
STATUS_RANK,
|
|
39
|
+
ROSTER_SNAPSHOT,
|
|
40
|
+
ROSTER_DELTA,
|
|
41
|
+
} from './core/rosterStore';
|