Class: WhereIsWaldo::Roster

Inherits:
Object
  • Object
show all
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

Class Method Details

.delta_message(member) ⇒ Object



77
78
79
# File 'app/services/where_is_waldo/roster.rb', line 77

def delta_message(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.

Returns:

  • (String)

    "active" | "idle" | "background" | "offline"



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.

Returns:

  • (Hash{Object => Hash})

    id => member hash



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_messageObject

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 nudge_message
  { 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, delta_message(member))
  true
rescue StandardError => e
  Rails.logger&.warn("[WhereIsWaldo::Roster] publish failed for #{subject_id}: #{e.class}: #{e.message}")
  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

  message = delta_message(build_member_by_id(subject, subject_id, timeout: timeout))
  audience.pluck(:id).each { |viewer_id| ActionCable.server.broadcast(viewer_stream(viewer_id), message) }
  true
rescue StandardError => e
  Rails.logger&.warn("[WhereIsWaldo::Roster] fanout failed for #{subject_id}: #{e.class}: #{e.message}")
  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, nudge_message)
  true
rescue StandardError => e
  Rails.logger&.warn("[WhereIsWaldo::Roster] nudge failed for #{subject_id}: #{e.class}: #{e.message}")
  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 removed_message(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.

Returns:

  • (Array<Hash>)

    member state hashes



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 snapshot_message(members, mode:)
  message = { type: "roster_snapshot", mode: mode.to_s, members: members }
  message[:poll_interval] = WhereIsWaldo.config.roster_poll_interval if %i[poll nudge].include?(mode.to_sym)
  message[:nudge_jitter] = WhereIsWaldo.config.roster_nudge_jitter if mode.to_sym == :nudge
  message
end

.state_for(subject_id, timeout: nil) ⇒ Hash

Aggregate presence state for a single subject across all their sessions.

Returns:

  • (Hash)

    { status:, devices: { platform => status } }



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