where_is_waldo 0.1.1
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +467 -0
- data/VERSION +1 -0
- data/app/channels/where_is_waldo/application_cable/channel.rb +19 -0
- data/app/channels/where_is_waldo/application_cable/connection.rb +45 -0
- data/app/channels/where_is_waldo/jwt_connection.rb +83 -0
- data/app/channels/where_is_waldo/presence_channel.rb +86 -0
- data/app/channels/where_is_waldo/roster_channel.rb +52 -0
- data/app/jobs/where_is_waldo/application_job.rb +6 -0
- data/app/jobs/where_is_waldo/presence_cleanup_job.rb +13 -0
- data/app/models/concerns/where_is_waldo/broadcastable.rb +85 -0
- data/app/models/where_is_waldo/application_record.rb +7 -0
- data/app/models/where_is_waldo/presence.rb +87 -0
- data/app/services/where_is_waldo/adapters/base_adapter.rb +80 -0
- data/app/services/where_is_waldo/adapters/database_adapter.rb +104 -0
- data/app/services/where_is_waldo/adapters/redis_adapter.rb +205 -0
- data/app/services/where_is_waldo/broadcaster.rb +79 -0
- data/app/services/where_is_waldo/presence_service.rb +119 -0
- data/app/services/where_is_waldo/roster.rb +246 -0
- data/app/services/where_is_waldo/roster_delivery.rb +184 -0
- data/lib/generators/where_is_waldo/install/install_generator.rb +103 -0
- data/lib/generators/where_is_waldo/install/templates/channel.rb.tt +6 -0
- data/lib/generators/where_is_waldo/install/templates/connection.rb.tt +51 -0
- data/lib/generators/where_is_waldo/install/templates/create_presences.rb.tt +39 -0
- data/lib/generators/where_is_waldo/install/templates/initializer.rb.tt +37 -0
- data/lib/generators/where_is_waldo/install/templates/migration.rb.tt +20 -0
- data/lib/generators/where_is_waldo/install/templates/presence_channel.rb.tt +37 -0
- data/lib/where_is_waldo/configuration.rb +209 -0
- data/lib/where_is_waldo/engine.rb +17 -0
- data/lib/where_is_waldo/version.rb +5 -0
- data/lib/where_is_waldo.rb +117 -0
- metadata +249 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e8d42866099dafaaf3fcbcd2ea265cdff18d6eff9ffe040df788f59ff97f7f92
|
|
4
|
+
data.tar.gz: 2362c64ad821aae70425e26dafbb3a227903fbd70f7c33817aeaaa878e8f2586
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0b96cfebe30d871334617f83ab6aa5ef9087e95f5dc5886b061b86a7556c9e1cc5edb2b15329713f7f0887dd5f9d2958d4cbe21cb731f31cdbb53df60a511db6
|
|
7
|
+
data.tar.gz: 9905f053dd841bea8d9563de3f488c7ef81093ea0c202835e4d4556108f678f1b636bc559627ef299ac2e7cdb9fea2bbc08cf33145aab134b07f026a0cbff35d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to where_is_waldo. Format loosely follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/).
|
|
5
|
+
|
|
6
|
+
## 0.1.1
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- Package the `VERSION` file (and `CHANGELOG.md`) in the gem. `0.1.0`'s gemspec
|
|
11
|
+
omitted `VERSION`, but `version.rb` reads it at load time, so the installed
|
|
12
|
+
gem raised `Errno::ENOENT` on require and failed to boot. (npm `0.1.0` was
|
|
13
|
+
unaffected — it reads its version from `package.json`.)
|
|
14
|
+
|
|
15
|
+
## 0.1.0
|
|
16
|
+
|
|
17
|
+
First release to public **npm** (`@byscott-io/where-is-waldo`) and **RubyGems**
|
|
18
|
+
(`where_is_waldo`).
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Live presence roster** — per-device presence (`active` / `idle` /
|
|
23
|
+
`background` / `offline`, aggregated across web, mobile, and multiple
|
|
24
|
+
sessions), delivered as **data** via the `usePresenceRoster` hook (bring your
|
|
25
|
+
own UI).
|
|
26
|
+
- **Pluggable per-account delivery** (`roster_mode`) — trade latency ×
|
|
27
|
+
visibility × cost per account:
|
|
28
|
+
- `:poll` — client polls; server replies with a server-filtered diff.
|
|
29
|
+
- `:nudge` — `:poll` plus a content-free "re-poll" signal for near-instant refresh.
|
|
30
|
+
- `:fanout` — instant per-viewer push (arbitrary/asymmetric visibility).
|
|
31
|
+
- `:broadcast` — instant shared-stream push (open-visibility accounts).
|
|
32
|
+
- **Server-side visibility enforcement** via `roster_visible_to` (subjects a
|
|
33
|
+
viewer may see) and `roster_viewers_of` (its inverse, for `:fanout`); org
|
|
34
|
+
boundary via `roster_org`.
|
|
35
|
+
- **DOM-free presence reporter core** (`createPresenceReporter`) shared by the
|
|
36
|
+
web `usePresence` hook and a React Native reporter (`metadata.platform`).
|
|
37
|
+
- Docs: mode decision guide (README) and `docs/SERVER_SIDE_SUBSCRIPTIONS.md`
|
|
38
|
+
(future authorized pub/sub direction).
|
|
39
|
+
|
|
40
|
+
### Notes
|
|
41
|
+
|
|
42
|
+
- npm entry points now ship the built bundle (`dist/`); `@rails/actioncable` is
|
|
43
|
+
a peer dependency (not bundled).
|
data/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.
|
data/README.md
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
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 — published on RubyGems.org
|
|
22
|
+
gem 'where_is_waldo', '~> 0.1'
|
|
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
|
+
# published on npm (public registry)
|
|
36
|
+
npm install @byscott-io/where-is-waldo @rails/actioncable
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. Configure
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/initializers/where_is_waldo.rb
|
|
43
|
+
WhereIsWaldo.configure do |config|
|
|
44
|
+
config.subject_class = "User"
|
|
45
|
+
config.authenticate_proc = ->(request) {
|
|
46
|
+
# Return user_id from your auth token
|
|
47
|
+
decode_token(request.params[:token])[:user_id]
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```jsx
|
|
53
|
+
// app.jsx — configure the connection once, wrap the app
|
|
54
|
+
import { configureCable, PresenceProvider } from '@byscott-io/where-is-waldo';
|
|
55
|
+
|
|
56
|
+
configureCable({
|
|
57
|
+
url: '/cable',
|
|
58
|
+
getToken: () => localStorage.getItem('token'),
|
|
59
|
+
presence: {
|
|
60
|
+
debug: true, // Enable console logging for troubleshooting
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
<PresenceProvider>
|
|
65
|
+
<App />
|
|
66
|
+
</PresenceProvider>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```jsx
|
|
70
|
+
// any component — subscribe to an event type and filter the payload yourself.
|
|
71
|
+
// Auto-unsubscribes on unmount; no central handler config.
|
|
72
|
+
import { useWaldoEvent } from '@byscott-io/where-is-waldo';
|
|
73
|
+
|
|
74
|
+
function Notifications() {
|
|
75
|
+
useWaldoEvent('notification', (data) => showToast(data.message));
|
|
76
|
+
useWaldoEvent('force_logout', () => logout());
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 3. Use
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# Query who's online
|
|
85
|
+
WhereIsWaldo.online(org.users) # => AR relation
|
|
86
|
+
WhereIsWaldo.online(org.users.admin) # => filter by scope
|
|
87
|
+
WhereIsWaldo.subject_online?(user.id) # => true/false
|
|
88
|
+
|
|
89
|
+
# Broadcast messages
|
|
90
|
+
WhereIsWaldo.broadcast_to(org.users, :notification, { message: "Hello!" })
|
|
91
|
+
WhereIsWaldo.broadcast_to(user, :force_logout, { reason: "Password changed" })
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Detailed Documentation
|
|
97
|
+
|
|
98
|
+
### Server Configuration
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
WhereIsWaldo.configure do |config|
|
|
102
|
+
config.adapter = :database # or :redis
|
|
103
|
+
config.table_name = "presences"
|
|
104
|
+
config.session_column = :session_id
|
|
105
|
+
config.subject_column = :user_id # or :member_id, :student_id
|
|
106
|
+
config.subject_class = "User" # or "Member", "Student"
|
|
107
|
+
|
|
108
|
+
config.timeout = 90 # seconds until offline
|
|
109
|
+
config.heartbeat_interval = 30
|
|
110
|
+
|
|
111
|
+
# Optional: custom subject data in presence hash. NOTE: with the roster
|
|
112
|
+
# enabled, these fields are broadcast to every member of the org — see
|
|
113
|
+
# Security.
|
|
114
|
+
config.subject_data_proc = ->(user) {
|
|
115
|
+
{ id: user.id, name: user.name, avatar: user.avatar_url }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Live presence roster (see "Live Presence Roster"). Set roster_org to
|
|
119
|
+
# enable; roster_members is optional (defaults to org.<subjects>).
|
|
120
|
+
config.roster_org = ->(user) { user.account }
|
|
121
|
+
config.roster_members = ->(org) { org.users.active }
|
|
122
|
+
|
|
123
|
+
# Redis adapter
|
|
124
|
+
# config.redis_client = Redis.new(url: ENV["REDIS_URL"])
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Querying Presence
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# Get online subjects from any AR scope
|
|
132
|
+
WhereIsWaldo.online(org.users)
|
|
133
|
+
WhereIsWaldo.online(User.where(role: "admin"))
|
|
134
|
+
WhereIsWaldo.online(classroom.students)
|
|
135
|
+
|
|
136
|
+
# Get just IDs
|
|
137
|
+
WhereIsWaldo.online_ids(org.users)
|
|
138
|
+
|
|
139
|
+
# Check specific subject
|
|
140
|
+
WhereIsWaldo.subject_online?(user.id)
|
|
141
|
+
|
|
142
|
+
# Get all sessions for a subject
|
|
143
|
+
WhereIsWaldo.sessions_for_subject(user.id)
|
|
144
|
+
# => [{ session_id: "...", tab_visible: true, subject_active: false, ... }]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Broadcasting
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# To any AR scope
|
|
151
|
+
WhereIsWaldo.broadcast_to(org.users, :notification, { message: "Hi" })
|
|
152
|
+
WhereIsWaldo.broadcast_to(org.users.admin, :alert, { level: "warning" })
|
|
153
|
+
|
|
154
|
+
# Only to online subjects
|
|
155
|
+
WhereIsWaldo.broadcast_to_online(org.users, :update, { data: "..." })
|
|
156
|
+
|
|
157
|
+
# To a single subject (all their sessions)
|
|
158
|
+
WhereIsWaldo.broadcast_to(user, :force_logout, {})
|
|
159
|
+
|
|
160
|
+
# To a specific session
|
|
161
|
+
WhereIsWaldo.broadcast_to_session(session_id, :warning, { message: "..." })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Live Presence Roster ("who's around in my org")
|
|
165
|
+
|
|
166
|
+
A ready-made way to show live presence awareness across an org/account — who's
|
|
167
|
+
here right now, on what device, and how active. It is built as **data, not UI**:
|
|
168
|
+
the server keeps the client's roster in sync and you render whatever component
|
|
169
|
+
you like.
|
|
170
|
+
|
|
171
|
+
**Delivery is a per-account strategy** so you can trade latency × cost ×
|
|
172
|
+
visibility-enforcement to fit your app. The server picks the mode and the client
|
|
173
|
+
adapts automatically (no client mode config). Modes (default `:poll`):
|
|
174
|
+
|
|
175
|
+
| Mode | Latency | Visibility | Cost/transition |
|
|
176
|
+
|------|---------|------------|-----------------|
|
|
177
|
+
| `:poll` (default) | ~poll interval | server-side query — **any** rule | flat (1 cached query/poll) |
|
|
178
|
+
| `:nudge` | near-instant | server-side query — **any** rule | O(1) content-free trigger + filtered poll |
|
|
179
|
+
| `:fanout` | instant | server-side per-viewer — **any** rule (incl. asymmetric) | O(audience) |
|
|
180
|
+
| `:broadcast` | instant | **none** (everyone in account sees everyone) | O(1) |
|
|
181
|
+
|
|
182
|
+
`:poll` sends a full **snapshot** on connect, then the client polls and the
|
|
183
|
+
server replies with a server-*filtered* diff (baseline cached per session,
|
|
184
|
+
TTL'd for auto-resync) — so arbitrary/asymmetric visibility "just works" via
|
|
185
|
+
`roster_visible_to`. `:nudge` is `:poll` plus a content-free "re-poll"
|
|
186
|
+
broadcast on each transition, so clients refresh near-instantly instead of
|
|
187
|
+
waiting for the next interval — same airtight server-side filtering, just lower
|
|
188
|
+
latency; the nudge carries no identity/state (only "activity happened").
|
|
189
|
+
`:fanout` pushes instantly to a **per-viewer** stream: on a transition the
|
|
190
|
+
subject's delta goes to every viewer in its directional `roster_viewers_of`
|
|
191
|
+
(the inverse of `roster_visible_to`), so even *asymmetric* visibility
|
|
192
|
+
(manager-sees-report-but-not-vice-versa) is exact — at O(audience) broadcasts
|
|
193
|
+
per transition. `:broadcast` instead streams one shared account stream and
|
|
194
|
+
pushes deltas instantly, with **no** visibility filtering (open-visibility
|
|
195
|
+
accounts only); pair it with the client `filter` option below for *cosmetic*
|
|
196
|
+
per-viewer hiding.
|
|
197
|
+
|
|
198
|
+
```jsx
|
|
199
|
+
// :broadcast + cosmetic client filter (NOT a security boundary — the full
|
|
200
|
+
// roster still reaches the client; use a server-side mode to truly enforce).
|
|
201
|
+
const { online } = usePresenceRoster({ filter: (m) => canISee(m.id) });
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### Which mode should I use?
|
|
205
|
+
|
|
206
|
+
Two questions decide it:
|
|
207
|
+
|
|
208
|
+
**1. Is the client *always* interested, or only *sometimes*?**
|
|
209
|
+
- **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.
|
|
210
|
+
- **Always** — presence *is* the live experience (chat, collaborative cursors). Use the **push family** (`:fanout` / `:broadcast`): the server streams every change as it happens.
|
|
211
|
+
|
|
212
|
+
**2. Then the within-family choice:**
|
|
213
|
+
- `: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).
|
|
214
|
+
- `: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).
|
|
215
|
+
|
|
216
|
+
Two things people get subtly wrong:
|
|
217
|
+
- **Filtering is not fanout-only.** `:poll`, `:nudge`, *and* `:fanout` all enforce visibility server-side. **`:broadcast` is the only unfiltered mode.**
|
|
218
|
+
- **`: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.
|
|
219
|
+
|
|
220
|
+
See `docs/PRESENCE_ROSTER_PLAN.md` for the full mode matrix and tradeoffs.
|
|
221
|
+
|
|
222
|
+
**Per-device, multi-session.** A subject's state is aggregated across *all*
|
|
223
|
+
their live sessions (multiple browser tabs, mobile, etc.):
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
{ id: 7, status: "active", devices: { web: "idle", mobile: "active" } }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
- `status` — highest activity across devices (the "active anywhere?" answer):
|
|
230
|
+
- `active` — a live session is visible/foreground **and** working
|
|
231
|
+
- `idle` — a live session is visible/foreground but not actively using
|
|
232
|
+
- `background` — only backgrounded/hidden sessions are live
|
|
233
|
+
- `offline` — no live sessions
|
|
234
|
+
- `devices[platform]` — that platform's own status (answers "active on
|
|
235
|
+
**mobile**?" vs. "active at all?").
|
|
236
|
+
|
|
237
|
+
#### Configure
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
WhereIsWaldo.configure do |config|
|
|
241
|
+
# The org/account a subject belongs to. Required to enable the roster.
|
|
242
|
+
config.roster_org = ->(subject) { subject.account }
|
|
243
|
+
|
|
244
|
+
# What a VIEWER may see (:poll/:nudge/:fanout). Any visibility rule,
|
|
245
|
+
# server-enforced. Defaults to the viewer's whole org when unset.
|
|
246
|
+
config.roster_visible_to = ->(viewer) { viewer.visible_users }
|
|
247
|
+
|
|
248
|
+
# Who may see a SUBJECT — the inverse of roster_visible_to. Only needed
|
|
249
|
+
# for :fanout (enables instant push under asymmetric visibility).
|
|
250
|
+
config.roster_viewers_of = ->(subject) { subject.visible_to_users }
|
|
251
|
+
|
|
252
|
+
# Delivery mode, per account. Symbol or a callable resolving account -> mode.
|
|
253
|
+
# MUST be a function of the account (uniform for all its members). Default :poll.
|
|
254
|
+
config.roster_mode = ->(account) { account.everyone_admin? ? :broadcast : :poll }
|
|
255
|
+
|
|
256
|
+
# :broadcast only — the member list for the shared snapshot. Defaults to
|
|
257
|
+
# org.public_send(<subjects>) inferred from subject_class (e.g. :users).
|
|
258
|
+
config.roster_members = ->(org) { org.users.active }
|
|
259
|
+
|
|
260
|
+
# Tuning (:poll/:nudge)
|
|
261
|
+
config.roster_poll_interval = 15 # seconds
|
|
262
|
+
config.roster_cache_ttl = 90 # seconds; > poll gap → auto-resync
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The `RosterChannel` is provided by the gem; no app code is needed beyond config.
|
|
267
|
+
|
|
268
|
+
#### Consume it (React — data hook, bring your own UI)
|
|
269
|
+
|
|
270
|
+
```jsx
|
|
271
|
+
import { usePresenceRoster, presenceColor, memberLabel } from '@byscott-io/where-is-waldo';
|
|
272
|
+
|
|
273
|
+
function TeamPresence() {
|
|
274
|
+
const { online, members, onlineCount, byId } = usePresenceRoster();
|
|
275
|
+
|
|
276
|
+
// `members` is always the full, live set (snapshot seeded, deltas patched).
|
|
277
|
+
return (
|
|
278
|
+
<ul>
|
|
279
|
+
{online.map((m) => (
|
|
280
|
+
<li key={m.id}>
|
|
281
|
+
<span style={{ color: presenceColor(m.status) }}>●</span>
|
|
282
|
+
{memberLabel(m)} — {m.status}
|
|
283
|
+
{m.devices.mobile && ' 📱'}
|
|
284
|
+
</li>
|
|
285
|
+
))}
|
|
286
|
+
</ul>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The hook and its reducer (`../core/rosterStore`) are pure — **no DOM** — so a
|
|
292
|
+
React Native app reuses the exact same data logic and only swaps the view.
|
|
293
|
+
|
|
294
|
+
#### Query presence server-side
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
WhereIsWaldo.roster_snapshot(org) # full roster + per-device state
|
|
298
|
+
WhereIsWaldo.roster_state_for(user.id) # => { status:, devices: }
|
|
299
|
+
WhereIsWaldo.presence_on(user.id, :mobile) # => "idle" (per-device)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
#### Reporting presence from mobile
|
|
303
|
+
|
|
304
|
+
Mobile is "logged in" purely by connecting with `metadata: { platform: "mobile" }`
|
|
305
|
+
and sending the same heartbeat shape as the web client. The heartbeat/activity
|
|
306
|
+
state machine lives in a DOM-free core, `createPresenceReporter`, that the web
|
|
307
|
+
`usePresence` hook wraps — a React Native app reuses the **same core** and only
|
|
308
|
+
swaps the sensors: map app foreground/background to `setVisible`, and touches to
|
|
309
|
+
`reportActivity`. No server-side changes; `platform` is read from the metadata
|
|
310
|
+
(defaults to `"web"`).
|
|
311
|
+
|
|
312
|
+
```jsx
|
|
313
|
+
// React Native reporter — same core, native sensors.
|
|
314
|
+
import { useEffect, useRef } from 'react';
|
|
315
|
+
import { AppState, PanResponder } from 'react-native';
|
|
316
|
+
import { createPresenceReporter, configureCable } from '@byscott-io/where-is-waldo';
|
|
317
|
+
|
|
318
|
+
configureCable({ url: WS_URL, getToken: () => token });
|
|
319
|
+
|
|
320
|
+
export function usePresenceNative() {
|
|
321
|
+
const reporterRef = useRef(null);
|
|
322
|
+
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
const reporter = createPresenceReporter({ metadata: { platform: 'mobile' } });
|
|
325
|
+
reporterRef.current = reporter;
|
|
326
|
+
reporter.start();
|
|
327
|
+
reporter.setVisible(AppState.currentState === 'active');
|
|
328
|
+
|
|
329
|
+
const sub = AppState.addEventListener('change', (s) =>
|
|
330
|
+
reporter.setVisible(s === 'active'),
|
|
331
|
+
);
|
|
332
|
+
return () => {
|
|
333
|
+
sub.remove();
|
|
334
|
+
reporter.stop();
|
|
335
|
+
};
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
// Feed touches as activity (attach these handlers to your root view).
|
|
339
|
+
const pan = useRef(
|
|
340
|
+
PanResponder.create({
|
|
341
|
+
onStartShouldSetPanResponderCapture: () => {
|
|
342
|
+
reporterRef.current?.reportActivity();
|
|
343
|
+
return false; // observe only; don't capture the gesture
|
|
344
|
+
},
|
|
345
|
+
}),
|
|
346
|
+
).current;
|
|
347
|
+
|
|
348
|
+
return pan.panHandlers;
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`createPresenceReporter` is pure JS (no DOM), and `@rails/actioncable` works on
|
|
353
|
+
React Native with the built-in `WebSocket` — so the reporter, the roster hook
|
|
354
|
+
(`usePresenceRoster`), and the roster reducer all run unchanged on mobile; only
|
|
355
|
+
the sensors and the view are platform-specific.
|
|
356
|
+
|
|
357
|
+
### Client Event Subscriptions
|
|
358
|
+
|
|
359
|
+
Components subscribe to a raw event **type** with the `useWaldoEvent` hook,
|
|
360
|
+
receive the payload, and decide for themselves whether it's relevant. The
|
|
361
|
+
subscription auto-unsubscribes on unmount — there is no central handler
|
|
362
|
+
registry. Adding a new event = a server `broadcast_to` + a `useWaldoEvent`
|
|
363
|
+
call in whatever component cares.
|
|
364
|
+
|
|
365
|
+
```jsx
|
|
366
|
+
import { useWaldoEvent } from '@byscott-io/where-is-waldo';
|
|
367
|
+
|
|
368
|
+
function ChatRoom({ roomId }) {
|
|
369
|
+
// Single type — filter the payload yourself
|
|
370
|
+
useWaldoEvent('chat_message', (data) => {
|
|
371
|
+
if (data.room_id === roomId) addMessage(data);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Many types at once. useWaldoEvent is a pure subscription — to coalesce a
|
|
375
|
+
// burst into one call, compose a debounce at the call site (e.g. corebyscott's
|
|
376
|
+
// useDebouncedCallback): const refetch = useDebouncedCallback(loadAll, 250);
|
|
377
|
+
useWaldoEvent(['notification', 'data_refresh'], refetch);
|
|
378
|
+
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
For a non-React / imperative context, `subscribeToEvent(type, cb)` returns an
|
|
384
|
+
unsubscribe function:
|
|
385
|
+
|
|
386
|
+
```js
|
|
387
|
+
import { subscribeToEvent } from '@byscott-io/where-is-waldo';
|
|
388
|
+
|
|
389
|
+
const unsubscribe = subscribeToEvent('chat_message', (data) => addMessage(data));
|
|
390
|
+
// later: unsubscribe();
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### React Hooks
|
|
394
|
+
|
|
395
|
+
```jsx
|
|
396
|
+
import { usePresenceContext } from '@byscott-io/where-is-waldo';
|
|
397
|
+
|
|
398
|
+
function StatusIndicator() {
|
|
399
|
+
const { connected, tabVisible, subjectActive } = usePresenceContext();
|
|
400
|
+
|
|
401
|
+
return <span>{connected ? 'Online' : 'Offline'}</span>;
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Cleanup Job
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
# For database adapter - schedule cleanup of stale records
|
|
409
|
+
# config/initializers/sidekiq.rb
|
|
410
|
+
Sidekiq::Cron::Job.create(
|
|
411
|
+
name: 'Presence cleanup',
|
|
412
|
+
cron: '*/5 * * * *',
|
|
413
|
+
class: 'WhereIsWaldo::PresenceCleanupJob'
|
|
414
|
+
)
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Version Management
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
rake version:show # Show current version
|
|
421
|
+
rake version:bump[0.1.0] # Bump gem and npm together
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
## Security
|
|
425
|
+
|
|
426
|
+
ActionCable presence is only as safe as the connection auth around it. What the
|
|
427
|
+
gem guarantees, and what your app must do:
|
|
428
|
+
|
|
429
|
+
**Guaranteed by the gem**
|
|
430
|
+
|
|
431
|
+
- **No client-chosen rooms.** `RosterChannel` derives the org from the
|
|
432
|
+
*authenticated connection* (`current_subject` → `roster_org`), never from a
|
|
433
|
+
client-supplied param — a user cannot subscribe to another org's roster.
|
|
434
|
+
- **Self-scoped subject streams.** `PresenceChannel` streams only the
|
|
435
|
+
connection's own subject id, so targeted messages can't be eavesdropped.
|
|
436
|
+
- **Unauthenticated connections are rejected** (`JwtConnection`).
|
|
437
|
+
|
|
438
|
+
**Your app's responsibility**
|
|
439
|
+
|
|
440
|
+
- **Identify the connection from a *verified* credential** (a signed JWT, as
|
|
441
|
+
`JwtConnection` does). Never trust a client-supplied `subject_id`. ⚠️ The
|
|
442
|
+
dummy app authenticates from a query param for tests only — do not copy that
|
|
443
|
+
into production.
|
|
444
|
+
- **`subject_data_proc` fans out org-wide.** Every field it returns is
|
|
445
|
+
broadcast to all roster members. Include only what all members may see; keep
|
|
446
|
+
PII out unless intended.
|
|
447
|
+
- **Visibility enforcement depends on the delivery mode.** `:poll`, `:nudge`,
|
|
448
|
+
and `:fanout` enforce server-side — `:poll`/`:nudge` by `presence_visible_
|
|
449
|
+
scope` (a `WHERE ... IN` clause), `:fanout` by `roster_viewers_of` — so
|
|
450
|
+
nothing an unauthorized member could read off the wire. `:broadcast` does
|
|
451
|
+
**no** filtering: it shares one account stream and pushes every member's
|
|
452
|
+
presence to everyone (`roster_members` only scopes the initial *snapshot
|
|
453
|
+
list*, not the live stream). The client `filter` option is **cosmetic** — the
|
|
454
|
+
full data still reaches the client — so it is not an access-control boundary.
|
|
455
|
+
Select `:broadcast` (± client filter) only for genuinely open-visibility
|
|
456
|
+
accounts; for any restricted visibility use a server-side mode (`:poll` is the
|
|
457
|
+
default). See `docs/PRESENCE_ROSTER_PLAN.md` for the full mode matrix.
|
|
458
|
+
- **Token in the URL.** The JWT is passed as `?token=…`; use WSS only, keep
|
|
459
|
+
tokens short-lived, and avoid logging query strings. Set
|
|
460
|
+
`config.action_cable.allowed_request_origins` as defense-in-depth.
|
|
461
|
+
- **Heartbeat/DoS.** Heartbeats are client-paced DB writes; roster broadcasts
|
|
462
|
+
are gated to transitions. For large or hostile deployments use the Redis
|
|
463
|
+
adapter and consider rate-limiting.
|
|
464
|
+
|
|
465
|
+
## License
|
|
466
|
+
|
|
467
|
+
MIT
|
data/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.1
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
module ApplicationCable
|
|
5
|
+
class Channel < ActionCable::Channel::Base
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def waldo_session_id
|
|
9
|
+
connection.session_id
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def waldo_subject_id
|
|
13
|
+
subject = connection.try(:current_user) ||
|
|
14
|
+
connection.try("current_#{WhereIsWaldo.configuration.subject_class.underscore}")
|
|
15
|
+
subject&.id
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
module ApplicationCable
|
|
5
|
+
class Connection < ActionCable::Connection::Base
|
|
6
|
+
identified_by :waldo_subject_id, :waldo_session_id
|
|
7
|
+
|
|
8
|
+
def connect
|
|
9
|
+
authenticate_connection
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def authenticate_connection
|
|
15
|
+
auth_proc = WhereIsWaldo.config.authenticate_proc
|
|
16
|
+
|
|
17
|
+
if auth_proc
|
|
18
|
+
# Use custom authentication
|
|
19
|
+
result = auth_proc.call(request)
|
|
20
|
+
|
|
21
|
+
if result.is_a?(Hash)
|
|
22
|
+
self.waldo_subject_id = result[:subject_id]
|
|
23
|
+
self.waldo_session_id = result[:session_id] || generate_session_id
|
|
24
|
+
elsif result
|
|
25
|
+
# If proc returns truthy non-hash, use it as subject_id
|
|
26
|
+
self.waldo_subject_id = result
|
|
27
|
+
self.waldo_session_id = generate_session_id
|
|
28
|
+
else
|
|
29
|
+
reject_unauthorized_connection
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
# Default: try to get subject_id from params
|
|
33
|
+
self.waldo_subject_id = request.params[:subject_id]
|
|
34
|
+
self.waldo_session_id = request.params[:session_id] || generate_session_id
|
|
35
|
+
|
|
36
|
+
reject_unauthorized_connection unless waldo_subject_id
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def generate_session_id
|
|
41
|
+
SecureRandom.uuid
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
# Turnkey ActionCable connection auth for corebyscott apps. The cable client
|
|
5
|
+
# appends the user's JWT as `?token=...`; this decodes it via
|
|
6
|
+
# Corebyscott::JwtService and identifies the connection by the authenticated
|
|
7
|
+
# user (the presence subject), the JWT's container (cid), and session (jti).
|
|
8
|
+
#
|
|
9
|
+
# # app/channels/application_cable/connection.rb
|
|
10
|
+
# module ApplicationCable
|
|
11
|
+
# class Connection < ActionCable::Connection::Base
|
|
12
|
+
# include WhereIsWaldo::JwtConnection
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The subject model comes from WhereIsWaldo.configuration.subject_class
|
|
17
|
+
# (e.g. "User"); the container class from Corebyscott.config. Opt-in: only
|
|
18
|
+
# include it in apps that authenticate with corebyscott JWTs.
|
|
19
|
+
module JwtConnection
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
included do
|
|
23
|
+
identified_by :current_user, :current_container, :session_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def connect
|
|
27
|
+
self.current_user = wiw_verified_user
|
|
28
|
+
self.current_container = wiw_container
|
|
29
|
+
self.session_id = wiw_session_id
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Memoized decode of the ?token= param. nil when absent/invalid.
|
|
35
|
+
def wiw_token_payload
|
|
36
|
+
return @wiw_token_payload if defined?(@wiw_token_payload)
|
|
37
|
+
|
|
38
|
+
token = request.params[:token]
|
|
39
|
+
@wiw_token_payload =
|
|
40
|
+
if token.present? && defined?(::Corebyscott::JwtService)
|
|
41
|
+
begin
|
|
42
|
+
::Corebyscott::JwtService.decode(token, touch_last_used: false)
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def wiw_verified_user
|
|
50
|
+
payload = wiw_token_payload
|
|
51
|
+
reject_unauthorized_connection unless payload
|
|
52
|
+
|
|
53
|
+
klass = WhereIsWaldo.configuration.subject_class_constant
|
|
54
|
+
user = klass&.find_by(id: payload["sub"] || payload[:sub])
|
|
55
|
+
reject_unauthorized_connection unless user
|
|
56
|
+
|
|
57
|
+
user
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Optional container scoping from the JWT's `cid` claim. nil when the app
|
|
61
|
+
# doesn't scope tokens to a container, or corebyscott isn't present.
|
|
62
|
+
def wiw_container
|
|
63
|
+
payload = wiw_token_payload
|
|
64
|
+
return nil unless payload && current_user
|
|
65
|
+
return nil unless defined?(::Corebyscott)
|
|
66
|
+
|
|
67
|
+
cid = payload["cid"] || payload[:cid]
|
|
68
|
+
return nil if cid.blank?
|
|
69
|
+
|
|
70
|
+
container_class = begin
|
|
71
|
+
::Corebyscott.config.container_class&.constantize
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
container_class&.find_by(id: cid)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def wiw_session_id
|
|
79
|
+
payload = wiw_token_payload
|
|
80
|
+
payload&.dig("jti") || payload&.dig(:jti) || SecureRandom.uuid
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|