phlex-reactive 0.14.4 → 0.15.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 +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +211 -4
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
- data/app/javascript/phlex/reactive/core.js +5 -5
- data/app/javascript/phlex/reactive/core.min.js +3 -3
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/early.js +36 -9
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +125 -140
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/form.js +63 -41
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/presence.js +205 -0
- data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
- data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +5 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
- data/app/javascript/phlex/reactive/runtime.js +310 -14
- data/lib/phlex/reactive/component/dsl.rb +5 -0
- data/lib/phlex/reactive/component/helpers.rb +94 -8
- data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
- data/lib/phlex/reactive/component/registry.rb +3 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +5 -0
- data/lib/phlex/reactive/engine.rb +9 -4
- data/lib/phlex/reactive/js.rb +44 -0
- data/lib/phlex/reactive/presence/component_methods.rb +130 -0
- data/lib/phlex/reactive/presence/roster.rb +90 -0
- data/lib/phlex/reactive/presence/transport.rb +154 -0
- data/lib/phlex/reactive/presence.rb +162 -0
- data/lib/phlex/reactive/presence_render_job.rb +26 -0
- data/lib/phlex/reactive/shortcut.rb +118 -0
- data/lib/phlex/reactive/streamable.rb +12 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +10 -0
- metadata +11 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
module Presence
|
|
8
|
+
# Every pgbus touchpoint of presence (issue #358), in one place. The
|
|
9
|
+
# transport half lives in pgbus (zoolutions/pgbus#515): presence rows keyed
|
|
10
|
+
# by SSE connection, each with the tab's seat and a state jsonb, a change
|
|
11
|
+
# hook, and a heartbeat that expires silent rows. Nothing else in the gem
|
|
12
|
+
# names a pgbus presence constant or configuration key.
|
|
13
|
+
#
|
|
14
|
+
# pgbus stays optional: the capability is a probe of the real method
|
|
15
|
+
# signatures, never a version string, and without it nothing here runs —
|
|
16
|
+
# reactive_presence raises a ConfigurationError at render instead.
|
|
17
|
+
module Transport
|
|
18
|
+
# Every presence stream's name ends in ":presence:<name>" — the parts
|
|
19
|
+
# [record, :presence, name] joined by pgbus/Turbo. Registered with the
|
|
20
|
+
# transport at boot so a subscription to one joins on connect and leaves
|
|
21
|
+
# on disconnect, with no controller code.
|
|
22
|
+
PATTERN = /:presence:[^:]+\z/
|
|
23
|
+
# Hex characters of the subject's digest that start a presence key.
|
|
24
|
+
DIGEST_LENGTH = 16
|
|
25
|
+
# What joins the digest and the presence name.
|
|
26
|
+
SEPARATOR = ":presence:"
|
|
27
|
+
|
|
28
|
+
# pgbus's default streams_heartbeat_interval (seconds), used when pgbus
|
|
29
|
+
# is absent or does not expose the setting.
|
|
30
|
+
DEFAULT_HEARTBEAT = 15
|
|
31
|
+
|
|
32
|
+
# The change hook install! sets: the app's own hook (if any) first, then
|
|
33
|
+
# presence's. Its class is how a second install! knows it is in place.
|
|
34
|
+
ChainedHook = Data.define(:previous, :on_change) do
|
|
35
|
+
def call(stream_name, change)
|
|
36
|
+
previous&.call(stream_name, change)
|
|
37
|
+
on_change.call(stream_name, change)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# The code is there: pgbus with reactive Streams AND presence keyed by
|
|
43
|
+
# connection (a join that takes connection_id:, a set_state,
|
|
44
|
+
# seats_supported?), a change hook and a ttl setting, and ActiveJob
|
|
45
|
+
# (the roster re-renders off the hook's thread). Signatures only, no
|
|
46
|
+
# query: boot and class load gate on this, and both run before a
|
|
47
|
+
# database exists (`rails db:create`, `assets:precompile`). What it
|
|
48
|
+
# gates is configuration, harmless on an unmigrated table.
|
|
49
|
+
def wired?
|
|
50
|
+
return false unless Phlex::Reactive.pgbus_streams?
|
|
51
|
+
return false unless defined?(::Pgbus::Streams::Presence) && defined?(::ActiveJob::Base)
|
|
52
|
+
|
|
53
|
+
presence = ::Pgbus::Streams::Presence
|
|
54
|
+
join_keys = presence.instance_method(:join).parameters.map(&:last)
|
|
55
|
+
return false unless join_keys.include?(:connection_id) && presence.method_defined?(:set_state)
|
|
56
|
+
return false unless presence.respond_to?(:seats_supported?)
|
|
57
|
+
|
|
58
|
+
# streams_authorize= marks pgbus 0.18 (#524), which also fires
|
|
59
|
+
# pgbus:connected only after the seat joined (#523) and drops a
|
|
60
|
+
# closed tab at once (#520); neither of those has a signature.
|
|
61
|
+
config = ::Pgbus.configuration
|
|
62
|
+
%i[streams_presence_on_change= streams_presence_ttl streams_presence_patterns streams_authorize=].all? do
|
|
63
|
+
config.respond_to?(it)
|
|
64
|
+
end
|
|
65
|
+
rescue ::NameError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Wired AND the seat table is migrated. seats_supported? is a query
|
|
70
|
+
# (pgbus memoizes a success), so only a render and the render job ask.
|
|
71
|
+
# A database error propagates: answering false would hide it.
|
|
72
|
+
def capable?
|
|
73
|
+
wired? && ::Pgbus::Streams::Presence.seats_supported?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# How often the transport's heartbeat touches a live tab (seconds):
|
|
77
|
+
# configuration only, no query, so a declaration can read it at
|
|
78
|
+
# class load.
|
|
79
|
+
def heartbeat_interval
|
|
80
|
+
config = ::Pgbus.configuration if Phlex::Reactive.pgbus? && ::Pgbus.respond_to?(:configuration)
|
|
81
|
+
interval = config.streams_heartbeat_interval if config.respond_to?(:streams_heartbeat_interval)
|
|
82
|
+
interval.is_a?(Numeric) ? interval : DEFAULT_HEARTBEAT
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The stream name the transport (and every Turbo broadcast) uses for
|
|
86
|
+
# these parts.
|
|
87
|
+
# "<16 hex of the subject>:presence:<name>": the subject (the record,
|
|
88
|
+
# or a record-less roster's key) becomes a fixed-length digest of its
|
|
89
|
+
# stream name, so the key fits pgbus's queue-name budget (41 chars by
|
|
90
|
+
# default) whatever the model is called — the raw gid form, or pgbus's
|
|
91
|
+
# own "<param_key>_<digest>" fragment, overflows it for Document,
|
|
92
|
+
# Conversation, ProjectBoard, …. Leaves 15 chars for the presence
|
|
93
|
+
# name; ":presence:<name>" stays the suffix the patterns match. Not
|
|
94
|
+
# reversible: the render job finds the record through the row's note.
|
|
95
|
+
# pgbus raises ArgumentError when it still does not fit.
|
|
96
|
+
def stream_name(parts)
|
|
97
|
+
*subject, presence, name = parts
|
|
98
|
+
digest = Digest::SHA256.hexdigest(::Pgbus::Streams::Stream.name_from(subject))[0, DIGEST_LENGTH]
|
|
99
|
+
::Pgbus.stream_key!("#{digest}:#{presence}:#{name}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def stream_name_budget = ::Pgbus.stream_name_budget
|
|
103
|
+
|
|
104
|
+
# Whether a presence name fits the key: pure configuration (the queue
|
|
105
|
+
# prefix), no database, so the declaration can check it.
|
|
106
|
+
def fits?(name) = DIGEST_LENGTH + SEPARATOR.length + name.to_s.length <= stream_name_budget
|
|
107
|
+
|
|
108
|
+
def over_budget_message(klass, name, detail = nil)
|
|
109
|
+
"#{klass.name}: the presence stream for reactive_presence :#{name} does not fit pgbus's " \
|
|
110
|
+
"stream-name budget of #{stream_name_budget} chars (a #{DIGEST_LENGTH}-char digest of the record, " \
|
|
111
|
+
"\":presence:\" and the name). Use a shorter presence name.#{" (#{detail})" if detail}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The signed SSE URL a <pgbus-stream-source> subscribes with.
|
|
115
|
+
def source_src(stream_name) = Phlex::Reactive::Defer.stream_src(stream_name)
|
|
116
|
+
|
|
117
|
+
# The rows of a stream: Hashes with "id" (the member), "connection_id",
|
|
118
|
+
# "seat", "state", "metadata" and "joined_at".
|
|
119
|
+
def members(stream_name) = ::Pgbus.stream(stream_name).presence.members
|
|
120
|
+
|
|
121
|
+
# Writes the state of ONE connection's row on the stream (pgbus
|
|
122
|
+
# shallow-merges it into what the row had).
|
|
123
|
+
def set_state(stream_name, connection_id:, state:)
|
|
124
|
+
::Pgbus.stream(stream_name).presence.set_state(connection_id:, state:)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# At boot: auto-join presence streams, and hear their changes. An
|
|
128
|
+
# app's own change hook keeps running (chained, first).
|
|
129
|
+
def install!(on_change)
|
|
130
|
+
config = ::Pgbus.configuration
|
|
131
|
+
patterns = config.streams_presence_patterns
|
|
132
|
+
patterns << PATTERN unless patterns.include?(PATTERN)
|
|
133
|
+
previous = config.streams_presence_on_change
|
|
134
|
+
return if previous.is_a?(ChainedHook)
|
|
135
|
+
|
|
136
|
+
config.streams_presence_on_change = ChainedHook.new(previous:, on_change:)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A component's ttl: — seconds a silent seat survives — keyed by its
|
|
140
|
+
# presence name, which every stream of that component ends with.
|
|
141
|
+
# pgbus takes an Integer (every stream) or a Hash of stream name /
|
|
142
|
+
# pattern => seconds with a :default; an Integer becomes the Hash's
|
|
143
|
+
# default so the app's own setting still covers every other stream.
|
|
144
|
+
def register_ttl(name, seconds)
|
|
145
|
+
config = ::Pgbus.configuration
|
|
146
|
+
ttl = config.streams_presence_ttl
|
|
147
|
+
config.streams_presence_ttl = ttl = { default: ttl } unless ttl.is_a?(Hash)
|
|
148
|
+
ttl.store(/:presence:#{Regexp.escape(name.to_s)}\z/, seconds)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# Presence (issue #358): who is viewing a stream, one SEAT per browser tab,
|
|
6
|
+
# each with an ephemeral, declared state (the item it has selected), and a
|
|
7
|
+
# roster component that re-renders itself in every viewer's tab on join,
|
|
8
|
+
# leave, expiry and state change.
|
|
9
|
+
#
|
|
10
|
+
# class Boards::Viewers < Phlex::HTML
|
|
11
|
+
# include Phlex::Reactive::Component
|
|
12
|
+
#
|
|
13
|
+
# reactive_record :board
|
|
14
|
+
# reactive_presence :viewers, state: { selected: :integer }, ttl: 45
|
|
15
|
+
#
|
|
16
|
+
# def view_template
|
|
17
|
+
# div(**reactive_root) do
|
|
18
|
+
# presence.members.each do |member|
|
|
19
|
+
# span(**reactive_presence_member(member)) { member.metadata["name"] }
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# The transport does the membership (pgbus, zoolutions/pgbus#515): an SSE
|
|
26
|
+
# connection to a presence stream joins on connect and leaves on
|
|
27
|
+
# disconnect, a silent one expires after the stream's ttl, and each change
|
|
28
|
+
# calls the hook this module installs. The hook enqueues
|
|
29
|
+
# PresenceRenderJob, which re-renders the roster for everyone.
|
|
30
|
+
#
|
|
31
|
+
# Every pgbus call is in Presence::Transport. Without a capable transport
|
|
32
|
+
# (no pgbus, an older one, Action Cable) a presence root raises
|
|
33
|
+
# Phlex::Reactive::ConfigurationError at render: there is no degraded mode.
|
|
34
|
+
module Presence
|
|
35
|
+
# The reverse note a presence write leaves in the seat's state: the class
|
|
36
|
+
# that wrote it and the record it renders. A stream name cannot be turned
|
|
37
|
+
# back into a record (pgbus digests long names), so the render job reads
|
|
38
|
+
# this from any seat still on the stream. Never shown to the app: Peer#state
|
|
39
|
+
# holds only the declared keys.
|
|
40
|
+
NOTE_KEY = "_rp"
|
|
41
|
+
|
|
42
|
+
# A silent seat survives this many seconds (the transport's heartbeat,
|
|
43
|
+
# 15 s by default, keeps a live one fresh: two missed beats).
|
|
44
|
+
DEFAULT_TTL = 45
|
|
45
|
+
|
|
46
|
+
# Roster broadcasts coalesce: a page reload is a leave and a join within
|
|
47
|
+
# milliseconds, and a burst of selections is one morph per window.
|
|
48
|
+
COALESCE_MS = 150
|
|
49
|
+
|
|
50
|
+
# The client sends its presence source's connection id in this header, so
|
|
51
|
+
# a write lands on the row of THIS tab's presence connection — not on the
|
|
52
|
+
# first <pgbus-stream-source> on the page (X-Pgbus-Connection).
|
|
53
|
+
HEADER = "X-Reactive-Presence"
|
|
54
|
+
|
|
55
|
+
# What reactive_presence declared: the presence name, the state schema,
|
|
56
|
+
# the ttl, and the raw stream key parts of a record-less component.
|
|
57
|
+
Declaration = Data.define(:name, :state, :ttl, :key)
|
|
58
|
+
|
|
59
|
+
# One tab on the stream. `seat` is the tab's id (opaque; rendered by
|
|
60
|
+
# reactive_presence_peer so the client can find "me"), `state` the
|
|
61
|
+
# declared keys only. No connection id: it is the bearer key for writing
|
|
62
|
+
# the tab's row, so it stays inside the Roster.
|
|
63
|
+
Peer = Data.define(:seat, :member_id, :metadata, :state, :joined_at)
|
|
64
|
+
|
|
65
|
+
# One person: their seats, the newest one's metadata and state.
|
|
66
|
+
Member = Data.define(:id, :metadata, :seats) do
|
|
67
|
+
def state = seats.last&.state || {}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# Wired and migrated (a query: render time only). See Transport.
|
|
72
|
+
def capable? = Transport.capable?
|
|
73
|
+
|
|
74
|
+
# The actor's presence connection during an action (the HEADER), or nil.
|
|
75
|
+
def connection_id
|
|
76
|
+
Thread.current[:phlex_reactive_presence_connection]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def with_connection_id(connection_id)
|
|
80
|
+
previous = Thread.current[:phlex_reactive_presence_connection]
|
|
81
|
+
Thread.current[:phlex_reactive_presence_connection] = connection_id.presence
|
|
82
|
+
yield
|
|
83
|
+
ensure
|
|
84
|
+
Thread.current[:phlex_reactive_presence_connection] = previous
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Raise unless the transport can carry presence — at first render of a
|
|
88
|
+
# presence root, naming what is missing.
|
|
89
|
+
def require_capable!(klass)
|
|
90
|
+
return if capable?
|
|
91
|
+
|
|
92
|
+
raise Phlex::Reactive::ConfigurationError,
|
|
93
|
+
"#{klass.name} declares reactive_presence, which needs pgbus >= 0.18.1 (presence keyed by " \
|
|
94
|
+
"SSE connection, config.streams_authorize) with its presence table created or migrated " \
|
|
95
|
+
"(`rails g pgbus:add_presence`, `rails g pgbus:add_presence_seats`, or `rails pgbus:update`), " \
|
|
96
|
+
"and ActiveJob. Action Cable has no connection identity, so there is no fallback."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Boot (the engine's after_initialize): register the presence stream
|
|
100
|
+
# pattern and the change hook. Gated on the signatures alone, never a
|
|
101
|
+
# query — boot runs before a database exists. Nothing is touched
|
|
102
|
+
# without them: an app on Action Cable boots exactly as before.
|
|
103
|
+
def install!
|
|
104
|
+
Transport.install!(method(:changed)) if Transport.wired?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The transport's change hook (it runs on the SSE dispatcher thread):
|
|
108
|
+
# enqueue a roster render for a presence stream, nothing else.
|
|
109
|
+
def changed(stream_name, _change)
|
|
110
|
+
return unless Transport::PATTERN.match?(stream_name.to_s)
|
|
111
|
+
|
|
112
|
+
Phlex::Reactive::PresenceRenderJob.perform_later(stream_name.to_s)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The job's body: rebuild every roster component a seat on the stream
|
|
116
|
+
# names, render it as the table is NOW, and morph it into every viewer's
|
|
117
|
+
# tab (the joiner included: its first paint ran before its connection
|
|
118
|
+
# existed). Nobody left, or a gone record, renders nothing.
|
|
119
|
+
def render_roster(stream_name)
|
|
120
|
+
return unless capable?
|
|
121
|
+
|
|
122
|
+
# One render per class and record. A roster's reactive_state is shared
|
|
123
|
+
# markup under one id, the same for every viewer, so the state any
|
|
124
|
+
# note carries will do; per-viewer state belongs in presence state.
|
|
125
|
+
notes = Transport.members(stream_name).filter_map { it["state"]&.[](NOTE_KEY) }
|
|
126
|
+
notes.uniq { [it["c"], it["gid"]] }.each do
|
|
127
|
+
component = build(it, stream_name)
|
|
128
|
+
broadcast(component, exclude: nil) if component
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Morph `component`'s roster into its stream's viewers.
|
|
133
|
+
def broadcast(component, exclude:)
|
|
134
|
+
Phlex::Reactive::Streamable.broadcast_component(
|
|
135
|
+
component.class, :replace, component, component, [component.reactive_presence_stream_name],
|
|
136
|
+
morph: true, target: nil, exclude:, visible_to: nil, coalesce: COALESCE_MS
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A roster component rebuilt from a note: the named class, only when it
|
|
141
|
+
# declares presence, with its record re-found (nil when it is gone).
|
|
142
|
+
def build(note, stream_name)
|
|
143
|
+
klass = note["c"].to_s.safe_constantize
|
|
144
|
+
return unless klass.respond_to?(:reactive_presence_declaration) && klass.reactive_presence_declaration
|
|
145
|
+
|
|
146
|
+
klass.from_identity(note.slice("gid", "s"))
|
|
147
|
+
rescue ::ActiveRecord::RecordNotFound
|
|
148
|
+
# The record is gone: there is nothing left to render.
|
|
149
|
+
nil
|
|
150
|
+
rescue Phlex::Reactive::Error => e
|
|
151
|
+
# The note cannot rebuild the component (no gid for one that needs
|
|
152
|
+
# its record): render nothing rather than fail the job on every retry.
|
|
153
|
+
Phlex::Reactive.send(:default_logger)&.warn(
|
|
154
|
+
"[phlex-reactive] presence stream #{stream_name}: cannot rebuild #{note["c"]} from its note " \
|
|
155
|
+
"(#{e.message}); its roster is not re-rendered"
|
|
156
|
+
)
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# A presence stream changed (issue #358): a tab joined, left, or expired.
|
|
6
|
+
# Re-render the roster as the table is when the job runs — not the change
|
|
7
|
+
# it was enqueued for — and morph it into every viewer's tab. A page
|
|
8
|
+
# reload (a leave, then a join within milliseconds) therefore renders the
|
|
9
|
+
# seat present, and coalescing folds the pair into one morph.
|
|
10
|
+
#
|
|
11
|
+
# Enqueued by Phlex::Reactive::Presence.changed, the transport's change
|
|
12
|
+
# hook, which runs on the SSE dispatcher thread: rendering there would hold
|
|
13
|
+
# it up. On defer_job_queue, like the push lane's render job.
|
|
14
|
+
#
|
|
15
|
+
# NOT eager-loaded (see the loader config in phlex/reactive.rb): the class
|
|
16
|
+
# body needs ActiveJob::Base, which isn't a gem dependency — the constant
|
|
17
|
+
# is only referenced behind Phlex::Reactive::Presence.capable?.
|
|
18
|
+
class PresenceRenderJob < ::ActiveJob::Base
|
|
19
|
+
queue_as { Phlex::Reactive.defer_job_queue }
|
|
20
|
+
|
|
21
|
+
def perform(stream_name)
|
|
22
|
+
Phlex::Reactive::Presence.render_roster(stream_name)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# A page-level keyboard shortcut (issue #357), the value behind
|
|
6
|
+
# `on(..., shortcut:)` and `on_client(:keydown, ..., shortcut:)`.
|
|
7
|
+
#
|
|
8
|
+
# Shortcut.parse("mod+z") # String form
|
|
9
|
+
# Shortcut.parse({ keys: "esc", in_editable: true }) # Hash form
|
|
10
|
+
#
|
|
11
|
+
# `mod` is meta on Apple platforms and ctrl elsewhere. Stimulus has no
|
|
12
|
+
# "either" modifier (its key filter compares all four modifier flags
|
|
13
|
+
# exactly), so a `mod` chord renders TWO descriptors, a meta twin and a ctrl
|
|
14
|
+
# twin, and the client runs only the twin for its platform. Stimulus keeps
|
|
15
|
+
# the key filtering; the client adds the platform check, the editable-target
|
|
16
|
+
# guard and preventDefault. The exception is a key Stimulus's default map
|
|
17
|
+
# lacks (delete, backspace — issue #366): it binds a bare window keydown,
|
|
18
|
+
# no twins, and the client matches the whole chord.
|
|
19
|
+
#
|
|
20
|
+
# Validation is loud, at render: Stimulus reports an unknown key token by
|
|
21
|
+
# throwing at keypress time, which is too late to notice.
|
|
22
|
+
class Shortcut
|
|
23
|
+
MODIFIERS = %w[mod meta ctrl alt shift].freeze
|
|
24
|
+
# Stimulus's default keyMappings: the named keys plus a-z and 0-9.
|
|
25
|
+
STIMULUS_KEYS = %w[enter tab esc space up down left right home end page_up page_down].freeze
|
|
26
|
+
# Keys Stimulus's default map lacks (issue #366): a key filter naming one
|
|
27
|
+
# makes Stimulus throw at keypress, so such a chord renders a bare
|
|
28
|
+
# `keydown@window` and the client alone matches it.
|
|
29
|
+
LIBRARY_KEYS = %w[delete backspace].freeze
|
|
30
|
+
NAMED_KEYS = (STIMULUS_KEYS + LIBRARY_KEYS).freeze
|
|
31
|
+
HASH_KEYS = %i[keys in_editable].freeze
|
|
32
|
+
|
|
33
|
+
private_constant :MODIFIERS, :STIMULUS_KEYS, :LIBRARY_KEYS, :NAMED_KEYS, :HASH_KEYS
|
|
34
|
+
|
|
35
|
+
attr_reader :keys
|
|
36
|
+
|
|
37
|
+
def self.parse(spec)
|
|
38
|
+
case spec
|
|
39
|
+
when String then new(spec, in_editable: false)
|
|
40
|
+
when Hash then from_hash(spec)
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"shortcut: takes a String (\"mod+z\") or a Hash ({ keys: \"esc\", in_editable: true }), " \
|
|
44
|
+
"got #{spec.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.from_hash(spec)
|
|
49
|
+
keys = spec[:keys]
|
|
50
|
+
in_editable = spec.fetch(:in_editable, false)
|
|
51
|
+
unless (spec.keys - HASH_KEYS).empty? && keys.is_a?(String)
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"shortcut: as a Hash takes keys: (a String, e.g. \"esc\") and an optional " \
|
|
54
|
+
"in_editable: (true/false), got #{spec.inspect}"
|
|
55
|
+
end
|
|
56
|
+
unless [true, false].include?(in_editable)
|
|
57
|
+
raise ArgumentError, "shortcut: in_editable: is true or false, got #{in_editable.inspect}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
new(keys, in_editable:)
|
|
61
|
+
end
|
|
62
|
+
private_class_method :from_hash
|
|
63
|
+
|
|
64
|
+
def initialize(keys, in_editable:)
|
|
65
|
+
tokens = keys.downcase.split("+").map(&:strip)
|
|
66
|
+
@in_editable = in_editable
|
|
67
|
+
@keys = canonical(keys, tokens).freeze
|
|
68
|
+
freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# False for a chord whose key Stimulus's default map lacks (delete,
|
|
72
|
+
# backspace): it binds without a key filter and without twins — the
|
|
73
|
+
# client's shortcut check matches the key and picks the platform's `mod`.
|
|
74
|
+
def stimulus_filterable? = !LIBRARY_KEYS.include?(@keys.split("+").last)
|
|
75
|
+
|
|
76
|
+
# The Stimulus key filters to bind: the meta and ctrl twins for a `mod`
|
|
77
|
+
# chord, else the chord itself.
|
|
78
|
+
def filters
|
|
79
|
+
return [@keys] unless @keys.start_with?("mod+")
|
|
80
|
+
|
|
81
|
+
%w[meta ctrl].map { @keys.sub("mod", it) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The wire form the client reads (data-reactive-shortcut-param, or the
|
|
85
|
+
# on_client record's "shortcut"): the canonical String, or the Hash form
|
|
86
|
+
# when the shortcut also fires inside editable targets.
|
|
87
|
+
def param = @in_editable ? { "keys" => @keys, "inEditable" => true } : @keys
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def canonical(source, tokens)
|
|
92
|
+
mods = tokens.select { MODIFIERS.include?(it) }
|
|
93
|
+
key = tokens - MODIFIERS
|
|
94
|
+
validate!(source, mods, key)
|
|
95
|
+
[*MODIFIERS.select { mods.include?(it) }, key.first].join("+")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate!(source, mods, key)
|
|
99
|
+
raise ArgumentError, "shortcut: #{source.inspect} must name exactly one key (e.g. \"mod+z\")" unless key.size == 1
|
|
100
|
+
raise ArgumentError, "shortcut: #{source.inspect} repeats a modifier" unless mods.uniq.size == mods.size
|
|
101
|
+
if mods.include?("mod") && mods.intersect?(%w[meta ctrl])
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"shortcut: #{source.inspect} combines mod with meta/ctrl — mod already is meta on Apple " \
|
|
104
|
+
"platforms and ctrl elsewhere"
|
|
105
|
+
end
|
|
106
|
+
return if known_key?(key.first)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"shortcut: #{source.inspect} has an unknown key #{key.first.inspect} — use a letter, a digit, " \
|
|
110
|
+
"or one of #{NAMED_KEYS.join(", ")} (Stimulus's default key map, plus delete and backspace, " \
|
|
111
|
+
"which the library matches itself; custom keyMappings are not known on the server, bind " \
|
|
112
|
+
"those with event: and window: instead)"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def known_key?(key) = NAMED_KEYS.include?(key) || key.match?(/\A[a-z0-9]\z/)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -37,10 +37,16 @@ module Phlex
|
|
|
37
37
|
# Focus ops steal focus in every subscriber's tab (issue #96); submit
|
|
38
38
|
# (issue #226) would force-submit every subscriber's form; paste_into
|
|
39
39
|
# (issue #228) would read every subscriber's clipboard; check_group
|
|
40
|
-
# (issue #342) would tick or clear every subscriber's selection
|
|
40
|
+
# (issue #342) would tick or clear every subscriber's selection; presence
|
|
41
|
+
# (issue #358) would write every subscriber's presence state; show_modal
|
|
42
|
+
# and close_dialog (issue #365) would open or close a modal, and move
|
|
43
|
+
# focus, on every screen at once. They
|
|
41
44
|
# belong to the actor's own reply (reply.js) or gesture (on_client / a
|
|
42
45
|
# reducer's $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
|
|
43
|
-
BROADCAST_REFUSED_OPS = %w[
|
|
46
|
+
BROADCAST_REFUSED_OPS = %w[
|
|
47
|
+
focus focus_first submit paste_into persist_state persist_clear check_group presence
|
|
48
|
+
show_modal close_dialog
|
|
49
|
+
].freeze
|
|
44
50
|
|
|
45
51
|
# The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
|
|
46
52
|
# SELF-TARGETING verbs derive the target from the component's #id (require a
|
|
@@ -193,7 +199,10 @@ module Phlex
|
|
|
193
199
|
"broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
|
|
194
200
|
"steals it in every subscriber's tab; broadcasting submit force-submits every " \
|
|
195
201
|
"subscriber's form; broadcasting paste_into reads every subscriber's clipboard; " \
|
|
196
|
-
"broadcasting check_group ticks or clears every subscriber's selection
|
|
202
|
+
"broadcasting check_group ticks or clears every subscriber's selection; " \
|
|
203
|
+
"broadcasting presence writes every subscriber's presence state; " \
|
|
204
|
+
"broadcasting show_modal or close_dialog opens or closes a modal (and moves focus) on " \
|
|
205
|
+
"every screen — render the dialog open or closed, with data: { reactive_modal: true }, instead." \
|
|
197
206
|
"These are actor concerns (reply.js / on_client / $ops)."
|
|
198
207
|
end
|
|
199
208
|
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -66,6 +66,12 @@ module Phlex
|
|
|
66
66
|
# authorization method / mark_authorized! / declare skip_verify_authorized).
|
|
67
67
|
class AuthorizationNotVerified < Error; end
|
|
68
68
|
|
|
69
|
+
# Raised when a feature needs a transport capability the app does not have
|
|
70
|
+
# (issue #358: reactive_presence without a pgbus that keys presence by
|
|
71
|
+
# connection). Loud at first render, with the requirement in the message —
|
|
72
|
+
# there is no degraded mode to fall back to.
|
|
73
|
+
class ConfigurationError < Error; end
|
|
74
|
+
|
|
69
75
|
# Purpose string bound into every identity token's signature so a token
|
|
70
76
|
# minted for phlex-reactive can't be replayed against another verifier use.
|
|
71
77
|
IDENTITY_PURPOSE = "phlex-reactive/identity"
|
|
@@ -1283,6 +1289,10 @@ loader.do_not_eager_load("#{__dir__}/reactive/engine.rb")
|
|
|
1283
1289
|
# boot. The constant autoloads on first reference, which only happens behind
|
|
1284
1290
|
# Phlex::Reactive.defer_push_capable? (that gate requires ActiveJob::Base).
|
|
1285
1291
|
loader.do_not_eager_load("#{__dir__}/reactive/deferred_render_job.rb")
|
|
1292
|
+
# The presence roster's render job (issue #358), for the same reason: it is
|
|
1293
|
+
# only referenced behind Phlex::Reactive::Presence.capable? (which requires
|
|
1294
|
+
# ActiveJob::Base).
|
|
1295
|
+
loader.do_not_eager_load("#{__dir__}/reactive/presence_render_job.rb")
|
|
1286
1296
|
loader.setup
|
|
1287
1297
|
|
|
1288
1298
|
require "phlex/reactive/engine" if defined?(Rails::Engine)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-reactive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -158,6 +158,9 @@ files:
|
|
|
158
158
|
- app/javascript/phlex/reactive/features/persist.js
|
|
159
159
|
- app/javascript/phlex/reactive/features/persist.min.js
|
|
160
160
|
- app/javascript/phlex/reactive/features/persist.min.js.map
|
|
161
|
+
- app/javascript/phlex/reactive/features/presence.js
|
|
162
|
+
- app/javascript/phlex/reactive/features/presence.min.js
|
|
163
|
+
- app/javascript/phlex/reactive/features/presence.min.js.map
|
|
161
164
|
- app/javascript/phlex/reactive/features/sortable.js
|
|
162
165
|
- app/javascript/phlex/reactive/features/sortable.min.js
|
|
163
166
|
- app/javascript/phlex/reactive/features/sortable.min.js.map
|
|
@@ -195,6 +198,7 @@ files:
|
|
|
195
198
|
- lib/phlex/reactive/component/helpers.rb
|
|
196
199
|
- lib/phlex/reactive/component/identity.rb
|
|
197
200
|
- lib/phlex/reactive/component/lazy.rb
|
|
201
|
+
- lib/phlex/reactive/component/presence_declaration.rb
|
|
198
202
|
- lib/phlex/reactive/component/registry.rb
|
|
199
203
|
- lib/phlex/reactive/component/selection.rb
|
|
200
204
|
- lib/phlex/reactive/component/sortable.rb
|
|
@@ -222,10 +226,16 @@ files:
|
|
|
222
226
|
- lib/phlex/reactive/param_schema.rb
|
|
223
227
|
- lib/phlex/reactive/pending.rb
|
|
224
228
|
- lib/phlex/reactive/pending/markup.rb
|
|
229
|
+
- lib/phlex/reactive/presence.rb
|
|
230
|
+
- lib/phlex/reactive/presence/component_methods.rb
|
|
231
|
+
- lib/phlex/reactive/presence/roster.rb
|
|
232
|
+
- lib/phlex/reactive/presence/transport.rb
|
|
233
|
+
- lib/phlex/reactive/presence_render_job.rb
|
|
225
234
|
- lib/phlex/reactive/reply.rb
|
|
226
235
|
- lib/phlex/reactive/response.rb
|
|
227
236
|
- lib/phlex/reactive/settle.rb
|
|
228
237
|
- lib/phlex/reactive/settles.rb
|
|
238
|
+
- lib/phlex/reactive/shortcut.rb
|
|
229
239
|
- lib/phlex/reactive/show_conditions.rb
|
|
230
240
|
- lib/phlex/reactive/stream.rb
|
|
231
241
|
- lib/phlex/reactive/streamable.rb
|