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,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # Included by `acts_as_messager`. Gives any model an inbox and the two
5
+ # verbs that make the whole gem read like plain English:
6
+ #
7
+ # alice.chat_with(bob) # find-or-create the DM
8
+ # alice.chat_with(bob, about: ride) # the DM about that ride
9
+ # alice.chat_with(bob, carol, title: "Trip") # a group
10
+ #
11
+ # alice.message!(bob, "hola!") # DM in one line
12
+ # alice.message!(conversation, "hola!") # or into a conversation
13
+ #
14
+ # alice.chats # her inbox, newest first
15
+ # alice.unread_chats_count # for the nav badge
16
+ module Messager
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ has_many :chat_participations,
21
+ class_name: "Chats::Participant",
22
+ as: :messager,
23
+ inverse_of: :messager,
24
+ dependent: :destroy
25
+
26
+ has_many :chat_conversations,
27
+ through: :chat_participations,
28
+ source: :conversation
29
+
30
+ # `dependent: :nullify`: when a messager account is destroyed, their
31
+ # messages stay as context for everyone else (sender shows as a
32
+ # localized "deleted account"), mirroring what every serious messaging
33
+ # product does. The sender-presence validation only runs on create, so
34
+ # orphaned rows remain valid.
35
+ has_many :chat_messages,
36
+ class_name: "Chats::Message",
37
+ as: :sender,
38
+ dependent: :nullify
39
+
40
+ Chats.register_messager(self)
41
+ end
42
+
43
+ # Find-or-create a conversation with one or more other messagers.
44
+ # One other → the direct thread (per-pair, or per-pair-per-subject when
45
+ # `about:` is given). Several others → a group.
46
+ #
47
+ # Raises Chats::BlockedError / Chats::NotAllowedError — see
48
+ # Conversation.direct_between! / .group!.
49
+ def chat_with(*others, about: nil, title: nil)
50
+ others = others.flatten.compact
51
+ raise ArgumentError, "chat_with requires at least one other messager" if others.empty?
52
+
53
+ if others.size == 1
54
+ Chats::Conversation.direct_between!(self, others.first, about: about)
55
+ else
56
+ Chats::Conversation.group!(self, others, title: title, about: about)
57
+ end
58
+ end
59
+
60
+ # Send a message — to a messager (resolving/creating the direct thread)
61
+ # or straight into a Chats::Conversation. Returns the Chats::Message.
62
+ #
63
+ # alice.message!(bob, "are you coming?")
64
+ # alice.message!(bob, "about the ride", about: ride)
65
+ # alice.message!(conversation, "hi all!", files: [photo])
66
+ def message!(target, body = nil, about: nil, files: [], reply_to: nil)
67
+ conversation =
68
+ case target
69
+ when Chats::Conversation then target
70
+ else chat_with(target, about: about)
71
+ end
72
+
73
+ attributes = { sender: self, body: body, reply_to: reply_to }
74
+ attributes[:files] = files if files.present?
75
+ conversation.messages.create!(**attributes)
76
+ end
77
+
78
+ # The inbox: every active conversation, blocked counterparts hidden,
79
+ # newest activity first. A relation — chain `.limit`, `.includes`, etc.
80
+ def chats
81
+ Chats::Conversation.inbox_for(self)
82
+ end
83
+
84
+ # Number of conversations with unread messages (the WhatsApp-style nav
85
+ # badge counts conversations, not messages — 47 unread messages in one
86
+ # thread is still "1 thing to deal with").
87
+ def unread_chats_count
88
+ # reorder(nil): the inbox scope orders by a COALESCE expression, which
89
+ # PostgreSQL rejects inside a COUNT(DISTINCT …) aggregate.
90
+ chats.unread_by(self).reorder(nil).distinct.count
91
+ end
92
+
93
+ def unread_chats?
94
+ chats.unread_by(self).reorder(nil).exists?
95
+ end
96
+
97
+ # This messager's participant row in +conversation+ (nil when not in it).
98
+ def chat_participation_in(conversation)
99
+ conversation.participant_for(self)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # A conversation: either a +direct+ 1:1 thread or a +group+. Optionally
5
+ # *about* a host record (the polymorphic +subject+ — a ride, an order, a
6
+ # listing), which is how marketplace-style apps attach a chat to a domain
7
+ # object.
8
+ #
9
+ # == Direct conversations & the +direct_key+
10
+ #
11
+ # Two people opening a DM with each other at the same instant must end up
12
+ # in the SAME conversation. We guarantee that with a deterministic
13
+ # +direct_key+ ("the sorted pair of participant keys, plus the subject key
14
+ # when the conversation is about something") backed by a UNIQUE index, and
15
+ # `create_or_find_by!` which turns the index violation into a find. See
16
+ # `.direct_between!`.
17
+ #
18
+ # The subject participates in the key on purpose: the same pair can have
19
+ # one thread per subject (per-listing threads, marketplace-style) AND one
20
+ # subjectless thread (classic DMs). The host picks the cardinality simply
21
+ # by passing or omitting `about:`.
22
+ #
23
+ # == Denormalization
24
+ #
25
+ # +last_message_at+ and +last_message_id+ exist so the inbox (the hottest
26
+ # query in any messaging product) is one indexed ORDER BY plus one
27
+ # `includes(:last_message)` — no MAX() subqueries, no N+1.
28
+ class Conversation < ApplicationRecord
29
+ self.table_name = "chats_conversations"
30
+
31
+ KINDS = %w[direct group].freeze
32
+ TITLE_MAX_LENGTH = 120
33
+ EPOCH = Time.utc(1970).freeze
34
+
35
+ belongs_to :subject, polymorphic: true, optional: true
36
+ # No DB foreign key on last_message_id (see migration): a circular
37
+ # conversations<->messages FK pair makes deletes order-dependent. The
38
+ # association is best-effort denormalization, never authority.
39
+ belongs_to :last_message, class_name: "Chats::Message", optional: true
40
+
41
+ has_many :participants,
42
+ class_name: "Chats::Participant",
43
+ inverse_of: :conversation,
44
+ dependent: :destroy
45
+ has_many :messages,
46
+ class_name: "Chats::Message",
47
+ inverse_of: :conversation,
48
+ dependent: :destroy
49
+
50
+ scope :direct, -> { where(kind: "direct") }
51
+ # `groups` (plural): `scope :group` would collide with ActiveRecord's own
52
+ # GROUP BY class method.
53
+ scope :groups, -> { where(kind: "group") }
54
+ scope :about, ->(subject) { where(subject: subject) }
55
+ scope :recent_first, lambda {
56
+ # COALESCE keeps freshly-created (still message-less) conversations
57
+ # sorted by creation time, portable across sqlite/postgres/mysql
58
+ # (no NULLS LAST, which sqlite/mysql spell differently).
59
+ order(Arel.sql("COALESCE(chats_conversations.last_message_at, chats_conversations.created_at) DESC"))
60
+ }
61
+
62
+ # A messager's inbox: every conversation they're an active participant of
63
+ # (not left), minus direct threads with someone they're blocked with,
64
+ # newest activity first. THE hot query — keep it composable and indexed.
65
+ scope :inbox_for, lambda { |messager|
66
+ joins(:participants)
67
+ .where(chats_participants: { left_at: nil })
68
+ .where(chats_participants: { messager_type: messager.class.polymorphic_name, messager_id: messager.id })
69
+ .excluding_blocked_for(messager)
70
+ .recent_first
71
+ }
72
+
73
+ # Hide direct conversations whose counterpart is blocked (either
74
+ # direction — `Chats.blocked_ids_for` is bidirectional by contract).
75
+ # Group conversations are NOT hidden: industry standard is that blocking
76
+ # someone doesn't eject you from shared group spaces. Data is never
77
+ # deleted — lift the block and the thread reappears.
78
+ scope :excluding_blocked_for, lambda { |messager|
79
+ blocked_ids = Chats.blocked_ids_for(messager)
80
+ next all if blocked_ids.respond_to?(:empty?) && blocked_ids.empty?
81
+
82
+ blocked_seats = Chats::Participant.select(:conversation_id).where(
83
+ messager_type: messager.class.polymorphic_name, messager_id: blocked_ids
84
+ )
85
+
86
+ where.not(id: direct.where(id: blocked_seats))
87
+ }
88
+
89
+ # Conversations holding messages the messager hasn't read yet (excluding
90
+ # their own messages and tombstones). Compose with `inbox_for`:
91
+ # Chats::Conversation.inbox_for(user).unread_by(user).distinct.count
92
+ scope :unread_by, lambda { |messager|
93
+ joins(:participants, :messages)
94
+ .where(chats_participants: { messager_type: messager.class.polymorphic_name, messager_id: messager.id })
95
+ .where(chats_messages: { deleted_at: nil })
96
+ .where("chats_messages.created_at > COALESCE(chats_participants.last_read_at, ?)", EPOCH)
97
+ # "Not sent by me" — with an explicit IS NULL leg: system messages
98
+ # have a NULL sender, and in SQL's three-valued logic a bare
99
+ # NOT(sender_type = X AND …) evaluates to NULL (not TRUE) for them,
100
+ # silently dropping system messages from unread counts.
101
+ .where(
102
+ "chats_messages.sender_type IS NULL OR NOT (chats_messages.sender_type = ? AND chats_messages.sender_id = ?)",
103
+ messager.class.polymorphic_name, messager.id.to_s
104
+ )
105
+ }
106
+
107
+ validates :kind, inclusion: { in: KINDS }
108
+ validates :title, length: { maximum: TITLE_MAX_LENGTH }, allow_blank: true
109
+ # direct_key uniqueness is enforced by the DB unique index ONLY — on
110
+ # purpose, not an oversight. `create_or_find_by!` (the race-safe
111
+ # find-or-create in .direct_between!) works by attempting the INSERT and
112
+ # converting the index violation into a find; a model-level uniqueness
113
+ # validation would fire first, raise RecordInvalid, and break the whole
114
+ # mechanism. https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
115
+ validate :groups_must_be_enabled, if: :group?
116
+
117
+ # --- Finding & creating ---------------------------------------------------
118
+
119
+ class << self
120
+ # The direct conversation between +a+ and +b+ (about +subject+, when
121
+ # given), or nil. Read-only twin of `.direct_between!`.
122
+ def direct_between(a, b, about: nil)
123
+ find_by(direct_key: direct_key_for([a, b], subject: about))
124
+ end
125
+
126
+ # Find-or-create the direct conversation between two messagers,
127
+ # race-safely (see class comment). Raises:
128
+ # Chats::BlockedError if the pair is blocked (either direction)
129
+ # Chats::NotAllowedError if the host `can_message` policy says no
130
+ def direct_between!(a, b, about: nil)
131
+ raise ArgumentError, "both participants are required" if a.nil? || b.nil?
132
+ raise ArgumentError, "can't open a conversation with yourself" if a == b
133
+ raise Chats::BlockedError, "messagers are blocked" if Chats.blocked_between?(a, b)
134
+ raise Chats::NotAllowedError, "policy forbids messaging" unless Chats.can_message?(a, b)
135
+
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
139
+ 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
+ end
147
+
148
+ # Create a group conversation. +others+ excludes the creator (who joins
149
+ # as "owner"). Raises Chats::NotAllowedError when groups are disabled
150
+ # or the host `can_create_group` policy says no.
151
+ def group!(creator, others, title: nil, about: nil)
152
+ raise Chats::NotAllowedError, "group conversations are disabled" unless Chats.config.groups
153
+ unless Chats.config.can_create_group.call(creator)
154
+ raise Chats::NotAllowedError,
155
+ "policy forbids creating groups"
156
+ end
157
+
158
+ others = Array(others) - [creator]
159
+ raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
160
+
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
+ end
167
+ end
168
+
169
+ # Deterministic identity for a direct pair (+ optional subject).
170
+ # GlobalID params already encode class + id, so "User 4" and
171
+ # "Organization 4" can never collide. Sorting makes it order-independent.
172
+ def direct_key_for(pair, subject: nil)
173
+ key = pair.map { |messager| Chats.messager_key(messager) }.sort.join("|")
174
+ key += "|about:#{subject.to_global_id.to_param}" if subject
175
+ key
176
+ end
177
+
178
+ # Per-conversation unread counts for a messager over a set of
179
+ # conversations, in ONE grouped query — the inbox uses this to render
180
+ # row badges without N+1:
181
+ # Chats::Conversation.unread_counts_for(user, conversations) # => { id => count }
182
+ def unread_counts_for(messager, conversations)
183
+ ids = Array(conversations).map { |c| c.is_a?(Conversation) ? c.id : c }
184
+ return {} if ids.empty?
185
+
186
+ unread_by(messager).where(chats_conversations: { id: ids })
187
+ .group("chats_conversations.id")
188
+ .count("chats_messages.id")
189
+ end
190
+ end
191
+
192
+ # --- Predicates & lookups -------------------------------------------------
193
+
194
+ def direct? = kind == "direct"
195
+ def group? = kind == "group"
196
+
197
+ def participant_for(messager)
198
+ return nil if messager.nil?
199
+
200
+ participants.find_by(messager: messager)
201
+ end
202
+
203
+ def participant?(messager)
204
+ return false if messager.nil?
205
+
206
+ participants.active.exists?(messager_type: messager.class.polymorphic_name, messager_id: messager.id)
207
+ end
208
+
209
+ def other_participants(messager)
210
+ participants.active.where.not(
211
+ messager_type: messager.class.polymorphic_name, messager_id: messager.id
212
+ )
213
+ end
214
+
215
+ # What this conversation is called from +viewer+'s seat: a direct thread
216
+ # is named after the counterpart; a group after its title (or its
217
+ # members, when untitled).
218
+ def title_for(viewer)
219
+ if direct?
220
+ other = other_participants(viewer).includes(:messager).first
221
+ other ? Chats.display_name_for(other.messager) : I18n.t("chats.conversation.empty_title")
222
+ else
223
+ title.presence || participants.active.includes(:messager).limit(4).map do |p|
224
+ Chats.display_name_for(p.messager)
225
+ end.join(", ")
226
+ end
227
+ end
228
+
229
+ # A short human label for the subject ("Madrid → Barcelona", "Order
230
+ # #4221"), provided by the subject model via `chat_subject_label`
231
+ # (see Chats::ChatSubject). Nil when the conversation is about nothing.
232
+ def subject_label
233
+ return nil if subject.nil?
234
+
235
+ subject.try(:chat_subject_label) || "#{subject.class.model_name.human} #{subject.id}"
236
+ end
237
+
238
+ # --- Membership -----------------------------------------------------------
239
+
240
+ # Idempotent, race-safe membership. Re-adding someone who left re-joins
241
+ # them (their read state survives — by design, so history isn't re-marked
242
+ # unread).
243
+ def add_participant!(messager, role: "member")
244
+ participant = participants.create_or_find_by!(
245
+ messager_type: messager.class.polymorphic_name, messager_id: messager.id
246
+ ) do |p|
247
+ p.role = role
248
+ end
249
+ participant.update!(left_at: nil) if participant.left?
250
+ participant
251
+ end
252
+
253
+ # --- Messaging ------------------------------------------------------------
254
+
255
+ # Post a message from your APP into the conversation — "Your ride was
256
+ # cancelled", "Payment received" — rendered as a centered system note,
257
+ # not a bubble. This is the stable entry point notification systems
258
+ # (e.g. a Noticed delivery method) should call.
259
+ def post_system_message!(body)
260
+ messages.create!(kind: "system", body: body)
261
+ end
262
+
263
+ # --- Read state -----------------------------------------------------------
264
+
265
+ def unread_count_for(messager)
266
+ participant_for(messager)&.unread_count || 0
267
+ end
268
+
269
+ def unread_for?(messager)
270
+ unread_count_for(messager).positive?
271
+ end
272
+
273
+ def mark_read_by!(messager)
274
+ participant_for(messager)&.read!
275
+ end
276
+
277
+ # Denormalized pointers the inbox sorts/previews by. Called from
278
+ # Chats::Message callbacks inside the message's own transaction.
279
+ # `update_columns` on purpose: no validations/callbacks/broadcast loops —
280
+ # this is bookkeeping, not domain change. updated_at is bumped manually
281
+ # so fragment caches keyed on the conversation still invalidate.
282
+ def register_last_message!(message) # :nodoc:
283
+ update_columns(
284
+ last_message_at: message.created_at,
285
+ last_message_id: message.id,
286
+ updated_at: Time.current
287
+ )
288
+ end
289
+
290
+ def recompute_last_message! # :nodoc:
291
+ latest = messages.order(created_at: :desc, id: :desc).first
292
+ update_columns(
293
+ last_message_at: latest&.created_at,
294
+ last_message_id: latest&.id,
295
+ updated_at: Time.current
296
+ )
297
+ end
298
+
299
+ # --- Moderation contract (duck-typed, zero coupling) ------------------------
300
+ #
301
+ # These methods make the conversation a well-behaved reportable the moment
302
+ # the HOST includes the moderate gem's concern (`Chats::Conversation.
303
+ # has_reportable_content :title`). They're plain Ruby — defining them
304
+ # without moderate installed costs nothing. See README "Trust & Safety".
305
+
306
+ def reported_owner
307
+ # The accountable human for a conversation: its owner (groups) or the
308
+ # first participant (direct — moderation of direct threads usually
309
+ # targets a specific *message*, but DSA tooling wants an owner).
310
+ owner = participants.find_by(role: "owner") || participants.order(:created_at).first
311
+ owner&.messager
312
+ end
313
+
314
+ def moderation_label
315
+ "Chat conversation #{id}"
316
+ end
317
+
318
+ def moderation_snapshot(field)
319
+ title if field.to_s == "title"
320
+ end
321
+
322
+ def removable_reported_field?(field)
323
+ field.to_s == "title" && title.present?
324
+ end
325
+
326
+ def remove_reported_field!(field)
327
+ return false unless field.to_s == "title"
328
+
329
+ update!(title: nil)
330
+ true
331
+ end
332
+
333
+ def report_visible_to?(viewer, field: nil)
334
+ participant?(viewer)
335
+ end
336
+
337
+ def moderation_content_type
338
+ group? ? "group" : "conversation"
339
+ end
340
+
341
+ private
342
+
343
+ def groups_must_be_enabled
344
+ errors.add(:kind, :groups_disabled) unless Chats.config.groups
345
+ end
346
+ end
347
+ end