chats 0.1.1 → 0.6.2

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. metadata +51 -2
@@ -6,20 +6,13 @@ module Chats
6
6
  before_action :set_conversation
7
7
  before_action :set_message, only: %i[show update destroy]
8
8
  before_action :require_ownership!, only: %i[update destroy]
9
+ # A locked conversation refuses every write, not just new messages.
10
+ # `create` is NOT in this list: its own validation produces the same
11
+ # response, and going through the model keeps the "locked since you
12
+ # opened the composer" race in one place.
13
+ before_action :refuse_when_locked!, only: %i[update destroy]
9
14
 
10
- # Per-sender send throttle via Rails 8's built-in controller rate
11
- # limiting (https://api.rubyonrails.org/classes/ActionController/RateLimiting.html).
12
- # Feature-detected so the gem still loads on Rails 7.1 (where this is
13
- # simply not enforced). Keyed by messager, not IP — one abusive account
14
- # behind a corporate NAT must not silence the rest.
15
- if respond_to?(:rate_limit) && Chats.config.send_rate_limit
16
- rate_limit(
17
- **Chats.config.send_rate_limit,
18
- only: :create,
19
- by: -> { send(Chats.config.current_messager_method)&.to_gid&.to_s || request.remote_ip },
20
- with: -> { head :too_many_requests }
21
- )
22
- end
15
+ include Chats::SendRateLimited
23
16
 
24
17
  # A single bubble, re-rendered. Exists for one delightful reason: it's
25
18
  # the "cancel edit" target — replacing the inline edit form back with the
@@ -40,6 +33,17 @@ module Chats
40
33
  format.turbo_stream
41
34
  format.html { redirect_to conversation_path(@conversation) }
42
35
  end
36
+ elsif locked?
37
+ # The subject closed the conversation (Chats::ChatSubject#
38
+ # chat_locked?) — possibly while this composer sat open. Swap the
39
+ # composer for the locked notice instead of flashing an error at
40
+ # someone whose screen is now lying to them. 422, never a raise.
41
+ respond_to do |format|
42
+ format.turbo_stream { render :locked, status: :unprocessable_entity }
43
+ format.html do
44
+ redirect_to conversation_path(@conversation), alert: @conversation.locked_notice
45
+ end
46
+ end
43
47
  else
44
48
  respond_to do |format|
45
49
  format.turbo_stream { render :errors, status: :unprocessable_entity }
@@ -85,6 +89,24 @@ module Chats
85
89
  @conversation = find_conversation(params[:conversation_id])
86
90
  end
87
91
 
92
+ # Did THIS save fail because the conversation is locked? Reads the error
93
+ # type, not the conversation, so a message that also failed validation
94
+ # for another reason still reports that reason.
95
+ def locked?
96
+ @message.errors.of_kind?(:base, :locked)
97
+ end
98
+
99
+ # Gate the action, explain it in place: the composer becomes the locked
100
+ # notice (422), or a plain redirect carrying the notice without Turbo.
101
+ def refuse_when_locked!
102
+ return unless @conversation.locked?
103
+
104
+ respond_to do |format|
105
+ format.turbo_stream { render :locked, status: :unprocessable_entity }
106
+ format.html { redirect_to conversation_path(@conversation), alert: @conversation.locked_notice }
107
+ end
108
+ end
109
+
88
110
  def set_message
89
111
  @message = @conversation.messages.find(params[:id])
90
112
  end
@@ -9,6 +9,7 @@ module Chats
9
9
  def create
10
10
  conversation = find_conversation(params[:conversation_id])
11
11
  message = conversation.messages.find(params[:message_id])
12
+ return refuse_when_locked(conversation) if conversation.locked?
12
13
 
13
14
  Chats::Reaction.toggle!(
14
15
  message: message,
@@ -29,5 +30,18 @@ module Chats
29
30
  rescue ActiveRecord::RecordInvalid
30
31
  head :unprocessable_entity
31
32
  end
33
+
34
+ private
35
+
36
+ # Same shape as the messages controller: swap the composer for the
37
+ # reason, 422, never an exception page.
38
+ def refuse_when_locked(conversation)
39
+ @conversation = conversation
40
+
41
+ respond_to do |format|
42
+ format.turbo_stream { render "chats/messages/locked", status: :unprocessable_entity }
43
+ format.html { redirect_to conversation_path(conversation), alert: conversation.locked_notice }
44
+ end
45
+ end
32
46
  end
33
47
  end
@@ -5,6 +5,19 @@ module Chats
5
5
  # HOST app's views (mixed into ActionView via the engine's on_load hook,
6
6
  # the same pattern the moderate gem uses for `report_link`).
7
7
  module EngineHelper
8
+ # Every slot the bundled views render, and the whole list of them. A
9
+ # host drops `app/views/chats/slots/_<name>.html.erb` in and it appears;
10
+ # a name that isn't here renders nothing.
11
+ SLOTS = %w[
12
+ inbox_top
13
+ inbox_empty
14
+ conversation_header_actions
15
+ composer_actions
16
+ locked_composer
17
+ message_meta
18
+ dock_panel_footer
19
+ ].freeze
20
+
8
21
  # The "message this person" affordance for host pages — a listing, a
9
22
  # profile, an order. Renders nothing when there's no viewer, the viewer
10
23
  # IS the target, or policy/blocks forbid the pair, so it's always safe
@@ -12,18 +25,24 @@ module Chats
12
25
  #
13
26
  # <%= chat_button_to @driver, about: @ride, label: "Chat with driver" %>
14
27
  #
28
+ # `dock: true` opens the conversation in a floating window of the
29
+ # messaging dock (`chats_dock`) instead of navigating to it. It belongs
30
+ # with `chats_dock` on the same page: without the dock the button's Turbo
31
+ # Stream has nowhere to land and nothing happens.
32
+ #
15
33
  # The recipient/subject travel as SIGNED GlobalIDs (purpose-scoped,
16
34
  # minted here, verified in Chats::ConversationsController#create), so the
17
35
  # endpoint never trusts raw polymorphic params. `expires_in: nil` because
18
36
  # these buttons sit on long-lived pages — the default 1-month sgid expiry
19
37
  # would quietly break stale tabs.
20
- def chat_button_to(other, about: nil, label: nil, **html_options)
38
+ def chat_button_to(other, about: nil, label: nil, dock: false, **html_options)
21
39
  viewer = chats_viewer
22
40
  return if viewer.nil? || other.nil? || viewer == other
23
41
  return unless Chats.can_message?(viewer, other)
24
42
 
25
43
  params = { recipient_sgid: other.to_sgid(expires_in: nil, for: :chats_recipient).to_s }
26
44
  params[:subject_sgid] = about.to_sgid(expires_in: nil, for: :chats_subject).to_s if about
45
+ params[:dock] = 1 if dock
27
46
 
28
47
  button_to(
29
48
  label || I18n.t("chats.buttons.chat"),
@@ -38,13 +57,97 @@ module Chats
38
57
  # tab docks). Subscribes to the messager's badge stream so it updates in
39
58
  # real time without refreshing the page it sits on (see
40
59
  # Chats::Broadcasts for why badges get their own stream).
60
+ #
61
+ # The viewer's own badge also catches up on updates its stream missed
62
+ # (the chats--unread-badge controller asks GET /badge on a reconnect, on
63
+ # a return to a hidden page, on a restored page). Its wrapper is
64
+ # `display: contents` in chats.css, so it changes no layout.
41
65
  def chats_unread_badge(messager = chats_viewer)
42
66
  return if messager.nil?
43
67
 
44
- safe_join([
45
- turbo_stream_from(messager, :chats_badge),
46
- render(partial: "chats/shared/unread_badge", locals: { count: messager.unread_chats_count })
47
- ])
68
+ badge = safe_join([
69
+ turbo_stream_from(messager, :chats_badge),
70
+ render(partial: "chats/shared/unread_badge", locals: { count: messager.unread_chats_count })
71
+ ])
72
+ return badge unless messager == chats_viewer
73
+
74
+ tag.span(badge, class: "chats-unread-badge-live",
75
+ data: { controller: "chats--unread-badge",
76
+ "chats--unread-badge-url-value": chats_routes.badge_conversations_path })
77
+ end
78
+
79
+ # The messaging dock, LinkedIn / Steam style: a bar in the bottom-right
80
+ # corner with the unread badge, a panel of recent conversations, and
81
+ # floating conversation windows. One line in the host's layout:
82
+ #
83
+ # <%= chats_dock %>
84
+ #
85
+ # Renders nothing for a signed-out visitor, and nothing on the engine's
86
+ # own pages (the inbox, a thread): those already ARE the messaging UI,
87
+ # and a window there would duplicate the thread's DOM ids the live
88
+ # streams address. The dock is permanent across Turbo visits, so the
89
+ # server can't take it away on the next page: it also hides ITSELF on
90
+ # any page carrying `data-chats-dock-hide` (the engine's pages do; put
91
+ # it on your own messaging pages). `everywhere: true` renders it on the
92
+ # engine's pages and ignores data-chats-dock-hide (it still never opens
93
+ # a window for a conversation the page itself shows).
94
+ #
95
+ # windows: how many windows may be open at once (3, and never more
96
+ # than fit beside the panel: 2 at 1280px, 1 at 768px)
97
+ # recent: how many conversations the panel lists (10)
98
+ # launcher: false drops the phone's floating button, for a host that
99
+ # opens the sheet from its own nav (see chats_dock_toggle)
100
+ def chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)
101
+ viewer = chats_viewer
102
+ return if viewer.nil?
103
+ return if chats_engine_page? && !everywhere
104
+
105
+ render(partial: "chats/dock/dock",
106
+ locals: { viewer: viewer, windows: windows, recent: recent, launcher: launcher, everywhere: everywhere })
107
+ end
108
+
109
+ # The data that makes ANY link to a conversation — a recent-list row, a
110
+ # host's own link — open that conversation's dock window instead of
111
+ # navigating, when a dock is on the page. The link stays a real link: a
112
+ # modified click, a page without the dock, or no JavaScript navigates.
113
+ #
114
+ # <%= link_to "Chat", chats.conversation_path(c), data: chats_dock_opener(c) %>
115
+ def chats_dock_opener(conversation)
116
+ { chats_dock_conversation_id: conversation.id }
117
+ end
118
+
119
+ # The data that makes a host's own Messages link open the dock's panel
120
+ # (the full-screen sheet on a phone) instead of navigating, when a dock
121
+ # is on the page. Pair it with `chats_dock(launcher: false)` to replace
122
+ # the phone's floating button with your nav's. Without the dock, or
123
+ # without JavaScript, the link goes where its href says.
124
+ #
125
+ # <%= link_to "Messages", chats.conversations_path, data: chats_dock_toggle %>
126
+ def chats_dock_toggle
127
+ { chats_dock_toggle: true }
128
+ end
129
+
130
+ # The CLDR plural categories JavaScript's Intl.PluralRules answers with.
131
+ PLURAL_CATEGORIES = %i[zero one two few many other].freeze
132
+
133
+ # Every plural form of +key+ the current locale provides, for client-side
134
+ # pluralization ({ one: "…", few: "…", other: "…" }). The gem ships
135
+ # one/other; a host adds the forms its languages need (Polish few/many,
136
+ # Arabic zero/two…) in its own locale files. Empty when the key is
137
+ # missing, never a "translation missing" string.
138
+ def chats_plural_forms(key)
139
+ forms = I18n.t(key, default: {})
140
+ forms.is_a?(Hash) ? forms.slice(*PLURAL_CATEGORIES) : {}
141
+ end
142
+
143
+ # Whether +viewer+ muted +conversation+, read from the preloaded seats
144
+ # (the inbox loads them), so a row costs no query for it.
145
+ def chats_muted?(conversation, viewer)
146
+ return false if viewer.nil?
147
+
148
+ conversation.participants.any? do |seat|
149
+ seat.muted? && seat.messager_type == viewer.class.polymorphic_name && seat.messager_id == viewer.id
150
+ end
48
151
  end
49
152
 
50
153
  # An avatar for any messager: whatever `config.messager_avatar` returns
@@ -117,8 +220,7 @@ module Chats
117
220
  # initials disc from the group name.
118
221
  def chats_conversation_avatar(conversation, viewer)
119
222
  if conversation.direct?
120
- other = conversation.other_participants(viewer).first
121
- chats_messager_avatar(other&.messager)
223
+ chats_messager_avatar(conversation.counterpart_for(viewer))
122
224
  else
123
225
  initials = conversation.title_for(viewer).split.first(2).map { |word| word[0] }.join.upcase
124
226
  tag.span(initials.presence || "👥", class: "chats-avatar chats-avatar--initials chats-avatar--group",
@@ -126,6 +228,122 @@ module Chats
126
228
  end
127
229
  end
128
230
 
231
+ # --- Slots ----------------------------------------------------------------
232
+ #
233
+ # Named extension points the bundled views render WHEN a partial exists
234
+ # at `chats/slots/_<name>`. Hosts (and engines mounted on top of chats,
235
+ # like support_desk) drop a file in and it appears; nobody has to eject
236
+ # a whole screen to add one row or one button. Absent slots cost one
237
+ # memoized template lookup and render nothing.
238
+ #
239
+ # app/views/chats/slots/_inbox_top.html.erb
240
+ #
241
+ # The slots: inbox_top, inbox_empty, conversation_header_actions,
242
+ # composer_actions, locked_composer, message_meta, dock_panel_footer.
243
+ def chats_slot(name, **locals)
244
+ return unless chats_slot?(name)
245
+
246
+ render(partial: "chats/slots/#{name}", locals: locals)
247
+ end
248
+
249
+ # Whether a slot partial exists. Memoized per view instance, so a slot
250
+ # rendered inside a collection costs ONE lookup per request, not one per
251
+ # row. Anything outside SLOTS is ignored rather than looked up: the slot
252
+ # names are a contract, and a typo should render nothing instead of
253
+ # quietly becoming a new extension point nobody documented.
254
+ def chats_slot?(name)
255
+ key = name.to_s
256
+ return false unless Chats::EngineHelper::SLOTS.include?(key)
257
+
258
+ @chats_slots ||= {}
259
+ return @chats_slots[key] if @chats_slots.key?(key)
260
+
261
+ @chats_slots[key] = lookup_context.exists?("chats/slots/#{key}", [], true)
262
+ end
263
+
264
+ # --- Messager display -----------------------------------------------------
265
+
266
+ # Whether block/report affordances apply to this messager. False for
267
+ # `acts_as_messager blockable: false` (a support desk, a bot) — the
268
+ # bundled views hide the affordance instead of asking hosts to branch on
269
+ # class.
270
+ def chats_blockable?(messager)
271
+ Chats.blockable?(messager)
272
+ end
273
+
274
+ # A messager's profile URL per `config.messager_url`, or nil.
275
+ def chats_messager_url(messager)
276
+ Chats.messager_url_for(messager)
277
+ end
278
+
279
+ # A messager's name, linked to their profile when `config.messager_url`
280
+ # gives one and plain text when it doesn't — so the gem never renders a
281
+ # dead anchor or assumes a `user_path` exists.
282
+ def chats_messager_name(messager, css_class: nil)
283
+ name = Chats.display_name_for(messager)
284
+ url = chats_messager_url(messager)
285
+
286
+ url.present? ? link_to(name, url, class: css_class) : tag.span(name, class: css_class)
287
+ end
288
+
289
+ # The "official account" mark for a messager declared `acts_as_messager
290
+ # verified: true`, and NIL for everyone else — so any view, bundled or
291
+ # host, can drop it next to a name unconditionally:
292
+ #
293
+ # <%= chats_messager_name(author) %><%= chats_verified_badge(author) %>
294
+ #
295
+ # `config.verified_badge` swaps the glyph for the host's own design
296
+ # system; the default renders `chats/shared/_verified_badge`, whose
297
+ # colour is the `--chats-verified` CSS variable.
298
+ def chats_verified_badge(messager)
299
+ return unless Chats.verified?(messager)
300
+
301
+ custom = Chats.config.verified_badge
302
+ return custom.call(messager) if custom
303
+
304
+ render(partial: "chats/shared/verified_badge", locals: { messager: messager })
305
+ end
306
+
307
+ # The signature line under a signed message ("— Lucía G."), or nil.
308
+ def chats_message_signature(message)
309
+ Chats.message_signature_for(message)
310
+ end
311
+
312
+ # --- Grouped inbox rows ---------------------------------------------------
313
+
314
+ # Where a stacked inbox row goes: straight to the thread when the stack
315
+ # holds exactly one conversation, otherwise to the stack itself.
316
+ def chats_group_path(group)
317
+ return chats_routes.conversation_path(group.conversation) if group.single?
318
+
319
+ chats_group_path_for(group.messager)
320
+ end
321
+
322
+ # The stack list for a messager: the host's `group_path:` callable when
323
+ # `acts_as_messager` declared one (support_desk points it at its own
324
+ # screen), else chats' own filtered inbox.
325
+ def chats_group_path_for(messager)
326
+ custom = messager.class.try(:chat_group_path)
327
+ path = custom&.call(chats_viewer)
328
+
329
+ path.presence || chats_routes.conversations_path(with: Chats.inbox_with_sgid(messager))
330
+ end
331
+
332
+ # The messaging page's title: the inbox's, or +conversation+'s.
333
+ # `config.page_title` when the host sets one, else the localized
334
+ # `chats.page_title.*`. ONE source for both ways a conversation opens:
335
+ # the bundled pages hand it to `content_for :title` on a full visit, and
336
+ # the two-pane page's pane carries it (`data-title`) for chats--split to
337
+ # give the tab when a row opens it, since Turbo leaves <head> alone then.
338
+ def chats_page_title(conversation = nil)
339
+ viewer = chats_current_messager
340
+ custom = Chats.config.page_title&.call(conversation, viewer)
341
+ return custom.to_s if custom.present?
342
+ return t("chats.page_title.inbox") unless conversation
343
+
344
+ t("chats.page_title.conversation", name: conversation.title_for(viewer))
345
+ end
346
+
129
347
  # The gem's bundled stylesheet (CSS-variable themed — see chats.css).
130
348
  # Called from the engine's own views; hosts that eject + restyle the
131
349
  # views with their own framework simply don't include it.
@@ -163,6 +381,11 @@ module Chats
163
381
  respond_to?(method_name) ? send(method_name) : nil
164
382
  end
165
383
 
384
+ # Whether this view is one of the engine's own screens.
385
+ def chats_engine_page?
386
+ respond_to?(:controller) && controller.is_a?(Chats::ApplicationController)
387
+ end
388
+
166
389
  # Active Storage routes are drawn on the host app, not on this isolated
167
390
  # engine. A bare `image_tag variant` is fine in normal host views, but
168
391
  # inside engine views it asks the engine route set to polymorphically
@@ -76,8 +76,11 @@ export default class extends Controller {
76
76
  // are disabled while editing (edits are body-only by design).
77
77
 
78
78
  beginEdit(event) {
79
- const { id, body } = event.detail || {}
79
+ const { id, body, thread } = event.detail || {}
80
80
  if (!id || !this.hasInputTarget) return
81
+ // Several threads can share a page (the dock's windows): only the
82
+ // composer of the thread the message lives in edits it.
83
+ if (thread && !thread.contains(this.element)) return
81
84
 
82
85
  this.createAction ||= this.element.action
83
86
  this.element.action = `${this.createAction}/${id}`
@@ -112,6 +115,16 @@ export default class extends Controller {
112
115
  this.inputTarget.setSelectionRange(this.inputTarget.value.length, this.inputTarget.value.length)
113
116
  }
114
117
 
118
+ // Escape while editing cancels the edit, and only that: the dock (if the
119
+ // composer is in one of its windows) sees the event handled and leaves
120
+ // the window alone.
121
+ escape(event) {
122
+ if (!this.editing) return
123
+
124
+ event.preventDefault()
125
+ this.cancelEdit()
126
+ }
127
+
115
128
  cancelEdit(event) {
116
129
  event?.preventDefault()
117
130
  this.exitEdit()