chats 0.1.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. metadata +51 -2
@@ -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.6.2"
5
5
  end
data/lib/chats.rb CHANGED
@@ -6,6 +6,8 @@ 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"
10
+ require_relative "chats/send_rate_limited"
9
11
  require_relative "chats/macros"
10
12
 
11
13
  require_relative "chats/engine" if defined?(::Rails::Engine)
@@ -24,6 +26,8 @@ require_relative "chats/engine" if defined?(::Rails::Engine)
24
26
  # user.chat_with(other) # find-or-create a direct conversation
25
27
  # user.message!(other, "hello!") # ...and say something in one line
26
28
  #
29
+ # Chats.on(:message_created) { |m| } # subscribe to the domain moments
30
+ #
27
31
  # Everything else (controllers, views, broadcasts) ships with the engine and
28
32
  # is overridable the Devise way (`rails g chats:views`).
29
33
  module Chats
@@ -48,9 +52,16 @@ module Chats
48
52
  @config = Configuration.new
49
53
  @messager_classes = nil
50
54
  @subject_classes = nil
55
+ reset_subscribers!
51
56
  self
52
57
  end
53
58
 
59
+ # The gem's own deprecator (registered with `Rails.application.deprecators`
60
+ # by the engine, so `config.active_support.deprecation` governs it).
61
+ def deprecator
62
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "chats")
63
+ end
64
+
54
65
  # --- Registries -----------------------------------------------------------
55
66
  #
56
67
  # `acts_as_messager` / `acts_as_chat_subject` self-register the calling
@@ -123,23 +134,34 @@ module Chats
123
134
  config.can_message.call(sender, recipient)
124
135
  end
125
136
 
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)
137
+ # --- Events ---------------------------------------------------------------
138
+
139
+ # Subscribe to a domain moment. Many subscribers per event; each one runs
140
+ # isolated, so a raising subscriber is reported and the others still run.
141
+ #
142
+ # Chats.on(:message_created) { |message| }
143
+ # Chats.on(:conversation_created) { |conversation| }
144
+ # Chats.on(:participant_left) { |participant| }
145
+ # Chats.on(:conversation_read) { |conversation:, participant:| }
130
146
  #
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
- # }
147
+ # Pass `key:` from reloadable code (a `to_prepare` block): re-registering
148
+ # the same key REPLACES the previous subscriber instead of stacking a
149
+ # duplicate on every code reload.
150
+ def on(event, key: nil, &block)
151
+ Subscribers.on(event, key: key, &block)
152
+ end
153
+
154
+ # Drop every `Chats.on` registration (also called by `reset!`).
155
+ def reset_subscribers!
156
+ Subscribers.reset!
157
+ self
158
+ end
159
+
160
+ # Fire a domain event at every subscriber (see Chats.on). Error-isolated:
161
+ # a broken subscriber must never break message delivery itself — the
162
+ # message is already committed; notifications are best-effort fan-out.
135
163
  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
164
+ Subscribers.emit(event, **payload)
143
165
  end
144
166
 
145
167
  # --- Display helpers (used by the bundled views) --------------------------
@@ -156,6 +178,80 @@ module Chats
156
178
  config.messager_avatar.call(messager)
157
179
  end
158
180
 
181
+ # Where a messager's profile lives, per `config.messager_url` (nil by
182
+ # default — the bundled views then render plain text, never a dead link).
183
+ def messager_url_for(messager)
184
+ return nil if messager.nil?
185
+
186
+ config.messager_url.call(messager).presence
187
+ end
188
+
189
+ # The signature line under a signed message ("— Lucía G."), per
190
+ # `config.message_signature` when the host sets one. Nil for messages
191
+ # that aren't signed (see Chats::Message#signed?).
192
+ def message_signature_for(message)
193
+ return nil if message.nil? || !message.signed?
194
+
195
+ if config.message_signature
196
+ config.message_signature.call(message).presence
197
+ else
198
+ I18n.t("chats.message.signature", name: display_name_for(message.author))
199
+ end
200
+ end
201
+
202
+ # --- Messager options (see acts_as_messager) --------------------------------
203
+
204
+ # Whether +messager+ can be notified at all. False for headless messagers
205
+ # declared with `acts_as_messager notifications: false` (a support desk, a
206
+ # bot): hosts stop branching on class in every notifier.
207
+ def notifications_for?(messager)
208
+ messager_option(messager, :chat_notifications?)
209
+ end
210
+
211
+ # Whether block/report affordances make sense against +messager+.
212
+ # False for `acts_as_messager blockable: false`.
213
+ def blockable?(messager)
214
+ messager_option(messager, :chat_blockable?)
215
+ end
216
+
217
+ # Whether +messager+'s direct conversations stack into one inbox row
218
+ # (`acts_as_messager inbox: :grouped`).
219
+ def grouped_inbox?(messager)
220
+ messager_option(messager, :chat_grouped_inbox?, default: false)
221
+ end
222
+
223
+ # Whether +messager+ is an OFFICIAL account (`acts_as_messager verified:
224
+ # true`) — a support desk, an organization, a brand. The bundled views
225
+ # badge its name; hosts can read it to do the same on their own screens.
226
+ # Defaults to false: nothing is verified until a model says so.
227
+ def verified?(messager)
228
+ messager_option(messager, :chat_verified?, default: false)
229
+ end
230
+
231
+ # The polymorphic type names of every registered messager class that
232
+ # stacks (`inbox: :grouped`). Empty in an ordinary app — which is what
233
+ # keeps the inbox query there byte-identical to 0.1.x. Used as a SQL
234
+ # PREFILTER only; whether a given counterpart actually stacks is still
235
+ # decided per-record by `grouped_inbox?` (STI subclasses share a
236
+ # polymorphic_name with siblings that may not be grouped).
237
+ def grouped_messager_types
238
+ messager_class_names.filter_map do |name|
239
+ klass = name.safe_constantize
240
+ next unless klass.respond_to?(:chat_grouped_inbox?) && klass.chat_grouped_inbox?
241
+
242
+ klass.polymorphic_name
243
+ end.uniq
244
+ end
245
+
246
+ # The signed GlobalID that scopes the inbox to conversations with
247
+ # +messager+ (`GET /conversations?with=…`). Purpose-scoped and
248
+ # non-expiring: inbox rows live on long-lived pages.
249
+ def inbox_with_sgid(messager)
250
+ return nil if messager.nil?
251
+
252
+ messager.to_sgid(expires_in: nil, for: :chats_inbox_with).to_s
253
+ end
254
+
159
255
  # --- Internals ------------------------------------------------------------
160
256
 
161
257
  def logger
@@ -172,6 +268,18 @@ module Chats
172
268
 
173
269
  private
174
270
 
271
+ # Ask a messager's CLASS about an `acts_as_messager` option. Duck-typed
272
+ # (never `is_a?`): anything that doesn't answer is treated as a stock
273
+ # messager, so a plain host model keeps 0.1.x behaviour.
274
+ def messager_option(messager, predicate, default: true)
275
+ return default if messager.nil?
276
+
277
+ klass = messager.is_a?(Class) ? messager : messager.class
278
+ return default unless klass.respond_to?(predicate)
279
+
280
+ klass.public_send(predicate)
281
+ end
282
+
175
283
  def registered_class?(registry, klass)
176
284
  klass = klass.class unless klass.is_a?(Class) || klass.is_a?(String)
177
285
  name = klass.is_a?(String) ? klass : klass.name
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # chats 0.4.0 — message ACTIONS.
4
+ #
5
+ # A message may carry one featured button: a label and the url it opens
6
+ # ("Your invoice is ready" → Pay now). Stored as {"label":…,"url":…} in its
7
+ # own JSON column — `metadata` stays yours. Nullable, no default: most
8
+ # messages have no action, and MySQL refuses defaults on JSON columns.
9
+ # See Chats::Action.
10
+ #
11
+ # Safe to run on an install that already has the column (a 0.4.0 fresh
12
+ # install does): every step is guarded, so this migration is a no-op there.
13
+ class AddActionToChatsMessages < ActiveRecord::Migration<%= migration_version %>
14
+ def up
15
+ return if column_exists?(:chats_messages, :action)
16
+
17
+ add_column :chats_messages, :action, json_column_type
18
+ end
19
+
20
+ # Rolling back KEEPS the column, on purpose: this migration never removes
21
+ # a column it may not have added. On a fresh 0.4.0 install the create
22
+ # migration owns `action` and `up` above did nothing, so dropping it here
23
+ # would destroy every message's button with it — and nothing a migration
24
+ # can see records which of the two added it. A leftover nullable column
25
+ # is harmless to older versions of chats; drop it by hand if you mean to.
26
+ def down; end
27
+
28
+ private
29
+
30
+ # jsonb on every PostgreSQL adapter, like the install migration — matched
31
+ # by prefix because PostGIS answers "PostGIS", not "PostgreSQL".
32
+ def json_column_type
33
+ return :jsonb if connection.adapter_name.match?(/\Apostg/i)
34
+
35
+ :json
36
+ end
37
+ end
@@ -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,
@@ -95,6 +101,10 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
95
101
  t.datetime :edited_at
96
102
  t.datetime :deleted_at
97
103
  t.send(json_column_type, :metadata, default: json_column_default)
104
+ # The one featured button a message may carry ({"label":…,"url":…}),
105
+ # set by the host from Ruby — see Chats::Action. Nullable with no
106
+ # default: most messages have none, and MySQL refuses JSON defaults.
107
+ t.send(json_column_type, :action)
98
108
 
99
109
  t.timestamps
100
110
  end
@@ -103,6 +113,7 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
103
113
  # (created_at, id) — see Chats::Message.before_message.
104
114
  add_index :chats_messages, [ :conversation_id, :created_at, :id ], name: "index_chats_messages_on_conversation_and_created_at"
105
115
  add_index :chats_messages, [ :sender_type, :sender_id ], name: "index_chats_messages_on_sender"
116
+ add_index :chats_messages, [ :author_type, :author_id ], name: "index_chats_messages_on_author"
106
117
  add_index :chats_messages, :reply_to_id, name: "index_chats_messages_on_reply_to_id"
107
118
 
108
119
  # ---------------------------------------------------------------------------
@@ -142,8 +153,12 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
142
153
  [primary_key_type, foreign_key_type]
143
154
  end
144
155
 
156
+ # jsonb on every PostgreSQL adapter — matched by prefix because PostGIS
157
+ # (activerecord-postgis-adapter) answers "PostGIS", not "PostgreSQL", and
158
+ # an `include?("postgresql")` check silently sent such hosts down the plain
159
+ # json path.
145
160
  def json_column_type
146
- return :jsonb if connection.adapter_name.downcase.include?("postgresql")
161
+ return :jsonb if connection.adapter_name.match?(/\Apostg/i)
147
162
 
148
163
  :json
149
164
  end
@@ -151,8 +166,13 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
151
166
  # MySQL 8+ doesn't allow default values on JSON columns.
152
167
  # Returns an empty-hash default for SQLite/PostgreSQL, nil for MySQL.
153
168
  # The model handles nil metadata gracefully (attribute default {}).
169
+ #
170
+ # Trilogy is MySQL under a different ADAPTER_NAME (Rails reports "Trilogy"),
171
+ # so match both — a /mysql/ pattern alone hands a Trilogy host a default
172
+ # MySQL rejects. api_keys hit this first; see its create_api_keys_table
173
+ # template, which matches /mysql|trilogy/.
154
174
  def json_column_default
155
- return nil if connection.adapter_name.downcase.include?("mysql")
175
+ return nil if connection.adapter_name.match?(/mysql|trilogy/i)
156
176
 
157
177
  {}
158
178
  end
@@ -14,6 +14,22 @@ 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
+ # verified: true # an OFFICIAL account: the
25
+ # # views badge its name
26
+ # end
27
+ #
28
+ # `group_path:` says where that stacked row goes when it holds more than
29
+ # one conversation (default: chats' own filtered inbox):
30
+ #
31
+ # acts_as_messager inbox: :grouped, group_path: ->(viewer) { support_path }
32
+
17
33
  # ==========================================================================
18
34
  # CONTROLLER INTEGRATION
19
35
  # ==========================================================================
@@ -62,6 +78,29 @@ Chats.configure do |config|
62
78
  #
63
79
  # config.send_rate_limit = { to: 60, within: 1.minute }
64
80
  #
81
+ # How many conversations the inbox loads (and therefore how deep search
82
+ # and stacking see). The inbox is a "recent activity" surface, not an
83
+ # archive.
84
+ #
85
+ # config.inbox_limit = 200
86
+ #
87
+ # The messaging page's layout. :split (the default): from 700px wide,
88
+ # /messages is two panes, LinkedIn-style: the conversation list on the
89
+ # left, the open conversation on the right (/messages/:id is that same
90
+ # page, its conversation open); narrower, the list and the thread are
91
+ # separate pages. :stacked keeps them separate pages at every width.
92
+ # (Views you ejected with `rails g chats:views` keep rendering as they are.)
93
+ #
94
+ # config.inbox_layout = :split
95
+ #
96
+ # The messaging page's title (the bundled views give it to
97
+ # `content_for :title`, and the two-pane page to the tab when a row opens
98
+ # a conversation). nil: "Messages" / "Bob · Messages" (chats.page_title.*).
99
+ #
100
+ # config.page_title = ->(conversation, viewer) {
101
+ # conversation ? "#{conversation.title_for(viewer)} | MyApp" : "Messages | MyApp"
102
+ # }
103
+ #
65
104
  # Encrypt message bodies at rest (ActiveRecord Encryption; requires
66
105
  # `bin/rails db:encryption:init`). Body search degrades when enabled.
67
106
  #
@@ -80,6 +119,23 @@ Chats.configure do |config|
80
119
  # }
81
120
  #
82
121
  # config.can_create_group = ->(creator) { creator.admin? }
122
+ #
123
+ # Composed into the inbox query before the limit — hide rows, re-scope
124
+ # them, whatever your product needs, without overriding the controller:
125
+ #
126
+ # config.inbox_scope = ->(relation, viewer) { relation }
127
+ #
128
+ # Whether a conversation still accepts messages is NOT a proc: the SUBJECT
129
+ # owns it, because the subject already owns the conversation's meaning.
130
+ #
131
+ # class Ticket < ApplicationRecord
132
+ # acts_as_chat_subject
133
+ # def chat_locked? = closed?
134
+ # def chat_locked_notice = "This ticket is closed. Reply to reopen it."
135
+ # end
136
+ #
137
+ # Locking gates SENDING only: the thread stays readable and the composer
138
+ # is replaced by the notice (see the `locked_composer` slot below).
83
139
 
84
140
  # ==========================================================================
85
141
  # TRUST & SAFETY — snap onto the `moderate` gem (or anything else)
@@ -103,25 +159,35 @@ Chats.configure do |config|
103
159
  # config.filter "Chats::Message", :body, mode: :flag
104
160
 
105
161
  # ==========================================================================
106
- # NOTIFICATIONS — one hook, fan out anywhere
162
+ # EVENTS — subscribe to the domain moments, fan out anywhere
107
163
  # ==========================================================================
108
164
  #
109
- # Called on notification-worthy domain moments. Keep it fast (enqueue jobs,
110
- # don't do work inline). Events:
165
+ # Many subscribers per event; each runs isolated (a raising one is reported
166
+ # through Rails.error and never breaks message delivery). Keep them fast —
167
+ # enqueue jobs, don't do work inline.
111
168
  #
112
- # :message_created message: every persisted human message
113
- # :participant_added participant: someone added to a group
169
+ # Chats.on(:message_created) { |message| } # every human message
170
+ # Chats.on(:conversation_created) { |conversation| } # a thread came into being
171
+ # Chats.on(:participant_left) { |participant| } # someone left a group
172
+ # Chats.on(:conversation_read) { |conversation:, participant:| }
114
173
  #
115
174
  # 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
- # }
175
+ # Chats.on(:message_created) { |message| NewMessageNotifier.with(record: message).deliver }
176
+ #
177
+ # With a debounced-email job (see Chats::Participant#should_notify? for the
178
+ # "only email once until they come back" etiquette helper):
179
+ # Chats.on(:message_created) { |message| ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) }
180
+ #
181
+ # Registering from reloadable code? Pass a key, and a reload replaces the
182
+ # subscriber instead of stacking a second one:
183
+ #
184
+ # Rails.application.config.to_prepare do
185
+ # Chats.on(:message_created, key: :unread_email) { |message| … }
186
+ # end
187
+ #
188
+ # DEPRECATED (removed in 1.0): `config.notifier = ->(event, **payload) {}`
189
+ # still works, but receives :message_created and :conversation_read only —
190
+ # the events 0.1.1 had. The ones added in 0.2.0 are Chats.on-only.
125
191
 
126
192
  # ==========================================================================
127
193
  # DISPLAY — how messagers appear in the bundled views
@@ -135,4 +201,49 @@ Chats.configure do |config|
135
201
  # config.messager_avatar = ->(messager) {
136
202
  # messager.avatar.attached? ? messager.avatar.variant(:thumb) : nil
137
203
  # }
204
+ #
205
+ # Where a messager's profile lives. nil (the default) means the bundled
206
+ # views render names as plain text — chats never assumes you have a
207
+ # `user_path`, and never renders a dead anchor:
208
+ #
209
+ # config.messager_url = lambda do |messager|
210
+ # routes = Rails.application.routes.url_helpers
211
+ #
212
+ # case messager
213
+ # when User then routes.user_path(messager) # a desk or a bot has no profile: nil
214
+ # end
215
+ # end
216
+ #
217
+ # The signature under a message written by an AUTHOR on a sender's behalf
218
+ # (`desk.message!(user, "On it!", author: agent)`). Defaults to the
219
+ # localized "— Agent Name":
220
+ #
221
+ # config.message_signature = ->(message) { "answered by #{message.author.first_name}" }
222
+ #
223
+ # The "official account" badge next to a `verified: true` messager's name.
224
+ # nil (the default) renders chats' own rosette, recoloured by the
225
+ # `--chats-verified` CSS variable. Set this to hand back your design
226
+ # system's mark instead — return html_safe markup, or nil for no badge:
227
+ #
228
+ # config.verified_badge = lambda do |messager|
229
+ # ApplicationController.helpers.image_tag("official.svg", class: "badge", alt: "Official account")
230
+ # end
231
+
232
+ # ==========================================================================
233
+ # SLOTS — add one row or one button without ejecting a screen
234
+ # ==========================================================================
235
+ #
236
+ # The bundled views render a partial named `chats/slots/_<slot>` whenever
237
+ # one exists in your app. No configuration, no registration: create the
238
+ # file and it appears.
239
+ #
240
+ # app/views/chats/slots/_inbox_top.html.erb above the first inbox row
241
+ # app/views/chats/slots/_inbox_empty.html.erb inside the empty state
242
+ # app/views/chats/slots/_conversation_header_actions.html.erb thread menu
243
+ # app/views/chats/slots/_composer_actions.html.erb your own buttons above the composer
244
+ # app/views/chats/slots/_locked_composer.html.erb the locked composer's body
245
+ # app/views/chats/slots/_message_meta.html.erb after each bubble's timestamp
246
+ # app/views/chats/slots/_dock_panel_footer.html.erb beside "See all messages" in the dock
247
+ #
248
+ # `rails generate chats:views` is still there for wholesale restyling.
138
249
  end