chats 0.1.1 → 0.3.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +233 -0
  3. data/README.md +214 -28
  4. data/app/assets/stylesheets/chats.css +86 -0
  5. data/app/controllers/chats/conversations_controller.rb +39 -42
  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 +113 -2
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
  11. data/app/views/chats/conversations/_group.html.erb +41 -0
  12. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  13. data/app/views/chats/conversations/index.html.erb +39 -9
  14. data/app/views/chats/conversations/show.html.erb +48 -3
  15. data/app/views/chats/messages/_composer.html.erb +4 -0
  16. data/app/views/chats/messages/_message.html.erb +29 -8
  17. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  18. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  19. data/config/importmap.rb +2 -1
  20. data/config/locales/en.yml +13 -0
  21. data/config/locales/es.yml +13 -0
  22. data/context7.json +4 -0
  23. data/docs/PRD.md +1 -1
  24. data/docs/campfire_review.md +1 -1
  25. data/gemfiles/rails_7.1.gemfile +1 -0
  26. data/gemfiles/rails_7.2.gemfile +1 -0
  27. data/gemfiles/rails_8.1.gemfile +1 -0
  28. data/lib/chats/configuration.rb +79 -1
  29. data/lib/chats/engine.rb +29 -7
  30. data/lib/chats/errors.rb +16 -0
  31. data/lib/chats/inbox.rb +303 -0
  32. data/lib/chats/inbox_group.rb +75 -0
  33. data/lib/chats/macros.rb +33 -1
  34. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  35. data/lib/chats/models/concerns/messager.rb +99 -2
  36. data/lib/chats/models/conversation.rb +78 -7
  37. data/lib/chats/models/message.rb +67 -3
  38. data/lib/chats/models/participant.rb +59 -0
  39. data/lib/chats/models/reaction.rb +5 -0
  40. data/lib/chats/subscribers.rb +156 -0
  41. data/lib/chats/version.rb +1 -1
  42. data/lib/chats.rb +122 -15
  43. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  44. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  45. data/lib/generators/chats/templates/initializer.rb +106 -14
  46. data/lib/generators/chats/upgrade_generator.rb +48 -0
  47. metadata +13 -2
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,80 @@ 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
+ # Whether +messager+ is an OFFICIAL account (`acts_as_messager verified:
223
+ # true`) — a support desk, an organization, a brand. The bundled views
224
+ # badge its name; hosts can read it to do the same on their own screens.
225
+ # Defaults to false: nothing is verified until a model says so.
226
+ def verified?(messager)
227
+ messager_option(messager, :chat_verified?, default: false)
228
+ end
229
+
230
+ # The polymorphic type names of every registered messager class that
231
+ # stacks (`inbox: :grouped`). Empty in an ordinary app — which is what
232
+ # keeps the inbox query there byte-identical to 0.1.x. Used as a SQL
233
+ # PREFILTER only; whether a given counterpart actually stacks is still
234
+ # decided per-record by `grouped_inbox?` (STI subclasses share a
235
+ # polymorphic_name with siblings that may not be grouped).
236
+ def grouped_messager_types
237
+ messager_class_names.filter_map do |name|
238
+ klass = name.safe_constantize
239
+ next unless klass.respond_to?(:chat_grouped_inbox?) && klass.chat_grouped_inbox?
240
+
241
+ klass.polymorphic_name
242
+ end.uniq
243
+ end
244
+
245
+ # The signed GlobalID that scopes the inbox to conversations with
246
+ # +messager+ (`GET /conversations?with=…`). Purpose-scoped and
247
+ # non-expiring: inbox rows live on long-lived pages.
248
+ def inbox_with_sgid(messager)
249
+ return nil if messager.nil?
250
+
251
+ messager.to_sgid(expires_in: nil, for: :chats_inbox_with).to_s
252
+ end
253
+
159
254
  # --- Internals ------------------------------------------------------------
160
255
 
161
256
  def logger
@@ -172,6 +267,18 @@ module Chats
172
267
 
173
268
  private
174
269
 
270
+ # Ask a messager's CLASS about an `acts_as_messager` option. Duck-typed
271
+ # (never `is_a?`): anything that doesn't answer is treated as a stock
272
+ # messager, so a plain host model keeps 0.1.x behaviour.
273
+ def messager_option(messager, predicate, default: true)
274
+ return default if messager.nil?
275
+
276
+ klass = messager.is_a?(Class) ? messager : messager.class
277
+ return default unless klass.respond_to?(predicate)
278
+
279
+ klass.public_send(predicate)
280
+ end
281
+
175
282
  def registered_class?(registry, klass)
176
283
  klass = klass.class unless klass.is_a?(Class) || klass.is_a?(String)
177
284
  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,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,12 @@ 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
+ #
65
87
  # Encrypt message bodies at rest (ActiveRecord Encryption; requires
66
88
  # `bin/rails db:encryption:init`). Body search degrades when enabled.
67
89
  #
@@ -80,6 +102,23 @@ Chats.configure do |config|
80
102
  # }
81
103
  #
82
104
  # config.can_create_group = ->(creator) { creator.admin? }
105
+ #
106
+ # Composed into the inbox query before the limit — hide rows, re-scope
107
+ # them, whatever your product needs, without overriding the controller:
108
+ #
109
+ # config.inbox_scope = ->(relation, viewer) { relation }
110
+ #
111
+ # Whether a conversation still accepts messages is NOT a proc: the SUBJECT
112
+ # owns it, because the subject already owns the conversation's meaning.
113
+ #
114
+ # class Ticket < ApplicationRecord
115
+ # acts_as_chat_subject
116
+ # def chat_locked? = closed?
117
+ # def chat_locked_notice = "This ticket is closed. Reply to reopen it."
118
+ # end
119
+ #
120
+ # Locking gates SENDING only: the thread stays readable and the composer
121
+ # is replaced by the notice (see the `locked_composer` slot below).
83
122
 
84
123
  # ==========================================================================
85
124
  # TRUST & SAFETY — snap onto the `moderate` gem (or anything else)
@@ -103,25 +142,35 @@ Chats.configure do |config|
103
142
  # config.filter "Chats::Message", :body, mode: :flag
104
143
 
105
144
  # ==========================================================================
106
- # NOTIFICATIONSone hook, fan out anywhere
145
+ # EVENTSsubscribe to the domain moments, fan out anywhere
107
146
  # ==========================================================================
108
147
  #
109
- # Called on notification-worthy domain moments. Keep it fast (enqueue jobs,
110
- # don't do work inline). Events:
148
+ # Many subscribers per event; each runs isolated (a raising one is reported
149
+ # through Rails.error and never breaks message delivery). Keep them fast —
150
+ # enqueue jobs, don't do work inline.
111
151
  #
112
- # :message_created message: every persisted human message
113
- # :participant_added participant: someone added to a group
152
+ # Chats.on(:message_created) { |message| } # every human message
153
+ # Chats.on(:conversation_created) { |conversation| } # a thread came into being
154
+ # Chats.on(:participant_left) { |participant| } # someone left a group
155
+ # Chats.on(:conversation_read) { |conversation:, participant:| }
114
156
  #
115
157
  # 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
- # }
158
+ # Chats.on(:message_created) { |message| NewMessageNotifier.with(record: message).deliver }
159
+ #
160
+ # With a debounced-email job (see Chats::Participant#should_notify? for the
161
+ # "only email once until they come back" etiquette helper):
162
+ # Chats.on(:message_created) { |message| ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) }
163
+ #
164
+ # Registering from reloadable code? Pass a key, and a reload replaces the
165
+ # subscriber instead of stacking a second one:
166
+ #
167
+ # Rails.application.config.to_prepare do
168
+ # Chats.on(:message_created, key: :unread_email) { |message| … }
169
+ # end
170
+ #
171
+ # DEPRECATED (removed in 1.0): `config.notifier = ->(event, **payload) {}`
172
+ # still works, but receives :message_created and :conversation_read only —
173
+ # the events 0.1.1 had. The ones added in 0.2.0 are Chats.on-only.
125
174
 
126
175
  # ==========================================================================
127
176
  # DISPLAY — how messagers appear in the bundled views
@@ -135,4 +184,47 @@ Chats.configure do |config|
135
184
  # config.messager_avatar = ->(messager) {
136
185
  # messager.avatar.attached? ? messager.avatar.variant(:thumb) : nil
137
186
  # }
187
+ #
188
+ # Where a messager's profile lives. nil (the default) means the bundled
189
+ # views render names as plain text — chats never assumes you have a
190
+ # `user_path`, and never renders a dead anchor:
191
+ #
192
+ # config.messager_url = lambda do |messager|
193
+ # routes = Rails.application.routes.url_helpers
194
+ #
195
+ # case messager
196
+ # when User then routes.user_path(messager) # a desk or a bot has no profile: nil
197
+ # end
198
+ # end
199
+ #
200
+ # The signature under a message written by an AUTHOR on a sender's behalf
201
+ # (`desk.message!(user, "On it!", author: agent)`). Defaults to the
202
+ # localized "— Agent Name":
203
+ #
204
+ # config.message_signature = ->(message) { "answered by #{message.author.first_name}" }
205
+ #
206
+ # The "official account" badge next to a `verified: true` messager's name.
207
+ # nil (the default) renders chats' own rosette, recoloured by the
208
+ # `--chats-verified` CSS variable. Set this to hand back your design
209
+ # system's mark instead — return html_safe markup, or nil for no badge:
210
+ #
211
+ # config.verified_badge = lambda do |messager|
212
+ # ApplicationController.helpers.image_tag("official.svg", class: "badge", alt: "Official account")
213
+ # end
214
+
215
+ # ==========================================================================
216
+ # SLOTS — add one row or one button without ejecting a screen
217
+ # ==========================================================================
218
+ #
219
+ # The bundled views render a partial named `chats/slots/_<slot>` whenever
220
+ # one exists in your app. No configuration, no registration: create the
221
+ # file and it appears.
222
+ #
223
+ # app/views/chats/slots/_inbox_top.html.erb above the first inbox row
224
+ # app/views/chats/slots/_inbox_empty.html.erb inside the empty state
225
+ # app/views/chats/slots/_conversation_header_actions.html.erb thread menu
226
+ # app/views/chats/slots/_locked_composer.html.erb the locked composer's body
227
+ # app/views/chats/slots/_message_meta.html.erb after each bubble's timestamp
228
+ #
229
+ # `rails generate chats:views` is still there for wholesale restyling.
138
230
  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
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-10 00:00:00.000000000 Z
10
+ date: 2026-09-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -135,8 +135,11 @@ files:
135
135
  - app/helpers/chats/engine_helper.rb
136
136
  - app/javascript/chats/composer_controller.js
137
137
  - app/javascript/chats/debounced_submit_controller.js
138
+ - app/javascript/chats/refresh_inbox_controller.js
138
139
  - app/javascript/chats/thread_controller.js
139
140
  - app/views/chats/conversations/_conversation_row.html.erb
141
+ - app/views/chats/conversations/_group.html.erb
142
+ - app/views/chats/conversations/_locked_composer.html.erb
140
143
  - app/views/chats/conversations/_messages_page.html.erb
141
144
  - app/views/chats/conversations/_read_state.html.erb
142
145
  - app/views/chats/conversations/index.html.erb
@@ -146,11 +149,14 @@ files:
146
149
  - app/views/chats/messages/_message.html.erb
147
150
  - app/views/chats/messages/create.turbo_stream.erb
148
151
  - app/views/chats/messages/errors.turbo_stream.erb
152
+ - app/views/chats/messages/locked.turbo_stream.erb
149
153
  - app/views/chats/shared/_unread_badge.html.erb
154
+ - app/views/chats/shared/_verified_badge.html.erb
150
155
  - config/importmap.rb
151
156
  - config/locales/en.yml
152
157
  - config/locales/es.yml
153
158
  - config/routes.rb
159
+ - context7.json
154
160
  - docs/PRD.md
155
161
  - docs/campfire_review.md
156
162
  - gemfiles/rails_7.1.gemfile
@@ -161,6 +167,8 @@ files:
161
167
  - lib/chats/configuration.rb
162
168
  - lib/chats/engine.rb
163
169
  - lib/chats/errors.rb
170
+ - lib/chats/inbox.rb
171
+ - lib/chats/inbox_group.rb
164
172
  - lib/chats/macros.rb
165
173
  - lib/chats/models/application_record.rb
166
174
  - lib/chats/models/concerns/chat_subject.rb
@@ -169,10 +177,13 @@ files:
169
177
  - lib/chats/models/message.rb
170
178
  - lib/chats/models/participant.rb
171
179
  - lib/chats/models/reaction.rb
180
+ - lib/chats/subscribers.rb
172
181
  - lib/chats/version.rb
173
182
  - lib/generators/chats/install_generator.rb
183
+ - lib/generators/chats/templates/add_author_to_chats_messages.rb.erb
174
184
  - lib/generators/chats/templates/create_chats_tables.rb.erb
175
185
  - lib/generators/chats/templates/initializer.rb
186
+ - lib/generators/chats/upgrade_generator.rb
176
187
  - lib/generators/chats/views_generator.rb
177
188
  homepage: https://github.com/rameerez/chats
178
189
  licenses: