chats 0.1.1 → 0.2.0

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +163 -28
  4. data/app/assets/stylesheets/chats.css +44 -0
  5. data/app/controllers/chats/conversations_controller.rb +43 -43
  6. data/app/controllers/chats/messages_controller.rb +34 -0
  7. data/app/controllers/chats/reactions_controller.rb +14 -0
  8. data/app/helpers/chats/engine_helper.rb +94 -0
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_group.html.erb +38 -0
  11. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  12. data/app/views/chats/conversations/index.html.erb +39 -9
  13. data/app/views/chats/conversations/show.html.erb +44 -3
  14. data/app/views/chats/messages/_composer.html.erb +4 -0
  15. data/app/views/chats/messages/_message.html.erb +29 -8
  16. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  17. data/config/importmap.rb +2 -1
  18. data/config/locales/en.yml +11 -0
  19. data/config/locales/es.yml +11 -0
  20. data/context7.json +4 -0
  21. data/docs/PRD.md +1 -1
  22. data/docs/campfire_review.md +1 -1
  23. data/gemfiles/rails_7.1.gemfile +1 -0
  24. data/gemfiles/rails_7.2.gemfile +1 -0
  25. data/gemfiles/rails_8.1.gemfile +1 -0
  26. data/lib/chats/configuration.rb +63 -1
  27. data/lib/chats/engine.rb +29 -7
  28. data/lib/chats/errors.rb +16 -0
  29. data/lib/chats/inbox.rb +303 -0
  30. data/lib/chats/inbox_group.rb +75 -0
  31. data/lib/chats/macros.rb +24 -1
  32. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  33. data/lib/chats/models/concerns/messager.rb +81 -2
  34. data/lib/chats/models/conversation.rb +59 -5
  35. data/lib/chats/models/message.rb +67 -3
  36. data/lib/chats/models/participant.rb +59 -0
  37. data/lib/chats/models/reaction.rb +5 -0
  38. data/lib/chats/subscribers.rb +156 -0
  39. data/lib/chats/version.rb +1 -1
  40. data/lib/chats.rb +114 -15
  41. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  42. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  43. data/lib/generators/chats/templates/initializer.rb +95 -14
  44. data/lib/generators/chats/upgrade_generator.rb +48 -0
  45. metadata +12 -2
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # A stack of direct conversations that share one counterpart, shown as a
5
+ # SINGLE inbox row. Built by Chats::Inbox for messagers declared with
6
+ # `acts_as_messager inbox: :grouped` — a support desk, a marketplace
7
+ # storefront, any seat a person ends up with many separate threads with.
8
+ #
9
+ # It quacks like the parts of Chats::Conversation the inbox row needs
10
+ # (+last_message+, +last_message_at+, +unread_count+) so the two row
11
+ # partials stay symmetrical.
12
+ #
13
+ # +unread_count+ and +open_count+ are GLOBAL: they describe the whole
14
+ # stack, however deep it runs, not the bounded window in +conversations+.
15
+ # Chats::Inbox reads them with two indexed aggregates per stack.
16
+ class InboxGroup
17
+ # +conversations+ is the loaded WINDOW of the stack (freshest first,
18
+ # bounded by config.inbox_limit); +open_count+ and +unread_count+ describe
19
+ # the whole stack, however deep it runs.
20
+ attr_reader :messager, :conversations, :unread_count, :open_count
21
+
22
+ def initialize(messager:, conversations:, unread_count: 0, open_count: nil)
23
+ @messager = messager
24
+ @conversations = conversations
25
+ @unread_count = unread_count
26
+ @open_count = open_count || conversations.size
27
+ end
28
+
29
+ # A stack of one is really just a conversation: the row links straight to
30
+ # it, and the thread carries a "see all" link back to the stack.
31
+ def single?
32
+ open_count == 1
33
+ end
34
+
35
+ # The freshest conversation in the stack — what the row previews, and
36
+ # what it links to when the stack holds exactly one.
37
+ def conversation
38
+ conversations.first
39
+ end
40
+
41
+ # The stack's most recent message (the row's preview line).
42
+ def last_message
43
+ conversation&.last_message
44
+ end
45
+
46
+ # The sort key the inbox orders rows by: the freshest activity in the
47
+ # stack (mirrors Conversation.recent_first's COALESCE).
48
+ def last_message_at
49
+ conversations.filter_map { |c| c.last_message_at || c.created_at }.max
50
+ end
51
+
52
+ # Whether the stack has anything unread in it, anywhere.
53
+ def unread?
54
+ unread_count.positive?
55
+ end
56
+
57
+ # What the row is called. A stack is named after its counterpart from
58
+ # every seat, so the viewer is accepted and ignored — the signature
59
+ # matches Chats::Conversation#title_for so both row partials can call it.
60
+ def title_for(_viewer)
61
+ Chats.display_name_for(messager)
62
+ end
63
+
64
+ # Stable DOM id for the row (no AR record to derive one from).
65
+ def dom_id
66
+ "chats_inbox_group_#{messager.class.polymorphic_name.underscore.tr("/", "_")}_#{messager.id}"
67
+ end
68
+
69
+ # The signed, purpose-scoped GlobalID that filters the inbox to this
70
+ # stack (`GET /conversations?with=…`).
71
+ def with_sgid
72
+ Chats.inbox_with_sgid(messager)
73
+ end
74
+ end
75
+ end
data/lib/chats/macros.rb CHANGED
@@ -17,8 +17,31 @@ module Chats
17
17
  # lives in Chats::Messager / Chats::ChatSubject so it's discoverable,
18
18
  # testable, and `include`-able directly when a host prefers that style.
19
19
  module Macros
20
- def acts_as_messager
20
+ # Options (all optional; the defaults are 0.1.x behaviour):
21
+ #
22
+ # notifications: false this messager is never notifiable — a support
23
+ # desk, a bot, an org mailbox. `Participant#
24
+ # notifiable_for?` says no, so hosts stop
25
+ # branching on class in every notifier.
26
+ # blockable: false block/report affordances don't apply to it, so
27
+ # the bundled views hide them.
28
+ # inbox: :grouped every direct conversation with this messager
29
+ # stacks into ONE inbox row (see Chats::Inbox).
30
+ # group_path: ->(viewer) { } where that stacked row links to; defaults
31
+ # to the filtered inbox (`?with=<sgid>`).
32
+ #
33
+ # class Desk < ApplicationRecord
34
+ # acts_as_messager notifications: false, blockable: false, inbox: :grouped
35
+ # end
36
+ def acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil)
21
37
  include Chats::Messager
38
+
39
+ self.chat_options = Chats::Messager.normalize_options(
40
+ notifications: notifications,
41
+ blockable: blockable,
42
+ inbox: inbox,
43
+ group_path: group_path
44
+ )
22
45
  end
23
46
 
24
47
  def acts_as_chat_subject
@@ -31,5 +31,28 @@ module Chats
31
31
  def chat_subject_label
32
32
  "#{self.class.model_name.human} #{id}"
33
33
  end
34
+
35
+ # Whether conversations ABOUT this record still accept messages. The
36
+ # subject already owns the conversation's meaning, so it owns its
37
+ # openness too — a closed ticket, a delivered order, an archived listing:
38
+ #
39
+ # class Ticket < ApplicationRecord
40
+ # acts_as_chat_subject
41
+ # def chat_locked? = closed?
42
+ # def chat_locked_notice = "This ticket is closed. Reply to reopen it."
43
+ # end
44
+ #
45
+ # System messages are exempt (the host can always post "Ticket closed"),
46
+ # and locking is a WRITE rule only: the thread stays readable. See
47
+ # Chats::Conversation#locked?.
48
+ def chat_locked?
49
+ false
50
+ end
51
+
52
+ # The sentence shown where the composer would be. Nil falls back to the
53
+ # gem's localized "This conversation is closed."
54
+ def chat_locked_notice
55
+ nil
56
+ end
34
57
  end
35
58
  end
@@ -16,7 +16,75 @@ module Chats
16
16
  module Messager
17
17
  extend ActiveSupport::Concern
18
18
 
19
+ # What `acts_as_messager` declares about a messager class. The defaults
20
+ # are exactly 0.1.x behaviour, so a bare `acts_as_messager` is unchanged.
21
+ DEFAULT_CHAT_OPTIONS = {
22
+ notifications: true,
23
+ blockable: true,
24
+ inbox: :default,
25
+ group_path: nil
26
+ }.freeze
27
+
28
+ INBOX_MODES = %i[default grouped].freeze
29
+
30
+ # Validate + freeze the macro's options, failing at BOOT with a plain
31
+ # English message rather than at 3am with a NoMethodError.
32
+ def self.normalize_options(notifications:, blockable:, inbox:, group_path:)
33
+ inbox = inbox.to_sym
34
+ unless INBOX_MODES.include?(inbox)
35
+ raise Chats::ConfigurationError,
36
+ "acts_as_messager inbox: must be one of #{INBOX_MODES.inspect}, got #{inbox.inspect}"
37
+ end
38
+
39
+ if group_path && !group_path.respond_to?(:call)
40
+ raise Chats::ConfigurationError,
41
+ "acts_as_messager group_path: must respond to #call (a proc/lambda), got #{group_path.inspect}"
42
+ end
43
+
44
+ {
45
+ notifications: !!notifications,
46
+ blockable: !!blockable,
47
+ inbox: inbox,
48
+ group_path: group_path
49
+ }.freeze
50
+ end
51
+
52
+ class_methods do
53
+ # True unless declared with `acts_as_messager notifications: false`.
54
+ def chat_notifications?
55
+ chat_options[:notifications]
56
+ end
57
+
58
+ # True unless declared with `acts_as_messager blockable: false`.
59
+ def chat_blockable?
60
+ chat_options[:blockable]
61
+ end
62
+
63
+ # :default | :grouped (see Chats::Inbox).
64
+ def chat_inbox_mode
65
+ chat_options[:inbox]
66
+ end
67
+
68
+ # Whether every direct conversation with this messager folds into ONE
69
+ # inbox row (see Chats::InboxGroup).
70
+ def chat_grouped_inbox?
71
+ chat_inbox_mode == :grouped
72
+ end
73
+
74
+ # The `group_path:` callable, or nil (then the stacked row links to the
75
+ # filtered inbox).
76
+ def chat_group_path
77
+ chat_options[:group_path]
78
+ end
79
+ end
80
+
19
81
  included do
82
+ # Declared by `acts_as_messager`; a plain `include Chats::Messager`
83
+ # gets the defaults. class_attribute so STI subclasses inherit it.
84
+ class_attribute :chat_options,
85
+ instance_accessor: false,
86
+ default: Chats::Messager::DEFAULT_CHAT_OPTIONS
87
+
20
88
  has_many :chat_participations,
21
89
  class_name: "Chats::Participant",
22
90
  as: :messager,
@@ -63,14 +131,25 @@ module Chats
63
131
  # alice.message!(bob, "are you coming?")
64
132
  # alice.message!(bob, "about the ride", about: ride)
65
133
  # alice.message!(conversation, "hi all!", files: [photo])
66
- def message!(target, body = nil, about: nil, files: [], reply_to: nil)
134
+ #
135
+ # `author:` is the human (or bot) writing on the SENDER's behalf — an
136
+ # agent answering from a support desk seat. It signs the bubble; the
137
+ # sender stays the conversation identity. See Chats::Message#signed?.
138
+ #
139
+ # desk.message!(alice, "On it!", author: lucia)
140
+ def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil)
141
+ if author && !Chats.messager_class?(author.class)
142
+ raise Chats::NotAllowedError,
143
+ "author must be a messager (acts_as_messager), got #{author.class.name}"
144
+ end
145
+
67
146
  conversation =
68
147
  case target
69
148
  when Chats::Conversation then target
70
149
  else chat_with(target, about: about)
71
150
  end
72
151
 
73
- attributes = { sender: self, body: body, reply_to: reply_to }
152
+ attributes = { sender: self, body: body, reply_to: reply_to, author: author }
74
153
  attributes[:files] = files if files.present?
75
154
  conversation.messages.create!(**attributes)
76
155
  end
@@ -141,7 +141,12 @@ module Chats
141
141
  # `create_or_find_by!` may have FOUND a conversation created a moment
142
142
  # ago by the other side — participants are ensured idempotently
143
143
  # either way (their own unique index makes this race-safe too).
144
+ # `previously_new_record?` is how we tell the two apart, so the
145
+ # :conversation_created event fires ONCE per conversation, not on
146
+ # every resume.
147
+ created = conversation.previously_new_record?
144
148
  [a, b].each { |messager| conversation.add_participant!(messager) }
149
+ Chats.notify(:conversation_created, conversation: conversation) if created
145
150
  conversation
146
151
  end
147
152
 
@@ -158,12 +163,17 @@ module Chats
158
163
  others = Array(others) - [creator]
159
164
  raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
160
165
 
161
- transaction do
162
- conversation = create!(kind: "group", title: title, subject: about)
163
- conversation.add_participant!(creator, role: "owner")
164
- others.each { |messager| conversation.add_participant!(messager) }
165
- conversation
166
+ conversation = transaction do
167
+ created = create!(kind: "group", title: title, subject: about)
168
+ created.add_participant!(creator, role: "owner")
169
+ others.each { |messager| created.add_participant!(messager) }
170
+ created
166
171
  end
172
+
173
+ # Emitted AFTER the transaction: subscribers see a complete roster
174
+ # and never run inside the write that created it.
175
+ Chats.notify(:conversation_created, conversation: conversation)
176
+ conversation
167
177
  end
168
178
 
169
179
  # Deterministic identity for a direct pair (+ optional subject).
@@ -235,6 +245,31 @@ module Chats
235
245
  subject.try(:chat_subject_label) || "#{subject.class.model_name.human} #{subject.id}"
236
246
  end
237
247
 
248
+ # Whether new messages are refused here, decided by the SUBJECT (see
249
+ # Chats::ChatSubject#chat_locked?). A subjectless conversation is never
250
+ # locked. Reading is never affected — only sending.
251
+ def locked?
252
+ return false if subject.nil?
253
+
254
+ subject.try(:chat_locked?) || false
255
+ end
256
+
257
+ # The host's explanation for the lock, or the gem's localized fallback.
258
+ # Always a sentence worth showing: a locked composer that says nothing is
259
+ # indistinguishable from a broken one.
260
+ def locked_notice
261
+ return nil unless locked?
262
+
263
+ subject.try(:chat_locked_notice).presence || I18n.t("chats.composer.locked")
264
+ end
265
+
266
+ # The guard every write that ISN'T a message itself calls (reactions
267
+ # today). Message writes go through Chats::Message#refuse_when_locked!,
268
+ # which exempts system messages.
269
+ def refuse_writes_when_locked! # :nodoc:
270
+ raise Chats::LockedError.new(conversation: self) if locked?
271
+ end
272
+
238
273
  # --- Membership -----------------------------------------------------------
239
274
 
240
275
  # Idempotent, race-safe membership. Re-adding someone who left re-joins
@@ -250,6 +285,25 @@ module Chats
250
285
  participant
251
286
  end
252
287
 
288
+ # Recompute the deterministic identity of a DIRECT thread after its
289
+ # roster changed (see Chats::Participant#reseat!). Without this the key
290
+ # would still name the old pair, and `chat_with` would open a SECOND
291
+ # thread for the new one. No-op for groups (they have no key).
292
+ def reindex_direct_key! # :nodoc:
293
+ return self unless direct?
294
+
295
+ # `reset`: the caller just changed a seat, and a participants
296
+ # association loaded BEFORE that would name the old pair.
297
+ messagers = participants.reset.includes(:messager).filter_map(&:messager)
298
+ return self unless messagers.size == 2
299
+
300
+ update_columns(
301
+ direct_key: self.class.direct_key_for(messagers, subject: subject),
302
+ updated_at: Time.current
303
+ )
304
+ self
305
+ end
306
+
253
307
  # --- Messaging ------------------------------------------------------------
254
308
 
255
309
  # Post a message from your APP into the conversation — "Your ride was
@@ -33,6 +33,11 @@ module Chats
33
33
  inverse_of: :messages,
34
34
  counter_cache: :messages_count
35
35
  belongs_to :sender, polymorphic: true, optional: true
36
+ # The person (or bot) who WROTE this on the sender's behalf — an agent
37
+ # answering from a shared support-desk seat. The sender stays the
38
+ # conversation identity ("Soporte CarHey"); the author signs the bubble
39
+ # ("— Lucía G."). Optional, and nil for every ordinary message.
40
+ belongs_to :author, polymorphic: true, optional: true
36
41
  belongs_to :reply_to, class_name: "Chats::Message", optional: true
37
42
 
38
43
  has_many :reactions,
@@ -90,6 +95,8 @@ module Chats
90
95
  validate :body_must_fit_length_limit
91
96
  validate :sender_must_be_active_participant, on: :create
92
97
  validate :sender_must_not_be_blocked, on: :create
98
+ validate :conversation_must_not_be_locked, on: :create
99
+ validate :author_must_be_a_messager
93
100
  validate :files_must_be_allowed
94
101
 
95
102
  after_create :register_on_conversation
@@ -112,6 +119,19 @@ module Chats
112
119
  sender.present? && sender == messager
113
120
  end
114
121
 
122
+ # Written by someone OTHER than the seat it was sent from — the case a
123
+ # signature exists for. A message an author sent from their own seat is
124
+ # not "signed"; it's just theirs.
125
+ def signed?
126
+ author.present? && author != sender
127
+ end
128
+
129
+ # Whether +messager+ is the one who WROTE this (not necessarily the seat
130
+ # it was sent from).
131
+ def authored_by?(messager)
132
+ author.present? && author == messager
133
+ end
134
+
115
135
  # The body as the UI should show it (tombstones render a localized
116
136
  # "Message deleted" placeholder straight from the view, not from here —
117
137
  # this just guards against showing stale bodies by accident).
@@ -133,12 +153,19 @@ module Chats
133
153
  raise Chats::NotAllowedError, "editing is disabled" unless Chats.config.editing
134
154
  raise Chats::NotAllowedError, "can't edit a deleted message" if deleted?
135
155
 
156
+ refuse_when_locked!
136
157
  update!(body: new_body, edited_at: Time.current)
137
158
  end
138
159
 
139
160
  # Delete according to `config.deletion` (see class comment). Returns
140
161
  # false when deletion is disabled.
141
- def soft_delete!
162
+ #
163
+ # `enforce_lock: false` is for MODERATION only (see
164
+ # #remove_reported_field!): a product lock must never shield reported
165
+ # content from removal.
166
+ def soft_delete!(enforce_lock: true)
167
+ refuse_when_locked! if enforce_lock
168
+
142
169
  case Chats.config.deletion
143
170
  when :soft
144
171
  transaction do
@@ -158,6 +185,16 @@ module Chats
158
185
  respond_to?(:files) && files.attached?
159
186
  end
160
187
 
188
+ # Every WRITE to an existing message goes through here, for the same
189
+ # reason `create` validates the lock: a closed conversation is closed for
190
+ # editing and deleting too, not just for new messages. System messages
191
+ # stay exempt — the app owns them.
192
+ def refuse_when_locked! # :nodoc:
193
+ return if system? || conversation.nil? || !conversation.locked?
194
+
195
+ raise Chats::LockedError.new(conversation: conversation)
196
+ end
197
+
161
198
  # --- Moderation contract (duck-typed, zero coupling) ------------------------
162
199
  #
163
200
  # Plain-Ruby methods that make a message a first-class citizen of the
@@ -167,8 +204,14 @@ module Chats
167
204
  # seams for attachment filtering. Without moderate installed they're
168
205
  # inert and cost nothing.
169
206
 
207
+ # Who answers for this message: its AUTHOR when it is signed, else its
208
+ # sender. A signed message was written by a human from a seat that is not
209
+ # a person — an agent answering from a support desk — and the seat cannot
210
+ # be the owner a moderation flag or report points at: the host's `owner`
211
+ # is typed to its user class, so a desk there is a type mismatch raised
212
+ # from inside the agent's own reply the first time a filter trips.
170
213
  def reported_owner
171
- sender
214
+ author || sender
172
215
  end
173
216
 
174
217
  def moderation_label
@@ -189,7 +232,9 @@ module Chats
189
232
  def remove_reported_field!(field)
190
233
  return false unless field.to_s == "body"
191
234
 
192
- soft_delete!
235
+ # Trust & Safety outranks a product lock: a closed conversation must
236
+ # never be a place reported content can hide.
237
+ soft_delete!(enforce_lock: false)
193
238
  end
194
239
 
195
240
  # Only people *in* the conversation may report a message (a message
@@ -267,6 +312,25 @@ module Chats
267
312
  errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
268
313
  end
269
314
 
315
+ # An author signs the bubble with `Chats.display_name_for`, so it has to
316
+ # be something that HAS a name in this system — a messager, not a ride or
317
+ # a listing that would render as "Listing 1".
318
+ def author_must_be_a_messager
319
+ return if author.nil? || Chats.messager_class?(author.class)
320
+
321
+ errors.add(:author, :not_a_messager)
322
+ end
323
+
324
+ # The subject owns the conversation's openness (Chats::ChatSubject#
325
+ # chat_locked?). System messages are exempt: the host must always be able
326
+ # to post "This ticket was closed" into the thread it just closed.
327
+ def conversation_must_not_be_locked
328
+ return if system? || conversation.nil?
329
+ return unless conversation.locked?
330
+
331
+ errors.add(:base, :locked)
332
+ end
333
+
270
334
  def files_must_be_allowed
271
335
  return unless respond_to?(:files)
272
336
  return unless files.attached?
@@ -105,6 +105,37 @@ module Chats
105
105
 
106
106
  def leave!
107
107
  update!(left_at: Time.current)
108
+ Chats.notify(:participant_left, participant: self)
109
+ self
110
+ end
111
+
112
+ # Hand this seat to a different messager, keeping the read horizon, the
113
+ # role and the history: the guest who signs up, the agent who takes over
114
+ # a shared mailbox. The MESSAGES keep their original sender — what was
115
+ # said was said by whoever said it.
116
+ #
117
+ # Direct conversations have their +direct_key+ recomputed, so the thread
118
+ # keeps resolving through `chat_with` for the NEW pair instead of
119
+ # stranding a duplicate.
120
+ def reseat!(new_messager)
121
+ raise ArgumentError, "reseat! requires a messager" if new_messager.nil?
122
+ unless Chats.messager_class?(new_messager.class)
123
+ raise Chats::NotAllowedError, "#{new_messager.class.name} is not a messager (acts_as_messager)"
124
+ end
125
+
126
+ refuse_reseat_conflicts!(new_messager)
127
+
128
+ transaction do
129
+ update!(messager: new_messager)
130
+ conversation.reindex_direct_key!
131
+ end
132
+
133
+ self
134
+ rescue ActiveRecord::RecordNotUnique
135
+ # The race backstop for the checks above (two reseats, or a DM opened,
136
+ # between the check and the write). Translated so a host never has a
137
+ # driver-level exception poison its transaction.
138
+ raise Chats::NotAllowedError, "that conversation already exists for the new pair"
108
139
  end
109
140
 
110
141
  # --- Notification etiquette (for host notifier hooks) ----------------------
@@ -114,6 +145,10 @@ module Chats
114
145
  # re-derive it: don't notify yourself, the muted, the departed — and for
115
146
  # debounced email digests, don't notify twice for the same unread burst.
116
147
  def notifiable_for?(message)
148
+ # Headless messagers (`acts_as_messager notifications: false`) — a
149
+ # support desk, a bot, an org mailbox — are never notifiable. This is
150
+ # THE reason hosts no longer branch on class in their notifiers.
151
+ return false unless Chats.notifications_for?(messager)
117
152
  return false if left? || muted?
118
153
  return false if message.sender == messager
119
154
 
@@ -135,6 +170,30 @@ module Chats
135
170
 
136
171
  private
137
172
 
173
+ # Everything that would make the reseat collide, checked BEFORE the
174
+ # write. A unique-index violation inside the transaction would abort the
175
+ # host's transaction too on PostgreSQL, so the pre-check is the real
176
+ # guard and the RecordNotUnique rescue is only the race backstop.
177
+ def refuse_reseat_conflicts!(new_messager)
178
+ if conversation.participants.where.not(id: id).exists?(
179
+ messager_type: new_messager.class.polymorphic_name, messager_id: new_messager.id
180
+ )
181
+ raise Chats::NotAllowedError, "that messager already has a seat in this conversation"
182
+ end
183
+
184
+ return unless conversation.direct?
185
+
186
+ other = conversation.other_participants(messager).includes(:messager).first&.messager
187
+ return if other.nil?
188
+
189
+ existing = Chats::Conversation.direct_between(new_messager, other, about: conversation.subject)
190
+ return if existing.nil? || existing == conversation
191
+
192
+ raise Chats::NotAllowedError,
193
+ "#{Chats.display_name_for(new_messager)} already has a direct conversation with " \
194
+ "#{Chats.display_name_for(other)}"
195
+ end
196
+
138
197
  def group_must_have_room
139
198
  return if conversation.nil? || conversation.direct?
140
199
 
@@ -32,6 +32,11 @@ module Chats
32
32
  # toggled off. Race-safe: a concurrent double-tap resolves through the
33
33
  # unique index instead of raising.
34
34
  def self.toggle!(message:, reactor:, emoji:)
35
+ # Reacting is a write to the conversation, so a locked one refuses it —
36
+ # in BOTH directions: you can't add a reaction to a closed thread, and
37
+ # you can't take one back either. Same rule as editing and deleting.
38
+ message&.conversation&.refuse_writes_when_locked!
39
+
35
40
  existing = find_by(message: message, reactor: reactor, emoji: emoji)
36
41
  if existing
37
42
  existing.destroy!