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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +211 -4
  4. data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
  5. data/app/javascript/phlex/reactive/core.js +5 -5
  6. data/app/javascript/phlex/reactive/core.min.js +3 -3
  7. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  8. data/app/javascript/phlex/reactive/early.js +36 -9
  9. data/app/javascript/phlex/reactive/early.min.js +2 -2
  10. data/app/javascript/phlex/reactive/early.min.js.map +3 -3
  11. data/app/javascript/phlex/reactive/features/bindings.js +125 -140
  12. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  13. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  14. data/app/javascript/phlex/reactive/features/form.js +63 -41
  15. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  16. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  17. data/app/javascript/phlex/reactive/features/presence.js +205 -0
  18. data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
  19. data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
  20. data/app/javascript/phlex/reactive/inspect.js +5 -1
  21. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  22. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  23. data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
  24. data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
  25. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
  26. data/app/javascript/phlex/reactive/runtime.js +310 -14
  27. data/lib/phlex/reactive/component/dsl.rb +5 -0
  28. data/lib/phlex/reactive/component/helpers.rb +94 -8
  29. data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
  30. data/lib/phlex/reactive/component/registry.rb +3 -1
  31. data/lib/phlex/reactive/component.rb +1 -0
  32. data/lib/phlex/reactive/defer.rb +5 -0
  33. data/lib/phlex/reactive/engine.rb +9 -4
  34. data/lib/phlex/reactive/js.rb +44 -0
  35. data/lib/phlex/reactive/presence/component_methods.rb +130 -0
  36. data/lib/phlex/reactive/presence/roster.rb +90 -0
  37. data/lib/phlex/reactive/presence/transport.rb +154 -0
  38. data/lib/phlex/reactive/presence.rb +162 -0
  39. data/lib/phlex/reactive/presence_render_job.rb +26 -0
  40. data/lib/phlex/reactive/shortcut.rb +118 -0
  41. data/lib/phlex/reactive/streamable.rb +12 -3
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +10 -0
  44. 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. They
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[focus focus_first submit paste_into persist_state persist_clear check_group].freeze
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.14.4"
5
+ VERSION = "0.15.1"
6
6
  end
7
7
  end
@@ -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.14.4
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