where_is_waldo 0.1.11 → 0.1.13

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bd28c86d109c69fc976eaab788972106493a78b3fbd8e8e2370bae5dc936986
4
- data.tar.gz: fd49e94bf3a08dd4350fdc1b8b413ec5d66916ef1ada8c74162044b3ccbdc428
3
+ metadata.gz: d06a950b27e51d7506062dc38518912b7b631a91be886eb29801f01e0ac438ac
4
+ data.tar.gz: fb1f82a4742a5099eee4d7a70f9d5445010dc52bd17408cfe2451363c04051a2
5
5
  SHA512:
6
- metadata.gz: 620c8ce6c899937a25ab39f2858e45c5fa6ac68dc3510cf0e47a5739b36a8f95c24bb3daf385974600ffa652ad32f176a79ac0389286520af7a291bc48cda1e7
7
- data.tar.gz: 0ecf65ed538cbffc439428ed3f5acc690e178430ba62a9568fb1b4a1d4cb48f33303aaa9724979f8c1c0ebfc44da5eb35133665345a2dfc2d4a3a224373784d7
6
+ metadata.gz: 5bfd8270b07bed9227be801b90b1f354a30a1eeaf748a3b7b7ff0d1a551bb0f3d6685792862f7c8e5676d411ea263b14fe64c67a76be422b0e55055a01672900
7
+ data.tar.gz: '053823564618b09793ded991cf679d9017eb3be14c8069bf1d33ee0fbe50d8872b0e004a7d0ab8ac0969a08e3eb4c7b427c562800e2c002fa9ec8c1cc9bd3e22'
data/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ Notable changes to where_is_waldo. Format loosely follows
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.1.13 - 2026-11-04
9
+
10
+ ### Documentation
11
+
12
+ No code changes. This release exists to get the README onto the npm page, which
13
+ only refreshes its rendered README on a new version.
14
+
15
+ - **The README now says where this came from.** It previously mentioned
16
+ byscott.io zero times in 543 lines, so the one public, indexed page the
17
+ project owns pointed back at nothing. Adds a line under the tagline and an
18
+ About section naming the suite and three sibling tools.
19
+ - **States plainly what the gem requires, and what it does not.** No account, no
20
+ API key, and no outbound calls of any kind — verified against the shipped
21
+ code, which contains no HTTP client and no hostnames. The sibling tools linked
22
+ in that section are hosted services and do require accounts; this does not,
23
+ and the distinction is now explicit rather than left to the reader.
24
+ - **States that presence data is transient and lives in the consumer's own
25
+ store.** One table, or Redis keys with a TTL; a session's row is removed once
26
+ it stops heartbeating. This answers the question a developer actually asks of
27
+ a presence library — whether it quietly accumulates rows they then have to
28
+ manage.
29
+ - `homepage` now points at byscott.io. `repository` and `bugs` still point at
30
+ this public repo, so source and issues stay one click away from the npm page.
31
+
32
+ ## 0.1.12 - 2026-10-27
33
+
34
+ ### Fixed
35
+
36
+ - **A subject in more than one org could be put on the wrong roster.**
37
+ `roster_org` receives only the subject, so an app whose users belong to
38
+ several orgs had to guess, and the obvious guess (`accounts.first`) is
39
+ association order rather than the org the person is actually working in.
40
+ They were then invisible to the teammates they were with, and shown to an
41
+ org they were not in.
42
+
43
+ New `roster_org_for` receives `(subject, container)`, where the container is
44
+ the one the CONNECTION is scoped to -- the only place that answer exists. It
45
+ applies on BOTH sides: the presence row stores the resolved org at connect
46
+ and later broadcasts read it back, and the roster subscription resolves the
47
+ same way, so a viewer and the broadcasts meant for them can no longer land
48
+ on different streams. `container` comes from your connection's
49
+ `identified_by :current_container`; without that it is nil and behaviour is
50
+ unchanged. A subject present in two
51
+ orgs now appears in both rosters rather than one arbitrary one.
52
+
53
+ Stored in the existing `metadata` column, so **no migration is required**.
54
+ `roster_org` is untouched and still works for single-org apps.
55
+
56
+ - **A presence transition read a subject's sessions twice.** Publishing needs
57
+ both the org keys and the aggregate state, and both come from the same rows;
58
+ they are now derived from one read. This is the hottest path in the library,
59
+ so the second read was pure overhead.
60
+
61
+
8
62
  ## 0.1.11 - 2026-10-27
9
63
 
10
64
  ### Fixed
data/README.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  Real-time presence tracking for Rails + React using ActionCable.
4
4
 
5
+ Open source, and built for and used in production by the [byscott.io](https://byscott.io)
6
+ suite of tools for churches, educators, small businesses and families.
7
+
5
8
  [![CI](https://github.com/byscott-io/where_is_waldo/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/byscott-io/where_is_waldo/actions/workflows/ci.yml)
6
9
  [![Gem Version](https://img.shields.io/gem/v/where_is_waldo)](https://rubygems.org/gems/where_is_waldo)
7
10
  [![npm version](https://img.shields.io/npm/v/@byscott-io/where-is-waldo)](https://www.npmjs.com/package/@byscott-io/where-is-waldo)
@@ -290,6 +293,32 @@ their live sessions (multiple browser tabs, mobile, etc.):
290
293
  WhereIsWaldo.configure do |config|
291
294
  # The org/account a subject belongs to. Required to enable the roster.
292
295
  config.roster_org = ->(subject) { subject.account }
296
+ #
297
+ # MULTI-ORG SUBJECTS: roster_org is handed only the subject, so it has to
298
+ # guess which org the person is in right now -- and `accounts.first` guesses
299
+ # by association order. Someone who has switched is then put on the wrong
300
+ # roster: invisible to the teammates they are with, shown to an org they are
301
+ # not in. Use roster_org_for instead, which also receives the container the
302
+ # CONNECTION is scoped to:
303
+ #
304
+ # config.roster_org_for = ->(user, container) { container || user.accounts.first }
305
+ #
306
+ # `container` is read from your own ActionCable connection, so it is only
307
+ # ever non-nil if that connection identifies one:
308
+ #
309
+ # module ApplicationCable
310
+ # class Connection < ActionCable::Connection::Base
311
+ # identified_by :current_user, :current_container, :session_id
312
+ # end
313
+ # end
314
+ #
315
+ # WhereIsWaldo::JwtConnection already does this. Without it `container` is
316
+ # always nil and the fallback above behaves exactly like plain roster_org --
317
+ # which is to say, like the bug.
318
+ #
319
+ # The resolved org is stored on the presence row at connect and read back by
320
+ # later broadcasts, so the subscriber and the broadcaster always agree on the
321
+ # stream. roster_org keeps working unchanged for single-org apps.
293
322
 
294
323
  # What a VIEWER may see (:poll/:nudge/:fanout). Any visibility rule,
295
324
  # server-enforced. Defaults to the viewer's whole org when unset.
@@ -512,6 +541,34 @@ gem guarantees, and what your app must do:
512
541
  are gated to transitions. For large or hostile deployments use the Redis
513
542
  adapter and consider rate-limiting.
514
543
 
544
+ ## About
545
+
546
+ WhereIsWaldo is maintained by Scott Gibson and came out of a real need across
547
+ [byscott.io](https://byscott.io) — a suite of software tools for churches,
548
+ educators, small businesses and families, where several Rails and React apps
549
+ each wanted "who is online right now" without paying for a hosted websocket
550
+ service.
551
+
552
+ **No account or API key is required, and nothing phones home.** This gem keeps
553
+ no store of its own. Presence lives in one table in your own database, or as
554
+ expiring keys in your own Redis, and it is transient either way — a session's
555
+ row is removed once it stops heartbeating, and Redis keys carry a TTL. The only
556
+ infrastructure it needs is the ActionCable you already run.
557
+
558
+ The sibling tools below are hosted services and do require an account; this gem
559
+ is a library and does not.
560
+
561
+ Other tools from the same suite, if a shared backend is what you actually want
562
+ rather than a library:
563
+
564
+ - **[IssuesByScott](https://issues.byscott.io)** — error reporting, an issue
565
+ portal your users can file into, and deploy tracking
566
+ - **[BillingByScott](https://billing.byscott.io)** — subscriptions, invoicing
567
+ and entitlement checks across several apps
568
+ - **[MetricsByScott](https://metrics.byscott.io)** — events, page views and
569
+ time-varied metrics, with a data warehouse rather than a dashboard you cannot
570
+ query
571
+
515
572
  ## License
516
573
 
517
574
  MIT
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.11
1
+ 0.1.13
@@ -88,13 +88,43 @@ module WhereIsWaldo
88
88
  end
89
89
 
90
90
  def register_presence
91
+ # The org key is computed HERE and written over anything the client sent.
92
+ # params[:metadata] is client-supplied, so letting a tab name its own
93
+ # roster would let it subscribe itself into an org it has no business
94
+ # seeing.
95
+ #
96
+ # The client's value is REMOVED FIRST, unconditionally. Only overwriting
97
+ # it when the server resolves an org leaves the hole open in exactly the
98
+ # case that matters: a connection with no org of its own could name any
99
+ # org it liked, the value would persist on the presence row, and later
100
+ # broadcasts read it back verbatim and push that subject's presence into
101
+ # a stream they have no claim on.
102
+ metadata = (params[:metadata] || {}).to_h
103
+ metadata.delete(WhereIsWaldo::Roster::ORG_KEY)
104
+ metadata.delete(WhereIsWaldo::Roster::ORG_KEY.to_sym)
105
+
106
+ key = WhereIsWaldo::Roster.org_key(wiw_connection_org)
107
+ metadata[WhereIsWaldo::Roster::ORG_KEY] = key if key
108
+
91
109
  WhereIsWaldo.connect(
92
110
  session_id: waldo_session_id,
93
111
  subject_id: waldo_subject_id,
94
- metadata: params[:metadata] || {}
112
+ metadata: metadata
95
113
  )
96
114
  end
97
115
 
116
+ # The org this CONNECTION is scoped to. The container comes from the host
117
+ # app's own cable connection, which is the only place that knows the org
118
+ # the person is actually working in -- a lambda given just the subject has
119
+ # to guess, and guesses by association order.
120
+ def wiw_connection_org
121
+ return @wiw_connection_org if defined?(@wiw_connection_org)
122
+
123
+ subject = WhereIsWaldo.config.subject_class_constant&.find_by(id: waldo_subject_id)
124
+ container = connection.try(:current_container)
125
+ @wiw_connection_org = subject && WhereIsWaldo.config.resolve_roster_org(subject, container: container)
126
+ end
127
+
98
128
  def roster_transition?(tab_visible, subject_active)
99
129
  tab_visible != @wiw_tab_visible || subject_active != @wiw_subject_active
100
130
  end
@@ -104,8 +134,7 @@ module WhereIsWaldo
104
134
  def resolve_roster_strategy
105
135
  return nil unless WhereIsWaldo.config.roster_enabled?
106
136
 
107
- subject = WhereIsWaldo.config.subject_class_constant&.find_by(id: waldo_subject_id)
108
- org = subject && WhereIsWaldo.config.resolve_roster_org(subject)
137
+ org = wiw_connection_org
109
138
  return nil unless org
110
139
 
111
140
  WhereIsWaldo::RosterDelivery.for(WhereIsWaldo.config.resolve_mode(org))
@@ -13,12 +13,15 @@ module WhereIsWaldo
13
13
  class RosterChannel < ApplicationCable::Channel
14
14
  def subscribed
15
15
  subject = current_subject
16
- mode = resolve_mode(subject)
16
+ org = resolve_org(subject)
17
+ return reject unless org
18
+
19
+ mode = WhereIsWaldo.config.resolve_mode(org)
17
20
  return reject unless mode
18
21
 
19
22
  @wiw_subject = subject
20
23
  @wiw_strategy = RosterDelivery.for(mode)
21
- apply_plan(@wiw_strategy.subscribe_plan(subject, waldo_session_id))
24
+ apply_plan(@wiw_strategy.subscribe_plan(subject, waldo_session_id, org: org))
22
25
  end
23
26
 
24
27
  # Client-driven poll (pull/nudge modes). Transmits a filtered snapshot
@@ -31,11 +34,15 @@ module WhereIsWaldo
31
34
 
32
35
  private
33
36
 
34
- def resolve_mode(subject)
35
- org = WhereIsWaldo.config.resolve_roster_org(subject)
36
- return nil unless org
37
+ # The org this VIEWER is watching. Resolved from the connection's container
38
+ # like the writer side, so the stream they subscribe to is the one deltas
39
+ # are actually broadcast into. Resolving from the subject alone here was
40
+ # the other half of the same bug: a viewer scoped to their second org sat
41
+ # on their first org's stream and simply never saw anything.
42
+ def resolve_org(subject)
43
+ return nil unless subject
37
44
 
38
- WhereIsWaldo.config.resolve_mode(org)
45
+ WhereIsWaldo.config.resolve_roster_org(subject, container: connection.try(:current_container))
39
46
  end
40
47
 
41
48
  def apply_plan(plan)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class Roster
5
+ # Which roster a session belongs to, and how that is stored.
6
+ #
7
+ # Extracted from Roster so the resolution rules sit together and can be
8
+ # read without the delivery machinery around them.
9
+ module OrgKeys
10
+ # WHICH ROSTER A SESSION BELONGS TO IS DECIDED ONCE, AT CONNECT, AND
11
+ # STORED ON THE PRESENCE ROW.
12
+ #
13
+ # It cannot be re-derived later. The connection knows the container the
14
+ # person is actually working in; a background broadcast an hour later has
15
+ # only a subject id, and a subject can belong to several orgs. Deriving
16
+ # it twice is how the subscriber ends up listening to one stream while
17
+ # the broadcaster writes to another -- which looks exactly like presence
18
+ # being broken, not like a resolution bug.
19
+ #
20
+ # Stored in metadata rather than a column so this needs no migration in
21
+ # any consuming app.
22
+ def org_key(org)
23
+ return nil unless org
24
+
25
+ klass = org.class.respond_to?(:base_class) ? org.class.base_class : org.class
26
+ "#{klass.name}:#{org.id}"
27
+ end
28
+
29
+ def org_from_key(key)
30
+ type, id = key.to_s.split(":", 2)
31
+ return nil if type.blank? || id.blank?
32
+
33
+ type.safe_constantize&.find_by(id: id)
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ # Every org this subject is currently connected in -- usually one, but a
39
+ # person with two tabs in two accounts is present in both and must appear
40
+ # in both rosters. Falls back to resolving from the subject alone for
41
+ # rows written before this existed, and for apps with no container.
42
+ def org_keys_for_subject(subject_id, timeout: nil)
43
+ by_subject = WhereIsWaldo::PresenceService.sessions_for_subjects([subject_id], timeout: timeout)
44
+ org_keys_from(sessions_for(by_subject, subject_id), subject_id)
45
+ end
46
+
47
+ # The same answer from sessions the caller has ALREADY loaded. A presence
48
+ # transition needs both the org keys and the subject's aggregate state,
49
+ # and both are derived from one set of session rows -- reading them twice
50
+ # doubles the adapter traffic on the hottest path in the library.
51
+ def org_keys_from(sessions, subject_id)
52
+ keys = sessions.filter_map do |session|
53
+ session.dig(:metadata, ORG_KEY) || session.dig("metadata", ORG_KEY)
54
+ end.uniq
55
+ return keys if keys.any?
56
+
57
+ [org_key(WhereIsWaldo.config.resolve_roster_org(find_subject(subject_id)))].compact
58
+ end
59
+
60
+ private
61
+
62
+ # THE KEY TYPES ON THE TWO SIDES ARE NOT GUARANTEED TO MATCH.
63
+ #
64
+ # The database adapter keys its result by the AR-typecast column value,
65
+ # typically an Integer, while a subject id arriving from a cable
66
+ # connection is often a String -- authenticate_proc returns whatever the
67
+ # host app decides, and connection params are strings. A plain fetch then
68
+ # misses, and the caller falls back to resolving from the subject alone:
69
+ # silently, and straight back to the guess this exists to replace.
70
+ # Comparing as strings is what stops a type difference becoming a wrong
71
+ # roster.
72
+ def sessions_for(by_subject, subject_id)
73
+ return by_subject[subject_id] if by_subject.key?(subject_id)
74
+
75
+ wanted = subject_id.to_s
76
+ _, sessions = by_subject.find { |key, _| key.to_s == wanted }
77
+ sessions || []
78
+ end
79
+ end
80
+ end
81
+ end
@@ -34,6 +34,13 @@ module WhereIsWaldo
34
34
  RANK = { active: 3, idle: 2, background: 1, offline: 0 }.freeze
35
35
  DEFAULT_PLATFORM = "web"
36
36
 
37
+ # The metadata key holding which roster a session belongs to. Defined on
38
+ # the class, not the singleton, so callers can reference
39
+ # WhereIsWaldo::Roster::ORG_KEY.
40
+ ORG_KEY = "wiw_org"
41
+
42
+ extend OrgKeys
43
+
37
44
  class << self
38
45
  # Shared stream name for an org record (nil when org is absent).
39
46
  def stream_name(org)
@@ -45,8 +52,8 @@ module WhereIsWaldo
45
52
 
46
53
  # Shared stream name for the org a subject belongs to (nil when the roster
47
54
  # is unconfigured or the subject has no org).
48
- def stream_for_subject(subject)
49
- stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
55
+ def stream_for_subject(subject, container: nil)
56
+ stream_name(WhereIsWaldo.config.resolve_roster_org(subject, container: container))
50
57
  end
51
58
 
52
59
  # Full roster state for an org: every member subject with their current
@@ -118,12 +125,23 @@ module WhereIsWaldo
118
125
  def publish(subject_id, timeout: nil)
119
126
  return false if subject_id.blank?
120
127
 
121
- subject = find_subject(subject_id)
122
- stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
123
- return false unless stream
128
+ # ONE read of the subject's sessions, used for both halves. The org
129
+ # keys and the aggregate state are derived from the same rows, and a
130
+ # transition is the hottest path here -- fetching twice doubled the
131
+ # adapter traffic for no new information.
132
+ by_subject = WhereIsWaldo::PresenceService.sessions_for_subjects([subject_id], timeout: timeout)
133
+ sessions = sessions_for(by_subject, subject_id)
124
134
 
125
- member = build_member_by_id(subject, subject_id, timeout: timeout)
126
- ActionCable.server.broadcast(stream, delta_message(member))
135
+ keys = org_keys_from(sessions, subject_id)
136
+ return false if keys.empty?
137
+
138
+ subject = find_subject(subject_id)
139
+ state = sessions.any? ? aggregate(sessions) : offline_state
140
+ member = merge_member(subject, state, id: subject_id)
141
+ keys.each do |key|
142
+ stream = "where_is_waldo:roster:#{key}"
143
+ ActionCable.server.broadcast(stream, delta_message(member))
144
+ end
127
145
  true
128
146
  rescue StandardError => e
129
147
  Rails.logger&.warn("[WhereIsWaldo::Roster] publish failed for #{subject_id}: #{e.class}: #{e.message}")
@@ -158,11 +176,10 @@ module WhereIsWaldo
158
176
  def publish_nudge(subject_id)
159
177
  return false if subject_id.blank?
160
178
 
161
- subject = find_subject(subject_id)
162
- stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
163
- return false unless stream
179
+ keys = org_keys_for_subject(subject_id)
180
+ return false if keys.empty?
164
181
 
165
- ActionCable.server.broadcast(stream, nudge_message)
182
+ keys.each { |key| ActionCable.server.broadcast("where_is_waldo:roster:#{key}", nudge_message) }
166
183
  true
167
184
  rescue StandardError => e
168
185
  Rails.logger&.warn("[WhereIsWaldo::Roster] nudge failed for #{subject_id}: #{e.class}: #{e.message}")
@@ -28,7 +28,7 @@ module WhereIsWaldo
28
28
  # Base contract. Channels call these; return values drive the channel's I/O.
29
29
  class Base
30
30
  # @return [Hash] { streams: [names], messages: [cable messages] }
31
- def subscribe_plan(_subject, _session_id)
31
+ def subscribe_plan(_subject, _session_id, org: nil) # rubocop:disable Lint/UnusedMethodArgument
32
32
  { streams: [], messages: [] }
33
33
  end
34
34
 
@@ -50,8 +50,11 @@ module WhereIsWaldo
50
50
  # Instant push to a single shared per-account stream. NO visibility
51
51
  # filtering — everyone in the account sees everyone. O(1) per transition.
52
52
  class Broadcast < Base
53
- def subscribe_plan(subject, _session_id)
54
- org = config.resolve_roster_org(subject)
53
+ def subscribe_plan(subject, _session_id, org: nil)
54
+ # org comes from the channel, which knows the connection's container.
55
+ # Resolving it again here would reach a different answer for a subject
56
+ # in several orgs, and put the viewer on a stream nobody broadcasts to.
57
+ org ||= config.resolve_roster_org(subject)
55
58
  stream = Roster.stream_name(org)
56
59
  return { streams: [], messages: [] } unless stream
57
60
 
@@ -77,10 +80,13 @@ module WhereIsWaldo
77
80
  :poll
78
81
  end
79
82
 
80
- def subscribe_plan(subject, session_id)
83
+ def subscribe_plan(subject, session_id, org: nil)
81
84
  current = current_members(subject)
82
85
  write_baseline(session_id, current)
83
- { streams: subscribe_streams(subject), messages: [Roster.snapshot_message(current.values, mode: mode)] }
86
+ {
87
+ streams: subscribe_streams(subject, org: org),
88
+ messages: [Roster.snapshot_message(current.values, mode: mode)]
89
+ }
84
90
  end
85
91
 
86
92
  def poll_messages(subject, session_id)
@@ -101,7 +107,7 @@ module WhereIsWaldo
101
107
 
102
108
  # Streams to subscribe on connect. :poll needs none (data rides the poll
103
109
  # transmit); :nudge overrides this to also receive the account nudge.
104
- def subscribe_streams(_subject)
110
+ def subscribe_streams(_subject, org: nil) # rubocop:disable Lint/UnusedMethodArgument
105
111
  []
106
112
  end
107
113
 
@@ -156,8 +162,9 @@ module WhereIsWaldo
156
162
  private
157
163
 
158
164
  # Also subscribe the account stream so this client receives nudges.
159
- def subscribe_streams(subject)
160
- stream = Roster.stream_name(config.resolve_roster_org(subject))
165
+ def subscribe_streams(subject, org: nil)
166
+ org ||= config.resolve_roster_org(subject)
167
+ stream = Roster.stream_name(org)
161
168
  stream ? [stream] : []
162
169
  end
163
170
  end
@@ -168,7 +175,7 @@ module WhereIsWaldo
168
175
  # its directional audience (config.roster_viewers_of). O(audience) per
169
176
  # transition — the price of instant delivery under restricted visibility.
170
177
  class Fanout < Base
171
- def subscribe_plan(subject, _session_id)
178
+ def subscribe_plan(subject, _session_id, org: nil) # rubocop:disable Lint/UnusedMethodArgument
172
179
  members = Roster.members_for(config.resolve_visible_to(subject))
173
180
  {
174
181
  streams: [Roster.viewer_stream(subject.id)],
@@ -83,6 +83,16 @@ module WhereIsWaldo
83
83
  # roster_members_association: association used to build the default roster
84
84
  # from the org when roster_members is unset (defaults to the pluralized
85
85
  # subject_class, e.g. :users).
86
+ # roster_org_for: the container-aware form of roster_org. Receives
87
+ # (subject, container) where container is the one the CONNECTION is
88
+ # scoped to -- the account the person is actually working in, which a
89
+ # subject-only lambda cannot know. Prefer this whenever subjects can
90
+ # belong to more than one org; roster_org picks by association order,
91
+ # which puts a switched user on the wrong roster: invisible to the
92
+ # teammates they are with, visible to an org they are not.
93
+ # config.roster_org_for = ->(user, container) { container || user.accounts.first }
94
+ attr_accessor :roster_org_for
95
+
86
96
  attr_accessor :roster_org, :roster_members, :roster_members_association
87
97
 
88
98
  # roster_visible_to: given a VIEWER, return the AR scope of subjects
@@ -142,6 +152,7 @@ module WhereIsWaldo
142
152
 
143
153
  # Presence roster (set per app to enable)
144
154
  @roster_org = nil
155
+ @roster_org_for = nil
145
156
  @roster_members = nil
146
157
  @roster_members_association = nil
147
158
  @roster_visible_to = nil
@@ -165,10 +176,17 @@ module WhereIsWaldo
165
176
  end
166
177
 
167
178
  # Resolve the org/container record for a subject (nil if unset/absent).
168
- def resolve_roster_org(subject)
169
- return nil unless roster_org && subject
170
-
171
- roster_org.call(subject)
179
+ # @param container [Object, nil] the connection's container, when known.
180
+ # Only roster_org_for receives it; roster_org keeps its old arity so
181
+ # existing apps are untouched.
182
+ def resolve_roster_org(subject, container: nil)
183
+ return nil unless subject
184
+
185
+ if roster_org_for
186
+ roster_org_for.call(subject, container)
187
+ elsif roster_org
188
+ roster_org.call(subject)
189
+ end
172
190
  end
173
191
 
174
192
  # Association name used to derive the default roster from an org when
@@ -193,7 +211,7 @@ module WhereIsWaldo
193
211
 
194
212
  # True when the live-presence roster feature is configured.
195
213
  def roster_enabled?
196
- !roster_org.nil?
214
+ !roster_org.nil? || !roster_org_for.nil?
197
215
  end
198
216
 
199
217
  # Resolve the AR scope of subjects a VIEWER may see. Defaults to the
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: where_is_waldo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.11
4
+ version: 0.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Gibson
@@ -211,6 +211,7 @@ files:
211
211
  - app/services/where_is_waldo/broadcaster.rb
212
212
  - app/services/where_is_waldo/presence_service.rb
213
213
  - app/services/where_is_waldo/roster.rb
214
+ - app/services/where_is_waldo/roster/org_keys.rb
214
215
  - app/services/where_is_waldo/roster_delivery.rb
215
216
  - lib/generators/where_is_waldo/install/install_generator.rb
216
217
  - lib/generators/where_is_waldo/install/templates/channel.rb.tt