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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
# Pluggable roster delivery strategies. Each app picks a mode per account (via
|
|
5
|
+
# config.roster_mode); the channels dispatch to the matching strategy. The
|
|
6
|
+
# data model (aggregation) and the wire message shapes are shared across all
|
|
7
|
+
# modes — only the *transport/trigger* differs, so the client reducer never
|
|
8
|
+
# forks.
|
|
9
|
+
#
|
|
10
|
+
# Strategies are pure/stateless (any state lives in Rails.cache): they COMPUTE
|
|
11
|
+
# streams + messages and perform cache/broadcast side effects, but the channel
|
|
12
|
+
# owns the actual ActionCable `transmit`/`stream_from`.
|
|
13
|
+
module RosterDelivery
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Resolve a strategy instance for a mode symbol.
|
|
17
|
+
def for(mode)
|
|
18
|
+
case mode.to_sym
|
|
19
|
+
when :broadcast then Broadcast.new
|
|
20
|
+
when :poll then Poll.new
|
|
21
|
+
when :nudge then Nudge.new
|
|
22
|
+
when :fanout then Fanout.new
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError, "Unknown roster_mode: #{mode.inspect}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Base contract. Channels call these; return values drive the channel's I/O.
|
|
29
|
+
class Base
|
|
30
|
+
# @return [Hash] { streams: [names], messages: [cable messages] }
|
|
31
|
+
def subscribe_plan(_subject, _session_id)
|
|
32
|
+
{ streams: [], messages: [] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Array<Hash>] cable messages to transmit to the polling client
|
|
36
|
+
def poll_messages(_subject, _session_id)
|
|
37
|
+
[]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Side effect on a presence transition (usually a broadcast). No return.
|
|
41
|
+
def on_transition(_subject_id); end
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
def config
|
|
46
|
+
WhereIsWaldo.config
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Instant push to a single shared per-account stream. NO visibility
|
|
51
|
+
# filtering — everyone in the account sees everyone. O(1) per transition.
|
|
52
|
+
class Broadcast < Base
|
|
53
|
+
def subscribe_plan(subject, _session_id)
|
|
54
|
+
org = config.resolve_roster_org(subject)
|
|
55
|
+
stream = Roster.stream_name(org)
|
|
56
|
+
return { streams: [], messages: [] } unless stream
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
streams: [stream],
|
|
60
|
+
messages: [Roster.snapshot_message(Roster.snapshot(org), mode: :broadcast)]
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def on_transition(subject_id)
|
|
65
|
+
Roster.publish(subject_id)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Heartbeat/poll pull: the client polls; the server transmits a per-viewer,
|
|
70
|
+
# server-filtered snapshot (first poll / resync) or diff (subsequent). The
|
|
71
|
+
# diff baseline lives in Rails.cache keyed per session (tab), TTL'd so a
|
|
72
|
+
# stale/expired cursor self-heals into a full snapshot. Handles ANY
|
|
73
|
+
# visibility rule via config.roster_visible_to. No broadcasts.
|
|
74
|
+
class Poll < Base
|
|
75
|
+
# Overridden by :nudge so the snapshot advertises the right mode.
|
|
76
|
+
def mode
|
|
77
|
+
:poll
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def subscribe_plan(subject, session_id)
|
|
81
|
+
current = current_members(subject)
|
|
82
|
+
write_baseline(session_id, current)
|
|
83
|
+
{ streams: subscribe_streams(subject), messages: [Roster.snapshot_message(current.values, mode: mode)] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def poll_messages(subject, session_id)
|
|
87
|
+
current = current_members(subject)
|
|
88
|
+
baseline = read_baseline(session_id)
|
|
89
|
+
write_baseline(session_id, current)
|
|
90
|
+
|
|
91
|
+
# Cache miss / expired cursor -> full snapshot (self-healing resync).
|
|
92
|
+
return [Roster.snapshot_message(current.values, mode: mode)] if baseline.nil?
|
|
93
|
+
|
|
94
|
+
diff_messages(baseline, current)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# No push work on transition — pull clients poll for changes.
|
|
98
|
+
def on_transition(_subject_id); end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Streams to subscribe on connect. :poll needs none (data rides the poll
|
|
103
|
+
# transmit); :nudge overrides this to also receive the account nudge.
|
|
104
|
+
def subscribe_streams(_subject)
|
|
105
|
+
[]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def current_members(subject)
|
|
109
|
+
Roster.members_for(config.resolve_visible_to(subject))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def diff_messages(baseline, current)
|
|
113
|
+
messages = []
|
|
114
|
+
current.each do |id, member|
|
|
115
|
+
prev = baseline[id]
|
|
116
|
+
messages << Roster.delta_message(member) if prev.nil? || presence_changed?(prev, member)
|
|
117
|
+
end
|
|
118
|
+
# Members that left the viewer's visible scope entirely (a visibility
|
|
119
|
+
# change) — tell the client to drop them.
|
|
120
|
+
(baseline.keys - current.keys).each do |gone_id|
|
|
121
|
+
messages << Roster.removed_message(gone_id)
|
|
122
|
+
end
|
|
123
|
+
messages
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def presence_changed?(prev, member)
|
|
127
|
+
prev[:status] != member[:status] || prev[:devices] != member[:devices]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def read_baseline(session_id)
|
|
131
|
+
Rails.cache.read(baseline_key(session_id))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def write_baseline(session_id, members)
|
|
135
|
+
Rails.cache.write(baseline_key(session_id), members, expires_in: config.roster_cache_ttl)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def baseline_key(session_id)
|
|
139
|
+
"where_is_waldo:roster:baseline:#{session_id}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Poll, plus a content-free nudge broadcast on transition so clients re-poll
|
|
144
|
+
# immediately instead of waiting for the next interval — near-instant
|
|
145
|
+
# latency with the same server-filtered (arbitrary-visibility) delivery. The
|
|
146
|
+
# nudge carries no identity/state, so it only reveals "activity happened".
|
|
147
|
+
class Nudge < Poll
|
|
148
|
+
def mode
|
|
149
|
+
:nudge
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def on_transition(subject_id)
|
|
153
|
+
Roster.publish_nudge(subject_id)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
# 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))
|
|
161
|
+
stream ? [stream] : []
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Instant per-viewer push for arbitrary/asymmetric visibility. Each viewer
|
|
166
|
+
# streams only their own stream and gets a server-filtered snapshot on
|
|
167
|
+
# connect; on a transition the subject's delta is pushed to every viewer in
|
|
168
|
+
# its directional audience (config.roster_viewers_of). O(audience) per
|
|
169
|
+
# transition — the price of instant delivery under restricted visibility.
|
|
170
|
+
class Fanout < Base
|
|
171
|
+
def subscribe_plan(subject, _session_id)
|
|
172
|
+
members = Roster.members_for(config.resolve_visible_to(subject))
|
|
173
|
+
{
|
|
174
|
+
streams: [Roster.viewer_stream(subject.id)],
|
|
175
|
+
messages: [Roster.snapshot_message(members.values, mode: :fanout)]
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def on_transition(subject_id)
|
|
180
|
+
Roster.publish_fanout(subject_id)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module WhereIsWaldo
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
class_option :adapter, type: :string, default: "database",
|
|
14
|
+
desc: "Storage adapter (database or redis)"
|
|
15
|
+
class_option :auth_method, type: :string, default: "jwt",
|
|
16
|
+
desc: "Authentication method (jwt, devise, custom)"
|
|
17
|
+
class_option :subject_class, type: :string, default: "User",
|
|
18
|
+
desc: "The model class being tracked (User, Member, etc.)"
|
|
19
|
+
class_option :subject_column, type: :string, default: "user_id",
|
|
20
|
+
desc: "Foreign key column name for the subject"
|
|
21
|
+
class_option :session_column, type: :string, default: "jti",
|
|
22
|
+
desc: "Column name for session identifier"
|
|
23
|
+
|
|
24
|
+
def create_initializer
|
|
25
|
+
template "initializer.rb.tt", "config/initializers/where_is_waldo.rb"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def generate_migration
|
|
29
|
+
return if adapter == "redis"
|
|
30
|
+
|
|
31
|
+
migration_template "migration.rb.tt", "db/migrate/create_presences.rb"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_channels
|
|
35
|
+
template "connection.rb.tt", "app/channels/application_cable/connection.rb"
|
|
36
|
+
template "channel.rb.tt", "app/channels/application_cable/channel.rb"
|
|
37
|
+
template "presence_channel.rb.tt", "app/channels/presence_channel.rb"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def show_post_install_message
|
|
41
|
+
say ""
|
|
42
|
+
say "WhereIsWaldo installed successfully!", :green
|
|
43
|
+
say ""
|
|
44
|
+
say "Next steps:"
|
|
45
|
+
say " 1. Review config/initializers/where_is_waldo.rb"
|
|
46
|
+
if adapter == "redis"
|
|
47
|
+
say " 2. Ensure REDIS_URL is configured"
|
|
48
|
+
else
|
|
49
|
+
say " 2. Run: rails db:migrate"
|
|
50
|
+
end
|
|
51
|
+
say " 3. Configure your frontend PresenceProvider:"
|
|
52
|
+
say ""
|
|
53
|
+
say " <PresenceProvider config={{ channelName: 'PresenceChannel' }}>"
|
|
54
|
+
say " <App />"
|
|
55
|
+
say " </PresenceProvider>"
|
|
56
|
+
say ""
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def migration_version
|
|
62
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def subject_class
|
|
66
|
+
options[:subject_class]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def subject_column
|
|
70
|
+
options[:subject_column]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def session_column
|
|
74
|
+
options[:session_column]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def auth_method
|
|
78
|
+
options[:auth_method]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def adapter
|
|
82
|
+
options[:adapter]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Detect database adapter and return appropriate JSON column type
|
|
86
|
+
def json_column_type
|
|
87
|
+
adapter_name = ActiveRecord::Base.connection.adapter_name.downcase
|
|
88
|
+
case adapter_name
|
|
89
|
+
when /postgresql/, /postgis/
|
|
90
|
+
:jsonb
|
|
91
|
+
when /mysql/, /trilogy/
|
|
92
|
+
:json
|
|
93
|
+
else
|
|
94
|
+
# SQLite and others - use text with serialization
|
|
95
|
+
:text
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError
|
|
98
|
+
# If we can't detect, default to text (most portable)
|
|
99
|
+
:text
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApplicationCable
|
|
4
|
+
class Connection < ActionCable::Connection::Base
|
|
5
|
+
<%- if auth_method == "jwt" -%>
|
|
6
|
+
# Turnkey corebyscott JWT auth: decodes the ?token= param, identifies the
|
|
7
|
+
# connection by current_user / current_container / session_id. Requires
|
|
8
|
+
# corebyscott. See WhereIsWaldo::JwtConnection to customize.
|
|
9
|
+
include WhereIsWaldo::JwtConnection
|
|
10
|
+
<%- elsif auth_method == "devise" -%>
|
|
11
|
+
identified_by :current_<%= subject_column.sub(/_id$/, '') %>, :session_id
|
|
12
|
+
|
|
13
|
+
def connect
|
|
14
|
+
self.current_<%= subject_column.sub(/_id$/, '') %> = find_verified_subject
|
|
15
|
+
self.session_id = find_session_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def find_verified_subject
|
|
21
|
+
# Find user from Devise session cookie
|
|
22
|
+
env["warden"].user || reject_unauthorized_connection
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def find_session_id
|
|
26
|
+
# Use Warden session key or generate new one
|
|
27
|
+
request.session.id || SecureRandom.uuid
|
|
28
|
+
end
|
|
29
|
+
<%- else -%>
|
|
30
|
+
identified_by :current_<%= subject_column.sub(/_id$/, '') %>, :session_id
|
|
31
|
+
|
|
32
|
+
def connect
|
|
33
|
+
self.current_<%= subject_column.sub(/_id$/, '') %> = find_verified_subject
|
|
34
|
+
self.session_id = find_session_id
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def find_verified_subject
|
|
40
|
+
# TODO: Implement your authentication logic here
|
|
41
|
+
# Return the authenticated subject or call reject_unauthorized_connection
|
|
42
|
+
raise NotImplementedError, "Implement find_verified_subject in ApplicationCable::Connection"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_session_id
|
|
46
|
+
# TODO: Return a unique session identifier (unique per browser tab)
|
|
47
|
+
SecureRandom.uuid
|
|
48
|
+
end
|
|
49
|
+
<%- end -%>
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Create<%= table_name.camelize %> < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :<%= table_name %> do |t|
|
|
6
|
+
# Identifiers - column names are configurable
|
|
7
|
+
t.string :<%= session_column %>, null: false # Unique session/connection identifier
|
|
8
|
+
t.bigint :<%= subject_column %>, null: false # Who (user, member, student, etc.)
|
|
9
|
+
|
|
10
|
+
# Timestamps
|
|
11
|
+
t.datetime :connected_at, null: false
|
|
12
|
+
t.datetime :last_heartbeat, null: false
|
|
13
|
+
t.datetime :last_activity
|
|
14
|
+
|
|
15
|
+
# Activity state
|
|
16
|
+
t.boolean :tab_visible, default: true, null: false
|
|
17
|
+
t.boolean :subject_active, default: true, null: false
|
|
18
|
+
|
|
19
|
+
# Extensible metadata
|
|
20
|
+
t.<%= json_column_type %> :metadata
|
|
21
|
+
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Session must be unique
|
|
26
|
+
add_index :<%= table_name %>, :<%= session_column %>, unique: true
|
|
27
|
+
|
|
28
|
+
# Query by subject
|
|
29
|
+
add_index :<%= table_name %>, :<%= subject_column %>
|
|
30
|
+
|
|
31
|
+
# Cleanup old records
|
|
32
|
+
add_index :<%= table_name %>, :last_heartbeat
|
|
33
|
+
|
|
34
|
+
<% if subject_table.present? -%>
|
|
35
|
+
# Foreign key to subject table
|
|
36
|
+
add_foreign_key :<%= table_name %>, :<%= subject_table %>, column: :<%= subject_column %>
|
|
37
|
+
<% end -%>
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
WhereIsWaldo.configure do |config|
|
|
4
|
+
# Storage adapter: :database or :redis
|
|
5
|
+
config.adapter = :<%= adapter %>
|
|
6
|
+
<% if adapter == "redis" %>
|
|
7
|
+
# Redis configuration
|
|
8
|
+
# config.redis_client = Redis.new(url: ENV["REDIS_URL"])
|
|
9
|
+
# config.redis_prefix = "where_is_waldo"
|
|
10
|
+
<% else %>
|
|
11
|
+
# Table name for presence records
|
|
12
|
+
config.table_name = "presences"
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
# Column names
|
|
16
|
+
config.session_column = :<%= session_column %>
|
|
17
|
+
config.subject_column = :<%= subject_column %>
|
|
18
|
+
|
|
19
|
+
# Subject model class
|
|
20
|
+
config.subject_class = "<%= subject_class %>"
|
|
21
|
+
|
|
22
|
+
# Build subject data for presence responses
|
|
23
|
+
config.subject_data_proc = ->(subject) {
|
|
24
|
+
return {} unless subject
|
|
25
|
+
{
|
|
26
|
+
id: subject.id,
|
|
27
|
+
# Add more fields as needed:
|
|
28
|
+
# first_name: subject.first_name,
|
|
29
|
+
# last_name: subject.last_name,
|
|
30
|
+
# email: subject.email,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Timing
|
|
35
|
+
config.timeout = 90 # seconds until considered offline
|
|
36
|
+
config.heartbeat_interval = 30 # expected heartbeat frequency
|
|
37
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreatePresences < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :presences do |t|
|
|
6
|
+
t.string :<%= session_column %>, null: false
|
|
7
|
+
t.references :<%= subject_column.sub(/_id$/, '') %>, null: false, foreign_key: true
|
|
8
|
+
t.datetime :connected_at, null: false
|
|
9
|
+
t.datetime :last_heartbeat, null: false
|
|
10
|
+
t.datetime :last_activity
|
|
11
|
+
t.boolean :tab_visible, default: true, null: false
|
|
12
|
+
t.boolean :subject_active, default: true, null: false
|
|
13
|
+
t.<%= json_column_type %> :metadata
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_index :presences, :<%= session_column %>, unique: true
|
|
18
|
+
add_index :presences, :last_heartbeat
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class PresenceChannel < ApplicationCable::Channel
|
|
4
|
+
def subscribed
|
|
5
|
+
stream_from subject_stream
|
|
6
|
+
register_presence
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def unsubscribed
|
|
10
|
+
WhereIsWaldo.disconnect(session_id: connection.session_id)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def heartbeat(data)
|
|
14
|
+
data = data.with_indifferent_access
|
|
15
|
+
|
|
16
|
+
WhereIsWaldo.heartbeat(
|
|
17
|
+
session_id: connection.session_id,
|
|
18
|
+
tab_visible: data[:tab_visible] != false,
|
|
19
|
+
subject_active: data[:subject_active] != false,
|
|
20
|
+
metadata: data[:metadata] || {}
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def register_presence
|
|
27
|
+
WhereIsWaldo.connect(
|
|
28
|
+
session_id: connection.session_id,
|
|
29
|
+
subject_id: connection.current_<%= subject_column.sub(/_id$/, '') %>.id,
|
|
30
|
+
metadata: params[:metadata] || {}
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def subject_stream
|
|
35
|
+
"where_is_waldo:subject:#{connection.current_<%= subject_column.sub(/_id$/, '') %>.id}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
class Configuration
|
|
5
|
+
# Storage settings
|
|
6
|
+
# :adapter - :database or :redis
|
|
7
|
+
# :table_name - defaults to 'presences'
|
|
8
|
+
# :redis_client - custom Redis instance (optional)
|
|
9
|
+
# :redis_prefix - key prefix for Redis (for multi-app setups)
|
|
10
|
+
attr_accessor :adapter, :table_name, :redis_client, :redis_prefix
|
|
11
|
+
|
|
12
|
+
# Column names - fully configurable
|
|
13
|
+
# :session_column - unique identifier per connection/tab (e.g., :session_id, :jti)
|
|
14
|
+
# :subject_column - who is present (e.g., :user_id, :member_id, :student_id)
|
|
15
|
+
attr_accessor :session_column, :subject_column
|
|
16
|
+
|
|
17
|
+
# Subject model (e.g., 'User', 'Member', 'Student')
|
|
18
|
+
# Required for scope-based broadcasting and queries
|
|
19
|
+
attr_accessor :subject_class
|
|
20
|
+
|
|
21
|
+
# Optional: proc that returns hash of subject info for presence data
|
|
22
|
+
# Called with the subject record to build presence hash
|
|
23
|
+
attr_accessor :subject_data_proc
|
|
24
|
+
|
|
25
|
+
# Timing
|
|
26
|
+
# :timeout - seconds until considered offline (default: 90)
|
|
27
|
+
# :heartbeat_interval - expected heartbeat frequency (default: 30)
|
|
28
|
+
attr_accessor :timeout, :heartbeat_interval
|
|
29
|
+
|
|
30
|
+
# ActionCable settings
|
|
31
|
+
# :channel_name - defaults to 'WhereIsWaldo::PresenceChannel'
|
|
32
|
+
# :authenticate_proc - proc to authenticate connection, receives request
|
|
33
|
+
attr_accessor :channel_name, :authenticate_proc
|
|
34
|
+
|
|
35
|
+
# Default audience resolver for the Broadcastable concern. A lambda that,
|
|
36
|
+
# given a record, returns the AR scope to broadcast to (e.g. that record's
|
|
37
|
+
# account members). Set once per app to match its container, e.g.:
|
|
38
|
+
# config.broadcast_audience = ->(rec) { rec.account.users }
|
|
39
|
+
# Models may override per-model via `broadcasts_realtime(scope: ...)`.
|
|
40
|
+
attr_accessor :broadcast_audience
|
|
41
|
+
|
|
42
|
+
# === Live presence roster ("who's around in my org/account") ===
|
|
43
|
+
#
|
|
44
|
+
# roster_org: given a subject, return the org/container record that
|
|
45
|
+
# defines the roster boundary. Its class + id key the single shared roster
|
|
46
|
+
# ActionCable stream, so every member of the same org subscribes to ONE
|
|
47
|
+
# stream and a presence change is a single O(1) broadcast (not per-member
|
|
48
|
+
# fan-out). Required to enable the roster feature — nil leaves it inert.
|
|
49
|
+
# config.roster_org = ->(subject) { subject.account }
|
|
50
|
+
#
|
|
51
|
+
# roster_members: given that org, return the AR scope of member subjects
|
|
52
|
+
# shown in the roster. Optional — defaults to org.public_send(<subjects>)
|
|
53
|
+
# inferred from subject_class (User => :users). Provide it to scope the
|
|
54
|
+
# list, e.g. only active members:
|
|
55
|
+
# config.roster_members = ->(org) { org.users.active }
|
|
56
|
+
#
|
|
57
|
+
# roster_members_association: association used to build the default roster
|
|
58
|
+
# from the org when roster_members is unset (defaults to the pluralized
|
|
59
|
+
# subject_class, e.g. :users).
|
|
60
|
+
attr_accessor :roster_org, :roster_members, :roster_members_association
|
|
61
|
+
|
|
62
|
+
# roster_visible_to: given a VIEWER, return the AR scope of subjects
|
|
63
|
+
# that viewer may see (for :poll/:nudge snapshots + diffs). Handles any
|
|
64
|
+
# visibility rule server-side. Defaults to the viewer's whole org roster
|
|
65
|
+
# (everyone-sees-everyone) when unset.
|
|
66
|
+
# config.roster_visible_to = ->(viewer) { viewer.visible_users }
|
|
67
|
+
#
|
|
68
|
+
# roster_viewers_of: given a SUBJECT, return the AR scope of viewers allowed
|
|
69
|
+
# to see it (ONLY used by :fanout, Phase 3). Must be the exact inverse of
|
|
70
|
+
# roster_visible_to.
|
|
71
|
+
attr_accessor :roster_visible_to, :roster_viewers_of
|
|
72
|
+
|
|
73
|
+
# roster_mode: delivery strategy, per account. A symbol, or a callable
|
|
74
|
+
# resolving an account -> mode. MUST be a function of the account (uniform
|
|
75
|
+
# for all its members). Default :poll (safe: server-side filtered).
|
|
76
|
+
# :poll - heartbeat/poll, server-filtered, ~interval latency
|
|
77
|
+
# :broadcast - instant shared-stream push, NO filtering (open account)
|
|
78
|
+
# :nudge - :poll + content-free trigger (Phase 2)
|
|
79
|
+
# :fanout - per-viewer push (Phase 3)
|
|
80
|
+
# config.roster_mode = ->(account) { account.everyone_admin? ? :broadcast : :poll }
|
|
81
|
+
attr_accessor :roster_mode
|
|
82
|
+
|
|
83
|
+
# Tuning (pull/nudge). roster_nudge_jitter (seconds) spreads clients' re-poll
|
|
84
|
+
# after a nudge so a change doesn't stampede every viewer at once.
|
|
85
|
+
attr_accessor :roster_poll_interval, :roster_cache_ttl, :roster_nudge_jitter
|
|
86
|
+
|
|
87
|
+
def initialize
|
|
88
|
+
# Storage defaults
|
|
89
|
+
@adapter = :database
|
|
90
|
+
@table_name = "presences"
|
|
91
|
+
@redis_client = nil
|
|
92
|
+
@redis_prefix = "where_is_waldo"
|
|
93
|
+
|
|
94
|
+
# Column defaults
|
|
95
|
+
@session_column = :session_id
|
|
96
|
+
@subject_column = :subject_id
|
|
97
|
+
|
|
98
|
+
# Subject model (required)
|
|
99
|
+
@subject_class = nil
|
|
100
|
+
@subject_data_proc = nil
|
|
101
|
+
|
|
102
|
+
# Timing defaults
|
|
103
|
+
@timeout = 90
|
|
104
|
+
@heartbeat_interval = 30
|
|
105
|
+
|
|
106
|
+
# ActionCable defaults
|
|
107
|
+
@channel_name = "WhereIsWaldo::PresenceChannel"
|
|
108
|
+
@authenticate_proc = nil
|
|
109
|
+
|
|
110
|
+
# Broadcastable default audience (set per app)
|
|
111
|
+
@broadcast_audience = nil
|
|
112
|
+
|
|
113
|
+
# Presence roster (set per app to enable)
|
|
114
|
+
@roster_org = nil
|
|
115
|
+
@roster_members = nil
|
|
116
|
+
@roster_members_association = nil
|
|
117
|
+
@roster_visible_to = nil
|
|
118
|
+
@roster_viewers_of = nil
|
|
119
|
+
@roster_mode = :poll
|
|
120
|
+
@roster_poll_interval = 15
|
|
121
|
+
@roster_cache_ttl = 90
|
|
122
|
+
@roster_nudge_jitter = 0.5
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Helper to get timeout as duration
|
|
126
|
+
def timeout_duration
|
|
127
|
+
timeout.is_a?(ActiveSupport::Duration) ? timeout : timeout.seconds
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Helper to get subject class constant
|
|
131
|
+
def subject_class_constant
|
|
132
|
+
return nil if subject_class.blank?
|
|
133
|
+
|
|
134
|
+
subject_class.is_a?(String) ? subject_class.safe_constantize : subject_class
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Resolve the org/container record for a subject (nil if unset/absent).
|
|
138
|
+
def resolve_roster_org(subject)
|
|
139
|
+
return nil unless roster_org && subject
|
|
140
|
+
|
|
141
|
+
roster_org.call(subject)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Association name used to derive the default roster from an org when
|
|
145
|
+
# roster_members is not configured (e.g. subject_class "User" => :users).
|
|
146
|
+
def members_association
|
|
147
|
+
return roster_members_association if roster_members_association
|
|
148
|
+
return nil if subject_class.blank?
|
|
149
|
+
|
|
150
|
+
subject_class.to_s.demodulize.underscore.pluralize.to_sym
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Resolve the AR scope of member subjects for an org (nil if not resolvable).
|
|
154
|
+
def resolve_members(org)
|
|
155
|
+
return nil unless org
|
|
156
|
+
|
|
157
|
+
if roster_members
|
|
158
|
+
roster_members.call(org)
|
|
159
|
+
elsif (assoc = members_association) && org.respond_to?(assoc)
|
|
160
|
+
org.public_send(assoc)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# True when the live-presence roster feature is configured.
|
|
165
|
+
def roster_enabled?
|
|
166
|
+
!roster_org.nil?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Resolve the AR scope of subjects a VIEWER may see. Defaults to the
|
|
170
|
+
# viewer's whole org roster (everyone-sees-everyone) when unset.
|
|
171
|
+
def resolve_visible_to(viewer)
|
|
172
|
+
return nil unless viewer
|
|
173
|
+
|
|
174
|
+
if roster_visible_to
|
|
175
|
+
roster_visible_to.call(viewer)
|
|
176
|
+
else
|
|
177
|
+
resolve_members(resolve_roster_org(viewer))
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Resolve the AR scope of viewers allowed to see a SUBJECT (:fanout only).
|
|
182
|
+
def resolve_viewers_of(subject)
|
|
183
|
+
return nil unless roster_viewers_of && subject
|
|
184
|
+
|
|
185
|
+
roster_viewers_of.call(subject)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Resolve the delivery mode for an account. Callable roster_mode is invoked
|
|
189
|
+
# with the account; a bare symbol is returned as-is. Defaults to :poll.
|
|
190
|
+
def resolve_mode(account)
|
|
191
|
+
mode = roster_mode
|
|
192
|
+
mode = mode.call(account) if mode.respond_to?(:call)
|
|
193
|
+
(mode || :poll).to_sym
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Build subject data hash from a subject record
|
|
197
|
+
def build_subject_data(subject)
|
|
198
|
+
return {} unless subject
|
|
199
|
+
|
|
200
|
+
if subject_data_proc
|
|
201
|
+
subject_data_proc.call(subject)
|
|
202
|
+
elsif subject.respond_to?(:id)
|
|
203
|
+
{ id: subject.id }
|
|
204
|
+
else
|
|
205
|
+
{}
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|