chats 0.3.1 → 0.4.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.
@@ -43,6 +43,8 @@ module Chats
43
43
  # concurrent joins race-safe. Same rationale as Conversation#direct_key.
44
44
  validate :group_must_have_room, on: :create
45
45
 
46
+ after_update_commit :notify_departure, if: -> { saved_change_to_left_at? && left? }
47
+
46
48
  def owner? = role == "owner"
47
49
  def left? = left_at.present?
48
50
  def active? = left_at.nil?
@@ -104,11 +106,17 @@ module Chats
104
106
  def unmute! = update!(muted_at: nil)
105
107
 
106
108
  def leave!
109
+ return self if left?
110
+
107
111
  update!(left_at: Time.current)
108
- Chats.notify(:participant_left, participant: self)
109
112
  self
110
113
  end
111
114
 
115
+ def notify_departure
116
+ Chats.notify(:participant_left, participant: self)
117
+ end
118
+ private :notify_departure
119
+
112
120
  # Hand this seat to a different messager, keeping the read horizon, the
113
121
  # role and the history: the guest who signs up, the agent who takes over
114
122
  # a shared mailbox. The MESSAGES keep their original sender — what was
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chats
4
+ # Share one sender budget across chat and product-specific HTTP composers.
5
+ # The host owns the cache store. Using its atomic increment also works on
6
+ # Rails 7 and avoids depending on Rails' private controller limiter API.
7
+ module SendRateLimited
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ before_action :enforce_chat_send_rate_limit, only: :create
12
+ end
13
+
14
+ private
15
+
16
+ def chat_rate_limit_messager
17
+ send(Chats.config.current_messager_method)
18
+ end
19
+
20
+ def enforce_chat_send_rate_limit
21
+ limit = Chats.config.send_rate_limit
22
+ return unless limit
23
+
24
+ sender = chat_rate_limit_messager&.to_gid&.to_s || request.remote_ip
25
+ count = self.class.cache_store.increment("rate-limit:chats/messages:#{sender}", 1,
26
+ expires_in: limit.fetch(:within))
27
+ head :too_many_requests if count && count > limit.fetch(:to)
28
+ end
29
+ end
30
+ 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.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/chats.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "chats/version"
7
7
  require_relative "chats/errors"
8
8
  require_relative "chats/configuration"
9
9
  require_relative "chats/subscribers"
10
+ require_relative "chats/send_rate_limited"
10
11
  require_relative "chats/macros"
11
12
 
12
13
  require_relative "chats/engine" if defined?(::Rails::Engine)
@@ -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
@@ -101,6 +101,10 @@ class CreateChatsTables < ActiveRecord::Migration<%= migration_version %>
101
101
  t.datetime :edited_at
102
102
  t.datetime :deleted_at
103
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)
104
108
 
105
109
  t.timestamps
106
110
  end
@@ -223,6 +223,7 @@ Chats.configure do |config|
223
223
  # app/views/chats/slots/_inbox_top.html.erb above the first inbox row
224
224
  # app/views/chats/slots/_inbox_empty.html.erb inside the empty state
225
225
  # app/views/chats/slots/_conversation_header_actions.html.erb thread menu
226
+ # app/views/chats/slots/_composer_actions.html.erb your own buttons above the composer
226
227
  # app/views/chats/slots/_locked_composer.html.erb the locked composer's body
227
228
  # app/views/chats/slots/_message_meta.html.erb after each bubble's timestamp
228
229
  #
@@ -9,14 +9,17 @@ module Chats
9
9
  # needs into an EXISTING install. Nothing else: the initializer, the
10
10
  # views and the routes you already own stay untouched.
11
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.
12
+ # Writes every migration since 0.1.x — 0.2.0's message authorship and
13
+ # 0.4.0's message action — in that order. Each is written guarded, so
14
+ # running this against an install that already has some of the columns
15
+ # (a fresh 0.2.0 install has the author ones) is a no-op for those
16
+ # rather than an error, and re-running it skips files already copied.
17
+ # The action migration is not written at all where the column exists.
15
18
  class UpgradeGenerator < Rails::Generators::Base
16
19
  include ActiveRecord::Generators::Migration
17
20
 
18
21
  source_root File.expand_path("templates", __dir__)
19
- desc "Add the migrations a chats version bump needs (0.2.0: message authorship)"
22
+ desc "Add the migrations a chats version bump needs (0.2.0: message authorship; 0.4.0: message actions)"
20
23
 
21
24
  def self.next_migration_number(dir)
22
25
  ActiveRecord::Generators::Base.next_migration_number(dir)
@@ -27,19 +30,47 @@ module Chats
27
30
  File.join(db_migrate_path, "add_author_to_chats_messages.rb")
28
31
  end
29
32
 
33
+ # Not written when the schema already HAS the column — a fresh 0.4.0
34
+ # install, whose create migration owns it. A second migration for a
35
+ # column it didn't add is one nobody can safely roll back. Without a
36
+ # database to ask, it is written anyway; its `up` is guarded, and its
37
+ # `down` never drops the column.
38
+ def create_action_migration
39
+ if action_column_present?
40
+ say_status :skip, "chats_messages.action already exists — no migration needed", :yellow
41
+ return
42
+ end
43
+
44
+ migration_template "add_action_to_chats_messages.rb.erb",
45
+ File.join(db_migrate_path, "add_action_to_chats_messages.rb")
46
+ end
47
+
30
48
  def display_post_upgrade_message
31
49
  say "\n💬 chats upgrade migrations copied.", :green
32
50
  say "\n 1. Run 'rails db:migrate'."
33
- say " 2. New in 0.2.0 — all opt-in, nothing changes until you ask:"
51
+ say " 2. New in 0.4.0 — a message can carry one button (the `action` column):"
52
+ say " alice.message!(bob, \"Let's play!\", action: { label: \"Join my room\", url: room_path(room) })"
53
+ say " app/views/chats/slots/_composer_actions.html.erb # your own buttons by the composer"
54
+ say " 3. New in 0.2.0 — all opt-in, nothing changes until you ask:"
34
55
  say " acts_as_messager notifications: false, blockable: false, inbox: :grouped"
35
56
  say " Chats.on(:message_created) { |message| … } # replaces config.notifier"
36
57
  say " config.messager_url / config.message_signature / config.inbox_scope"
37
58
  say " chat_locked? / chat_locked_notice on your chat subjects"
38
- say " 3. See the CHANGELOG for the full list.\n", :green
59
+ say " 4. See the CHANGELOG for the full list.\n", :green
39
60
  end
40
61
 
41
62
  private
42
63
 
64
+ def action_column_present?
65
+ ActiveRecord::Base.connection_pool.with_connection do |connection|
66
+ connection.column_exists?(:chats_messages, :action)
67
+ end
68
+ rescue ActiveRecord::ActiveRecordError
69
+ # No database configured, not created yet, or no chats tables: there
70
+ # is nothing to ask, so write the (guarded) migration.
71
+ false
72
+ end
73
+
43
74
  def migration_version
44
75
  "[#{ActiveRecord::VERSION::STRING.to_f}]"
45
76
  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.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-16 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -145,6 +145,7 @@ files:
145
145
  - app/views/chats/conversations/index.html.erb
146
146
  - app/views/chats/conversations/refresh.turbo_stream.erb
147
147
  - app/views/chats/conversations/show.html.erb
148
+ - app/views/chats/messages/_action.html.erb
148
149
  - app/views/chats/messages/_composer.html.erb
149
150
  - app/views/chats/messages/_message.html.erb
150
151
  - app/views/chats/messages/create.turbo_stream.erb
@@ -152,6 +153,7 @@ files:
152
153
  - app/views/chats/messages/locked.turbo_stream.erb
153
154
  - app/views/chats/shared/_unread_badge.html.erb
154
155
  - app/views/chats/shared/_verified_badge.html.erb
156
+ - chats-0.3.1.gem
155
157
  - config/importmap.rb
156
158
  - config/locales/en.yml
157
159
  - config/locales/es.yml
@@ -159,10 +161,15 @@ files:
159
161
  - context7.json
160
162
  - docs/PRD.md
161
163
  - docs/campfire_review.md
164
+ - docs/screenshots/composer-actions.png
165
+ - docs/screenshots/inbox.png
166
+ - docs/screenshots/system-note-action.png
167
+ - docs/screenshots/thread-actions-phone.png
162
168
  - gemfiles/rails_7.1.gemfile
163
169
  - gemfiles/rails_7.2.gemfile
164
170
  - gemfiles/rails_8.1.gemfile
165
171
  - lib/chats.rb
172
+ - lib/chats/action.rb
166
173
  - lib/chats/broadcasts.rb
167
174
  - lib/chats/configuration.rb
168
175
  - lib/chats/engine.rb
@@ -177,9 +184,11 @@ files:
177
184
  - lib/chats/models/message.rb
178
185
  - lib/chats/models/participant.rb
179
186
  - lib/chats/models/reaction.rb
187
+ - lib/chats/send_rate_limited.rb
180
188
  - lib/chats/subscribers.rb
181
189
  - lib/chats/version.rb
182
190
  - lib/generators/chats/install_generator.rb
191
+ - lib/generators/chats/templates/add_action_to_chats_messages.rb.erb
183
192
  - lib/generators/chats/templates/add_author_to_chats_messages.rb.erb
184
193
  - lib/generators/chats/templates/create_chats_tables.rb.erb
185
194
  - lib/generators/chats/templates/initializer.rb