@byscott-io/where-is-waldo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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';