@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Scott Gibson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,466 @@
1
+ # WhereIsWaldo
2
+
3
+ Real-time presence tracking for Rails + React using ActionCable.
4
+
5
+ ## Features
6
+
7
+ - **Presence tracking** - know who's online
8
+ - **Live presence roster** - per-device "who's around" (active/idle/background across web + mobile), pluggable per-account delivery (`:poll` / `:nudge` / `:fanout` / `:broadcast`)
9
+ - **Scope-based queries** - `online(org.users.admin)`
10
+ - **Targeted broadcasting** - send to any AR scope
11
+ - **Event subscriptions** - components subscribe by event type (`useWaldoEvent`)
12
+ - **Activity monitoring** - tab visibility, user activity
13
+ - **Multi-session** - same user, multiple tabs/devices
14
+ - **Flexible storage** - database or Redis
15
+
16
+ ## Quickstart
17
+
18
+ ### 1. Install
19
+
20
+ ```ruby
21
+ # Gemfile
22
+ gem 'where_is_waldo', github: 'byscott-io/where_is_waldo'
23
+ ```
24
+
25
+ ```bash
26
+ # Database adapter (default)
27
+ rails generate where_is_waldo:install --subject_column=user_id
28
+ rails db:migrate
29
+
30
+ # Redis adapter (no migration needed)
31
+ rails generate where_is_waldo:install --adapter=redis --subject_column=user_id
32
+ ```
33
+
34
+ ```bash
35
+ npm install @byscott-io/where-is-waldo @rails/actioncable
36
+ ```
37
+
38
+ ### 2. Configure
39
+
40
+ ```ruby
41
+ # config/initializers/where_is_waldo.rb
42
+ WhereIsWaldo.configure do |config|
43
+ config.subject_class = "User"
44
+ config.authenticate_proc = ->(request) {
45
+ # Return user_id from your auth token
46
+ decode_token(request.params[:token])[:user_id]
47
+ }
48
+ end
49
+ ```
50
+
51
+ ```jsx
52
+ // app.jsx — configure the connection once, wrap the app
53
+ import { configureCable, PresenceProvider } from '@byscott-io/where-is-waldo';
54
+
55
+ configureCable({
56
+ url: '/cable',
57
+ getToken: () => localStorage.getItem('token'),
58
+ presence: {
59
+ debug: true, // Enable console logging for troubleshooting
60
+ },
61
+ });
62
+
63
+ <PresenceProvider>
64
+ <App />
65
+ </PresenceProvider>
66
+ ```
67
+
68
+ ```jsx
69
+ // any component — subscribe to an event type and filter the payload yourself.
70
+ // Auto-unsubscribes on unmount; no central handler config.
71
+ import { useWaldoEvent } from '@byscott-io/where-is-waldo';
72
+
73
+ function Notifications() {
74
+ useWaldoEvent('notification', (data) => showToast(data.message));
75
+ useWaldoEvent('force_logout', () => logout());
76
+ return null;
77
+ }
78
+ ```
79
+
80
+ ### 3. Use
81
+
82
+ ```ruby
83
+ # Query who's online
84
+ WhereIsWaldo.online(org.users) # => AR relation
85
+ WhereIsWaldo.online(org.users.admin) # => filter by scope
86
+ WhereIsWaldo.subject_online?(user.id) # => true/false
87
+
88
+ # Broadcast messages
89
+ WhereIsWaldo.broadcast_to(org.users, :notification, { message: "Hello!" })
90
+ WhereIsWaldo.broadcast_to(user, :force_logout, { reason: "Password changed" })
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Detailed Documentation
96
+
97
+ ### Server Configuration
98
+
99
+ ```ruby
100
+ WhereIsWaldo.configure do |config|
101
+ config.adapter = :database # or :redis
102
+ config.table_name = "presences"
103
+ config.session_column = :session_id
104
+ config.subject_column = :user_id # or :member_id, :student_id
105
+ config.subject_class = "User" # or "Member", "Student"
106
+
107
+ config.timeout = 90 # seconds until offline
108
+ config.heartbeat_interval = 30
109
+
110
+ # Optional: custom subject data in presence hash. NOTE: with the roster
111
+ # enabled, these fields are broadcast to every member of the org — see
112
+ # Security.
113
+ config.subject_data_proc = ->(user) {
114
+ { id: user.id, name: user.name, avatar: user.avatar_url }
115
+ }
116
+
117
+ # Live presence roster (see "Live Presence Roster"). Set roster_org to
118
+ # enable; roster_members is optional (defaults to org.<subjects>).
119
+ config.roster_org = ->(user) { user.account }
120
+ config.roster_members = ->(org) { org.users.active }
121
+
122
+ # Redis adapter
123
+ # config.redis_client = Redis.new(url: ENV["REDIS_URL"])
124
+ end
125
+ ```
126
+
127
+ ### Querying Presence
128
+
129
+ ```ruby
130
+ # Get online subjects from any AR scope
131
+ WhereIsWaldo.online(org.users)
132
+ WhereIsWaldo.online(User.where(role: "admin"))
133
+ WhereIsWaldo.online(classroom.students)
134
+
135
+ # Get just IDs
136
+ WhereIsWaldo.online_ids(org.users)
137
+
138
+ # Check specific subject
139
+ WhereIsWaldo.subject_online?(user.id)
140
+
141
+ # Get all sessions for a subject
142
+ WhereIsWaldo.sessions_for_subject(user.id)
143
+ # => [{ session_id: "...", tab_visible: true, subject_active: false, ... }]
144
+ ```
145
+
146
+ ### Broadcasting
147
+
148
+ ```ruby
149
+ # To any AR scope
150
+ WhereIsWaldo.broadcast_to(org.users, :notification, { message: "Hi" })
151
+ WhereIsWaldo.broadcast_to(org.users.admin, :alert, { level: "warning" })
152
+
153
+ # Only to online subjects
154
+ WhereIsWaldo.broadcast_to_online(org.users, :update, { data: "..." })
155
+
156
+ # To a single subject (all their sessions)
157
+ WhereIsWaldo.broadcast_to(user, :force_logout, {})
158
+
159
+ # To a specific session
160
+ WhereIsWaldo.broadcast_to_session(session_id, :warning, { message: "..." })
161
+ ```
162
+
163
+ ### Live Presence Roster ("who's around in my org")
164
+
165
+ A ready-made way to show live presence awareness across an org/account — who's
166
+ here right now, on what device, and how active. It is built as **data, not UI**:
167
+ the server keeps the client's roster in sync and you render whatever component
168
+ you like.
169
+
170
+ **Delivery is a per-account strategy** so you can trade latency × cost ×
171
+ visibility-enforcement to fit your app. The server picks the mode and the client
172
+ adapts automatically (no client mode config). Modes (default `:poll`):
173
+
174
+ | Mode | Latency | Visibility | Cost/transition |
175
+ |------|---------|------------|-----------------|
176
+ | `:poll` (default) | ~poll interval | server-side query — **any** rule | flat (1 cached query/poll) |
177
+ | `:nudge` | near-instant | server-side query — **any** rule | O(1) content-free trigger + filtered poll |
178
+ | `:fanout` | instant | server-side per-viewer — **any** rule (incl. asymmetric) | O(audience) |
179
+ | `:broadcast` | instant | **none** (everyone in account sees everyone) | O(1) |
180
+
181
+ `:poll` sends a full **snapshot** on connect, then the client polls and the
182
+ server replies with a server-*filtered* diff (baseline cached per session,
183
+ TTL'd for auto-resync) — so arbitrary/asymmetric visibility "just works" via
184
+ `roster_visible_to`. `:nudge` is `:poll` plus a content-free "re-poll"
185
+ broadcast on each transition, so clients refresh near-instantly instead of
186
+ waiting for the next interval — same airtight server-side filtering, just lower
187
+ latency; the nudge carries no identity/state (only "activity happened").
188
+ `:fanout` pushes instantly to a **per-viewer** stream: on a transition the
189
+ subject's delta goes to every viewer in its directional `roster_viewers_of`
190
+ (the inverse of `roster_visible_to`), so even *asymmetric* visibility
191
+ (manager-sees-report-but-not-vice-versa) is exact — at O(audience) broadcasts
192
+ per transition. `:broadcast` instead streams one shared account stream and
193
+ pushes deltas instantly, with **no** visibility filtering (open-visibility
194
+ accounts only); pair it with the client `filter` option below for *cosmetic*
195
+ per-viewer hiding.
196
+
197
+ ```jsx
198
+ // :broadcast + cosmetic client filter (NOT a security boundary — the full
199
+ // roster still reaches the client; use a server-side mode to truly enforce).
200
+ const { online } = usePresenceRoster({ filter: (m) => canISee(m.id) });
201
+ ```
202
+
203
+ #### Which mode should I use?
204
+
205
+ Two questions decide it:
206
+
207
+ **1. Is the client *always* interested, or only *sometimes*?**
208
+ - **Sometimes** — presence is ambient (an avatar stack / sidebar you glance at; the component mounts only when relevant). Use the **pull family** (`:poll` / `:nudge`): the client fetches only while something's mounted and cares, and the server is idle at rest.
209
+ - **Always** — presence *is* the live experience (chat, collaborative cursors). Use the **push family** (`:fanout` / `:broadcast`): the server streams every change as it happens.
210
+
211
+ **2. Then the within-family choice:**
212
+ - `:poll` vs `:nudge` → **latency.** Both pull, both filtered. `:poll` is interval-fresh; `:nudge` is near-instant (a content-free "changed" ping triggers an immediate poll).
213
+ - `:fanout` vs `:broadcast` → **visibility.** Both push. `:fanout` keeps per-viewer server-side filtering; `:broadcast` uses one shared stream with no filtering (open-visibility accounts only).
214
+
215
+ Two things people get subtly wrong:
216
+ - **Filtering is not fanout-only.** `:poll`, `:nudge`, *and* `:fanout` all enforce visibility server-side. **`:broadcast` is the only unfiltered mode.**
217
+ - **`:fanout` ships the data; `:nudge` ships a "time to poll" signal.** Both give instant, filtered updates — `:fanout` spends server fan-out (O(audience) data pushes), `:nudge` spends client polls (O(1) ping + a poll per interested viewer). Same result, different cost.
218
+
219
+ See `docs/PRESENCE_ROSTER_PLAN.md` for the full mode matrix and tradeoffs.
220
+
221
+ **Per-device, multi-session.** A subject's state is aggregated across *all*
222
+ their live sessions (multiple browser tabs, mobile, etc.):
223
+
224
+ ```
225
+ { id: 7, status: "active", devices: { web: "idle", mobile: "active" } }
226
+ ```
227
+
228
+ - `status` — highest activity across devices (the "active anywhere?" answer):
229
+ - `active` — a live session is visible/foreground **and** working
230
+ - `idle` — a live session is visible/foreground but not actively using
231
+ - `background` — only backgrounded/hidden sessions are live
232
+ - `offline` — no live sessions
233
+ - `devices[platform]` — that platform's own status (answers "active on
234
+ **mobile**?" vs. "active at all?").
235
+
236
+ #### Configure
237
+
238
+ ```ruby
239
+ WhereIsWaldo.configure do |config|
240
+ # The org/account a subject belongs to. Required to enable the roster.
241
+ config.roster_org = ->(subject) { subject.account }
242
+
243
+ # What a VIEWER may see (:poll/:nudge/:fanout). Any visibility rule,
244
+ # server-enforced. Defaults to the viewer's whole org when unset.
245
+ config.roster_visible_to = ->(viewer) { viewer.visible_users }
246
+
247
+ # Who may see a SUBJECT — the inverse of roster_visible_to. Only needed
248
+ # for :fanout (enables instant push under asymmetric visibility).
249
+ config.roster_viewers_of = ->(subject) { subject.visible_to_users }
250
+
251
+ # Delivery mode, per account. Symbol or a callable resolving account -> mode.
252
+ # MUST be a function of the account (uniform for all its members). Default :poll.
253
+ config.roster_mode = ->(account) { account.everyone_admin? ? :broadcast : :poll }
254
+
255
+ # :broadcast only — the member list for the shared snapshot. Defaults to
256
+ # org.public_send(<subjects>) inferred from subject_class (e.g. :users).
257
+ config.roster_members = ->(org) { org.users.active }
258
+
259
+ # Tuning (:poll/:nudge)
260
+ config.roster_poll_interval = 15 # seconds
261
+ config.roster_cache_ttl = 90 # seconds; > poll gap → auto-resync
262
+ end
263
+ ```
264
+
265
+ The `RosterChannel` is provided by the gem; no app code is needed beyond config.
266
+
267
+ #### Consume it (React — data hook, bring your own UI)
268
+
269
+ ```jsx
270
+ import { usePresenceRoster, presenceColor, memberLabel } from '@byscott-io/where-is-waldo';
271
+
272
+ function TeamPresence() {
273
+ const { online, members, onlineCount, byId } = usePresenceRoster();
274
+
275
+ // `members` is always the full, live set (snapshot seeded, deltas patched).
276
+ return (
277
+ <ul>
278
+ {online.map((m) => (
279
+ <li key={m.id}>
280
+ <span style={{ color: presenceColor(m.status) }}>●</span>
281
+ {memberLabel(m)} — {m.status}
282
+ {m.devices.mobile && ' 📱'}
283
+ </li>
284
+ ))}
285
+ </ul>
286
+ );
287
+ }
288
+ ```
289
+
290
+ The hook and its reducer (`../core/rosterStore`) are pure — **no DOM** — so a
291
+ React Native app reuses the exact same data logic and only swaps the view.
292
+
293
+ #### Query presence server-side
294
+
295
+ ```ruby
296
+ WhereIsWaldo.roster_snapshot(org) # full roster + per-device state
297
+ WhereIsWaldo.roster_state_for(user.id) # => { status:, devices: }
298
+ WhereIsWaldo.presence_on(user.id, :mobile) # => "idle" (per-device)
299
+ ```
300
+
301
+ #### Reporting presence from mobile
302
+
303
+ Mobile is "logged in" purely by connecting with `metadata: { platform: "mobile" }`
304
+ and sending the same heartbeat shape as the web client. The heartbeat/activity
305
+ state machine lives in a DOM-free core, `createPresenceReporter`, that the web
306
+ `usePresence` hook wraps — a React Native app reuses the **same core** and only
307
+ swaps the sensors: map app foreground/background to `setVisible`, and touches to
308
+ `reportActivity`. No server-side changes; `platform` is read from the metadata
309
+ (defaults to `"web"`).
310
+
311
+ ```jsx
312
+ // React Native reporter — same core, native sensors.
313
+ import { useEffect, useRef } from 'react';
314
+ import { AppState, PanResponder } from 'react-native';
315
+ import { createPresenceReporter, configureCable } from '@byscott-io/where-is-waldo';
316
+
317
+ configureCable({ url: WS_URL, getToken: () => token });
318
+
319
+ export function usePresenceNative() {
320
+ const reporterRef = useRef(null);
321
+
322
+ useEffect(() => {
323
+ const reporter = createPresenceReporter({ metadata: { platform: 'mobile' } });
324
+ reporterRef.current = reporter;
325
+ reporter.start();
326
+ reporter.setVisible(AppState.currentState === 'active');
327
+
328
+ const sub = AppState.addEventListener('change', (s) =>
329
+ reporter.setVisible(s === 'active'),
330
+ );
331
+ return () => {
332
+ sub.remove();
333
+ reporter.stop();
334
+ };
335
+ }, []);
336
+
337
+ // Feed touches as activity (attach these handlers to your root view).
338
+ const pan = useRef(
339
+ PanResponder.create({
340
+ onStartShouldSetPanResponderCapture: () => {
341
+ reporterRef.current?.reportActivity();
342
+ return false; // observe only; don't capture the gesture
343
+ },
344
+ }),
345
+ ).current;
346
+
347
+ return pan.panHandlers;
348
+ }
349
+ ```
350
+
351
+ `createPresenceReporter` is pure JS (no DOM), and `@rails/actioncable` works on
352
+ React Native with the built-in `WebSocket` — so the reporter, the roster hook
353
+ (`usePresenceRoster`), and the roster reducer all run unchanged on mobile; only
354
+ the sensors and the view are platform-specific.
355
+
356
+ ### Client Event Subscriptions
357
+
358
+ Components subscribe to a raw event **type** with the `useWaldoEvent` hook,
359
+ receive the payload, and decide for themselves whether it's relevant. The
360
+ subscription auto-unsubscribes on unmount — there is no central handler
361
+ registry. Adding a new event = a server `broadcast_to` + a `useWaldoEvent`
362
+ call in whatever component cares.
363
+
364
+ ```jsx
365
+ import { useWaldoEvent } from '@byscott-io/where-is-waldo';
366
+
367
+ function ChatRoom({ roomId }) {
368
+ // Single type — filter the payload yourself
369
+ useWaldoEvent('chat_message', (data) => {
370
+ if (data.room_id === roomId) addMessage(data);
371
+ });
372
+
373
+ // Many types at once. useWaldoEvent is a pure subscription — to coalesce a
374
+ // burst into one call, compose a debounce at the call site (e.g. corebyscott's
375
+ // useDebouncedCallback): const refetch = useDebouncedCallback(loadAll, 250);
376
+ useWaldoEvent(['notification', 'data_refresh'], refetch);
377
+
378
+ return null;
379
+ }
380
+ ```
381
+
382
+ For a non-React / imperative context, `subscribeToEvent(type, cb)` returns an
383
+ unsubscribe function:
384
+
385
+ ```js
386
+ import { subscribeToEvent } from '@byscott-io/where-is-waldo';
387
+
388
+ const unsubscribe = subscribeToEvent('chat_message', (data) => addMessage(data));
389
+ // later: unsubscribe();
390
+ ```
391
+
392
+ ### React Hooks
393
+
394
+ ```jsx
395
+ import { usePresenceContext } from '@byscott-io/where-is-waldo';
396
+
397
+ function StatusIndicator() {
398
+ const { connected, tabVisible, subjectActive } = usePresenceContext();
399
+
400
+ return <span>{connected ? 'Online' : 'Offline'}</span>;
401
+ }
402
+ ```
403
+
404
+ ### Cleanup Job
405
+
406
+ ```ruby
407
+ # For database adapter - schedule cleanup of stale records
408
+ # config/initializers/sidekiq.rb
409
+ Sidekiq::Cron::Job.create(
410
+ name: 'Presence cleanup',
411
+ cron: '*/5 * * * *',
412
+ class: 'WhereIsWaldo::PresenceCleanupJob'
413
+ )
414
+ ```
415
+
416
+ ### Version Management
417
+
418
+ ```bash
419
+ rake version:show # Show current version
420
+ rake version:bump[0.1.0] # Bump gem and npm together
421
+ ```
422
+
423
+ ## Security
424
+
425
+ ActionCable presence is only as safe as the connection auth around it. What the
426
+ gem guarantees, and what your app must do:
427
+
428
+ **Guaranteed by the gem**
429
+
430
+ - **No client-chosen rooms.** `RosterChannel` derives the org from the
431
+ *authenticated connection* (`current_subject` → `roster_org`), never from a
432
+ client-supplied param — a user cannot subscribe to another org's roster.
433
+ - **Self-scoped subject streams.** `PresenceChannel` streams only the
434
+ connection's own subject id, so targeted messages can't be eavesdropped.
435
+ - **Unauthenticated connections are rejected** (`JwtConnection`).
436
+
437
+ **Your app's responsibility**
438
+
439
+ - **Identify the connection from a *verified* credential** (a signed JWT, as
440
+ `JwtConnection` does). Never trust a client-supplied `subject_id`. ⚠️ The
441
+ dummy app authenticates from a query param for tests only — do not copy that
442
+ into production.
443
+ - **`subject_data_proc` fans out org-wide.** Every field it returns is
444
+ broadcast to all roster members. Include only what all members may see; keep
445
+ PII out unless intended.
446
+ - **Visibility enforcement depends on the delivery mode.** `:poll`, `:nudge`,
447
+ and `:fanout` enforce server-side — `:poll`/`:nudge` by `presence_visible_
448
+ scope` (a `WHERE ... IN` clause), `:fanout` by `roster_viewers_of` — so
449
+ nothing an unauthorized member could read off the wire. `:broadcast` does
450
+ **no** filtering: it shares one account stream and pushes every member's
451
+ presence to everyone (`roster_members` only scopes the initial *snapshot
452
+ list*, not the live stream). The client `filter` option is **cosmetic** — the
453
+ full data still reaches the client — so it is not an access-control boundary.
454
+ Select `:broadcast` (± client filter) only for genuinely open-visibility
455
+ accounts; for any restricted visibility use a server-side mode (`:poll` is the
456
+ default). See `docs/PRESENCE_ROSTER_PLAN.md` for the full mode matrix.
457
+ - **Token in the URL.** The JWT is passed as `?token=…`; use WSS only, keep
458
+ tokens short-lived, and avoid logging query strings. Set
459
+ `config.action_cable.allowed_request_origins` as defense-in-depth.
460
+ - **Heartbeat/DoS.** Heartbeats are client-paced DB writes; roster broadcasts
461
+ are gated to transitions. For large or hostile deployments use the Redis
462
+ adapter and consider rate-limiting.
463
+
464
+ ## License
465
+
466
+ MIT