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,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view/record_identifier"
4
+
5
+ module Chats
6
+ # ALL of the gem's real-time fan-out, in one file — read this and you know
7
+ # the entire live behavior. Three streams exist:
8
+ #
9
+ # 1. The CONVERSATION stream (`turbo_stream_from @conversation` on the
10
+ # thread page): surgical message append/replace/remove, the read-state
11
+ # payload, and the typing custom action. One render per event, shared
12
+ # by every subscriber — bubbles are rendered VIEWER-AGNOSTIC and the
13
+ # `chats--thread` Stimulus controller aligns own-vs-other client-side
14
+ # (comparing each bubble's sender key against its own `me` value),
15
+ # which is what makes a single broadcast render possible at all.
16
+ # 2. The per-messager INBOX stream (`[messager, :chats_inbox]`, subscribed
17
+ # only on the inbox page): we broadcast Turbo 8 page REFRESHES instead
18
+ # of surgically patching rows. An inbox row is intensely per-viewer
19
+ # (unread badge, bold state, ordering), so patching it from a shared
20
+ # broadcast would force per-viewer renders of per-viewer partials;
21
+ # a refresh makes each client re-request the page and get a correct,
22
+ # personalized render with morphing + scroll preservation for free.
23
+ # Turbo debounces refreshes per stream and tags them with the
24
+ # originating request id so the tab that caused the change skips its
25
+ # own refresh. https://turbo.hotwired.dev/handbook/streams
26
+ #
27
+ # 3. The per-messager BADGE stream (`[messager, :chats_badge]`,
28
+ # subscribable from ANY page via the `chats_unread_badge` helper —
29
+ # e.g. a bottom-nav dock): a tiny `update` of the badge element.
30
+ # Deliberately separate from the inbox stream — if badges rode on it,
31
+ # every page embedding a badge would full-refresh on every message.
32
+ #
33
+ # Everything uses the `_later` variants (rendering happens in background
34
+ # jobs, never inline in the request) except `message_destroyed`, where the
35
+ # record is gone and can't be serialized for a job.
36
+ module Broadcasts
37
+ extend ActionView::RecordIdentifier # dom_id
38
+
39
+ MESSAGE_PARTIAL = "chats/messages/message"
40
+ BADGE_PARTIAL = "chats/shared/unread_badge"
41
+ READ_STATE_PARTIAL = "chats/conversations/read_state"
42
+
43
+ class << self
44
+ def message_created(message)
45
+ conversation = message.conversation
46
+
47
+ Turbo::StreamsChannel.broadcast_append_later_to(
48
+ conversation,
49
+ target: dom_id(conversation, :messages),
50
+ partial: MESSAGE_PARTIAL,
51
+ locals: { message: message }
52
+ )
53
+
54
+ fan_out_inboxes(conversation, skip_badge_for: message.sender)
55
+ end
56
+
57
+ def message_updated(message)
58
+ Turbo::StreamsChannel.broadcast_replace_later_to(
59
+ message.conversation,
60
+ target: dom_id(message),
61
+ partial: MESSAGE_PARTIAL,
62
+ locals: { message: message }
63
+ )
64
+
65
+ # Edits/deletes change the inbox preview when they touch the latest
66
+ # message; a refresh is debounced and cheap, so just fan out.
67
+ fan_out_inboxes(message.conversation, badges: false)
68
+ end
69
+
70
+ def message_destroyed(message)
71
+ # Hard delete: the row is gone — broadcast synchronously (nothing to
72
+ # serialize into a job) and let inboxes re-render.
73
+ Turbo::StreamsChannel.broadcast_remove_to(
74
+ message.conversation,
75
+ target: dom_id(message)
76
+ )
77
+
78
+ fan_out_inboxes(message.conversation, badges: false)
79
+ end
80
+
81
+ # The viewer-agnostic read-state payload (a hidden element carrying
82
+ # every participant's read horizon as JSON). The thread controller
83
+ # turns it into a per-viewer "Seen" indicator client-side.
84
+ def read_state(conversation)
85
+ Turbo::StreamsChannel.broadcast_replace_later_to(
86
+ conversation,
87
+ target: dom_id(conversation, :read_state),
88
+ partial: READ_STATE_PARTIAL,
89
+ locals: { conversation: conversation }
90
+ )
91
+ end
92
+
93
+ # Ephemeral "X is typing…" — a Turbo Stream CUSTOM ACTION (registered
94
+ # in chats/thread_controller.js as Turbo.StreamActions.chats_typing),
95
+ # carrying the typist in attributes. Nothing is persisted; if nobody's
96
+ # subscribed it evaporates, which is exactly right for presence-ish
97
+ # signals. No `_later`: typing is only meaningful NOW.
98
+ def typing(conversation, messager)
99
+ Turbo::StreamsChannel.broadcast_action_to(
100
+ conversation,
101
+ action: :chats_typing,
102
+ target: dom_id(conversation, :typing),
103
+ attributes: {
104
+ "data-name" => Chats.display_name_for(messager),
105
+ "data-key" => Chats.messager_key(messager)
106
+ }
107
+ )
108
+ end
109
+
110
+ def refresh_inbox_of(messager)
111
+ Turbo::StreamsChannel.broadcast_refresh_later_to(messager, :chats_inbox)
112
+ end
113
+
114
+ def update_badge_of(messager)
115
+ # REPLACE, not update: the partial renders the whole badge element
116
+ # (its id included) — an `update` would nest it inside the existing
117
+ # element and duplicate the id. Replace keeps it idempotent.
118
+ Turbo::StreamsChannel.broadcast_replace_later_to(
119
+ messager, :chats_badge,
120
+ target: "chats_unread_badge",
121
+ partial: BADGE_PARTIAL,
122
+ # Counted at broadcast-enqueue time: the badge is advisory UI; a
123
+ # count that's seconds stale self-heals on the next event.
124
+ # reorder(nil): COUNT(DISTINCT) + the inbox's COALESCE order would
125
+ # error on PostgreSQL.
126
+ locals: { count: Chats::Conversation.inbox_for(messager).unread_by(messager).reorder(nil).distinct.count }
127
+ )
128
+ end
129
+
130
+ private
131
+
132
+ # New activity: every active participant's inbox refreshes (including
133
+ # the sender's — their OTHER tabs/devices need the new ordering; the
134
+ # originating tab skips its own refresh via Turbo's request-id dedup),
135
+ # and every participant except the sender gets a fresh unread badge.
136
+ def fan_out_inboxes(conversation, skip_badge_for: nil, badges: true)
137
+ conversation.participants.active.includes(:messager).each do |participant|
138
+ messager = participant.messager
139
+ next if messager.nil?
140
+
141
+ refresh_inbox_of(messager)
142
+ update_badge_of(messager) if badges && messager != skip_badge_for
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # All of the gem's knobs, with delightful defaults: a fresh `Configuration`
5
+ # is fully working out of the box for the classic Devise + `User` Rails app.
6
+ #
7
+ # Two design rules, shared across the gem ecosystem (moderate, api_keys, …):
8
+ #
9
+ # 1. Class names are stored as STRINGS and constantized lazily, so the
10
+ # initializer can reference app classes before they're loaded and
11
+ # everything survives Zeitwerk reloads.
12
+ # 2. Cross-gem seams are PROCS with no-op defaults, so `chats` runs
13
+ # standalone and lights up when the host wires moderate / Noticed /
14
+ # goodmail in.
15
+ class Configuration
16
+ ATTACHMENT_MODES = [false, :images, :any].freeze
17
+ DELETION_MODES = [false, :soft, :hard].freeze
18
+
19
+ # --- Host integration -----------------------------------------------------
20
+
21
+ # The primary conversing model. Participants stay polymorphic regardless
22
+ # (any `acts_as_messager` model can join a conversation); this is the
23
+ # class the engine's controllers resolve `current_messager` against and
24
+ # the default for docs/generators.
25
+ attr_reader :messager_class
26
+
27
+ # The controller the engine inherits from. Pointing this at the host's
28
+ # `ApplicationController` (the default) gives the engine the host's
29
+ # layout, helpers, `current_user`, locale switching, etc. — the same
30
+ # pattern api_keys uses for its dashboard.
31
+ attr_reader :parent_controller
32
+
33
+ # Method called on the controller to fetch the current messager
34
+ # (`:current_user` works with Devise out of the box).
35
+ attr_accessor :current_messager_method
36
+
37
+ # Method called as a `before_action` to require authentication
38
+ # (`:authenticate_user!` works with Devise out of the box).
39
+ attr_accessor :authenticate_method
40
+
41
+ # Optional explicit layout for the engine's screens. `nil` (default)
42
+ # inherits whatever layout the parent controller resolves — usually the
43
+ # host's `application` layout. Set to e.g. `"app"` if your host renders
44
+ # its logged-in surfaces with a different layout.
45
+ attr_accessor :layout
46
+
47
+ # --- Feature flags --------------------------------------------------------
48
+
49
+ # Group conversations (3+ participants, a title, join/leave). Direct 1:1
50
+ # conversations are always available.
51
+ attr_accessor :groups
52
+
53
+ # Emoji reactions on messages.
54
+ attr_accessor :reactions
55
+
56
+ # Read receipts ("Seen") + unread tracking. Read state is stored on the
57
+ # participant (`last_read_at`), not per-message — see Chats::Participant
58
+ # for the rationale.
59
+ attr_accessor :read_receipts
60
+
61
+ # Live "X is typing…" indicators (Turbo Stream custom action; no Action
62
+ # Cable channel of its own, see Chats::ConversationsController#typing).
63
+ attr_accessor :typing_indicators
64
+
65
+ # Whether senders can edit their own messages after sending.
66
+ attr_accessor :editing
67
+
68
+ # What "delete" means: `:soft` keeps a tombstone ("message deleted", body
69
+ # gone — the WhatsApp model, and the safest for Trust & Safety evidence),
70
+ # `:hard` destroys the row, `false` disables deletion entirely.
71
+ attr_reader :deletion
72
+
73
+ # Message attachments: `false` (none), `:images` (images only — the
74
+ # default), or `:any` (any content type). Backed by ActiveStorage's
75
+ # `has_many_attached`, so the host must have ActiveStorage installed to
76
+ # enable this.
77
+ attr_reader :attachments
78
+
79
+ # Inbox search box (partial matching across participant names, conversation
80
+ # titles, subject labels, and message bodies — no extra dependencies; swap
81
+ # in pg_search & friends by overriding the controller if you outgrow it).
82
+ attr_accessor :search
83
+
84
+ # --- Limits ---------------------------------------------------------------
85
+
86
+ attr_accessor :messages_per_page, :max_message_length, :max_group_size, :max_attachment_size,
87
+ :max_attachments_per_message
88
+
89
+ # Per-sender send throttle, enforced with Rails 8's built-in controller
90
+ # `rate_limit` when available (feature-detected; on Rails 7.1 it's a
91
+ # no-op). Shape: `{ to: Integer, within: ActiveSupport::Duration }`.
92
+ # Set to `nil` to disable.
93
+ attr_reader :send_rate_limit
94
+
95
+ # Encrypt message bodies at rest with ActiveRecord Encryption
96
+ # (`encrypts :body`). Requires the host to have AR encryption keys
97
+ # configured (`bin/rails db:encryption:init`). Note: turning this on
98
+ # makes message-body search degrade to title/participant matching.
99
+ attr_accessor :encrypt_messages
100
+
101
+ # --- Policies (procs) -----------------------------------------------------
102
+
103
+ # Host authorization on top of block enforcement: may +sender+ open a
104
+ # conversation with / send to +recipient+? Blocking is ALWAYS enforced
105
+ # underneath this (see Chats.can_message?) so a permissive or buggy
106
+ # policy can never let a blocked pair talk.
107
+ attr_reader :can_message
108
+
109
+ # May +creator+ create a group conversation?
110
+ attr_reader :can_create_group
111
+
112
+ # --- Ecosystem seams (procs, no-op defaults) ------------------------------
113
+
114
+ # ->(messager) { ids } — every messager id that can't talk with the given
115
+ # one (bidirectional). Wire to the moderate gem with one line:
116
+ # config.blocked_messager_ids = ->(user) { Moderate.blocked_ids_for(user) }
117
+ attr_reader :blocked_messager_ids
118
+
119
+ # ->(event, **payload) — domain-moment fan-out (see Chats.notify).
120
+ attr_reader :notifier
121
+
122
+ # --- Display procs (used by the bundled views) ----------------------------
123
+
124
+ # ->(messager) { String } — how a messager is named in inboxes, bubbles
125
+ # and typing indicators. The default tries the obvious candidates.
126
+ attr_reader :messager_display_name
127
+
128
+ # ->(messager) { url/attachment/nil } — an avatar for the messager.
129
+ # Return anything `image_tag` accepts (a URL, an ActiveStorage attachment
130
+ # or variant), or nil to render an initials placeholder.
131
+ attr_reader :messager_avatar
132
+
133
+ def initialize
134
+ @messager_class = "User"
135
+ @parent_controller = "::ApplicationController"
136
+ @current_messager_method = :current_user
137
+ @authenticate_method = :authenticate_user!
138
+ @layout = nil
139
+
140
+ @groups = true
141
+ @reactions = true
142
+ @read_receipts = true
143
+ @typing_indicators = true
144
+ @editing = true
145
+ @deletion = :soft
146
+ @attachments = :images
147
+ @search = true
148
+
149
+ @messages_per_page = 30
150
+ @max_message_length = 5_000
151
+ @max_group_size = 32
152
+ @max_attachment_size = 10 * 1024 * 1024 # 10 MB
153
+ @max_attachments_per_message = 4
154
+ @send_rate_limit = { to: 60, within: 60 } # 60 messages per minute per sender
155
+
156
+ @encrypt_messages = false
157
+
158
+ @can_message = ->(_sender, _recipient) { true }
159
+ @can_create_group = ->(_creator) { true }
160
+
161
+ @blocked_messager_ids = ->(_messager) { [] }
162
+ @notifier = ->(_event, **_payload) {}
163
+
164
+ @messager_display_name = lambda do |messager|
165
+ messager.try(:display_name) || messager.try(:name) ||
166
+ messager.try(:full_name) || messager.try(:username) ||
167
+ messager.try(:email) || "#{messager.class.model_name.human} #{messager.id}"
168
+ end
169
+ @messager_avatar = ->(messager) { messager.try(:avatar) }
170
+ end
171
+
172
+ # --- Validating setters ---------------------------------------------------
173
+ #
174
+ # Fail at boot with a plain-English message, not at 3am with a NoMethodError.
175
+
176
+ def messager_class=(value)
177
+ name = value.is_a?(Class) ? value.name : value.to_s
178
+ raise ConfigurationError, "messager_class can't be blank" if name.strip.empty?
179
+
180
+ @messager_class = name
181
+ end
182
+
183
+ def parent_controller=(value)
184
+ name = value.is_a?(Class) ? value.name : value.to_s
185
+ raise ConfigurationError, "parent_controller can't be blank" if name.strip.empty?
186
+
187
+ @parent_controller = name
188
+ end
189
+
190
+ def attachments=(value)
191
+ normalized = normalize_flag(value)
192
+ unless ATTACHMENT_MODES.include?(normalized)
193
+ raise ConfigurationError, "attachments must be one of #{ATTACHMENT_MODES.inspect}, got #{value.inspect}"
194
+ end
195
+
196
+ @attachments = normalized
197
+ end
198
+
199
+ def deletion=(value)
200
+ normalized = normalize_flag(value)
201
+ unless DELETION_MODES.include?(normalized)
202
+ raise ConfigurationError, "deletion must be one of #{DELETION_MODES.inspect}, got #{value.inspect}"
203
+ end
204
+
205
+ @deletion = normalized
206
+ end
207
+
208
+ def send_rate_limit=(value)
209
+ if value.nil?
210
+ @send_rate_limit = nil
211
+ return
212
+ end
213
+
214
+ hash = value.to_h.symbolize_keys
215
+ unless hash[:to].is_a?(Integer) && hash[:to].positive? && hash[:within].respond_to?(:to_i)
216
+ raise ConfigurationError,
217
+ "send_rate_limit must be nil or { to: Integer, within: duration }, got #{value.inspect}"
218
+ end
219
+
220
+ @send_rate_limit = hash
221
+ end
222
+
223
+ def can_message=(value)
224
+ @can_message = ensure_callable(value, "can_message")
225
+ end
226
+
227
+ def can_create_group=(value)
228
+ @can_create_group = ensure_callable(value, "can_create_group")
229
+ end
230
+
231
+ def blocked_messager_ids=(value)
232
+ @blocked_messager_ids = ensure_callable(value, "blocked_messager_ids")
233
+ end
234
+
235
+ def notifier=(value)
236
+ @notifier = ensure_callable(value, "notifier")
237
+ end
238
+
239
+ def messager_display_name=(value)
240
+ @messager_display_name = ensure_callable(value, "messager_display_name")
241
+ end
242
+
243
+ def messager_avatar=(value)
244
+ @messager_avatar = ensure_callable(value, "messager_avatar")
245
+ end
246
+
247
+ # Cross-field validation, run at the end of `Chats.configure`.
248
+ def validate!
249
+ if max_message_length && max_message_length < 1
250
+ raise ConfigurationError, "max_message_length must be positive (got #{max_message_length})"
251
+ end
252
+
253
+ if max_group_size && max_group_size < 3
254
+ # 2 participants is a direct conversation; a "group" of 2 is a smell.
255
+ raise ConfigurationError, "max_group_size must be at least 3 (got #{max_group_size})"
256
+ end
257
+
258
+ if messages_per_page.to_i < 1
259
+ raise ConfigurationError, "messages_per_page must be positive (got #{messages_per_page.inspect})"
260
+ end
261
+
262
+ true
263
+ end
264
+
265
+ # The constantized messager class (resolved lazily — see class comment).
266
+ def messager_model
267
+ messager_class.constantize
268
+ end
269
+
270
+ private
271
+
272
+ def normalize_flag(value)
273
+ return value if value == false || value.nil?
274
+
275
+ value.to_sym
276
+ end
277
+
278
+ def ensure_callable(value, name)
279
+ unless value.respond_to?(:call)
280
+ raise ConfigurationError, "#{name} must respond to #call (a proc/lambda), got #{value.inspect}"
281
+ end
282
+
283
+ value
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "turbo-rails"
5
+
6
+ module Chats
7
+ # The mountable engine: wires autoloading, migrations, locales, the
8
+ # ActiveRecord macros, importmap pins, asset paths, and boot-time model
9
+ # configuration into the host app.
10
+ class Engine < ::Rails::Engine
11
+ isolate_namespace Chats
12
+
13
+ # -------------------------------------------------------------------------
14
+ # Zeitwerk: the gem keeps its ActiveRecord models under lib/chats/models
15
+ # (same layout as the moderate gem) so the whole domain ships in lib/ and
16
+ # the engine's app/ tree only holds the web layer (controllers, helpers,
17
+ # views). For that to autoload correctly we manage the loader by hand:
18
+ #
19
+ # - `push_dir(lib/chats, namespace: Chats)` makes lib/chats/models/...
20
+ # autoloadable *under the Chats namespace*.
21
+ # - `collapse(models)` + `collapse(models/concerns)` mean the files in
22
+ # those folders define Chats::Conversation / Chats::Messager — not
23
+ # Chats::Models::Conversation.
24
+ # - The SPINE files (version/errors/configuration/macros/engine) are
25
+ # required explicitly by lib/chats.rb at boot, so they must be
26
+ # *ignored* by the loader or Zeitwerk would complain about double
27
+ # definitions / unmanaged constants.
28
+ # -------------------------------------------------------------------------
29
+ LIB_ROOT = File.expand_path("..", __dir__)
30
+ CHATS_LIB = File.expand_path("chats", LIB_ROOT)
31
+
32
+ ZEITWERK_IGNORED = %w[
33
+ version.rb errors.rb configuration.rb engine.rb macros.rb
34
+ ].freeze
35
+
36
+ initializer "chats.autoload", before: :set_autoload_paths do
37
+ loader = Rails.autoloaders.main
38
+
39
+ ZEITWERK_IGNORED.each do |file|
40
+ path = File.join(CHATS_LIB, file)
41
+ loader.ignore(path) if File.exist?(path)
42
+ end
43
+
44
+ %w[models models/concerns].each do |dir|
45
+ path = File.join(CHATS_LIB, dir)
46
+ loader.collapse(path) if File.directory?(path)
47
+ end
48
+
49
+ loader.push_dir(CHATS_LIB, namespace: Chats)
50
+ end
51
+
52
+ config.eager_load_paths << CHATS_LIB
53
+
54
+ # Make the gem's migrations runnable from the host without copying
55
+ # (`rails db:migrate` picks them up) — the install generator still copies
56
+ # a host-owned migration, which is the recommended path; this initializer
57
+ # mainly serves the dummy app and hosts that prefer engine-owned
58
+ # migrations.
59
+ initializer "chats.migrations" do |app|
60
+ unless app.root.to_s == root.to_s
61
+ config.paths["db/migrate"].expanded.each do |path|
62
+ app.config.paths["db/migrate"] << path
63
+ end
64
+ end
65
+ end
66
+
67
+ # Expose `acts_as_messager` / `acts_as_chat_subject` on every AR model.
68
+ initializer "chats.active_record" do
69
+ ActiveSupport.on_load(:active_record) do
70
+ extend Chats::Macros
71
+ end
72
+ end
73
+
74
+ # Ship the gem's locale files (en, es). Host locale files with the same
75
+ # keys override these automatically (I18n's load order puts the app last).
76
+ initializer "chats.locales" do |app|
77
+ app.config.i18n.load_path += Dir[root.join("config", "locales", "**", "*.{rb,yml}").to_s]
78
+ end
79
+
80
+ # NOTE: the host-facing helpers (`chat_button_to`, `chats_unread_badge`, …)
81
+ # are exposed to ActionView from the BOTTOM of engine_helper.rb itself
82
+ # (moderate's proven pattern), NOT from an initializer here: an
83
+ # `on_load(:action_view)` registered during initializers fires
84
+ # IMMEDIATELY in hosts where something (web-console, a mailer preview…)
85
+ # already loaded ActionView — and at that point the autoloader can't
86
+ # resolve Chats::EngineHelper yet (NameError at boot). Keeping the hook
87
+ # in the same file as the constant makes it self-resolving; the
88
+ # `to_prepare` touch below guarantees the file loads on every boot and
89
+ # code reload even before anything references it.
90
+
91
+ # -------------------------------------------------------------------------
92
+ # JavaScript: the engine ships tiny Stimulus controllers (thread, composer,
93
+ # debounced-submit) with NO build step, pinned for importmap-rails hosts.
94
+ #
95
+ # The pin keys live under "controllers/chats/..." ON PURPOSE: the stock
96
+ # Rails `app/javascript/controllers/index.js` calls
97
+ # `eagerLoadControllersFrom("controllers", application)`, which scans the
98
+ # rendered importmap for keys matching ^controllers/.*_controller$ and
99
+ # registers each one, deriving the identifier from the path
100
+ # ("controllers/chats/thread_controller" → "chats--thread"; see
101
+ # stimulus-rails' stimulus-loading.js, registerControllerFromPath).
102
+ # Result: a host with the default Stimulus setup gets the chats--*
103
+ # controllers REGISTERED AUTOMATICALLY, zero JS changes.
104
+ #
105
+ # We `unshift` (not `<<`) our importmap so the HOST's pins are drawn after
106
+ # ours — importmap-rails resolves duplicate pins last-wins, so a host can
107
+ # override any chats controller by pinning the same key (or just dropping
108
+ # a file at app/javascript/controllers/chats/thread_controller.js, which
109
+ # `pin_all_from "app/javascript/controllers"` then pins over ours).
110
+ # importmap-rails appends the app's own config/importmap.rb inside its
111
+ # "importmap" initializer and draws everything in path order:
112
+ # https://github.com/rails/importmap-rails (lib/importmap/engine.rb)
113
+ # -------------------------------------------------------------------------
114
+ initializer "chats.importmap", before: "importmap" do |app|
115
+ if app.config.respond_to?(:importmap)
116
+ app.config.importmap.paths.unshift(root.join("config/importmap.rb"))
117
+ # Sweep the importmap cache when our JS changes (dev nicety).
118
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
119
+ end
120
+ end
121
+
122
+ # Serve the engine's JS + CSS through the host's asset pipeline
123
+ # (propshaft or sprockets — both honor config.assets.paths).
124
+ initializer "chats.assets" do |app|
125
+ if app.config.respond_to?(:assets)
126
+ app.config.assets.paths << root.join("app/javascript")
127
+ app.config.assets.paths << root.join("app/assets/stylesheets")
128
+ end
129
+ end
130
+
131
+ # Apply boot-time configuration that has to touch autoloaded classes —
132
+ # runs on every reload in development so it stays applied to fresh ones.
133
+ config.to_prepare do
134
+ # Touch the helper so its bottom-of-file on_load(:action_view) hook
135
+ # registers even if no engine code was referenced yet (see NOTE above).
136
+ # (Assigned to appease Lint/Void — the constant REFERENCE is the point.)
137
+ _loaded = Chats::EngineHelper
138
+
139
+ if Chats.config.encrypt_messages && Chats::Message.respond_to?(:encrypts)
140
+ # Opt-in encryption at rest (config.encrypt_messages = true).
141
+ # `deterministic: false` (the default) is correct for free text.
142
+ Chats::Message.encrypts :body
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # Base class for every error this gem raises, so hosts can
5
+ # `rescue Chats::Error` to catch anything chats-specific.
6
+ class Error < StandardError; end
7
+
8
+ # Raised by `Chats.configure` / setters when the configuration is invalid
9
+ # (unknown filter mode, blank class name, non-callable hook, …).
10
+ class ConfigurationError < Error; end
11
+
12
+ # Raised when trying to open a conversation with (or send a message to)
13
+ # someone the sender is blocked with — in either direction. Controllers
14
+ # translate this into a friendly flash; model-level callers get the raise.
15
+ class BlockedError < Error; end
16
+
17
+ # Raised when the host `can_message` policy (or a feature flag like
18
+ # `config.groups = false`) forbids the attempted action.
19
+ class NotAllowedError < Error; end
20
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # The two host-facing macros. The engine extends `ActiveRecord::Base` with
5
+ # this module (via `ActiveSupport.on_load(:active_record)`), so any model
6
+ # can declare:
7
+ #
8
+ # class User < ApplicationRecord
9
+ # acts_as_messager # can converse: inbox, DMs, groups
10
+ # end
11
+ #
12
+ # class Ride < ApplicationRecord
13
+ # acts_as_chat_subject # conversations can be *about* a ride
14
+ # end
15
+ #
16
+ # Each macro just includes the corresponding concern — all the behavior
17
+ # lives in Chats::Messager / Chats::ChatSubject so it's discoverable,
18
+ # testable, and `include`-able directly when a host prefers that style.
19
+ module Macros
20
+ def acts_as_messager
21
+ include Chats::Messager
22
+ end
23
+
24
+ def acts_as_chat_subject
25
+ include Chats::ChatSubject
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # Abstract base for every chats model. Kept separate from the host's
5
+ # ApplicationRecord on purpose: the gem's models must not inherit host
6
+ # callbacks/scopes, and the host must be able to swap its own base class
7
+ # without touching ours.
8
+ class ApplicationRecord < ActiveRecord::Base
9
+ self.abstract_class = true
10
+ end
11
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # Included by `acts_as_chat_subject`. Lets conversations be *about* a host
5
+ # record — `user.chat_with(driver, about: ride)` — which both threads the
6
+ # right people into the right context and shows up as a context line in
7
+ # the inbox/thread UI.
8
+ #
9
+ # Override +chat_subject_label+ to control that context line:
10
+ #
11
+ # class Ride::Listing < ApplicationRecord
12
+ # acts_as_chat_subject
13
+ # def chat_subject_label = "#{origin_locality} → #{destination_locality}"
14
+ # end
15
+ module ChatSubject
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ # `nullify`, not `destroy`: deleting the subject (a ride, an order)
20
+ # must never delete people's conversation history — the thread just
21
+ # loses its context line.
22
+ has_many :chat_conversations,
23
+ class_name: "Chats::Conversation",
24
+ as: :subject,
25
+ dependent: :nullify
26
+
27
+ Chats.register_chat_subject(self)
28
+ end
29
+
30
+ # Short human label shown as the conversation's context line.
31
+ def chat_subject_label
32
+ "#{self.class.model_name.human} #{id}"
33
+ end
34
+ end
35
+ end