Class: WhereIsWaldo::Roster
- Inherits:
-
Object
- Object
- WhereIsWaldo::Roster
- Defined in:
- app/services/where_is_waldo/roster.rb
Overview
Live presence roster for an org/account: "who's around, and how present are they right now." Built for efficiency — a single shared ActionCable stream per org, an initial snapshot on subscribe, and compact deltas broadcast only on state transitions (never on steady-state heartbeats).
A member's presence is reported per device AND as an overall roll-up:
{ id: 7, status: "active", devices: { "web" => "active", "mobile" => "idle" } }
devices[platform] is that platform's own aggregate status (several browser
tabs roll up into one "web" status); status is the highest level across
all platforms — the "is the user active anywhere?" answer, while
devices["mobile"] answers "is the user active on mobile?". Each status is:
active - a live session on that device is tab-visible AND working
idle - a live session is tab-visible but not actively using
background - only backgrounded/hidden sessions are live (tab hidden or
mobile app backgrounded)
offline - no live sessions (overall status only; absent from devices)
Enable by configuring roster_org (and optionally roster_members).
Constant Summary collapse
- RANK =
Activity ranking used to pick a subject's aggregate status from the "most present" of their sessions.
{ active: 3, idle: 2, background: 1, offline: 0 }.freeze
- DEFAULT_PLATFORM =
"web"
Class Method Summary collapse
- .delta_message(member) ⇒ Object
-
.device_status(subject_id, platform, timeout: nil) ⇒ String
Presence status for a subject on a specific device/platform, e.g.
-
.members_for(scope, timeout: nil) ⇒ Hash{Object => Hash}
Member states for an arbitrary AR scope of subjects, keyed by id.
-
.nudge_message ⇒ Object
Content-free "roster changed, re-poll" trigger (:nudge mode).
-
.publish(subject_id, timeout: nil) ⇒ Object
Broadcast a compact delta for one subject to their org roster stream.
-
.publish_fanout(subject_id, timeout: nil) ⇒ Object
Push a subject's delta to every viewer allowed to see them (:fanout mode).
-
.publish_nudge(subject_id) ⇒ Object
Broadcast a content-free nudge to a subject's org roster stream (:nudge mode).
-
.removed_message(subject_id) ⇒ Object
Tells the client to drop a member who left the viewer's visible scope (distinct from going offline, which stays present with status "offline").
-
.snapshot(org, timeout: nil) ⇒ Array<Hash>
Full roster state for an org: every member subject with their current aggregate presence.
-
.snapshot_message(members, mode:) ⇒ Object
Cable message builders — identical shapes across every delivery mode so the client reducer never forks ===.
-
.state_for(subject_id, timeout: nil) ⇒ Hash
Aggregate presence state for a single subject across all their sessions.
-
.stream_for_subject(subject) ⇒ Object
Shared stream name for the org a subject belongs to (nil when the roster is unconfigured or the subject has no org).
-
.stream_name(org) ⇒ Object
Shared stream name for an org record (nil when org is absent).
-
.viewer_stream(viewer_id) ⇒ Object
Per-viewer stream (:fanout mode).
Class Method Details
.delta_message(member) ⇒ Object
77 78 79 |
# File 'app/services/where_is_waldo/roster.rb', line 77 def (member) { type: "roster_delta", member: member } end |
.device_status(subject_id, platform, timeout: nil) ⇒ String
Presence status for a subject on a specific device/platform, e.g. WhereIsWaldo::Roster.device_status(user.id, :mobile) # => "idle" Answers "is the user active on mobile?" vs the overall state_for.
103 104 105 |
# File 'app/services/where_is_waldo/roster.rb', line 103 def device_status(subject_id, platform, timeout: nil) state_for(subject_id, timeout: timeout)[:devices][platform.to_s] || "offline" end |
.members_for(scope, timeout: nil) ⇒ Hash{Object => Hash}
Member states for an arbitrary AR scope of subjects, keyed by id. Used by :poll to snapshot / diff a viewer's visible scope.
59 60 61 62 63 64 65 |
# File 'app/services/where_is_waldo/roster.rb', line 59 def members_for(scope, timeout: nil) return {} unless scope records = scope.to_a states = states_for(records.map(&:id), timeout: timeout) records.to_h { |record| [record.id, build_member(record, states[record.id])] } end |
.nudge_message ⇒ Object
Content-free "roster changed, re-poll" trigger (:nudge mode). Carries no identity/state, so it leaks nothing beyond "activity happened".
83 84 85 |
# File 'app/services/where_is_waldo/roster.rb', line 83 def { type: "roster_nudge" } end |
.publish(subject_id, timeout: nil) ⇒ Object
Broadcast a compact delta for one subject to their org roster stream. Intended to be called ONLY on transitions (connect/disconnect/active flip). No-ops safely when the roster is unconfigured or the subject has no org. Recomputes the subject's aggregate so multi-session state (e.g. a second tab still active) is always correct.
112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'app/services/where_is_waldo/roster.rb', line 112 def publish(subject_id, timeout: nil) return false if subject_id.blank? subject = find_subject(subject_id) stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject)) return false unless stream member = build_member_by_id(subject, subject_id, timeout: timeout) ActionCable.server.broadcast(stream, (member)) true rescue StandardError => e Rails.logger&.warn("[WhereIsWaldo::Roster] publish failed for #{subject_id}: #{e.class}: #{e.}") false end |
.publish_fanout(subject_id, timeout: nil) ⇒ Object
Push a subject's delta to every viewer allowed to see them (:fanout mode). Directional audience (config.roster_viewers_of) makes this correct for arbitrary/asymmetric visibility, at O(audience) broadcasts.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'app/services/where_is_waldo/roster.rb', line 135 def publish_fanout(subject_id, timeout: nil) return false if subject_id.blank? subject = find_subject(subject_id) audience = subject && WhereIsWaldo.config.resolve_viewers_of(subject) return false unless audience = (build_member_by_id(subject, subject_id, timeout: timeout)) audience.pluck(:id).each { |viewer_id| ActionCable.server.broadcast(viewer_stream(viewer_id), ) } true rescue StandardError => e Rails.logger&.warn("[WhereIsWaldo::Roster] fanout failed for #{subject_id}: #{e.class}: #{e.}") false end |
.publish_nudge(subject_id) ⇒ Object
Broadcast a content-free nudge to a subject's org roster stream (:nudge mode). Tells watchers to re-poll; carries no protected data.
152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'app/services/where_is_waldo/roster.rb', line 152 def publish_nudge(subject_id) return false if subject_id.blank? subject = find_subject(subject_id) stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject)) return false unless stream ActionCable.server.broadcast(stream, ) true rescue StandardError => e Rails.logger&.warn("[WhereIsWaldo::Roster] nudge failed for #{subject_id}: #{e.class}: #{e.}") false end |
.removed_message(subject_id) ⇒ Object
Tells the client to drop a member who left the viewer's visible scope (distinct from going offline, which stays present with status "offline").
89 90 91 |
# File 'app/services/where_is_waldo/roster.rb', line 89 def (subject_id) { type: "roster_delta", member: { id: subject_id, _removed: true } } end |
.snapshot(org, timeout: nil) ⇒ Array<Hash>
Full roster state for an org: every member subject with their current aggregate presence. Sent once, on subscribe.
49 50 51 52 53 54 |
# File 'app/services/where_is_waldo/roster.rb', line 49 def snapshot(org, timeout: nil) members = WhereIsWaldo.config.resolve_members(org) return [] unless members members_for(members, timeout: timeout).values end |
.snapshot_message(members, mode:) ⇒ Object
Cable message builders — identical shapes across every delivery mode
so the client reducer never forks ===
70 71 72 73 74 75 |
# File 'app/services/where_is_waldo/roster.rb', line 70 def (members, mode:) = { type: "roster_snapshot", mode: mode.to_s, members: members } [:poll_interval] = WhereIsWaldo.config.roster_poll_interval if %i[poll nudge].include?(mode.to_sym) [:nudge_jitter] = WhereIsWaldo.config.roster_nudge_jitter if mode.to_sym == :nudge end |
.state_for(subject_id, timeout: nil) ⇒ Hash
Aggregate presence state for a single subject across all their sessions.
95 96 97 |
# File 'app/services/where_is_waldo/roster.rb', line 95 def state_for(subject_id, timeout: nil) states_for([subject_id], timeout: timeout).fetch(subject_id, offline_state) end |
.stream_for_subject(subject) ⇒ Object
Shared stream name for the org a subject belongs to (nil when the roster is unconfigured or the subject has no org).
42 43 44 |
# File 'app/services/where_is_waldo/roster.rb', line 42 def stream_for_subject(subject) stream_name(WhereIsWaldo.config.resolve_roster_org(subject)) end |
.stream_name(org) ⇒ Object
Shared stream name for an org record (nil when org is absent).
33 34 35 36 37 38 |
# File 'app/services/where_is_waldo/roster.rb', line 33 def stream_name(org) return nil unless org klass = org.class.respond_to?(:base_class) ? org.class.base_class : org.class "where_is_waldo:roster:#{klass.name}:#{org.id}" end |
.viewer_stream(viewer_id) ⇒ Object
Per-viewer stream (:fanout mode). Each viewer streams only their own.
128 129 130 |
# File 'app/services/where_is_waldo/roster.rb', line 128 def viewer_stream(viewer_id) "where_is_waldo:roster:viewer:#{viewer_id}" end |