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
@@ -10,13 +10,101 @@ module Chats
10
10
  #
11
11
  # alice.message!(bob, "hola!") # DM in one line
12
12
  # alice.message!(conversation, "hola!") # or into a conversation
13
+ # alice.message!(bob, "Let's play!", # with a button under it
14
+ # action: { label: "Join my room", url: "/play/abcd" })
13
15
  #
14
16
  # alice.chats # her inbox, newest first
15
17
  # alice.unread_chats_count # for the nav badge
16
18
  module Messager
17
19
  extend ActiveSupport::Concern
18
20
 
21
+ # What `acts_as_messager` declares about a messager class. The defaults
22
+ # are exactly 0.1.x behaviour, so a bare `acts_as_messager` is unchanged.
23
+ DEFAULT_CHAT_OPTIONS = {
24
+ notifications: true,
25
+ blockable: true,
26
+ inbox: :default,
27
+ group_path: nil,
28
+ verified: false
29
+ }.freeze
30
+
31
+ INBOX_MODES = %i[default grouped].freeze
32
+
33
+ # Validate + freeze the macro's options, failing at BOOT with a plain
34
+ # English message rather than at 3am with a NoMethodError.
35
+ def self.normalize_options(notifications:, blockable:, inbox:, group_path:, verified: false)
36
+ inbox = inbox.to_sym
37
+ unless INBOX_MODES.include?(inbox)
38
+ raise Chats::ConfigurationError,
39
+ "acts_as_messager inbox: must be one of #{INBOX_MODES.inspect}, got #{inbox.inspect}"
40
+ end
41
+
42
+ if group_path && !group_path.respond_to?(:call)
43
+ raise Chats::ConfigurationError,
44
+ "acts_as_messager group_path: must respond to #call (a proc/lambda), got #{group_path.inspect}"
45
+ end
46
+
47
+ # Deliberately STRICTER than its boolean neighbours, which coerce with
48
+ # `!!`. "Official account" is a trust claim shown to everyone who talks
49
+ # to this messager, so `verified: "false"` (an ENV var, a YAML
50
+ # round-trip) has to fail at boot rather than quietly verify it.
51
+ unless [true, false].include?(verified)
52
+ raise Chats::ConfigurationError,
53
+ "acts_as_messager verified: must be true or false, got #{verified.inspect}"
54
+ end
55
+
56
+ {
57
+ notifications: !!notifications,
58
+ blockable: !!blockable,
59
+ inbox: inbox,
60
+ group_path: group_path,
61
+ verified: verified
62
+ }.freeze
63
+ end
64
+
65
+ class_methods do
66
+ # True unless declared with `acts_as_messager notifications: false`.
67
+ def chat_notifications?
68
+ chat_options[:notifications]
69
+ end
70
+
71
+ # True unless declared with `acts_as_messager blockable: false`.
72
+ def chat_blockable?
73
+ chat_options[:blockable]
74
+ end
75
+
76
+ # :default | :grouped (see Chats::Inbox).
77
+ def chat_inbox_mode
78
+ chat_options[:inbox]
79
+ end
80
+
81
+ # Whether every direct conversation with this messager folds into ONE
82
+ # inbox row (see Chats::InboxGroup).
83
+ def chat_grouped_inbox?
84
+ chat_inbox_mode == :grouped
85
+ end
86
+
87
+ # The `group_path:` callable, or nil (then the stacked row links to the
88
+ # filtered inbox).
89
+ def chat_group_path
90
+ chat_options[:group_path]
91
+ end
92
+
93
+ # True when declared with `acts_as_messager verified: true` — an
94
+ # OFFICIAL account (a support desk, an organization, a brand). The
95
+ # bundled views badge its name wherever they show it.
96
+ def chat_verified?
97
+ chat_options[:verified]
98
+ end
99
+ end
100
+
19
101
  included do
102
+ # Declared by `acts_as_messager`; a plain `include Chats::Messager`
103
+ # gets the defaults. class_attribute so STI subclasses inherit it.
104
+ class_attribute :chat_options,
105
+ instance_accessor: false,
106
+ default: Chats::Messager::DEFAULT_CHAT_OPTIONS
107
+
20
108
  has_many :chat_participations,
21
109
  class_name: "Chats::Participant",
22
110
  as: :messager,
@@ -63,30 +151,58 @@ module Chats
63
151
  # alice.message!(bob, "are you coming?")
64
152
  # alice.message!(bob, "about the ride", about: ride)
65
153
  # alice.message!(conversation, "hi all!", files: [photo])
66
- def message!(target, body = nil, about: nil, files: [], reply_to: nil)
154
+ #
155
+ # `author:` is the human (or bot) writing on the SENDER's behalf — an
156
+ # agent answering from a support desk seat. It signs the bubble; the
157
+ # sender stays the conversation identity. See Chats::Message#signed?.
158
+ #
159
+ # desk.message!(alice, "On it!", author: lucia)
160
+ #
161
+ # `action:` is the one featured button under the words — a label and a
162
+ # url (see Chats::Action). It rides on a message the body rules already
163
+ # accept; an action alone is not a message.
164
+ #
165
+ # alice.message!(bob, "Let's play!", action: { label: "Join my room", url: room_path(room) })
166
+ def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil, action: nil)
67
167
  conversation =
68
168
  case target
69
169
  when Chats::Conversation then target
70
170
  else chat_with(target, about: about)
71
171
  end
72
172
 
73
- attributes = { sender: self, body: body, reply_to: reply_to }
173
+ attributes = { sender: self, body: body, reply_to: reply_to, author: author, action: action }
74
174
  attributes[:files] = files if files.present?
75
175
  conversation.messages.create!(**attributes)
76
176
  end
77
177
 
78
178
  # The inbox: every active conversation, blocked counterparts hidden,
79
- # newest activity first. A relation — chain `.limit`, `.includes`, etc.
179
+ # `config.inbox_scope` applied, newest activity first. A relation —
180
+ # chain `.limit`, `.includes`, etc.
181
+ #
182
+ # THE one definition of "what is in this messager's inbox": the inbox
183
+ # screen, the unread count, the live badge and a host's own queries all
184
+ # read it, so a conversation the scope hides is hidden everywhere. Until
185
+ # 0.4.1 the badge counted through the raw `inbox_for` relation and could
186
+ # light up for a conversation the inbox would never show.
187
+ #
188
+ # It is also the AUTHORIZATION relation (every engine lookup is
189
+ # `viewer.chats.find(id)`), which is why it includes conversations
190
+ # with no messages yet: the thread you just opened must open. The
191
+ # lists narrow it with `Chats::Conversation.with_messages` (see
192
+ # Chats::Inbox); `chats.with_messages` is what your own lists want too.
80
193
  def chats
81
- Chats::Conversation.inbox_for(self)
194
+ relation = Chats::Conversation.inbox_for(self)
195
+ Chats.config.inbox_scope.call(relation, self) || relation
82
196
  end
83
197
 
84
198
  # Number of conversations with unread messages (the WhatsApp-style nav
85
199
  # badge counts conversations, not messages — 47 unread messages in one
86
200
  # thread is still "1 thing to deal with").
87
201
  def unread_chats_count
88
- # reorder(nil): the inbox scope orders by a COALESCE expression, which
89
- # PostgreSQL rejects inside a COUNT(DISTINCT …) aggregate.
202
+ # No `with_messages` needed to agree with the inbox: `unread_by` joins
203
+ # the messages, so a conversation with no messages can never be
204
+ # counted. reorder(nil): the inbox scope orders by a COALESCE
205
+ # expression, which PostgreSQL rejects inside a COUNT(DISTINCT …).
90
206
  chats.unread_by(self).reorder(nil).distinct.count
91
207
  end
92
208
 
@@ -59,6 +59,41 @@ module Chats
59
59
  order(Arel.sql("COALESCE(chats_conversations.last_message_at, chats_conversations.created_at) DESC"))
60
60
  }
61
61
 
62
+ # Conversations that have at least one message, system messages and
63
+ # tombstones included (`last_message_id` set). Not "a person wrote here":
64
+ # a conversation holding only a system note, or only a deleted message,
65
+ # has messages. A conversation exists from the moment it is OPENED
66
+ # (`chat_with`, the Message button), and nobody wants an inbox full of
67
+ # the threads they opened and never used — "No messages yet" rows. So
68
+ # every LIST the gem renders (the inbox, its stacks, search, the
69
+ # `?with=` view, the dock's recent list) reads through this scope, while
70
+ # the authorization relation (`Messager#chats`) does NOT: an empty
71
+ # conversation still opens when addressed directly, which is how its
72
+ # first message gets written.
73
+ #
74
+ # Free, because the denormalized pointer already says it: a conversation
75
+ # has a last message exactly when it has messages (the pointer is set on
76
+ # every create and healed on every hard delete; a soft-deleted tombstone
77
+ # is still a message, so a thread whose only message was deleted stays
78
+ # listed, like everywhere else).
79
+ scope :with_messages, -> { where.not(last_message_id: nil) }
80
+
81
+ # Conversations a messager wrote in, and their complement. A narrower
82
+ # question than `with_messages`, which counts every message: a system
83
+ # line is the app talking ("Your ride was cancelled", which the inbox
84
+ # rightly lists), and a tombstone no longer says anything. These count
85
+ # only Chats::Message.human, what is left when both are set aside. For
86
+ # the readers that must not count a thread holding only "Alice joined":
87
+ # ops walls, moderation lists, analytics.
88
+ #
89
+ # One correlated EXISTS / NOT EXISTS, so they compose with any other
90
+ # scope (`alice.chats`, `inbox_for`, `about`, `unread_by`,
91
+ # `with_messages`) and never multiply rows:
92
+ # alice.chats.with_human_messages
93
+ # Chats::Conversation.about(ride).without_human_messages.count # admin-wide
94
+ scope :with_human_messages, -> { where(human_messages_exist) }
95
+ scope :without_human_messages, -> { where.not(human_messages_exist) }
96
+
62
97
  # A messager's inbox: every conversation they're an active participant of
63
98
  # (not left), minus direct threads with someone they're blocked with,
64
99
  # newest activity first. THE hot query — keep it composable and indexed.
@@ -114,6 +149,8 @@ module Chats
114
149
  # mechanism. https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
115
150
  validate :groups_must_be_enabled, if: :group?
116
151
 
152
+ after_create_commit -> { Chats.notify(:conversation_created, conversation: self) }
153
+
117
154
  # --- Finding & creating ---------------------------------------------------
118
155
 
119
156
  class << self
@@ -133,16 +170,17 @@ module Chats
133
170
  raise Chats::BlockedError, "messagers are blocked" if Chats.blocked_between?(a, b)
134
171
  raise Chats::NotAllowedError, "policy forbids messaging" unless Chats.can_message?(a, b)
135
172
 
136
- conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
137
- c.kind = "direct"
138
- c.subject = about
173
+ transaction do
174
+ conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
175
+ c.kind = "direct"
176
+ c.subject = about
177
+ end
178
+ # Keep the roster in the creation transaction: the after-commit
179
+ # event must describe a complete conversation, including under an
180
+ # outer host transaction or a failed participant insertion.
181
+ [a, b].each { |messager| conversation.add_participant!(messager) }
182
+ conversation
139
183
  end
140
-
141
- # `create_or_find_by!` may have FOUND a conversation created a moment
142
- # ago by the other side — participants are ensured idempotently
143
- # either way (their own unique index makes this race-safe too).
144
- [a, b].each { |messager| conversation.add_participant!(messager) }
145
- conversation
146
184
  end
147
185
 
148
186
  # Create a group conversation. +others+ excludes the creator (who joins
@@ -159,10 +197,10 @@ module Chats
159
197
  raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
160
198
 
161
199
  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
200
+ created = create!(kind: "group", title: title, subject: about)
201
+ created.add_participant!(creator, role: "owner")
202
+ others.each { |messager| created.add_participant!(messager) }
203
+ created
166
204
  end
167
205
  end
168
206
 
@@ -187,6 +225,20 @@ module Chats
187
225
  .group("chats_conversations.id")
188
226
  .count("chats_messages.id")
189
227
  end
228
+
229
+ # EXISTS (SELECT 1 FROM chats_messages WHERE conversation_id =
230
+ # chats_conversations.id AND kind = 'text' AND deleted_at IS NULL),
231
+ # built from Chats::Message.human so the two definitions can never
232
+ # drift, and unscoped so a Message scope the caller happens to be inside
233
+ # never leaks in. Public only because scope bodies run on the relation,
234
+ # which cannot reach a private class method.
235
+ def human_messages_exist # :nodoc:
236
+ messages = Chats::Message.arel_table
237
+ Chats::Message.unscoped.human
238
+ .where(messages[:conversation_id].eq(arel_table[:id]))
239
+ .select(Arel.sql("1"))
240
+ .arel.exists
241
+ end
190
242
  end
191
243
 
192
244
  # --- Predicates & lookups -------------------------------------------------
@@ -212,13 +264,30 @@ module Chats
212
264
  )
213
265
  end
214
266
 
267
+ # The OTHER messager in a direct thread, from +viewer+'s seat — nil for a
268
+ # group, and nil for a direct thread whose other seat has left. The one
269
+ # place the counterpart is resolved, so the title, the avatar and the
270
+ # verified badge on an inbox row always name the same person.
271
+ #
272
+ # Memoized per viewer: an inbox row asks for it two or three times, and a
273
+ # row that cost one query in 0.2.0 must not start costing three.
274
+ def counterpart_for(viewer)
275
+ return nil unless direct?
276
+
277
+ @counterparts ||= {}
278
+ key = viewer && Chats.messager_key(viewer)
279
+ return @counterparts[key] if @counterparts.key?(key)
280
+
281
+ @counterparts[key] = other_participants(viewer).includes(:messager).first&.messager
282
+ end
283
+
215
284
  # What this conversation is called from +viewer+'s seat: a direct thread
216
285
  # is named after the counterpart; a group after its title (or its
217
286
  # members, when untitled).
218
287
  def title_for(viewer)
219
288
  if direct?
220
- other = other_participants(viewer).includes(:messager).first
221
- other ? Chats.display_name_for(other.messager) : I18n.t("chats.conversation.empty_title")
289
+ other = counterpart_for(viewer)
290
+ other ? Chats.display_name_for(other) : I18n.t("chats.conversation.empty_title")
222
291
  else
223
292
  title.presence || participants.active.includes(:messager).limit(4).map do |p|
224
293
  Chats.display_name_for(p.messager)
@@ -235,6 +304,31 @@ module Chats
235
304
  subject.try(:chat_subject_label) || "#{subject.class.model_name.human} #{subject.id}"
236
305
  end
237
306
 
307
+ # Whether new messages are refused here, decided by the SUBJECT (see
308
+ # Chats::ChatSubject#chat_locked?). A subjectless conversation is never
309
+ # locked. Reading is never affected — only sending.
310
+ def locked?
311
+ return false if subject.nil?
312
+
313
+ subject.try(:chat_locked?) || false
314
+ end
315
+
316
+ # The host's explanation for the lock, or the gem's localized fallback.
317
+ # Always a sentence worth showing: a locked composer that says nothing is
318
+ # indistinguishable from a broken one.
319
+ def locked_notice
320
+ return nil unless locked?
321
+
322
+ subject.try(:chat_locked_notice).presence || I18n.t("chats.composer.locked")
323
+ end
324
+
325
+ # The guard every write that ISN'T a message itself calls (reactions
326
+ # today). Message writes go through Chats::Message#refuse_when_locked!,
327
+ # which exempts system messages.
328
+ def refuse_writes_when_locked! # :nodoc:
329
+ raise Chats::LockedError.new(conversation: self) if locked?
330
+ end
331
+
238
332
  # --- Membership -----------------------------------------------------------
239
333
 
240
334
  # Idempotent, race-safe membership. Re-adding someone who left re-joins
@@ -250,14 +344,46 @@ module Chats
250
344
  participant
251
345
  end
252
346
 
347
+ # Recompute the deterministic identity of a DIRECT thread after its
348
+ # roster changed (see Chats::Participant#reseat!). Without this the key
349
+ # would still name the old pair, and `chat_with` would open a SECOND
350
+ # thread for the new one. No-op for groups (they have no key).
351
+ def reindex_direct_key! # :nodoc:
352
+ return self unless direct?
353
+
354
+ # `reset`: the caller just changed a seat, and a participants
355
+ # association loaded BEFORE that would name the old pair.
356
+ messagers = participants.reset.includes(:messager).filter_map(&:messager)
357
+ return self unless messagers.size == 2
358
+
359
+ update_columns(
360
+ direct_key: self.class.direct_key_for(messagers, subject: subject),
361
+ updated_at: Time.current
362
+ )
363
+ self
364
+ end
365
+
253
366
  # --- Messaging ------------------------------------------------------------
254
367
 
255
368
  # Post a message from your APP into the conversation — "Your ride was
256
369
  # cancelled", "Payment received" — rendered as a centered system note,
257
370
  # not a bubble. This is the stable entry point notification systems
258
371
  # (e.g. a Noticed delivery method) should call.
259
- def post_system_message!(body)
260
- messages.create!(kind: "system", body: body)
372
+ #
373
+ # `action:` puts a button under the note (see Chats::Action):
374
+ #
375
+ # conversation.post_system_message!("Your ride was cancelled",
376
+ # action: { label: "Book another", url: "/rides/new" })
377
+ def post_system_message!(body, action: nil)
378
+ messages.create!(kind: "system", body: body, action: action)
379
+ end
380
+
381
+ # The newest thing a messager wrote here (see `.with_human_messages`), or
382
+ # nil when none did. Unlike +last_message+ it skips system lines and
383
+ # tombstones, and it is a query, not the denormalized pointer: one per
384
+ # call. For a list, batch it with Chats::Message.last_human_in(conversations).
385
+ def last_human_message
386
+ messages.human.recent_first.first
261
387
  end
262
388
 
263
389
  # --- Read state -----------------------------------------------------------
@@ -323,11 +449,19 @@ module Chats
323
449
  field.to_s == "title" && title.present?
324
450
  end
325
451
 
452
+ # Whether anything was removed (see Chats::Message#remove_reported_field!):
453
+ # false for a title already gone, decided under the row lock.
326
454
  def remove_reported_field!(field)
327
455
  return false unless field.to_s == "title"
328
456
 
329
- update!(title: nil)
330
- true
457
+ with_lock do
458
+ next false if title.blank?
459
+
460
+ update!(title: nil)
461
+ true
462
+ end
463
+ rescue ActiveRecord::RecordNotFound
464
+ false
331
465
  end
332
466
 
333
467
  def report_visible_to?(viewer, field: nil)