chats 0.3.1 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0e116038dc60b864f51b65edb4f5b879305da0cabef4c8aa2db73e23464c674
4
- data.tar.gz: '0179846f4e07b60cb1d07a9d50909a836456794636da584a6178843f0d39f8ff'
3
+ metadata.gz: 80fff570dabbff3e99a5a5f37c43b43acd334a94aae30c23b8c76bfbab5441e8
4
+ data.tar.gz: 72efe83f785fe99876d6b5922a5d834897f1771f3880bead3b43ae6f342ba96d
5
5
  SHA512:
6
- metadata.gz: 1c3dc69a2bdba881bc7fc6543bc768885797e121f8d23b619e72a578cb3e7c6555c9b19c6dde52bec3085efe52d98e40dc277af932015bb08f65a7aaf67ca5c1
7
- data.tar.gz: 4ab1a0ae08133bbe02240e12b1efa8efccb6dfe5bfbf53a1662acce5542a74ec4bb3adfc1eeaf10e6db94a961b17dc81523e8b2ac5044fa02ff22bba6904e303
6
+ metadata.gz: 362eda363af938a99823b38d7fd4e0a5594d62d305f6a47f88a1dc9978c6556ab37561259007f1649567472a98deecff6cc96050afbbedc3d36637ee99207a84
7
+ data.tar.gz: aa7b170ba97de94d58b08516dbd4674905f1ab78e2bcd4a2e9af997e0aab6d71a3fc35b1aea2f6f0ab97fd02d1d6fa46494a1826f784b35184105c0c1c3c89b6
data/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.2] - 2026-09-17
8
+
9
+ ### Fixed
10
+
11
+ - Grouped inbox limits preserve other counterparts even when one desk has many recent conversations.
12
+ - Conversation creation and participant departure events run after commit and remain silent on rollback. Direct conversation creation includes its roster in the transaction.
13
+ - A message author may be any persisted model; staff need not become messagers to sign a desk's reply.
14
+
15
+ ### Added
16
+
17
+ - `Chats::SendRateLimited`, a controller concern sharing the configured sender budget across chat and product-specific composers, using the host's cache store on all supported Rails versions.
18
+
7
19
  ## [0.3.1] - 2026-09-16
8
20
 
9
21
  ### Fixed
data/README.md CHANGED
@@ -368,6 +368,10 @@ message.authored_by?(lucia)
368
368
 
369
369
  The bundled bubble renders a signature line ("— Lucía G.") via `Chats.display_name_for`; `config.message_signature = ->(message) { … }` rewrites it. Ordinary messages have no author and render exactly as before.
370
370
 
371
+ An author must be persisted, but needs neither `acts_as_messager` nor a conversation
372
+ seat. The host authorizes who may send on behalf of a shared identity; the HTTP
373
+ message controller never accepts an author from request parameters.
374
+
371
375
  Existing installs get the columns with one command:
372
376
 
373
377
  ```bash
@@ -457,7 +461,7 @@ Chats.configure do |config|
457
461
  config.max_group_size = 32
458
462
  config.max_attachment_size = 10.megabytes
459
463
  config.max_attachments_per_message = 4
460
- config.send_rate_limit = { to: 60, within: 1.minute } # Rails 8 rate_limit; nil disables
464
+ config.send_rate_limit = { to: 60, within: 1.minute } # shared sender budget; nil disables
461
465
  config.encrypt_messages = false # ActiveRecord Encryption on bodies
462
466
 
463
467
  # Policies (on top of — never instead of — block enforcement)
@@ -12,19 +12,7 @@ module Chats
12
12
  # opened the composer" race in one place.
13
13
  before_action :refuse_when_locked!, only: %i[update destroy]
14
14
 
15
- # Per-sender send throttle via Rails 8's built-in controller rate
16
- # limiting (https://api.rubyonrails.org/classes/ActionController/RateLimiting.html).
17
- # Feature-detected so the gem still loads on Rails 7.1 (where this is
18
- # simply not enforced). Keyed by messager, not IP — one abusive account
19
- # behind a corporate NAT must not silence the rest.
20
- if respond_to?(:rate_limit) && Chats.config.send_rate_limit
21
- rate_limit(
22
- **Chats.config.send_rate_limit,
23
- only: :create,
24
- by: -> { send(Chats.config.current_messager_method)&.to_gid&.to_s || request.remote_ip },
25
- with: -> { head :too_many_requests }
26
- )
27
- end
15
+ include Chats::SendRateLimited
28
16
 
29
17
  # A single bubble, re-rendered. Exists for one delightful reason: it's
30
18
  # the "cancel edit" target — replacing the inline edit form back with the
data/chats-0.3.1.gem ADDED
Binary file
data/lib/chats/engine.rb CHANGED
@@ -30,7 +30,7 @@ module Chats
30
30
  CHATS_LIB = File.expand_path("chats", LIB_ROOT)
31
31
 
32
32
  ZEITWERK_IGNORED = %w[
33
- version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb
33
+ version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb send_rate_limited.rb
34
34
  ].freeze
35
35
 
36
36
  initializer "chats.autoload", before: :set_autoload_paths do
data/lib/chats/inbox.rb CHANGED
@@ -151,10 +151,13 @@ module Chats
151
151
  else
152
152
  stacked = Chats::Conversation.direct.where(id: stacked_seats)
153
153
  @ungrouped_relation = base_relation.where.not(id: stacked).limit(limit)
154
- # Ordered by recency and limited like the other leg, which also makes
155
- # the FIRST conversation of each counterpart that counterpart's
156
- # freshest that's the one the stacked row previews.
157
- @stacked = apply_search(base_relation.direct.where(id: stacked_seats).limit(limit))
154
+ recent = base_relation.direct.where(id: stacked_seats).except(:includes).select(:id).limit(limit)
155
+ # Preserve the recent search window, plus the freshest conversation
156
+ # of each counterpart. A busy stack cannot consume another stack's
157
+ # row; at most twice the row limit is materialized.
158
+ candidates = base_relation.where(id: limited_ids(recent))
159
+ .or(base_relation.where(id: limited_ids(stack_representatives)))
160
+ @stacked = apply_search(candidates)
158
161
  end
159
162
 
160
163
  @ungrouped = apply_search(@ungrouped_relation)
@@ -163,6 +166,29 @@ module Chats
163
166
  @flat = @stacked.empty? && query.nil? ? @ungrouped_relation : @ungrouped + @stacked
164
167
  end
165
168
 
169
+ # MySQL rejects LIMIT directly in an IN subquery. A derived table keeps
170
+ # the bounded selection in SQL and works on all three supported adapters.
171
+ def limited_ids(relation)
172
+ Chats::Conversation.from(relation, :limited_conversations).select(:id)
173
+ end
174
+
175
+ # ROW_NUMBER works on every supported adapter (PostgreSQL, SQLite and
176
+ # MySQL 8). Ranking in SQL avoids loading an entire busy desk's history.
177
+ def stack_representatives
178
+ activity = "COALESCE(chats_conversations.last_message_at, chats_conversations.created_at)"
179
+ ranking = "ROW_NUMBER() OVER (PARTITION BY stack_seats.messager_type, stack_seats.messager_id " \
180
+ "ORDER BY #{activity} DESC, chats_conversations.id DESC) AS stack_rank"
181
+ ranked = base_relation.direct.except(:includes, :order)
182
+ .joins("INNER JOIN chats_participants stack_seats " \
183
+ "ON stack_seats.conversation_id = chats_conversations.id")
184
+ .where(stack_seats: { messager_type: grouped_types })
185
+ .where.not(stack_seats: { messager_type: viewer.class.polymorphic_name,
186
+ messager_id: viewer.id })
187
+ .select("chats_conversations.id, #{activity} AS activity_at", ranking)
188
+ Chats::Conversation.from(ranked, :ranked_stacks).where("stack_rank = 1")
189
+ .order(Arel.sql("activity_at DESC, id DESC")).limit(limit).select(:id)
190
+ end
191
+
166
192
  # Only the direct threads shared with one counterpart. Direct only, by
167
193
  # design: a group that happens to include the desk is not part of the
168
194
  # desk's stack.
@@ -156,11 +156,6 @@ module Chats
156
156
  #
157
157
  # desk.message!(alice, "On it!", author: lucia)
158
158
  def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil)
159
- if author && !Chats.messager_class?(author.class)
160
- raise Chats::NotAllowedError,
161
- "author must be a messager (acts_as_messager), got #{author.class.name}"
162
- end
163
-
164
159
  conversation =
165
160
  case target
166
161
  when Chats::Conversation then target
@@ -114,6 +114,8 @@ module Chats
114
114
  # mechanism. https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
115
115
  validate :groups_must_be_enabled, if: :group?
116
116
 
117
+ after_create_commit -> { Chats.notify(:conversation_created, conversation: self) }
118
+
117
119
  # --- Finding & creating ---------------------------------------------------
118
120
 
119
121
  class << self
@@ -133,21 +135,17 @@ module Chats
133
135
  raise Chats::BlockedError, "messagers are blocked" if Chats.blocked_between?(a, b)
134
136
  raise Chats::NotAllowedError, "policy forbids messaging" unless Chats.can_message?(a, b)
135
137
 
136
- conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
137
- c.kind = "direct"
138
- c.subject = about
138
+ transaction do
139
+ conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
140
+ c.kind = "direct"
141
+ c.subject = about
142
+ end
143
+ # Keep the roster in the creation transaction: the after-commit
144
+ # event must describe a complete conversation, including under an
145
+ # outer host transaction or a failed participant insertion.
146
+ [a, b].each { |messager| conversation.add_participant!(messager) }
147
+ conversation
139
148
  end
140
-
141
- # `create_or_find_by!` may have FOUND a conversation created a moment
142
- # ago by the other side — participants are ensured idempotently
143
- # either way (their own unique index makes this race-safe too).
144
- # `previously_new_record?` is how we tell the two apart, so the
145
- # :conversation_created event fires ONCE per conversation, not on
146
- # every resume.
147
- created = conversation.previously_new_record?
148
- [a, b].each { |messager| conversation.add_participant!(messager) }
149
- Chats.notify(:conversation_created, conversation: conversation) if created
150
- conversation
151
149
  end
152
150
 
153
151
  # Create a group conversation. +others+ excludes the creator (who joins
@@ -163,17 +161,12 @@ module Chats
163
161
  others = Array(others) - [creator]
164
162
  raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
165
163
 
166
- conversation = transaction do
164
+ transaction do
167
165
  created = create!(kind: "group", title: title, subject: about)
168
166
  created.add_participant!(creator, role: "owner")
169
167
  others.each { |messager| created.add_participant!(messager) }
170
168
  created
171
169
  end
172
-
173
- # Emitted AFTER the transaction: subscribers see a complete roster
174
- # and never run inside the write that created it.
175
- Chats.notify(:conversation_created, conversation: conversation)
176
- conversation
177
170
  end
178
171
 
179
172
  # Deterministic identity for a direct pair (+ optional subject).
@@ -96,7 +96,7 @@ module Chats
96
96
  validate :sender_must_be_active_participant, on: :create
97
97
  validate :sender_must_not_be_blocked, on: :create
98
98
  validate :conversation_must_not_be_locked, on: :create
99
- validate :author_must_be_a_messager
99
+ validate :author_must_be_persisted
100
100
  validate :files_must_be_allowed
101
101
 
102
102
  after_create :register_on_conversation
@@ -312,13 +312,13 @@ module Chats
312
312
  errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
313
313
  end
314
314
 
315
- # An author signs the bubble with `Chats.display_name_for`, so it has to
316
- # be something that HAS a name in this system a messager, not a ride or
317
- # a listing that would render as "Listing 1".
318
- def author_must_be_a_messager
319
- return if author.nil? || Chats.messager_class?(author.class)
315
+ # Authorship is a signature, not a conversation seat. A host may keep
316
+ # staff in a separate model without giving them messaging capabilities.
317
+ # Never implicitly create that identity while sending a message.
318
+ def author_must_be_persisted
319
+ return if author.nil? || author.persisted?
320
320
 
321
- errors.add(:author, :not_a_messager)
321
+ errors.add(:author, :invalid)
322
322
  end
323
323
 
324
324
  # The subject owns the conversation's openness (Chats::ChatSubject#
@@ -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.3.2"
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)
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.3.2
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-09-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -152,6 +152,7 @@ files:
152
152
  - app/views/chats/messages/locked.turbo_stream.erb
153
153
  - app/views/chats/shared/_unread_badge.html.erb
154
154
  - app/views/chats/shared/_verified_badge.html.erb
155
+ - chats-0.3.1.gem
155
156
  - config/importmap.rb
156
157
  - config/locales/en.yml
157
158
  - config/locales/es.yml
@@ -177,6 +178,7 @@ files:
177
178
  - lib/chats/models/message.rb
178
179
  - lib/chats/models/participant.rb
179
180
  - lib/chats/models/reaction.rb
181
+ - lib/chats/send_rate_limited.rb
180
182
  - lib/chats/subscribers.rb
181
183
  - lib/chats/version.rb
182
184
  - lib/generators/chats/install_generator.rb