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
@@ -17,6 +17,12 @@ module Chats
17
17
  # content AT REPORT TIME, so removing the body later never destroys the
18
18
  # evidence a moderator needs.
19
19
  #
20
+ # == Actions
21
+ #
22
+ # A message may carry ONE featured button — a label and a url, set by the
23
+ # host from Ruby ("Your invoice is ready" → *Pay now*). See Chats::Action
24
+ # for the value and #action_must_be_well_formed for what a safe one is.
25
+ #
20
26
  # == Real-time
21
27
  #
22
28
  # All Turbo Stream fan-out lives in Chats::Broadcasts (one place to read
@@ -28,11 +34,26 @@ module Chats
28
34
 
29
35
  KINDS = %w[text system].freeze
30
36
 
37
+ # The shape of a renderable action (see #action_must_be_well_formed): a
38
+ # label short enough to be a button, a url short enough to be a link,
39
+ # and only the schemes a browser navigates to safely.
40
+ ACTION_LABEL_LIMIT = 80
41
+ ACTION_URL_LIMIT = 2048
42
+ ACTION_URL_SCHEMES = %w[http https].freeze
43
+ # An authority ending in ":" with nothing after it ("https://host:/x").
44
+ EMPTY_PORT = %r{\A[a-z][a-z0-9+.-]*://[^/?#]*:(?:[/?#]|\z)}i
45
+ ACTION_URL_PORTS = (1..65_535)
46
+
31
47
  belongs_to :conversation,
32
48
  class_name: "Chats::Conversation",
33
49
  inverse_of: :messages,
34
50
  counter_cache: :messages_count
35
51
  belongs_to :sender, polymorphic: true, optional: true
52
+ # The person (or bot) who WROTE this on the sender's behalf — an agent
53
+ # answering from a shared support-desk seat. The sender stays the
54
+ # conversation identity ("Soporte CarHey"); the author signs the bubble
55
+ # ("— Lucía G."). Optional, and nil for every ordinary message.
56
+ belongs_to :author, polymorphic: true, optional: true
36
57
  belongs_to :reply_to, class_name: "Chats::Message", optional: true
37
58
 
38
59
  has_many :reactions,
@@ -51,10 +72,86 @@ module Chats
51
72
  # see the migration template's json_column_default).
52
73
  attribute :metadata, default: -> { {} }
53
74
 
75
+ # The featured button (Chats::Action), or nil. Its own column rather than
76
+ # a key in `metadata`, which stays the host's bag: an action is a
77
+ # contract the bundled bubble renders, so the gem must own where it lives.
78
+ attribute :action, Chats::Action::Type.new
79
+
54
80
  scope :recent_first, -> { order(created_at: :desc, id: :desc) }
55
81
  scope :oldest_first, -> { order(created_at: :asc, id: :asc) }
56
82
  scope :visible, -> { where(deleted_at: nil) }
57
83
 
84
+ # What messagers wrote: text messages that still stand. "Human" is the
85
+ # gem's word for a text message, written by a messager, person or not
86
+ # (a bot's or a headless desk's message counts), as opposed to a system
87
+ # line ("Alice joined", "Your ride was cancelled"), which is the app
88
+ # talking. A tombstone no longer says anything. A photo with no caption
89
+ # is still a text message and still counts. The inbox does NOT read through this,
90
+ # on purpose — see README "Real conversations vs system-only threads".
91
+ scope :human, -> { where(kind: "text").visible }
92
+
93
+ # The newest human message of each given conversation, at most one row
94
+ # per conversation, in ONE query: the batch twin of
95
+ # Conversation#last_human_message for lists (admin walls, moderation
96
+ # queues). +conversations+ is a page of records, ids, a single record, or
97
+ # a Chats::Conversation relation — an unloaded relation is read as a
98
+ # subquery, never loaded first, and without its `includes`/`preload`/
99
+ # `eager_load` (they load records; a subquery has none to load). So a
100
+ # relation that FILTERS on an associated table must join it explicitly
101
+ # (`joins` / `left_joins`): `includes(:x).references(:x).where(x: …)`
102
+ # loses the join and raises. Or pass the loaded page, or its ids.
103
+ #
104
+ # The result is a relation, so the columns can be narrowed:
105
+ #
106
+ # Chats::Message.last_human_in(conversations)
107
+ # .select(:id, :conversation_id, :created_at)
108
+ # .index_by(&:conversation_id) # { id => message }
109
+ #
110
+ # A conversation nobody wrote in has no row. ROW_NUMBER works on every
111
+ # supported adapter (PostgreSQL, SQLite and MySQL 8), the same ranking
112
+ # Chats::Inbox uses for its stacks. The ranked subquery is built unscoped,
113
+ # so neither a scope it is called on nor a `.select` chained onto the
114
+ # result leaks into it.
115
+ scope :last_human_in, lambda { |conversations|
116
+ ids = Chats::Message.conversation_ids_in(conversations)
117
+ next none if ids.is_a?(Array) && ids.empty?
118
+
119
+ ranking = "ROW_NUMBER() OVER (PARTITION BY chats_messages.conversation_id " \
120
+ "ORDER BY chats_messages.created_at DESC, chats_messages.id DESC) AS human_rank"
121
+ ranked = Chats::Message.unscoped.human.where(conversation_id: ids)
122
+ .select(Arel.sql("chats_messages.id"), Arel.sql(ranking))
123
+ newest = Chats::Message.unscoped.from(ranked, :ranked_messages)
124
+ .where(Arel.sql("ranked_messages.human_rank = 1"))
125
+ .select(Arel.sql("ranked_messages.id"))
126
+ where(id: newest)
127
+ }
128
+
129
+ # The conversation ids +last_human_in+ ranks within: an Array for records
130
+ # and ids (a loaded relation included, read from memory), a subquery for
131
+ # an unloaded relation, as a derived table because MySQL rejects LIMIT
132
+ # directly inside IN, and a page is usually limited.
133
+ #
134
+ # A LIMITed or OFFSET page is its own query, word for word (its select
135
+ # list, DISTINCT and ORDER BY decide WHICH rows it holds, and PostgreSQL
136
+ # refuses a DISTINCT whose ORDER BY left the select list), so it must
137
+ # expose an `id` column, as the default select list does. Any other
138
+ # relation is just a set of ids: narrowed to `id`, unordered (the inbox's
139
+ # ORDER BY COALESCE(…) next to DISTINCT id is another refusal).
140
+ def self.conversation_ids_in(conversations) # :nodoc:
141
+ unless conversations.is_a?(ActiveRecord::Relation)
142
+ return Array(conversations).map { |c| c.is_a?(Chats::Conversation) ? c.id : c }
143
+ end
144
+ unless conversations.klass <= Chats::Conversation
145
+ raise ArgumentError, "expected Chats::Conversation records, ids or a relation of them, " \
146
+ "got a #{conversations.klass.name} relation"
147
+ end
148
+ return conversations.map(&:id) if conversations.loaded?
149
+
150
+ page = conversations.except(:includes, :preload, :eager_load)
151
+ page = page.reselect(page.arel_table[:id]).reorder(nil) unless page.limit_value || page.offset_value
152
+ Chats::Conversation.unscoped.from(page, :conversation_page).select(Arel.sql("conversation_page.id"))
153
+ end
154
+
58
155
  # Keyset (cursor) pagination for infinite scroll-up. OFFSET pagination
59
156
  # drifts when new messages arrive mid-scroll; anchoring on the oldest
60
157
  # loaded message can't skip or duplicate. `(created_at, id)` because
@@ -67,17 +164,35 @@ module Chats
67
164
  }
68
165
 
69
166
  # Catch-up scopes for ConversationsController#refresh (the stale-thread
70
- # recovery path — see the thread controller's refresh trigger). `since`
71
- # is the newest `updated_at` the client has rendered:
72
- # created_since — messages that arrived while the tab was asleep
73
- # (appended);
74
- # updated_since — messages the client HAS rendered that changed since
75
- # (edits, soft-delete tombstones — replaced in place).
76
- # `updated_since` excludes fresh rows so nothing renders twice — a Turbo
77
- # append of an existing dom_id would otherwise also MOVE that bubble to
78
- # the end (Turbo removes-then-appends on id collision).
167
+ # recovery path — see the thread controller's catch-up), on the client's
168
+ # two cursors. Each is a moment, to the microsecond, up to which the
169
+ # client KNOWS it has everything (a complete render, or a catch-up that
170
+ # succeeded — never a live bubble, which can arrive past one it missed),
171
+ # with the ids it has on screen from that moment on:
172
+ # created_after(at, seen:) — created at or after that moment and not
173
+ # on screen: appended;
174
+ # created_through(at, seen:) — the complement: what the client has;
175
+ # updated_after(at, seen:) — changed after that moment, or at it and
176
+ # not among the ones seen in that version.
177
+ # Replacements are created_through(after).updated_after(changed): changes
178
+ # to what is on screen, never a second copy of what is appended — a Turbo
179
+ # append of an existing dom_id would MOVE that bubble to the end. Two
180
+ # cursors, because one can't answer both: a message created at 10:15 and
181
+ # missed is older than a 10:20 edit that did arrive.
79
182
  # Pattern from Basecamp's Campfire (Rooms::RefreshesController):
80
183
  # https://github.com/basecamp/once-campfire
184
+ scope :created_after, lambda { |at, seen: []|
185
+ where(arel_table[:created_at].gteq(at)).where.not(id: seen)
186
+ }
187
+ scope :created_through, lambda { |at, seen: []|
188
+ where(arel_table[:created_at].lt(at)).or(where(id: seen))
189
+ }
190
+ scope :updated_after, lambda { |at, seen: []|
191
+ where(arel_table[:updated_at].gt(at)).or(where(updated_at: at).where.not(id: seen))
192
+ }
193
+ # The one-cursor catch-up of bubbles ejected before they carried the
194
+ # cursors above (`data-updated-at-ms` alone, rounded up): what was
195
+ # created after it, and what was rendered by then and changed since.
81
196
  scope :created_since, ->(time) { where("chats_messages.created_at > ?", time) }
82
197
  scope :updated_since, lambda { |time|
83
198
  where("chats_messages.updated_at > ?", time).where("chats_messages.created_at <= ?", time)
@@ -89,8 +204,15 @@ module Chats
89
204
  validate :body_or_files_required
90
205
  validate :body_must_fit_length_limit
91
206
  validate :sender_must_be_active_participant, on: :create
92
- validate :sender_must_not_be_blocked, on: :create
207
+ validate :conversation_must_not_be_locked, on: :create
208
+ validate :author_must_be_persisted
209
+ # Checked at the write that SETS it, not on every save: an edit must
210
+ # never fail over an action nobody touched.
211
+ validate :action_must_be_well_formed, if: :will_save_change_to_action?
93
212
  validate :files_must_be_allowed
213
+ # Last on purpose: the host's policy is asked only of a send that is
214
+ # otherwise valid (see the method).
215
+ validate :sender_must_still_be_allowed, on: :create
94
216
 
95
217
  after_create :register_on_conversation
96
218
  after_create_commit :broadcast_created
@@ -103,6 +225,14 @@ module Chats
103
225
  def text? = kind == "text"
104
226
  def deleted? = deleted_at.present?
105
227
  def edited? = edited_at.present?
228
+ def action? = !action.nil?
229
+
230
+ # Wraps on the way in, not lazily on first read (ActiveModel casts user
231
+ # input when it is read): `message.action = "/pay"` raises right here,
232
+ # at the line that made the mistake.
233
+ def action=(value)
234
+ super(Chats::Action.wrap(value))
235
+ end
106
236
 
107
237
  def sender_key
108
238
  sender && Chats.messager_key(sender)
@@ -112,6 +242,19 @@ module Chats
112
242
  sender.present? && sender == messager
113
243
  end
114
244
 
245
+ # Written by someone OTHER than the seat it was sent from — the case a
246
+ # signature exists for. A message an author sent from their own seat is
247
+ # not "signed"; it's just theirs.
248
+ def signed?
249
+ author.present? && author != sender
250
+ end
251
+
252
+ # Whether +messager+ is the one who WROTE this (not necessarily the seat
253
+ # it was sent from).
254
+ def authored_by?(messager)
255
+ author.present? && author == messager
256
+ end
257
+
115
258
  # The body as the UI should show it (tombstones render a localized
116
259
  # "Message deleted" placeholder straight from the view, not from here —
117
260
  # this just guards against showing stale bodies by accident).
@@ -133,17 +276,28 @@ module Chats
133
276
  raise Chats::NotAllowedError, "editing is disabled" unless Chats.config.editing
134
277
  raise Chats::NotAllowedError, "can't edit a deleted message" if deleted?
135
278
 
279
+ refuse_when_locked!
136
280
  update!(body: new_body, edited_at: Time.current)
137
281
  end
138
282
 
139
283
  # Delete according to `config.deletion` (see class comment). Returns
140
284
  # false when deletion is disabled.
141
- def soft_delete!
285
+ #
286
+ # A tombstone shows neither the words nor the button that came with
287
+ # them, so the soft path clears the action too — and moderation, which
288
+ # removes content through here, takes the link down with the body.
289
+ #
290
+ # `enforce_lock: false` is for MODERATION only (see
291
+ # #remove_reported_field!): a product lock must never shield reported
292
+ # content from removal.
293
+ def soft_delete!(enforce_lock: true)
294
+ refuse_when_locked! if enforce_lock
295
+
142
296
  case Chats.config.deletion
143
297
  when :soft
144
298
  transaction do
145
299
  files.purge_later if respond_to?(:files) && files.attached?
146
- update!(body: nil, deleted_at: Time.current)
300
+ update!(body: nil, action: nil, deleted_at: Time.current)
147
301
  end
148
302
  true
149
303
  when :hard
@@ -158,6 +312,16 @@ module Chats
158
312
  respond_to?(:files) && files.attached?
159
313
  end
160
314
 
315
+ # Every WRITE to an existing message goes through here, for the same
316
+ # reason `create` validates the lock: a closed conversation is closed for
317
+ # editing and deleting too, not just for new messages. System messages
318
+ # stay exempt — the app owns them.
319
+ def refuse_when_locked! # :nodoc:
320
+ return if system? || conversation.nil? || !conversation.locked?
321
+
322
+ raise Chats::LockedError.new(conversation: conversation)
323
+ end
324
+
161
325
  # --- Moderation contract (duck-typed, zero coupling) ------------------------
162
326
  #
163
327
  # Plain-Ruby methods that make a message a first-class citizen of the
@@ -167,8 +331,14 @@ module Chats
167
331
  # seams for attachment filtering. Without moderate installed they're
168
332
  # inert and cost nothing.
169
333
 
334
+ # Who answers for this message: its AUTHOR when it is signed, else its
335
+ # sender. A signed message was written by a human from a seat that is not
336
+ # a person — an agent answering from a support desk — and the seat cannot
337
+ # be the owner a moderation flag or report points at: the host's `owner`
338
+ # is typed to its user class, so a desk there is a type mismatch raised
339
+ # from inside the agent's own reply the first time a filter trips.
170
340
  def reported_owner
171
- sender
341
+ author || sender
172
342
  end
173
343
 
174
344
  def moderation_label
@@ -186,10 +356,28 @@ module Chats
186
356
  # A moderator removing a reported message body = the soft-delete
187
357
  # tombstone path, so the thread shows "Message deleted" instead of a
188
358
  # hole, and attachments are purged with it.
359
+ #
360
+ # Returns whether anything was removed: true only when it actually
361
+ # removed something (the tombstone, or the row under `deletion: :hard`);
362
+ # false for a message already deleted (by its sender, by an earlier
363
+ # decision), already gone, or whose body is already blank. moderate
364
+ # 1.0.0.beta3 and later take that as the truth and refuse a removal that
365
+ # removed nothing, rather than record "content removed" with a statement
366
+ # of reasons that would be false; earlier versions ignore the return
367
+ # value. Decided under the message's row lock, read again under it: two
368
+ # decisions taken at once cannot both see it standing.
189
369
  def remove_reported_field!(field)
190
370
  return false unless field.to_s == "body"
191
371
 
192
- soft_delete!
372
+ with_lock do
373
+ next false if deleted? || body.blank?
374
+
375
+ # Trust & Safety outranks a product lock: a closed conversation must
376
+ # never be a place reported content can hide.
377
+ soft_delete!(enforce_lock: false)
378
+ end
379
+ rescue ActiveRecord::RecordNotFound
380
+ false # hard-deleted meanwhile: nothing left to remove
193
381
  end
194
382
 
195
383
  # Only people *in* the conversation may report a message (a message
@@ -256,15 +444,98 @@ module Chats
256
444
  errors.add(:sender, :not_a_participant) unless conversation.participant?(sender)
257
445
  end
258
446
 
259
- # Block enforcement at the WRITE, not just at conversation creation: a
260
- # block placed mid-conversation must stop the very next send. Direct
261
- # threads only — see Conversation.excluding_blocked_for for the group
262
- # rationale.
263
- def sender_must_not_be_blocked
264
- return if system? || sender.nil? || conversation.nil? || !conversation.direct?
447
+ # Who may talk to whom is decided at the WRITE, not just when the
448
+ # conversation was opened: a block placed mid-conversation stops the very
449
+ # next send, and so does the host's `can_message` policy turning false —
450
+ # an account banned, a listing closed, a membership ended. The pair was
451
+ # allowed when the thread began; what matters is whether it still is.
452
+ # Direct threads only — see Conversation.excluding_blocked_for for the
453
+ # group rationale. Two errors, because they read differently to the
454
+ # sender: `:blocked` (either side blocked the other) and `:not_allowed`
455
+ # (the policy said no).
456
+ #
457
+ # Asked last, and only of a send that is otherwise valid: a stranger to
458
+ # the conversation, a locked thread or an empty body is refused before
459
+ # the host's policy runs, so the policy is never consulted about a
460
+ # message that could not be sent anyway (it may be expensive, and it
461
+ # runs on every direct send now). A policy must not raise: an
462
+ # exception escapes the send like any other host error.
463
+ #
464
+ # A direct thread with nobody on the other side — the counterpart's
465
+ # account was destroyed and its seat went with it — takes no messages:
466
+ # there is no one the policy could allow.
467
+ def sender_must_still_be_allowed
468
+ return if system? || sender.nil? || conversation.nil? || !conversation.direct? || errors.any?
265
469
 
266
470
  other = conversation.other_participants(sender).first&.messager
267
- errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
471
+ if other.nil?
472
+ errors.add(:base, :not_allowed)
473
+ elsif Chats.blocked_between?(sender, other)
474
+ errors.add(:base, :blocked)
475
+ elsif !Chats.config.can_message.call(sender, other)
476
+ errors.add(:base, :not_allowed)
477
+ end
478
+ end
479
+
480
+ # Authorship is a signature, not a conversation seat. A host may keep
481
+ # staff in a separate model without giving them messaging capabilities.
482
+ # Never implicitly create that identity while sending a message.
483
+ def author_must_be_persisted
484
+ return if author.nil? || author.persisted?
485
+
486
+ errors.add(:author, :invalid)
487
+ end
488
+
489
+ # What a button the bubble renders may be: a short label, and a link to
490
+ # an http(s) URL or a path on this site. The host is trusted, but a bad
491
+ # action has to fail HERE, loudly, rather than render a dead link or a
492
+ # `javascript:` one.
493
+ #
494
+ # Two layers, because a browser and URI.parse disagree about where a url
495
+ # goes. First, a string check BEFORE parsing: nothing starting with `//`
496
+ # or `/\` is a path on this site. URI.parse reads `///evil.example/x`
497
+ # as a path with no host, yet a browser resolves it to
498
+ # https://evil.example/x, and treats `/\` like `//`. Then the parse,
499
+ # which rejects whitespace, control characters and backslashes anywhere
500
+ # (`/<tab>/evil.example` is stripped into `//evil.example` by browsers),
501
+ # and decides scheme, host and port.
502
+ def action_must_be_well_formed
503
+ return if action.nil?
504
+
505
+ errors.add(:action, :invalid, count: ACTION_LABEL_LIMIT) unless action_label_fits? && action_url_safe?
506
+ end
507
+
508
+ def action_label_fits?
509
+ action.label.present? && action.label.length <= ACTION_LABEL_LIMIT
510
+ end
511
+
512
+ def action_url_safe?
513
+ url = action.url
514
+ return false if url.blank? || url.length > ACTION_URL_LIMIT
515
+ return false if url.start_with?("//", "/\\")
516
+ # `https://host:` parses as the default port, so an empty port suffix
517
+ # would pass the range check below; a named port must have digits.
518
+ return false if url.match?(EMPTY_PORT)
519
+
520
+ uri = URI.parse(url)
521
+ if uri.scheme
522
+ ACTION_URL_SCHEMES.include?(uri.scheme.downcase) && uri.host.present? && ACTION_URL_PORTS.cover?(uri.port)
523
+ else
524
+ # A path on this site: "/rides/new", never "//elsewhere.example".
525
+ url.start_with?("/") && uri.host.nil?
526
+ end
527
+ rescue URI::InvalidURIError
528
+ false
529
+ end
530
+
531
+ # The subject owns the conversation's openness (Chats::ChatSubject#
532
+ # chat_locked?). System messages are exempt: the host must always be able
533
+ # to post "This ticket was closed" into the thread it just closed.
534
+ def conversation_must_not_be_locked
535
+ return if system? || conversation.nil?
536
+ return unless conversation.locked?
537
+
538
+ errors.add(:base, :locked)
268
539
  end
269
540
 
270
541
  def files_must_be_allowed
@@ -43,6 +43,8 @@ module Chats
43
43
  # concurrent joins race-safe. Same rationale as Conversation#direct_key.
44
44
  validate :group_must_have_room, on: :create
45
45
 
46
+ after_update_commit :notify_departure, if: -> { saved_change_to_left_at? && left? }
47
+
46
48
  def owner? = role == "owner"
47
49
  def left? = left_at.present?
48
50
  def active? = left_at.nil?
@@ -104,7 +106,44 @@ module Chats
104
106
  def unmute! = update!(muted_at: nil)
105
107
 
106
108
  def leave!
109
+ return self if left?
110
+
107
111
  update!(left_at: Time.current)
112
+ self
113
+ end
114
+
115
+ def notify_departure
116
+ Chats.notify(:participant_left, participant: self)
117
+ end
118
+ private :notify_departure
119
+
120
+ # Hand this seat to a different messager, keeping the read horizon, the
121
+ # role and the history: the guest who signs up, the agent who takes over
122
+ # a shared mailbox. The MESSAGES keep their original sender — what was
123
+ # said was said by whoever said it.
124
+ #
125
+ # Direct conversations have their +direct_key+ recomputed, so the thread
126
+ # keeps resolving through `chat_with` for the NEW pair instead of
127
+ # stranding a duplicate.
128
+ def reseat!(new_messager)
129
+ raise ArgumentError, "reseat! requires a messager" if new_messager.nil?
130
+ unless Chats.messager_class?(new_messager.class)
131
+ raise Chats::NotAllowedError, "#{new_messager.class.name} is not a messager (acts_as_messager)"
132
+ end
133
+
134
+ refuse_reseat_conflicts!(new_messager)
135
+
136
+ transaction do
137
+ update!(messager: new_messager)
138
+ conversation.reindex_direct_key!
139
+ end
140
+
141
+ self
142
+ rescue ActiveRecord::RecordNotUnique
143
+ # The race backstop for the checks above (two reseats, or a DM opened,
144
+ # between the check and the write). Translated so a host never has a
145
+ # driver-level exception poison its transaction.
146
+ raise Chats::NotAllowedError, "that conversation already exists for the new pair"
108
147
  end
109
148
 
110
149
  # --- Notification etiquette (for host notifier hooks) ----------------------
@@ -114,6 +153,10 @@ module Chats
114
153
  # re-derive it: don't notify yourself, the muted, the departed — and for
115
154
  # debounced email digests, don't notify twice for the same unread burst.
116
155
  def notifiable_for?(message)
156
+ # Headless messagers (`acts_as_messager notifications: false`) — a
157
+ # support desk, a bot, an org mailbox — are never notifiable. This is
158
+ # THE reason hosts no longer branch on class in their notifiers.
159
+ return false unless Chats.notifications_for?(messager)
117
160
  return false if left? || muted?
118
161
  return false if message.sender == messager
119
162
 
@@ -135,6 +178,30 @@ module Chats
135
178
 
136
179
  private
137
180
 
181
+ # Everything that would make the reseat collide, checked BEFORE the
182
+ # write. A unique-index violation inside the transaction would abort the
183
+ # host's transaction too on PostgreSQL, so the pre-check is the real
184
+ # guard and the RecordNotUnique rescue is only the race backstop.
185
+ def refuse_reseat_conflicts!(new_messager)
186
+ if conversation.participants.where.not(id: id).exists?(
187
+ messager_type: new_messager.class.polymorphic_name, messager_id: new_messager.id
188
+ )
189
+ raise Chats::NotAllowedError, "that messager already has a seat in this conversation"
190
+ end
191
+
192
+ return unless conversation.direct?
193
+
194
+ other = conversation.other_participants(messager).includes(:messager).first&.messager
195
+ return if other.nil?
196
+
197
+ existing = Chats::Conversation.direct_between(new_messager, other, about: conversation.subject)
198
+ return if existing.nil? || existing == conversation
199
+
200
+ raise Chats::NotAllowedError,
201
+ "#{Chats.display_name_for(new_messager)} already has a direct conversation with " \
202
+ "#{Chats.display_name_for(other)}"
203
+ end
204
+
138
205
  def group_must_have_room
139
206
  return if conversation.nil? || conversation.direct?
140
207
 
@@ -27,11 +27,25 @@ module Chats
27
27
  # Rails behavior: https://guides.rubyonrails.org/active_record_callbacks.html#using-both-after-create-commit-and-after-update-commit
28
28
  after_commit :broadcast_change, on: %i[create destroy]
29
29
 
30
+ # A reaction changes how its message renders, so it moves the message's
31
+ # updated_at: that is what a thread catching up on missed broadcasts asks
32
+ # for (Message.updated_after). update_columns, not touch: the
33
+ # broadcast above already re-renders the bubble, and touch would run the
34
+ # message's own update callbacks and broadcast it a second time. Not when
35
+ # the message itself is being destroyed and takes its reactions with it.
36
+ after_create :stamp_message
37
+ after_destroy :stamp_message, unless: :destroyed_by_association
38
+
30
39
  # Add the reaction if absent, remove it if present (the universal
31
40
  # tap-to-toggle semantic). Returns the created reaction, or false when
32
41
  # toggled off. Race-safe: a concurrent double-tap resolves through the
33
42
  # unique index instead of raising.
34
43
  def self.toggle!(message:, reactor:, emoji:)
44
+ # Reacting is a write to the conversation, so a locked one refuses it —
45
+ # in BOTH directions: you can't add a reaction to a closed thread, and
46
+ # you can't take one back either. Same rule as editing and deleting.
47
+ message&.conversation&.refuse_writes_when_locked!
48
+
35
49
  existing = find_by(message: message, reactor: reactor, emoji: emoji)
36
50
  if existing
37
51
  existing.destroy!
@@ -63,6 +77,10 @@ module Chats
63
77
  errors.add(:reactor, :not_a_participant) unless message.conversation.participant?(reactor)
64
78
  end
65
79
 
80
+ def stamp_message
81
+ message.update_columns(updated_at: Time.current)
82
+ end
83
+
66
84
  def broadcast_change
67
85
  Chats::Broadcasts.message_updated(message) unless message.destroyed?
68
86
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # Share one sender budget across chat and product-specific HTTP composers.
5
+ # The host owns the cache store. Using its atomic increment also works on
6
+ # Rails 7 and avoids depending on Rails' private controller limiter API.
7
+ module SendRateLimited
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ before_action :enforce_chat_send_rate_limit, only: :create
12
+ end
13
+
14
+ private
15
+
16
+ def chat_rate_limit_messager
17
+ send(Chats.config.current_messager_method)
18
+ end
19
+
20
+ def enforce_chat_send_rate_limit
21
+ limit = Chats.config.send_rate_limit
22
+ return unless limit
23
+
24
+ sender = chat_rate_limit_messager&.to_gid&.to_s || request.remote_ip
25
+ count = self.class.cache_store.increment("rate-limit:chats/messages:#{sender}", 1,
26
+ expires_in: limit.fetch(:within))
27
+ head :too_many_requests if count && count > limit.fetch(:to)
28
+ end
29
+ end
30
+ end