chats 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +163 -28
  4. data/app/assets/stylesheets/chats.css +44 -0
  5. data/app/controllers/chats/conversations_controller.rb +43 -43
  6. data/app/controllers/chats/messages_controller.rb +34 -0
  7. data/app/controllers/chats/reactions_controller.rb +14 -0
  8. data/app/helpers/chats/engine_helper.rb +94 -0
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_group.html.erb +38 -0
  11. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  12. data/app/views/chats/conversations/index.html.erb +39 -9
  13. data/app/views/chats/conversations/show.html.erb +44 -3
  14. data/app/views/chats/messages/_composer.html.erb +4 -0
  15. data/app/views/chats/messages/_message.html.erb +29 -8
  16. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  17. data/config/importmap.rb +2 -1
  18. data/config/locales/en.yml +11 -0
  19. data/config/locales/es.yml +11 -0
  20. data/context7.json +4 -0
  21. data/docs/PRD.md +1 -1
  22. data/docs/campfire_review.md +1 -1
  23. data/gemfiles/rails_7.1.gemfile +1 -0
  24. data/gemfiles/rails_7.2.gemfile +1 -0
  25. data/gemfiles/rails_8.1.gemfile +1 -0
  26. data/lib/chats/configuration.rb +63 -1
  27. data/lib/chats/engine.rb +29 -7
  28. data/lib/chats/errors.rb +16 -0
  29. data/lib/chats/inbox.rb +303 -0
  30. data/lib/chats/inbox_group.rb +75 -0
  31. data/lib/chats/macros.rb +24 -1
  32. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  33. data/lib/chats/models/concerns/messager.rb +81 -2
  34. data/lib/chats/models/conversation.rb +59 -5
  35. data/lib/chats/models/message.rb +67 -3
  36. data/lib/chats/models/participant.rb +59 -0
  37. data/lib/chats/models/reaction.rb +5 -0
  38. data/lib/chats/subscribers.rb +156 -0
  39. data/lib/chats/version.rb +1 -1
  40. data/lib/chats.rb +114 -15
  41. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  42. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  43. data/lib/generators/chats/templates/initializer.rb +95 -14
  44. data/lib/generators/chats/upgrade_generator.rb +48 -0
  45. metadata +12 -2
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # The event bus behind `Chats.on`. One registry per event, many
5
+ # subscribers per event, each one isolated:
6
+ #
7
+ # Chats.on(:message_created) { |message| NewMessageNotifier.deliver(message) }
8
+ # Chats.on(:conversation_read) { |conversation:, participant:| Bell.mark_read(participant) }
9
+ #
10
+ # Two rules make this safe to use from a gem (the shape the `wallets` gem's
11
+ # CallbackDispatcher established):
12
+ #
13
+ # 1. A raising subscriber is REPORTED, never swallowed and never fatal —
14
+ # `Rails.error.report(e, handled: true, context: { event: })` — so the
15
+ # next subscriber still runs and the message still gets delivered.
16
+ # 2. Registration is reload-safe: pass `key:` and re-registering the same
17
+ # key REPLACES the subscriber in place, so a `to_prepare` block in a
18
+ # host app doesn't stack duplicates on every code reload.
19
+ module Subscribers
20
+ # Every event the gem emits, mapped to the payload key it yields
21
+ # POSITIONALLY to subscribers (nil = the whole payload is yielded as
22
+ # keywords). Registering for anything else raises at boot.
23
+ #
24
+ # :message_created message: every persisted human message
25
+ # :conversation_created conversation: a conversation just came into being
26
+ # :participant_left participant: someone left a group
27
+ # :conversation_read conversation:, participant: a read consumed unread content
28
+ EVENTS = {
29
+ message_created: :message,
30
+ conversation_created: :conversation,
31
+ participant_left: :participant,
32
+ conversation_read: nil
33
+ }.freeze
34
+
35
+ # The events the deprecated `config.notifier=` hook is subscribed to:
36
+ # exactly the two that existed in 0.1.1, and no more. A 0.1.x notifier is
37
+ # commonly written `->(event, message:, **)`, which would raise
38
+ # ArgumentError on an event that carries no `message:` — so the new
39
+ # events are `Chats.on` only, and an old hook keeps behaving exactly as
40
+ # it did.
41
+ LEGACY_NOTIFIER_EVENTS = %i[message_created conversation_read].freeze
42
+
43
+ # Reserved key for the subscriber `config.notifier=` registers, so
44
+ # re-assigning the deprecated hook replaces rather than stacks.
45
+ NOTIFIER_KEY = :chats_config_notifier
46
+
47
+ # One registered callable. +style+ is how it gets invoked:
48
+ # :payload the modern `Chats.on` shape (positional record, or keywords)
49
+ # :event the deprecated `config.notifier` shape — `(event, **payload)`
50
+ class Subscriber
51
+ attr_reader :key, :callable, :style
52
+
53
+ def initialize(callable, key: nil, style: :payload)
54
+ @callable = callable
55
+ @key = key
56
+ @style = style
57
+ end
58
+
59
+ # Invoke the subscriber for +event+ with the emitted +payload+ hash.
60
+ def call(event, payload)
61
+ if style == :event
62
+ callable.call(event, **payload)
63
+ elsif (positional = EVENTS[event])
64
+ callable.call(payload[positional])
65
+ else
66
+ callable.call(**payload)
67
+ end
68
+ end
69
+ end
70
+
71
+ class << self
72
+ # Register +block+ for +event+. Returns the Subscriber.
73
+ def on(event, key: nil, style: :payload, &block)
74
+ event = validate_event!(event)
75
+ raise ConfigurationError, "Chats.on(#{event.inspect}) requires a block" if block.nil?
76
+
77
+ subscriber = Subscriber.new(block, key: key, style: style)
78
+ replace_or_append(registry[event], subscriber)
79
+ subscriber
80
+ end
81
+
82
+ # Every subscriber registered for +event+, in registration order.
83
+ def for(event)
84
+ registry[validate_event!(event)].dup
85
+ end
86
+
87
+ # Run every subscriber of +event+, each isolated from the others.
88
+ # Returns the number of subscribers invoked.
89
+ #
90
+ # Emitting is never allowed to raise: it runs inside `after_commit`
91
+ # hooks, where an exception would punish a write that already
92
+ # succeeded. An unknown event here is a bug in the CALLER, so it is
93
+ # logged and skipped; `Chats.on` is where a bad event name fails loudly,
94
+ # at boot, where someone can fix it.
95
+ def emit(event, **payload)
96
+ event = event.to_sym
97
+ unless EVENTS.key?(event)
98
+ Chats.logger&.error("[chats] ignoring unknown event #{event.inspect}")
99
+ return 0
100
+ end
101
+
102
+ subscribers = registry[event]
103
+
104
+ subscribers.each do |subscriber|
105
+ subscriber.call(event, payload)
106
+ rescue StandardError => e
107
+ report(e, event)
108
+ end
109
+
110
+ subscribers.size
111
+ end
112
+
113
+ # Drop every registration (used by `Chats.reset!` and by hosts that
114
+ # re-register from a `to_prepare` block).
115
+ def reset!
116
+ @registry = nil
117
+ self
118
+ end
119
+
120
+ private
121
+
122
+ def registry
123
+ @registry ||= EVENTS.keys.index_with { [] }
124
+ end
125
+
126
+ def validate_event!(event)
127
+ event = event.to_sym
128
+ return event if EVENTS.key?(event)
129
+
130
+ raise ConfigurationError,
131
+ "unknown chats event #{event.inspect} — valid events are #{EVENTS.keys.map(&:inspect).join(", ")}"
132
+ end
133
+
134
+ # Keyed subscribers replace in place (same position, so ordering is
135
+ # stable across code reloads); keyless ones always append.
136
+ def replace_or_append(list, subscriber)
137
+ index = subscriber.key && list.index { |existing| existing.key == subscriber.key }
138
+ if index
139
+ list[index] = subscriber
140
+ else
141
+ list << subscriber
142
+ end
143
+ end
144
+
145
+ # A failing subscriber must be VISIBLE (not just logged): the host's
146
+ # error reporter is the one surface that pages someone.
147
+ def report(error, event)
148
+ if defined?(::Rails) && ::Rails.respond_to?(:error) && ::Rails.error
149
+ ::Rails.error.report(error, handled: true, context: { event: event })
150
+ else
151
+ Chats.logger&.error("[chats] subscriber raised on #{event}: #{error.class}: #{error.message}")
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
data/lib/chats/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chats
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/chats.rb CHANGED
@@ -6,6 +6,7 @@ require "global_id"
6
6
  require_relative "chats/version"
7
7
  require_relative "chats/errors"
8
8
  require_relative "chats/configuration"
9
+ require_relative "chats/subscribers"
9
10
  require_relative "chats/macros"
10
11
 
11
12
  require_relative "chats/engine" if defined?(::Rails::Engine)
@@ -24,6 +25,8 @@ require_relative "chats/engine" if defined?(::Rails::Engine)
24
25
  # user.chat_with(other) # find-or-create a direct conversation
25
26
  # user.message!(other, "hello!") # ...and say something in one line
26
27
  #
28
+ # Chats.on(:message_created) { |m| } # subscribe to the domain moments
29
+ #
27
30
  # Everything else (controllers, views, broadcasts) ships with the engine and
28
31
  # is overridable the Devise way (`rails g chats:views`).
29
32
  module Chats
@@ -48,9 +51,16 @@ module Chats
48
51
  @config = Configuration.new
49
52
  @messager_classes = nil
50
53
  @subject_classes = nil
54
+ reset_subscribers!
51
55
  self
52
56
  end
53
57
 
58
+ # The gem's own deprecator (registered with `Rails.application.deprecators`
59
+ # by the engine, so `config.active_support.deprecation` governs it).
60
+ def deprecator
61
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "chats")
62
+ end
63
+
54
64
  # --- Registries -----------------------------------------------------------
55
65
  #
56
66
  # `acts_as_messager` / `acts_as_chat_subject` self-register the calling
@@ -123,23 +133,34 @@ module Chats
123
133
  config.can_message.call(sender, recipient)
124
134
  end
125
135
 
126
- # Fire a domain event through the host's notifier hook (no-op by default).
127
- # Events (see Chats::Configuration#notifier):
128
- # :message_created message: (every persisted, non-system message)
129
- # :participant_added participant: (someone added to a group)
136
+ # --- Events ---------------------------------------------------------------
137
+
138
+ # Subscribe to a domain moment. Many subscribers per event; each one runs
139
+ # isolated, so a raising subscriber is reported and the others still run.
140
+ #
141
+ # Chats.on(:message_created) { |message| }
142
+ # Chats.on(:conversation_created) { |conversation| }
143
+ # Chats.on(:participant_left) { |participant| }
144
+ # Chats.on(:conversation_read) { |conversation:, participant:| }
130
145
  #
131
- # Hosts typically point this at a Noticed notifier or a mailer job:
132
- # config.notifier = ->(event, **payload) {
133
- # NewMessageNotifier.with(**payload).deliver if event == :message_created
134
- # }
146
+ # Pass `key:` from reloadable code (a `to_prepare` block): re-registering
147
+ # the same key REPLACES the previous subscriber instead of stacking a
148
+ # duplicate on every code reload.
149
+ def on(event, key: nil, &block)
150
+ Subscribers.on(event, key: key, &block)
151
+ end
152
+
153
+ # Drop every `Chats.on` registration (also called by `reset!`).
154
+ def reset_subscribers!
155
+ Subscribers.reset!
156
+ self
157
+ end
158
+
159
+ # Fire a domain event at every subscriber (see Chats.on). Error-isolated:
160
+ # a broken subscriber must never break message delivery itself — the
161
+ # message is already committed; notifications are best-effort fan-out.
135
162
  def notify(event, **payload)
136
- config.notifier.call(event, **payload)
137
- rescue StandardError => e
138
- # A broken notifier must never break message delivery itself — the
139
- # message is already committed; notifications are best-effort fan-out.
140
- # Same error-isolation philosophy as pricing_plans' lifecycle callbacks.
141
- logger&.error("[chats] notifier raised on #{event}: #{e.class}: #{e.message}")
142
- nil
163
+ Subscribers.emit(event, **payload)
143
164
  end
144
165
 
145
166
  # --- Display helpers (used by the bundled views) --------------------------
@@ -156,6 +177,72 @@ module Chats
156
177
  config.messager_avatar.call(messager)
157
178
  end
158
179
 
180
+ # Where a messager's profile lives, per `config.messager_url` (nil by
181
+ # default — the bundled views then render plain text, never a dead link).
182
+ def messager_url_for(messager)
183
+ return nil if messager.nil?
184
+
185
+ config.messager_url.call(messager).presence
186
+ end
187
+
188
+ # The signature line under a signed message ("— Lucía G."), per
189
+ # `config.message_signature` when the host sets one. Nil for messages
190
+ # that aren't signed (see Chats::Message#signed?).
191
+ def message_signature_for(message)
192
+ return nil if message.nil? || !message.signed?
193
+
194
+ if config.message_signature
195
+ config.message_signature.call(message).presence
196
+ else
197
+ I18n.t("chats.message.signature", name: display_name_for(message.author))
198
+ end
199
+ end
200
+
201
+ # --- Messager options (see acts_as_messager) --------------------------------
202
+
203
+ # Whether +messager+ can be notified at all. False for headless messagers
204
+ # declared with `acts_as_messager notifications: false` (a support desk, a
205
+ # bot): hosts stop branching on class in every notifier.
206
+ def notifications_for?(messager)
207
+ messager_option(messager, :chat_notifications?)
208
+ end
209
+
210
+ # Whether block/report affordances make sense against +messager+.
211
+ # False for `acts_as_messager blockable: false`.
212
+ def blockable?(messager)
213
+ messager_option(messager, :chat_blockable?)
214
+ end
215
+
216
+ # Whether +messager+'s direct conversations stack into one inbox row
217
+ # (`acts_as_messager inbox: :grouped`).
218
+ def grouped_inbox?(messager)
219
+ messager_option(messager, :chat_grouped_inbox?, default: false)
220
+ end
221
+
222
+ # The polymorphic type names of every registered messager class that
223
+ # stacks (`inbox: :grouped`). Empty in an ordinary app — which is what
224
+ # keeps the inbox query there byte-identical to 0.1.x. Used as a SQL
225
+ # PREFILTER only; whether a given counterpart actually stacks is still
226
+ # decided per-record by `grouped_inbox?` (STI subclasses share a
227
+ # polymorphic_name with siblings that may not be grouped).
228
+ def grouped_messager_types
229
+ messager_class_names.filter_map do |name|
230
+ klass = name.safe_constantize
231
+ next unless klass.respond_to?(:chat_grouped_inbox?) && klass.chat_grouped_inbox?
232
+
233
+ klass.polymorphic_name
234
+ end.uniq
235
+ end
236
+
237
+ # The signed GlobalID that scopes the inbox to conversations with
238
+ # +messager+ (`GET /conversations?with=…`). Purpose-scoped and
239
+ # non-expiring: inbox rows live on long-lived pages.
240
+ def inbox_with_sgid(messager)
241
+ return nil if messager.nil?
242
+
243
+ messager.to_sgid(expires_in: nil, for: :chats_inbox_with).to_s
244
+ end
245
+
159
246
  # --- Internals ------------------------------------------------------------
160
247
 
161
248
  def logger
@@ -172,6 +259,18 @@ module Chats
172
259
 
173
260
  private
174
261
 
262
+ # Ask a messager's CLASS about an `acts_as_messager` option. Duck-typed
263
+ # (never `is_a?`): anything that doesn't answer is treated as a stock
264
+ # messager, so a plain host model keeps 0.1.x behaviour.
265
+ def messager_option(messager, predicate, default: true)
266
+ return default if messager.nil?
267
+
268
+ klass = messager.is_a?(Class) ? messager : messager.class
269
+ return default unless klass.respond_to?(predicate)
270
+
271
+ klass.public_send(predicate)
272
+ end
273
+
175
274
  def registered_class?(registry, klass)
176
275
  klass = klass.class unless klass.is_a?(Class) || klass.is_a?(String)
177
276
  name = klass.is_a?(String) ? klass : klass.name
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # chats 0.2.0 — message AUTHORSHIP.
4
+ #
5
+ # `sender` is the seat a message came from; `author` is who wrote it on that
6
+ # seat's behalf (an agent answering from a shared support desk). Both
7
+ # nullable, both polymorphic, indexed together because they're always read
8
+ # together. See Chats::Message#signed?.
9
+ #
10
+ # Safe to run on an install that already has the columns (a 0.2.0 fresh
11
+ # install does): every step is guarded, so this migration is a no-op there.
12
+ class AddAuthorToChatsMessages < ActiveRecord::Migration<%= migration_version %>
13
+ def up
14
+ unless column_exists?(:chats_messages, :author_type)
15
+ add_reference :chats_messages, :author, polymorphic: true, null: true,
16
+ type: chats_foreign_key_type, index: false
17
+ end
18
+
19
+ return if index_exists?(:chats_messages, [ :author_type, :author_id ], name: "index_chats_messages_on_author")
20
+
21
+ add_index :chats_messages, [ :author_type, :author_id ], name: "index_chats_messages_on_author"
22
+ end
23
+
24
+ def down
25
+ if index_exists?(:chats_messages, [ :author_type, :author_id ], name: "index_chats_messages_on_author")
26
+ remove_index :chats_messages, name: "index_chats_messages_on_author"
27
+ end
28
+
29
+ return unless column_exists?(:chats_messages, :author_type)
30
+
31
+ remove_column :chats_messages, :author_type
32
+ remove_column :chats_messages, :author_id
33
+ end
34
+
35
+ private
36
+
37
+ # Honor the host's configured primary key type (uuid vs bigint), exactly
38
+ # like the install migration does — the author columns must match the key
39
+ # type of the models they point at.
40
+ def chats_foreign_key_type
41
+ config = Rails.configuration.generators
42
+ config.options[config.orm][:primary_key_type] || :bigint
43
+ end
44
+ end
@@ -88,6 +88,12 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
88
88
  foreign_key: { to_table: :chats_conversations }, index: false
89
89
  t.references :sender, polymorphic: true, null: true, type: foreign_key_type, index: false
90
90
 
91
+ # Who WROTE it, when that isn't the seat it was sent FROM: an agent
92
+ # answering from a shared support-desk seat signs the bubble while the
93
+ # desk stays the conversation identity. Nullable — ordinary messages
94
+ # have no author. See Chats::Message#signed?.
95
+ t.references :author, polymorphic: true, null: true, type: foreign_key_type, index: false
96
+
91
97
  t.string :kind, null: false, default: "text"
92
98
  t.text :body
93
99
  t.references :reply_to, type: foreign_key_type, null: true,
@@ -103,6 +109,7 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
103
109
  # (created_at, id) — see Chats::Message.before_message.
104
110
  add_index :chats_messages, [ :conversation_id, :created_at, :id ], name: "index_chats_messages_on_conversation_and_created_at"
105
111
  add_index :chats_messages, [ :sender_type, :sender_id ], name: "index_chats_messages_on_sender"
112
+ add_index :chats_messages, [ :author_type, :author_id ], name: "index_chats_messages_on_author"
106
113
  add_index :chats_messages, :reply_to_id, name: "index_chats_messages_on_reply_to_id"
107
114
 
108
115
  # ---------------------------------------------------------------------------
@@ -142,8 +149,12 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
142
149
  [primary_key_type, foreign_key_type]
143
150
  end
144
151
 
152
+ # jsonb on every PostgreSQL adapter — matched by prefix because PostGIS
153
+ # (activerecord-postgis-adapter) answers "PostGIS", not "PostgreSQL", and
154
+ # an `include?("postgresql")` check silently sent such hosts down the plain
155
+ # json path.
145
156
  def json_column_type
146
- return :jsonb if connection.adapter_name.downcase.include?("postgresql")
157
+ return :jsonb if connection.adapter_name.match?(/\Apostg/i)
147
158
 
148
159
  :json
149
160
  end
@@ -151,8 +162,13 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
151
162
  # MySQL 8+ doesn't allow default values on JSON columns.
152
163
  # Returns an empty-hash default for SQLite/PostgreSQL, nil for MySQL.
153
164
  # The model handles nil metadata gracefully (attribute default {}).
165
+ #
166
+ # Trilogy is MySQL under a different ADAPTER_NAME (Rails reports "Trilogy"),
167
+ # so match both — a /mysql/ pattern alone hands a Trilogy host a default
168
+ # MySQL rejects. api_keys hit this first; see its create_api_keys_table
169
+ # template, which matches /mysql|trilogy/.
154
170
  def json_column_default
155
- return nil if connection.adapter_name.downcase.include?("mysql")
171
+ return nil if connection.adapter_name.match?(/mysql|trilogy/i)
156
172
 
157
173
  {}
158
174
  end
@@ -14,6 +14,20 @@ Chats.configure do |config|
14
14
  # Default: "User"
15
15
  config.messager_class = "User"
16
16
 
17
+ # Any model can converse, and a model that isn't a person can say so:
18
+ #
19
+ # class SupportDesk < ApplicationRecord
20
+ # acts_as_messager notifications: false, # never notifiable
21
+ # blockable: false, # no block/report affordances
22
+ # inbox: :grouped # every thread with it is ONE
23
+ # # inbox row (a "stack")
24
+ # end
25
+ #
26
+ # `group_path:` says where that stacked row goes when it holds more than
27
+ # one conversation (default: chats' own filtered inbox):
28
+ #
29
+ # acts_as_messager inbox: :grouped, group_path: ->(viewer) { support_path }
30
+
17
31
  # ==========================================================================
18
32
  # CONTROLLER INTEGRATION
19
33
  # ==========================================================================
@@ -62,6 +76,12 @@ Chats.configure do |config|
62
76
  #
63
77
  # config.send_rate_limit = { to: 60, within: 1.minute }
64
78
  #
79
+ # How many conversations the inbox loads (and therefore how deep search
80
+ # and stacking see). The inbox is a "recent activity" surface, not an
81
+ # archive.
82
+ #
83
+ # config.inbox_limit = 200
84
+ #
65
85
  # Encrypt message bodies at rest (ActiveRecord Encryption; requires
66
86
  # `bin/rails db:encryption:init`). Body search degrades when enabled.
67
87
  #
@@ -80,6 +100,23 @@ Chats.configure do |config|
80
100
  # }
81
101
  #
82
102
  # config.can_create_group = ->(creator) { creator.admin? }
103
+ #
104
+ # Composed into the inbox query before the limit — hide rows, re-scope
105
+ # them, whatever your product needs, without overriding the controller:
106
+ #
107
+ # config.inbox_scope = ->(relation, viewer) { relation }
108
+ #
109
+ # Whether a conversation still accepts messages is NOT a proc: the SUBJECT
110
+ # owns it, because the subject already owns the conversation's meaning.
111
+ #
112
+ # class Ticket < ApplicationRecord
113
+ # acts_as_chat_subject
114
+ # def chat_locked? = closed?
115
+ # def chat_locked_notice = "This ticket is closed. Reply to reopen it."
116
+ # end
117
+ #
118
+ # Locking gates SENDING only: the thread stays readable and the composer
119
+ # is replaced by the notice (see the `locked_composer` slot below).
83
120
 
84
121
  # ==========================================================================
85
122
  # TRUST & SAFETY — snap onto the `moderate` gem (or anything else)
@@ -103,25 +140,35 @@ Chats.configure do |config|
103
140
  # config.filter "Chats::Message", :body, mode: :flag
104
141
 
105
142
  # ==========================================================================
106
- # NOTIFICATIONSone hook, fan out anywhere
143
+ # EVENTSsubscribe to the domain moments, fan out anywhere
107
144
  # ==========================================================================
108
145
  #
109
- # Called on notification-worthy domain moments. Keep it fast (enqueue jobs,
110
- # don't do work inline). Events:
146
+ # Many subscribers per event; each runs isolated (a raising one is reported
147
+ # through Rails.error and never breaks message delivery). Keep them fast —
148
+ # enqueue jobs, don't do work inline.
111
149
  #
112
- # :message_created message: every persisted human message
113
- # :participant_added participant: someone added to a group
150
+ # Chats.on(:message_created) { |message| } # every human message
151
+ # Chats.on(:conversation_created) { |conversation| } # a thread came into being
152
+ # Chats.on(:participant_left) { |participant| } # someone left a group
153
+ # Chats.on(:conversation_read) { |conversation:, participant:| }
114
154
  #
115
155
  # With Noticed:
116
- # config.notifier = ->(event, **payload) {
117
- # NewMessageNotifier.with(**payload).deliver if event == :message_created
118
- # }
119
- #
120
- # With a plain debounced-email job (see Chats::Participant#should_notify?
121
- # for the "only email once until they come back" etiquette helper):
122
- # config.notifier = ->(event, message:, **) {
123
- # ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) if event == :message_created
124
- # }
156
+ # Chats.on(:message_created) { |message| NewMessageNotifier.with(record: message).deliver }
157
+ #
158
+ # With a debounced-email job (see Chats::Participant#should_notify? for the
159
+ # "only email once until they come back" etiquette helper):
160
+ # Chats.on(:message_created) { |message| ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) }
161
+ #
162
+ # Registering from reloadable code? Pass a key, and a reload replaces the
163
+ # subscriber instead of stacking a second one:
164
+ #
165
+ # Rails.application.config.to_prepare do
166
+ # Chats.on(:message_created, key: :unread_email) { |message| … }
167
+ # end
168
+ #
169
+ # DEPRECATED (removed in 1.0): `config.notifier = ->(event, **payload) {}`
170
+ # still works, but receives :message_created and :conversation_read only —
171
+ # the events 0.1.1 had. The ones added in 0.2.0 are Chats.on-only.
125
172
 
126
173
  # ==========================================================================
127
174
  # DISPLAY — how messagers appear in the bundled views
@@ -135,4 +182,38 @@ Chats.configure do |config|
135
182
  # config.messager_avatar = ->(messager) {
136
183
  # messager.avatar.attached? ? messager.avatar.variant(:thumb) : nil
137
184
  # }
185
+ #
186
+ # Where a messager's profile lives. nil (the default) means the bundled
187
+ # views render names as plain text — chats never assumes you have a
188
+ # `user_path`, and never renders a dead anchor:
189
+ #
190
+ # config.messager_url = lambda do |messager|
191
+ # routes = Rails.application.routes.url_helpers
192
+ #
193
+ # case messager
194
+ # when User then routes.user_path(messager) # a desk or a bot has no profile: nil
195
+ # end
196
+ # end
197
+ #
198
+ # The signature under a message written by an AUTHOR on a sender's behalf
199
+ # (`desk.message!(user, "On it!", author: agent)`). Defaults to the
200
+ # localized "— Agent Name":
201
+ #
202
+ # config.message_signature = ->(message) { "answered by #{message.author.first_name}" }
203
+
204
+ # ==========================================================================
205
+ # SLOTS — add one row or one button without ejecting a screen
206
+ # ==========================================================================
207
+ #
208
+ # The bundled views render a partial named `chats/slots/_<slot>` whenever
209
+ # one exists in your app. No configuration, no registration: create the
210
+ # file and it appears.
211
+ #
212
+ # app/views/chats/slots/_inbox_top.html.erb above the first inbox row
213
+ # app/views/chats/slots/_inbox_empty.html.erb inside the empty state
214
+ # app/views/chats/slots/_conversation_header_actions.html.erb thread menu
215
+ # app/views/chats/slots/_locked_composer.html.erb the locked composer's body
216
+ # app/views/chats/slots/_message_meta.html.erb after each bubble's timestamp
217
+ #
218
+ # `rails generate chats:views` is still there for wholesale restyling.
138
219
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Chats
7
+ module Generators
8
+ # `rails generate chats:upgrade` — copy the migrations a version bump
9
+ # needs into an EXISTING install. Nothing else: the initializer, the
10
+ # views and the routes you already own stay untouched.
11
+ #
12
+ # Currently writes the 0.2.0 migration (message authorship). It is
13
+ # written guarded, so running it against an install that already has the
14
+ # columns (a fresh 0.2.0 install) is a no-op rather than an error.
15
+ class UpgradeGenerator < Rails::Generators::Base
16
+ include ActiveRecord::Generators::Migration
17
+
18
+ source_root File.expand_path("templates", __dir__)
19
+ desc "Add the migrations a chats version bump needs (0.2.0: message authorship)"
20
+
21
+ def self.next_migration_number(dir)
22
+ ActiveRecord::Generators::Base.next_migration_number(dir)
23
+ end
24
+
25
+ def create_author_migration
26
+ migration_template "add_author_to_chats_messages.rb.erb",
27
+ File.join(db_migrate_path, "add_author_to_chats_messages.rb")
28
+ end
29
+
30
+ def display_post_upgrade_message
31
+ say "\n💬 chats upgrade migrations copied.", :green
32
+ say "\n 1. Run 'rails db:migrate'."
33
+ say " 2. New in 0.2.0 — all opt-in, nothing changes until you ask:"
34
+ say " acts_as_messager notifications: false, blockable: false, inbox: :grouped"
35
+ say " Chats.on(:message_created) { |message| … } # replaces config.notifier"
36
+ say " config.messager_url / config.message_signature / config.inbox_scope"
37
+ say " chat_locked? / chat_locked_notice on your chat subjects"
38
+ say " 3. See the CHANGELOG for the full list.\n", :green
39
+ end
40
+
41
+ private
42
+
43
+ def migration_version
44
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
45
+ end
46
+ end
47
+ end
48
+ end