chats 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +43 -0
  3. data/.simplecov +52 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +17 -0
  6. data/CHANGELOG.md +74 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +384 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/chats.css +818 -0
  12. data/app/controllers/chats/application_controller.rb +65 -0
  13. data/app/controllers/chats/conversations_controller.rb +198 -0
  14. data/app/controllers/chats/messages_controller.rb +118 -0
  15. data/app/controllers/chats/reactions_controller.rb +33 -0
  16. data/app/helpers/chats/engine_helper.rb +212 -0
  17. data/app/javascript/chats/composer_controller.js +258 -0
  18. data/app/javascript/chats/debounced_submit_controller.js +40 -0
  19. data/app/javascript/chats/thread_controller.js +855 -0
  20. data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
  21. data/app/views/chats/conversations/_messages_page.html.erb +16 -0
  22. data/app/views/chats/conversations/_read_state.html.erb +11 -0
  23. data/app/views/chats/conversations/index.html.erb +54 -0
  24. data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
  25. data/app/views/chats/conversations/show.html.erb +137 -0
  26. data/app/views/chats/messages/_composer.html.erb +67 -0
  27. data/app/views/chats/messages/_message.html.erb +158 -0
  28. data/app/views/chats/messages/create.turbo_stream.erb +6 -0
  29. data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
  30. data/app/views/chats/shared/_unread_badge.html.erb +6 -0
  31. data/config/importmap.rb +16 -0
  32. data/config/locales/en.yml +87 -0
  33. data/config/locales/es.yml +87 -0
  34. data/config/routes.rb +24 -0
  35. data/docs/PRD.md +254 -0
  36. data/docs/campfire_review.md +46 -0
  37. data/gemfiles/rails_7.1.gemfile +36 -0
  38. data/gemfiles/rails_7.2.gemfile +36 -0
  39. data/gemfiles/rails_8.1.gemfile +36 -0
  40. data/lib/chats/broadcasts.rb +147 -0
  41. data/lib/chats/configuration.rb +286 -0
  42. data/lib/chats/engine.rb +146 -0
  43. data/lib/chats/errors.rb +20 -0
  44. data/lib/chats/macros.rb +28 -0
  45. data/lib/chats/models/application_record.rb +11 -0
  46. data/lib/chats/models/concerns/chat_subject.rb +35 -0
  47. data/lib/chats/models/concerns/messager.rb +102 -0
  48. data/lib/chats/models/conversation.rb +347 -0
  49. data/lib/chats/models/message.rb +323 -0
  50. data/lib/chats/models/participant.rb +151 -0
  51. data/lib/chats/models/reaction.rb +70 -0
  52. data/lib/chats/version.rb +5 -0
  53. data/lib/chats.rb +188 -0
  54. data/lib/generators/chats/install_generator.rb +62 -0
  55. data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
  56. data/lib/generators/chats/templates/initializer.rb +138 -0
  57. data/lib/generators/chats/views_generator.rb +49 -0
  58. metadata +204 -0
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # A message in a conversation. Two kinds:
5
+ #
6
+ # "text" a human message from a +sender+ (any acts_as_messager model)
7
+ # "system" posted by the HOST APP via Conversation#post_system_message!
8
+ # ("Your ride was cancelled") — no sender, centered rendering
9
+ #
10
+ # == Soft deletion ("delete for everyone")
11
+ #
12
+ # With `config.deletion = :soft` (the default), deleting clears the body,
13
+ # purges attachments and stamps +deleted_at+ — the row stays as a tombstone
14
+ # ("Message deleted") exactly like WhatsApp/Telegram, which keeps thread
15
+ # continuity AND leaves an auditable trail for Trust & Safety. Note that
16
+ # moderation evidence is still safe: the moderate gem snapshots reported
17
+ # content AT REPORT TIME, so removing the body later never destroys the
18
+ # evidence a moderator needs.
19
+ #
20
+ # == Real-time
21
+ #
22
+ # All Turbo Stream fan-out lives in Chats::Broadcasts (one place to read
23
+ # the whole live behavior). Broadcasts run in `after_*_commit` callbacks —
24
+ # never inside the transaction — and use the `_later` job variants so
25
+ # rendering never blocks the request.
26
+ class Message < ApplicationRecord
27
+ self.table_name = "chats_messages"
28
+
29
+ KINDS = %w[text system].freeze
30
+
31
+ belongs_to :conversation,
32
+ class_name: "Chats::Conversation",
33
+ inverse_of: :messages,
34
+ counter_cache: :messages_count
35
+ belongs_to :sender, polymorphic: true, optional: true
36
+ belongs_to :reply_to, class_name: "Chats::Message", optional: true
37
+
38
+ has_many :reactions,
39
+ class_name: "Chats::Reaction",
40
+ inverse_of: :message,
41
+ foreign_key: :message_id,
42
+ dependent: :destroy
43
+
44
+ # Attachments ride on ActiveStorage when the host has it installed.
45
+ # What's *allowed* (none / images only / anything) is enforced by
46
+ # validations reading `Chats.config.attachments` — see below.
47
+ has_many_attached :files if defined?(ActiveStorage)
48
+
49
+ # Ruby-side default so metadata is always a Hash even on MySQL, where the
50
+ # migration can't give JSON columns a DB default (MySQL 8+ limitation —
51
+ # see the migration template's json_column_default).
52
+ attribute :metadata, default: -> { {} }
53
+
54
+ scope :recent_first, -> { order(created_at: :desc, id: :desc) }
55
+ scope :oldest_first, -> { order(created_at: :asc, id: :asc) }
56
+ scope :visible, -> { where(deleted_at: nil) }
57
+
58
+ # Keyset (cursor) pagination for infinite scroll-up. OFFSET pagination
59
+ # drifts when new messages arrive mid-scroll; anchoring on the oldest
60
+ # loaded message can't skip or duplicate. `(created_at, id)` because
61
+ # created_at alone isn't unique.
62
+ scope :before_message, lambda { |message|
63
+ where(
64
+ "(chats_messages.created_at < :at) OR (chats_messages.created_at = :at AND chats_messages.id < :id)",
65
+ at: message.created_at, id: message.id
66
+ )
67
+ }
68
+
69
+ # 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).
79
+ # Pattern from Basecamp's Campfire (Rooms::RefreshesController):
80
+ # https://github.com/basecamp/once-campfire
81
+ scope :created_since, ->(time) { where("chats_messages.created_at > ?", time) }
82
+ scope :updated_since, lambda { |time|
83
+ where("chats_messages.updated_at > ?", time).where("chats_messages.created_at <= ?", time)
84
+ }
85
+
86
+ validates :kind, inclusion: { in: KINDS }
87
+ validates :body, presence: true, if: :system?
88
+ validate :sender_required_for_text_messages, on: :create
89
+ validate :body_or_files_required
90
+ validate :body_must_fit_length_limit
91
+ validate :sender_must_be_active_participant, on: :create
92
+ validate :sender_must_not_be_blocked, on: :create
93
+ validate :files_must_be_allowed
94
+
95
+ after_create :register_on_conversation
96
+ after_create_commit :broadcast_created
97
+ after_create_commit :notify_host
98
+ after_update_commit :broadcast_updated
99
+ after_destroy_commit :broadcast_destroyed
100
+ after_destroy :heal_conversation_pointers
101
+
102
+ def system? = kind == "system"
103
+ def text? = kind == "text"
104
+ def deleted? = deleted_at.present?
105
+ def edited? = edited_at.present?
106
+
107
+ def sender_key
108
+ sender && Chats.messager_key(sender)
109
+ end
110
+
111
+ def sent_by?(messager)
112
+ sender.present? && sender == messager
113
+ end
114
+
115
+ # The body as the UI should show it (tombstones render a localized
116
+ # "Message deleted" placeholder straight from the view, not from here —
117
+ # this just guards against showing stale bodies by accident).
118
+ def visible_body
119
+ deleted? ? nil : body
120
+ end
121
+
122
+ # Whether +messager+ has read this message, derived from their read
123
+ # horizon (see Chats::Participant for why there's no receipts table).
124
+ def read_by?(messager)
125
+ participant = conversation.participant_for(messager)
126
+ participant&.last_read_at.present? && participant.last_read_at >= created_at
127
+ end
128
+
129
+ # Edit the body (sender-only — controllers enforce who; the model
130
+ # enforces *that* editing is enabled). Stamps +edited_at+ so the UI can
131
+ # show "edited", and broadcasts the replacement bubble.
132
+ def edit!(new_body)
133
+ raise Chats::NotAllowedError, "editing is disabled" unless Chats.config.editing
134
+ raise Chats::NotAllowedError, "can't edit a deleted message" if deleted?
135
+
136
+ update!(body: new_body, edited_at: Time.current)
137
+ end
138
+
139
+ # Delete according to `config.deletion` (see class comment). Returns
140
+ # false when deletion is disabled.
141
+ def soft_delete!
142
+ case Chats.config.deletion
143
+ when :soft
144
+ transaction do
145
+ files.purge_later if respond_to?(:files) && files.attached?
146
+ update!(body: nil, deleted_at: Time.current)
147
+ end
148
+ true
149
+ when :hard
150
+ destroy!
151
+ true
152
+ else
153
+ false
154
+ end
155
+ end
156
+
157
+ def attachments?
158
+ respond_to?(:files) && files.attached?
159
+ end
160
+
161
+ # --- Moderation contract (duck-typed, zero coupling) ------------------------
162
+ #
163
+ # Plain-Ruby methods that make a message a first-class citizen of the
164
+ # moderate gem the moment the HOST wires it up (see README "Trust &
165
+ # Safety"): they satisfy Moderate::Reportable's contract (owner, label,
166
+ # snapshot, removal, visibility) and Moderate::ContentFilterable's field
167
+ # seams for attachment filtering. Without moderate installed they're
168
+ # inert and cost nothing.
169
+
170
+ def reported_owner
171
+ sender
172
+ end
173
+
174
+ def moderation_label
175
+ "Chat message #{id}"
176
+ end
177
+
178
+ def moderation_snapshot(field)
179
+ body if field.to_s == "body"
180
+ end
181
+
182
+ def removable_reported_field?(field)
183
+ field.to_s == "body" && body.present? && !deleted?
184
+ end
185
+
186
+ # A moderator removing a reported message body = the soft-delete
187
+ # tombstone path, so the thread shows "Message deleted" instead of a
188
+ # hole, and attachments are purged with it.
189
+ def remove_reported_field!(field)
190
+ return false unless field.to_s == "body"
191
+
192
+ soft_delete!
193
+ end
194
+
195
+ # Only people *in* the conversation may report a message (a message
196
+ # isn't public content), and you can't report your own.
197
+ def report_visible_to?(viewer, field: nil)
198
+ return false if viewer.nil? || sent_by?(viewer)
199
+
200
+ conversation.participant?(viewer)
201
+ end
202
+
203
+ def moderation_content_type
204
+ "message"
205
+ end
206
+
207
+ # Moderate::ContentFilterable seam: when the host declares
208
+ # `moderates :files, mode: :flag, with: :some_image_adapter`, the default
209
+ # `public_send(:files)` would hand the adapter an ActiveStorage proxy.
210
+ # Most image adapters (like a custo ImageReviewAdapter, or an AWS
211
+ # Rekognition adapter fed via ClassifyJob) don't read the value anyway —
212
+ # they re-fetch the blob — but we make the value meaningful and
213
+ # change-detection correct regardless.
214
+ def moderation_field_value(field)
215
+ return files if field.to_s == "files" && respond_to?(:files)
216
+
217
+ public_send(field)
218
+ end
219
+
220
+ def moderation_field_changed_for_commit?(field)
221
+ if field.to_s == "files" && respond_to?(:files)
222
+ # Attachments don't have a column; detect "files changed in this
223
+ # commit" via the attachment records created in this transaction.
224
+ files.attachments.any?(&:previously_new_record?)
225
+ elsif respond_to?(:saved_change_to_attribute?)
226
+ saved_change_to_attribute?(field)
227
+ else
228
+ true
229
+ end
230
+ end
231
+
232
+ private
233
+
234
+ def sender_required_for_text_messages
235
+ errors.add(:sender, :blank) if text? && sender.nil?
236
+ end
237
+
238
+ def body_or_files_required
239
+ return if system? || deleted?
240
+ return if body.present?
241
+ return if attachments?
242
+
243
+ errors.add(:body, :blank)
244
+ end
245
+
246
+ def body_must_fit_length_limit
247
+ max = Chats.config.max_message_length
248
+ return if max.nil? || body.nil?
249
+
250
+ errors.add(:body, :too_long, count: max) if body.length > max
251
+ end
252
+
253
+ def sender_must_be_active_participant
254
+ return if system? || sender.nil? || conversation.nil?
255
+
256
+ errors.add(:sender, :not_a_participant) unless conversation.participant?(sender)
257
+ end
258
+
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?
265
+
266
+ other = conversation.other_participants(sender).first&.messager
267
+ errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
268
+ end
269
+
270
+ def files_must_be_allowed
271
+ return unless respond_to?(:files)
272
+ return unless files.attached?
273
+
274
+ mode = Chats.config.attachments
275
+ if mode == false
276
+ errors.add(:files, :not_allowed)
277
+ return
278
+ end
279
+
280
+ if (max_count = Chats.config.max_attachments_per_message) && files.size > max_count
281
+ errors.add(:files, :too_many, count: max_count)
282
+ end
283
+
284
+ files.each do |file|
285
+ errors.add(:files, :must_be_images) if mode == :images && !file.content_type.to_s.start_with?("image/")
286
+
287
+ if (max_size = Chats.config.max_attachment_size) && file.byte_size.to_i > max_size
288
+ errors.add(:files, :too_big, count: max_size / (1024 * 1024))
289
+ end
290
+ end
291
+ end
292
+
293
+ def register_on_conversation
294
+ conversation.register_last_message!(self)
295
+ end
296
+
297
+ def heal_conversation_pointers
298
+ conversation.recompute_last_message! if conversation.last_message_id == id
299
+ end
300
+
301
+ def broadcast_created
302
+ Chats::Broadcasts.message_created(self)
303
+ end
304
+
305
+ def broadcast_updated
306
+ Chats::Broadcasts.message_updated(self)
307
+ end
308
+
309
+ def broadcast_destroyed
310
+ Chats::Broadcasts.message_destroyed(self)
311
+ end
312
+
313
+ # The single notifier hook (see Chats.notify). System messages don't
314
+ # notify: the host posted them itself and already knows — re-emitting
315
+ # would double-count, the same reasoning the chats PRD applies to
316
+ # moderation flag events.
317
+ def notify_host
318
+ return if system?
319
+
320
+ Chats.notify(:message_created, message: self)
321
+ end
322
+ end
323
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # A messager's seat in a conversation. The +messager+ is polymorphic — any
5
+ # `acts_as_messager` model can sit here (a User, an Organization, a support
6
+ # Agent) — and ALL per-member state lives on this row:
7
+ #
8
+ # role "member" | "owner" (group creator/admin)
9
+ # last_read_at the read horizon (see "Read state" below)
10
+ # muted_at notifications muted (the gem still delivers messages;
11
+ # hosts consult `notifiable?` in their notifier hook)
12
+ # left_at soft-left groups (history kept, no new messages seen)
13
+ # last_notified_at bookkeeping for "notify once per unread burst"
14
+ # debounced notifications (see #should_notify?)
15
+ #
16
+ # == Read state: a horizon, not per-message receipts
17
+ #
18
+ # We deliberately store ONE timestamp per participant instead of a
19
+ # per-message receipts table. A message is unread for you iff
20
+ # `created_at > your last_read_at`; you "read" a conversation by advancing
21
+ # the horizon. This gives unread counts, unread badges and "Seen"
22
+ # indicators with zero extra writes per message (a receipts table writes
23
+ # N rows per message per N participants — the classic chat-schema scaling
24
+ # trap), and it's exactly how Basecamp's Campfire models it. If a future
25
+ # use case truly needs per-message receipts (e.g. per-member "read by 7/9"
26
+ # in large groups), they can be added as a new table without breaking this
27
+ # API.
28
+ class Participant < ApplicationRecord
29
+ self.table_name = "chats_participants"
30
+
31
+ ROLES = %w[member owner].freeze
32
+
33
+ belongs_to :conversation, class_name: "Chats::Conversation", inverse_of: :participants
34
+ belongs_to :messager, polymorphic: true
35
+
36
+ scope :active, -> { where(left_at: nil) }
37
+ scope :muted, -> { where.not(muted_at: nil) }
38
+
39
+ validates :role, inclusion: { in: ROLES }
40
+ # One-seat-per-messager uniqueness is enforced by the DB unique index
41
+ # ONLY: Conversation#add_participant! relies on `create_or_find_by!`,
42
+ # which needs the index violation (not a pre-insert validation) to make
43
+ # concurrent joins race-safe. Same rationale as Conversation#direct_key.
44
+ validate :group_must_have_room, on: :create
45
+
46
+ def owner? = role == "owner"
47
+ def left? = left_at.present?
48
+ def active? = left_at.nil?
49
+ def muted? = muted_at.present?
50
+
51
+ def display_name
52
+ Chats.display_name_for(messager)
53
+ end
54
+
55
+ # --- Read state -----------------------------------------------------------
56
+
57
+ # Messages this participant hasn't seen: anything newer than their read
58
+ # horizon, excluding their own messages and deleted tombstones. The
59
+ # explicit IS NULL leg keeps senderless SYSTEM messages counted — SQL's
60
+ # three-valued logic would otherwise drop them (NOT(NULL = …) is NULL,
61
+ # not TRUE). Same fix as Conversation.unread_by.
62
+ def unread_messages
63
+ conversation.messages.visible
64
+ .where("chats_messages.created_at > ?", last_read_at || Conversation::EPOCH)
65
+ .where(
66
+ "chats_messages.sender_type IS NULL OR " \
67
+ "NOT (chats_messages.sender_type = ? AND chats_messages.sender_id = ?)",
68
+ messager_type, messager_id.to_s
69
+ )
70
+ end
71
+
72
+ def unread_count
73
+ unread_messages.count
74
+ end
75
+
76
+ def unread?
77
+ unread_messages.exists?
78
+ end
79
+
80
+ # Advance the read horizon to now and tell everyone who cares:
81
+ # - the conversation stream gets a fresh read-state payload (powers the
82
+ # "Seen" indicator on the other side, when read receipts are on)
83
+ # - this messager's OWN inbox + badge refresh (their other devices/tabs
84
+ # should drop the unread highlight too)
85
+ # - the HOST gets a `:conversation_read` notifier event — but only when
86
+ # the horizon actually swallowed unread content. Hosts use it to keep
87
+ # external notification surfaces truthful (e.g. mark this chat's rows
88
+ # read in a notification center the moment the thread is read, so a
89
+ # bell badge doesn't keep advertising messages the user has already
90
+ # seen). Same notify hook as :message_created; error-isolated.
91
+ def read!(at: Time.current)
92
+ return self if last_read_at && last_read_at >= at
93
+
94
+ had_unread = unread?
95
+ update!(last_read_at: at)
96
+ broadcast_read_state if Chats.config.read_receipts
97
+ Chats::Broadcasts.refresh_inbox_of(messager)
98
+ Chats::Broadcasts.update_badge_of(messager)
99
+ Chats.notify(:conversation_read, conversation: conversation, participant: self) if had_unread
100
+ self
101
+ end
102
+
103
+ def mute! = update!(muted_at: Time.current)
104
+ def unmute! = update!(muted_at: nil)
105
+
106
+ def leave!
107
+ update!(left_at: Time.current)
108
+ end
109
+
110
+ # --- Notification etiquette (for host notifier hooks) ----------------------
111
+
112
+ # Should the host notify this participant about +message+? Encapsulates
113
+ # the etiquette every messaging product implements so each host doesn't
114
+ # re-derive it: don't notify yourself, the muted, the departed — and for
115
+ # debounced email digests, don't notify twice for the same unread burst.
116
+ def notifiable_for?(message)
117
+ return false if left? || muted?
118
+ return false if message.sender == messager
119
+
120
+ true
121
+ end
122
+
123
+ # For "email me only once until I come back" digests: true when there's
124
+ # something unread AND we haven't already notified since the last read.
125
+ # Pair with `mark_notified!` after actually sending.
126
+ def should_notify?
127
+ return false unless unread?
128
+
129
+ last_notified_at.nil? || last_notified_at < (last_read_at || Conversation::EPOCH)
130
+ end
131
+
132
+ def mark_notified!(at: Time.current)
133
+ update!(last_notified_at: at)
134
+ end
135
+
136
+ private
137
+
138
+ def group_must_have_room
139
+ return if conversation.nil? || conversation.direct?
140
+
141
+ max = Chats.config.max_group_size
142
+ return unless max && conversation.participants.active.count >= max
143
+
144
+ errors.add(:base, :group_full, count: max)
145
+ end
146
+
147
+ def broadcast_read_state
148
+ Chats::Broadcasts.read_state(conversation)
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # An emoji reaction on a message. One row per (message, reactor, emoji) —
5
+ # the unique index makes `toggle!` race-safe. Reactors are polymorphic like
6
+ # every actor in this gem.
7
+ class Reaction < ApplicationRecord
8
+ self.table_name = "chats_reactions"
9
+
10
+ # Reactions are short by nature; 16 chars comfortably fits any emoji
11
+ # grapheme cluster (ZWJ sequences like 👨‍👩‍👧‍👦 are up to ~11 chars) while
12
+ # making "smuggle a paragraph into a reaction" impossible.
13
+ EMOJI_MAX_LENGTH = 16
14
+
15
+ belongs_to :message, class_name: "Chats::Message", inverse_of: :reactions
16
+ belongs_to :reactor, polymorphic: true
17
+
18
+ validates :emoji, presence: true, length: { maximum: EMOJI_MAX_LENGTH }
19
+ validates :emoji, uniqueness: { scope: %i[message_id reactor_type reactor_id] }
20
+ validate :reactions_must_be_enabled, on: :create
21
+ validate :reactor_must_be_participant, on: :create
22
+
23
+ # ONE after_commit with on: — NOT separate after_create_commit +
24
+ # after_destroy_commit macros: registering the SAME method name through
25
+ # two *_commit macros silently keeps only the last registration (each
26
+ # macro defines an after_commit filter keyed by method name). Documented
27
+ # Rails behavior: https://guides.rubyonrails.org/active_record_callbacks.html#using-both-after-create-commit-and-after-update-commit
28
+ after_commit :broadcast_change, on: %i[create destroy]
29
+
30
+ # Add the reaction if absent, remove it if present (the universal
31
+ # tap-to-toggle semantic). Returns the created reaction, or false when
32
+ # toggled off. Race-safe: a concurrent double-tap resolves through the
33
+ # unique index instead of raising.
34
+ def self.toggle!(message:, reactor:, emoji:)
35
+ existing = find_by(message: message, reactor: reactor, emoji: emoji)
36
+ if existing
37
+ existing.destroy!
38
+ false
39
+ else
40
+ create!(message: message, reactor: reactor, emoji: emoji)
41
+ end
42
+ rescue ActiveRecord::RecordNotUnique
43
+ # Lost the race with an identical create — treat as toggle-off.
44
+ find_by(message: message, reactor: reactor, emoji: emoji)&.destroy!
45
+ false
46
+ end
47
+
48
+ # Grouped summary for rendering: [["👍", 3], ["🚗", 1]] — stable order so
49
+ # bubbles don't shuffle when counts change.
50
+ def self.summary_for(message)
51
+ where(message: message).group(:emoji).count.sort_by { |emoji, _count| emoji }
52
+ end
53
+
54
+ private
55
+
56
+ def reactions_must_be_enabled
57
+ errors.add(:base, :reactions_disabled) unless Chats.config.reactions
58
+ end
59
+
60
+ def reactor_must_be_participant
61
+ return if message.nil? || reactor.nil?
62
+
63
+ errors.add(:reactor, :not_a_participant) unless message.conversation.participant?(reactor)
64
+ end
65
+
66
+ def broadcast_change
67
+ Chats::Broadcasts.message_updated(message) unless message.destroyed?
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ VERSION = "0.1.1"
5
+ end