chats 0.2.0 → 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: c51ebc5c0e2da96363bb5b2e85d9a9c2a91e41f7ddb9d351ff10001654a48325
4
- data.tar.gz: a06cde14d3953972e7061b2950b7a52c696f4292e25a2a91c9c7bb6ce7d269cb
3
+ metadata.gz: 80fff570dabbff3e99a5a5f37c43b43acd334a94aae30c23b8c76bfbab5441e8
4
+ data.tar.gz: 72efe83f785fe99876d6b5922a5d834897f1771f3880bead3b43ae6f342ba96d
5
5
  SHA512:
6
- metadata.gz: 5d23c31395edb382ec05fc6b7478a4e219b1878dc29f67092451101ef720bf2df556ae92976384665c0e108ca4607557ed9d1badf0af9357143182f1fdb57e6d
7
- data.tar.gz: 3f02985c90410059af142a03fc3a3298540f71fb05d9b46991f39ea22999bfab64738f86beb92db7355e98c68e7962c2b6e79925335cf409d67842c1b1a7a5eb
6
+ metadata.gz: 362eda363af938a99823b38d7fd4e0a5594d62d305f6a47f88a1dc9978c6556ab37561259007f1649567472a98deecff6cc96050afbbedc3d36637ee99207a84
7
+ data.tar.gz: aa7b170ba97de94d58b08516dbd4674905f1ab78e2bcd4a2e9af997e0aab6d71a3fc35b1aea2f6f0ab97fd02d1d6fa46494a1826f784b35184105c0c1c3c89b6
data/CHANGELOG.md CHANGED
@@ -4,6 +4,85 @@ 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
+
19
+ ## [0.3.1] - 2026-09-16
20
+
21
+ ### Fixed
22
+ - **The verified badge was a star, not a check.** 0.3.0 shipped a
23
+ five-pointed star as the "official account" mark, copied from a host icon
24
+ that was named `verified_badge` but drawn as a star. A star reads as
25
+ "favourite" or "rated", not "this account is really us", and at 14px a
26
+ solid star and a solid rosette are the same blob. It is now heroicons
27
+ `check-badge`: a scalloped rosette with a tick knocked out of it, which
28
+ is the mark people already read as verified. `fill-rule="evenodd"` is
29
+ what knocks the tick out — without it the rosette fills solid.
30
+
31
+ ## [0.3.0] - 2026-09-16
32
+
33
+ Official accounts. Some counterparts are not people you met — they are a
34
+ support desk, an organization, a shop, a brand — and a person should be able
35
+ to tell at a glance. **Nothing changes until a model says so**: 0.2.x
36
+ installs upgrade by bumping the gem, with no migration and no new
37
+ configuration.
38
+
39
+ ### Added
40
+ - **`acts_as_messager verified: true`.** A messager declares itself an
41
+ OFFICIAL account, and every bundled view that shows its name marks it: the
42
+ inbox row, the stacked inbox row, and the thread header. `Klass.
43
+ chat_verified?` is the class predicate and `Chats.verified?(messager)`
44
+ reads it duck-typed — false for a plain model, a nil, a non-messager — so
45
+ hosts can badge their own screens without a class check. The option is
46
+ independent of the headless ones: a desk is usually headless *and*
47
+ official, a shop is usually official and nothing else. Unlike its boolean
48
+ neighbours it refuses to coerce — `verified: "false"` raises
49
+ `Chats::ConfigurationError` at boot rather than quietly verifying an
50
+ account, because a badge is a trust claim and not a display preference.
51
+ - **The badge itself**, `chats/shared/_verified_badge` — an inline rosette
52
+ that sizes itself from the text it sits beside. It is an image with a
53
+ name, not decoration: `role="img"` plus a localized label
54
+ (`chats.verified.label`, "Official account" / "Cuenta oficial"), and the
55
+ glyph is `aria-hidden` so a screen reader never announces it twice.
56
+ - **`config.verified_badge`** `->(messager) { markup }` — swap the glyph for
57
+ your design system's own, vary it per messager, or return nil for no badge.
58
+ The default (nil) renders the gem's rosette.
59
+ - **`--chats-verified`** (`#0284c7`) — the badge colour, a CSS custom
60
+ property like the rest of the gem's theming, inherited through
61
+ `currentColor`. Not the familiar `#1d9bf0`: the badge is a meaningful
62
+ graphic, so WCAG 1.4.11 asks 3:1 of it, and `#1d9bf0` is 3.00:1 on
63
+ `--chats-bg` but 2.73:1 on `--chats-surface` — the inbox row's HOVER
64
+ background, so it failed exactly while somebody was pointing at it.
65
+ `#0284c7` clears the bar on both grounds and on a dark one, so a host
66
+ inverting the palette inherits a badge that still passes.
67
+ `test/verified_badge_contrast_test.rb` computes it rather than trusting a
68
+ swatch.
69
+ - **`chats_verified_badge(messager)`** — the view helper behind all three
70
+ surfaces, available in host views too. Nil for everyone who hasn't
71
+ declared `verified: true`, so it is safe to drop next to any name
72
+ unconditionally.
73
+ - **`Chats::Conversation#counterpart_for(viewer)`** — the other messager in a
74
+ direct thread (nil for a group, and for a thread whose other seat left).
75
+ The title, the avatar and the badge on an inbox row now resolve the
76
+ counterpart through this one method, memoized per viewer, so a row that
77
+ cost one query in 0.2.0 still costs one.
78
+
79
+ ### Changed
80
+ - The bundled inbox row wraps its title in `.chats-row__name`, and the thread
81
+ header wraps its name in `.chats-thread__name`, so a long name still
82
+ ellipsizes while the badge beside it stays visible. If you ejected these
83
+ views with `rails generate chats:views`, your copies are untouched and keep
84
+ working; re-run the generator only if you want the badge.
85
+
7
86
  ## [0.2.0] - 2026-09-16
8
87
 
9
88
  The release that makes `chats` a foundation other products can be built on:
data/README.md CHANGED
@@ -97,7 +97,7 @@ Five concepts, namespaced and polymorphic from day one (no hard `User` coupling
97
97
  - **`Chats::Participant`** — a messager's seat in a conversation. Holds role, read horizon, mute, soft-leave, and notification bookkeeping.
98
98
  - **`Chats::Message`** — `text` (human) or `system` (posted by your app). Soft-deletes to a tombstone. Attachments via ActiveStorage.
99
99
  - **`Chats::Reaction`** — one row per (message, reactor, emoji); tap-to-toggle, race-safe.
100
- - **Any model with `acts_as_messager`** — users, organizations, support desks, bots: participants and senders are polymorphic. A messager that is not a person declares it (`notifications: false, blockable: false, inbox: :grouped`) and the gem stops treating it like one. See [`support_desk`](https://github.com/rameerez/support_desk) for the worked example.
100
+ - **Any model with `acts_as_messager`** — users, organizations, support desks, bots: participants and senders are polymorphic. A messager that is not a person declares it (`notifications: false, blockable: false, inbox: :grouped`) and the gem stops treating it like one; an official one (`verified: true`) gets the badge everywhere its name appears. See [`support_desk`](https://github.com/rameerez/support_desk) for the worked example.
101
101
 
102
102
  Two deliberate design decisions worth knowing:
103
103
 
@@ -269,6 +269,53 @@ end
269
269
 
270
270
  That's the whole point of the option: **your notifiers and views stop asking `is_a?(User)`**. The predicates are on the class (`SupportDesk.chat_notifications?`, `.chat_blockable?`, `.chat_inbox_mode`) and duck-typed everywhere the gem reads them, so an ordinary `acts_as_messager` model behaves exactly as it always did.
271
271
 
272
+ ## ✅ Official accounts: the verified badge
273
+
274
+ A support desk, an organization, a shop or a brand is an **official** counterpart, and the person talking to it should see that at a glance — the blue tick everyone already reads. Say it once, on the model, next to the other `acts_as_messager` options:
275
+
276
+ ```ruby
277
+ class SupportDesk < ApplicationRecord
278
+ acts_as_messager verified: true
279
+ end
280
+ ```
281
+
282
+ Every bundled view that shows a messager's name now marks it: the inbox row, the stacked inbox row, and the thread header. The mark is an image with a name, not decoration — `role="img"` plus a localized label (`chats.verified.label`: "Official account" / "Cuenta oficial"), with the glyph itself `aria-hidden` so nothing is announced twice.
283
+
284
+ `verified:` is **independent of everything else**. A desk is usually headless *and* official; a shop is usually official and completely ordinary otherwise. Combine what you need:
285
+
286
+ ```ruby
287
+ acts_as_messager verified: true # official, notifiable, blockable
288
+ acts_as_messager notifications: false, inbox: :grouped, verified: true # an official desk
289
+ ```
290
+
291
+ It is the one boolean option that **refuses to coerce**, and that is deliberate — please don't "fix" it into a `!!` to match its neighbours. `notifications:` and `blockable:` coerce, so `notifications: "false"` quietly means `true`; on those two the damage is a stray notification. Here the same slip would hand an account the mark that tells people it is really us, and the strings that reach a model declaration come from exactly the places that produce `"false"`: an ENV var, a YAML round-trip, a settings row. So `verified: "false"` raises `Chats::ConfigurationError` at boot, where somebody is looking, rather than shipping a verified impostor nobody notices.
292
+
293
+ Read it anywhere you render your own screens — duck-typed, never a class check:
294
+
295
+ ```ruby
296
+ SupportDesk.chat_verified? # the class predicate
297
+ Chats.verified?(messager) # false for a plain model, a nil, a non-messager
298
+ chats_verified_badge(messager) # the view helper: markup, or nil for everyone else
299
+ ```
300
+
301
+ **Change the colour** with one CSS variable (the badge inherits it through `currentColor`):
302
+
303
+ ```css
304
+ :root { --chats-verified: #0284c7; }
305
+ ```
306
+
307
+ The default is `#0284c7` rather than the more familiar `#1d9bf0`. The badge is a meaningful graphic, so it owes 3:1 against what it sits on (WCAG 1.4.11), and `#1d9bf0` is 3.00:1 on the page but **2.73:1 on `--chats-surface`** — the inbox row's hover background, so it failed exactly while someone was pointing at it. `#0284c7` clears the bar on both (4.10 and 3.72) and on a dark ground too (4.33 on `#111827`), so inverting the palette doesn't leave you with a badge you have to remember to fix. If you override it, `test/verified_badge_contrast_test.rb` shows the arithmetic worth repeating.
308
+
309
+ **Change the glyph** — to your design system's icon, a per-messager mark, or nothing — with a callable that gets the messager and returns html_safe markup (or `nil` for no badge):
310
+
311
+ ```ruby
312
+ config.verified_badge = lambda do |messager|
313
+ ApplicationController.helpers.image_tag("official.svg", class: "badge", alt: "Official account")
314
+ end
315
+ ```
316
+
317
+ Or eject `app/views/chats/shared/_verified_badge.html.erb` with `rails generate chats:views` and rewrite it.
318
+
272
319
  ## 🗂️ Grouped inbox rows
273
320
 
274
321
  With `inbox: :grouped`, every direct conversation a viewer has with that messager folds into a single inbox row — a stack:
@@ -321,6 +368,10 @@ message.authored_by?(lucia)
321
368
 
322
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.
323
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
+
324
375
  Existing installs get the columns with one command:
325
376
 
326
377
  ```bash
@@ -368,6 +419,7 @@ The bundled UI is intentionally framework-free (semantic `chats-*` classes + one
368
419
  :root {
369
420
  --chats-accent: #facc15; /* own bubbles, send button, badges */
370
421
  --chats-accent-contrast: #111827;
422
+ --chats-verified: #0284c7; /* the "official account" badge */
371
423
  }
372
424
  ```
373
425
 
@@ -409,7 +461,7 @@ Chats.configure do |config|
409
461
  config.max_group_size = 32
410
462
  config.max_attachment_size = 10.megabytes
411
463
  config.max_attachments_per_message = 4
412
- 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
413
465
  config.encrypt_messages = false # ActiveRecord Encryption on bodies
414
466
 
415
467
  # Policies (on top of — never instead of — block enforcement)
@@ -429,6 +481,7 @@ Chats.configure do |config|
429
481
  config.messager_avatar = ->(messager) { messager.avatar } # URL/attachment/variant or nil
430
482
  config.messager_url = ->(messager) { nil } # nil ⇒ names render as plain text
431
483
  config.message_signature = nil # ->(message) { } for signed bubbles
484
+ config.verified_badge = nil # ->(messager) { markup } for verified: true
432
485
  end
433
486
  ```
434
487
 
@@ -445,10 +498,12 @@ alice.chats # inbox relation, newest first
445
498
  alice.unread_chats_count # conversations with unread messages
446
499
  alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
447
500
  Chats::Inbox.for(alice) # [Conversation | InboxGroup] + #unread_count
501
+ Chats.verified?(desk) # official account? (acts_as_messager verified: true)
448
502
 
449
503
  # Conversations
450
504
  conversation.participant?(user) # active membership
451
505
  conversation.other_participants(user)
506
+ conversation.counterpart_for(viewer) # the other messager (nil for groups)
452
507
  conversation.title_for(viewer) # counterpart name / group title
453
508
  conversation.subject_label # "Madrid → Barcelona"
454
509
  conversation.unread_count_for(user)
@@ -11,6 +11,7 @@
11
11
  * :root {
12
12
  * --chats-accent: #facc15; /\* brand color: own bubbles, send button *\/
13
13
  * --chats-accent-contrast: #111827; /\* text on top of the accent *\/
14
+ * --chats-verified: #0284c7; /\* the "official account" badge *\/
14
15
  * }
15
16
  *
16
17
  * Want a completely different look? `rails g chats:views` ejects the
@@ -26,6 +27,7 @@
26
27
  --chats-border: rgba(0, 0, 0, 0.08);
27
28
  --chats-text: #111827;
28
29
  --chats-text-muted: #6b7280;
30
+ --chats-verified: #0284c7;
29
31
  --chats-bubble-bg: #f3f4f6;
30
32
  --chats-bubble-text: #111827;
31
33
  --chats-radius: 1.1rem;
@@ -79,6 +81,12 @@
79
81
  justify-content: space-between;
80
82
  gap: 0.75rem;
81
83
  }
84
+ .chats-row__name {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 0.25rem;
88
+ min-width: 0;
89
+ }
82
90
  .chats-row__title {
83
91
  font-weight: 650;
84
92
  white-space: nowrap;
@@ -147,6 +155,34 @@
147
155
  user-select: none;
148
156
  }
149
157
 
158
+ /* --- Verified messagers ------------------------------------------------------
159
+ *
160
+ * The "official account" mark (`acts_as_messager verified: true`). It sizes
161
+ * itself from the text it sits next to, so it reads right in an inbox row and
162
+ * in the thread header without either one hard-coding a pixel value. Recolour
163
+ * it with --chats-verified; replace the glyph with config.verified_badge.
164
+ *
165
+ * On #0284c7 rather than the more familiar #1d9bf0: the badge is a meaningful
166
+ * graphic, so WCAG 1.4.11 asks for 3:1 against what it sits on, and #1d9bf0
167
+ * is 3.00:1 on --chats-bg but only 2.73:1 on --chats-surface — which is the
168
+ * row hover background, so the mark failed exactly while someone was pointing
169
+ * at it. #0284c7 clears 3:1 on both (4.10 and 3.72) and on a dark ground too
170
+ * (4.33 on #111827), so a host that inverts the palette inherits a badge that
171
+ * still passes instead of one it has to remember to fix. Pinned by
172
+ * test/verified_badge_contrast_test.rb.
173
+ */
174
+ .chats-verified {
175
+ flex-shrink: 0;
176
+ display: inline-flex;
177
+ align-items: center;
178
+ color: var(--chats-verified);
179
+ line-height: 0;
180
+ }
181
+ .chats-verified__glyph {
182
+ width: 1em;
183
+ height: 1em;
184
+ }
185
+
150
186
  /* --- Thread -------------------------------------------------------------------- */
151
187
 
152
188
  .chats-thread {
@@ -179,6 +215,12 @@
179
215
  font-size: 1rem;
180
216
  font-weight: 750;
181
217
  margin: 0;
218
+ display: flex;
219
+ align-items: center;
220
+ gap: 0.25rem;
221
+ min-width: 0;
222
+ }
223
+ .chats-thread__name {
182
224
  white-space: nowrap;
183
225
  overflow: hidden;
184
226
  text-overflow: ellipsis;
@@ -189,10 +189,7 @@ module Chats
189
189
  def chats_counterpart
190
190
  return @chats_counterpart if defined?(@chats_counterpart)
191
191
 
192
- @chats_counterpart =
193
- if @conversation&.direct?
194
- @conversation.other_participants(chats_current_messager).includes(:messager).first&.messager
195
- end
192
+ @chats_counterpart = @conversation&.counterpart_for(chats_current_messager)
196
193
  end
197
194
  end
198
195
  end
@@ -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
@@ -128,8 +128,7 @@ module Chats
128
128
  # initials disc from the group name.
129
129
  def chats_conversation_avatar(conversation, viewer)
130
130
  if conversation.direct?
131
- other = conversation.other_participants(viewer).first
132
- chats_messager_avatar(other&.messager)
131
+ chats_messager_avatar(conversation.counterpart_for(viewer))
133
132
  else
134
133
  initials = conversation.title_for(viewer).split.first(2).map { |word| word[0] }.join.upcase
135
134
  tag.span(initials.presence || "👥", class: "chats-avatar chats-avatar--initials chats-avatar--group",
@@ -195,6 +194,24 @@ module Chats
195
194
  url.present? ? link_to(name, url, class: css_class) : tag.span(name, class: css_class)
196
195
  end
197
196
 
197
+ # The "official account" mark for a messager declared `acts_as_messager
198
+ # verified: true`, and NIL for everyone else — so any view, bundled or
199
+ # host, can drop it next to a name unconditionally:
200
+ #
201
+ # <%= chats_messager_name(author) %><%= chats_verified_badge(author) %>
202
+ #
203
+ # `config.verified_badge` swaps the glyph for the host's own design
204
+ # system; the default renders `chats/shared/_verified_badge`, whose
205
+ # colour is the `--chats-verified` CSS variable.
206
+ def chats_verified_badge(messager)
207
+ return unless Chats.verified?(messager)
208
+
209
+ custom = Chats.config.verified_badge
210
+ return custom.call(messager) if custom
211
+
212
+ render(partial: "chats/shared/verified_badge", locals: { messager: messager })
213
+ end
214
+
198
215
  # The signature line under a signed message ("— Lucía G."), or nil.
199
216
  def chats_message_signature(message)
200
217
  Chats.message_signature_for(message)
@@ -7,7 +7,13 @@
7
7
 
8
8
  <span class="chats-row__body">
9
9
  <span class="chats-row__top">
10
- <span class="chats-row__title"><%= conversation.title_for(viewer) %></span>
10
+ <%# The badge sits OUTSIDE the ellipsizing title so a long name
11
+ truncates without ever eating the mark. Nil for an ordinary
12
+ counterpart and for groups, so the row is unchanged. %>
13
+ <span class="chats-row__name">
14
+ <span class="chats-row__title"><%= conversation.title_for(viewer) %></span>
15
+ <%= chats_verified_badge(conversation.counterpart_for(viewer)) %>
16
+ </span>
11
17
  <time class="chats-row__time" datetime="<%= conversation.last_message_at&.iso8601 %>">
12
18
  <%= chats_timestamp(conversation.last_message_at) %>
13
19
  </time>
@@ -13,7 +13,10 @@
13
13
 
14
14
  <span class="chats-row__body">
15
15
  <span class="chats-row__top">
16
- <span class="chats-row__title"><%= group.title_for(viewer) %></span>
16
+ <span class="chats-row__name">
17
+ <span class="chats-row__title"><%= group.title_for(viewer) %></span>
18
+ <%= chats_verified_badge(group.messager) %>
19
+ </span>
17
20
  <time class="chats-row__time" datetime="<%= group.last_message_at&.iso8601 %>">
18
21
  <%= chats_timestamp(group.last_message_at) %>
19
22
  </time>
@@ -44,11 +44,15 @@
44
44
  <% end %>
45
45
 
46
46
  <div class="chats-thread__identity">
47
+ <%# Both arms wrap the name in .chats-thread__name: the <h1> is a flex
48
+ row (the badge is its second child), and only a real element can
49
+ carry the ellipsis. %>
47
50
  <h1 class="chats-thread__title">
48
51
  <% if chats_counterpart %>
49
- <%= chats_messager_name(chats_counterpart) %>
52
+ <%= chats_messager_name(chats_counterpart, css_class: "chats-thread__name") %>
53
+ <%= chats_verified_badge(chats_counterpart) %>
50
54
  <% else %>
51
- <%= @conversation.title_for(chats_current_messager) %>
55
+ <span class="chats-thread__name"><%= @conversation.title_for(chats_current_messager) %></span>
52
56
  <% end %>
53
57
  </h1>
54
58
  <% if @conversation.subject_label %>
@@ -0,0 +1,32 @@
1
+ <%# The "official account" mark. Rendered next to a verified messager's name
2
+ (`acts_as_messager verified: true`) in every bundled view that shows one:
3
+ the inbox row, the stacked row and the thread header.
4
+
5
+ It is an IMAGE with a name, not decoration: the wrapper carries
6
+ role="img" plus the localized label, so a screen reader announces
7
+ "Cuenta oficial" / "Official account" where a sighted person sees the
8
+ badge, and the glyph itself is aria-hidden so it is never read twice.
9
+
10
+ The glyph is heroicons `check-badge` (24/solid): a scalloped rosette
11
+ with a TICK knocked out of it, which is the mark people already read as
12
+ "verified". It is deliberately not a plain star — a star says "favourite"
13
+ or "rated", and at 14px a solid star and a solid rosette are the same
14
+ blob, so the tick is the whole point. `fill-rule: evenodd` is what knocks
15
+ the tick out; without it the rosette fills solid and the mark is a blob.
16
+
17
+ Colour comes from the --chats-verified CSS variable (see chats.css) via
18
+ `fill: currentColor` — override that variable, or swap this whole partial
19
+ with `config.verified_badge`. %>
20
+ <span class="chats-verified"
21
+ role="img"
22
+ title="<%= t("chats.verified.label") %>"
23
+ aria-label="<%= t("chats.verified.label") %>">
24
+ <svg xmlns="http://www.w3.org/2000/svg"
25
+ viewBox="0 0 24 24"
26
+ fill="currentColor"
27
+ class="chats-verified__glyph"
28
+ aria-hidden="true"
29
+ focusable="false">
30
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" />
31
+ </svg>
32
+ </span>
data/chats-0.3.1.gem ADDED
Binary file
@@ -30,6 +30,8 @@ en:
30
30
  see_all: "See all"
31
31
  conversation:
32
32
  empty_title: "Conversation"
33
+ verified:
34
+ label: "Official account"
33
35
  message:
34
36
  deleted: "Message deleted"
35
37
  edited: "edited"
@@ -30,6 +30,8 @@ es:
30
30
  see_all: "Ver todas"
31
31
  conversation:
32
32
  empty_title: "Conversación"
33
+ verified:
34
+ label: "Cuenta oficial"
33
35
  message:
34
36
  deleted: "Mensaje eliminado"
35
37
  edited: "editado"
@@ -153,6 +153,17 @@ module Chats
153
153
  # "— Author Name".
154
154
  attr_reader :message_signature
155
155
 
156
+ # ->(messager) { markup } — the badge shown next to an OFFICIAL
157
+ # account's name (`acts_as_messager verified: true`). nil (the default)
158
+ # renders the gem's own rosette, `chats/shared/_verified_badge`, coloured
159
+ # by the `--chats-verified` CSS variable.
160
+ #
161
+ # Return an html_safe value (anything `render`, `tag` or `image_tag`
162
+ # gives you) — a bare String is escaped, exactly as it would be anywhere
163
+ # else in a Rails view. Returning nil renders nothing, so a host can
164
+ # suppress the badge on some messagers without touching the models.
165
+ attr_reader :verified_badge
166
+
156
167
  def initialize
157
168
  @messager_class = "User"
158
169
  @parent_controller = "::ApplicationController"
@@ -187,6 +198,7 @@ module Chats
187
198
  @notifier = ->(_event, **_payload) {}
188
199
  @messager_url = ->(_messager) { nil }
189
200
  @message_signature = nil
201
+ @verified_badge = nil
190
202
 
191
203
  @messager_display_name = lambda do |messager|
192
204
  messager.try(:display_name) || messager.try(:name) ||
@@ -296,6 +308,10 @@ module Chats
296
308
  @message_signature = value.nil? ? nil : ensure_callable(value, "message_signature")
297
309
  end
298
310
 
311
+ def verified_badge=(value)
312
+ @verified_badge = value.nil? ? nil : ensure_callable(value, "verified_badge")
313
+ end
314
+
299
315
  def messager_display_name=(value)
300
316
  @messager_display_name = ensure_callable(value, "messager_display_name")
301
317
  end
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.
data/lib/chats/macros.rb CHANGED
@@ -29,18 +29,27 @@ module Chats
29
29
  # stacks into ONE inbox row (see Chats::Inbox).
30
30
  # group_path: ->(viewer) { } where that stacked row links to; defaults
31
31
  # to the filtered inbox (`?with=<sgid>`).
32
+ # verified: true this messager is an OFFICIAL account — a support
33
+ # desk, an organization, a brand. The bundled views
34
+ # mark its name with the rosette everyone already
35
+ # reads as "verified". Strictly true/false: a badge
36
+ # is a trust claim, so a stray "false" string must
37
+ # raise rather than quietly verify.
32
38
  #
33
39
  # class Desk < ApplicationRecord
34
- # acts_as_messager notifications: false, blockable: false, inbox: :grouped
40
+ # acts_as_messager notifications: false, blockable: false, inbox: :grouped,
41
+ # verified: true
35
42
  # end
36
- def acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil)
43
+ def acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil,
44
+ verified: false)
37
45
  include Chats::Messager
38
46
 
39
47
  self.chat_options = Chats::Messager.normalize_options(
40
48
  notifications: notifications,
41
49
  blockable: blockable,
42
50
  inbox: inbox,
43
- group_path: group_path
51
+ group_path: group_path,
52
+ verified: verified
44
53
  )
45
54
  end
46
55
 
@@ -22,14 +22,15 @@ module Chats
22
22
  notifications: true,
23
23
  blockable: true,
24
24
  inbox: :default,
25
- group_path: nil
25
+ group_path: nil,
26
+ verified: false
26
27
  }.freeze
27
28
 
28
29
  INBOX_MODES = %i[default grouped].freeze
29
30
 
30
31
  # Validate + freeze the macro's options, failing at BOOT with a plain
31
32
  # English message rather than at 3am with a NoMethodError.
32
- def self.normalize_options(notifications:, blockable:, inbox:, group_path:)
33
+ def self.normalize_options(notifications:, blockable:, inbox:, group_path:, verified: false)
33
34
  inbox = inbox.to_sym
34
35
  unless INBOX_MODES.include?(inbox)
35
36
  raise Chats::ConfigurationError,
@@ -41,11 +42,21 @@ module Chats
41
42
  "acts_as_messager group_path: must respond to #call (a proc/lambda), got #{group_path.inspect}"
42
43
  end
43
44
 
45
+ # Deliberately STRICTER than its boolean neighbours, which coerce with
46
+ # `!!`. "Official account" is a trust claim shown to everyone who talks
47
+ # to this messager, so `verified: "false"` (an ENV var, a YAML
48
+ # round-trip) has to fail at boot rather than quietly verify it.
49
+ unless [true, false].include?(verified)
50
+ raise Chats::ConfigurationError,
51
+ "acts_as_messager verified: must be true or false, got #{verified.inspect}"
52
+ end
53
+
44
54
  {
45
55
  notifications: !!notifications,
46
56
  blockable: !!blockable,
47
57
  inbox: inbox,
48
- group_path: group_path
58
+ group_path: group_path,
59
+ verified: verified
49
60
  }.freeze
50
61
  end
51
62
 
@@ -76,6 +87,13 @@ module Chats
76
87
  def chat_group_path
77
88
  chat_options[:group_path]
78
89
  end
90
+
91
+ # True when declared with `acts_as_messager verified: true` — an
92
+ # OFFICIAL account (a support desk, an organization, a brand). The
93
+ # bundled views badge its name wherever they show it.
94
+ def chat_verified?
95
+ chat_options[:verified]
96
+ end
79
97
  end
80
98
 
81
99
  included do
@@ -138,11 +156,6 @@ module Chats
138
156
  #
139
157
  # desk.message!(alice, "On it!", author: lucia)
140
158
  def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil)
141
- if author && !Chats.messager_class?(author.class)
142
- raise Chats::NotAllowedError,
143
- "author must be a messager (acts_as_messager), got #{author.class.name}"
144
- end
145
-
146
159
  conversation =
147
160
  case target
148
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).
@@ -222,13 +215,30 @@ module Chats
222
215
  )
223
216
  end
224
217
 
218
+ # The OTHER messager in a direct thread, from +viewer+'s seat — nil for a
219
+ # group, and nil for a direct thread whose other seat has left. The one
220
+ # place the counterpart is resolved, so the title, the avatar and the
221
+ # verified badge on an inbox row always name the same person.
222
+ #
223
+ # Memoized per viewer: an inbox row asks for it two or three times, and a
224
+ # row that cost one query in 0.2.0 must not start costing three.
225
+ def counterpart_for(viewer)
226
+ return nil unless direct?
227
+
228
+ @counterparts ||= {}
229
+ key = viewer && Chats.messager_key(viewer)
230
+ return @counterparts[key] if @counterparts.key?(key)
231
+
232
+ @counterparts[key] = other_participants(viewer).includes(:messager).first&.messager
233
+ end
234
+
225
235
  # What this conversation is called from +viewer+'s seat: a direct thread
226
236
  # is named after the counterpart; a group after its title (or its
227
237
  # members, when untitled).
228
238
  def title_for(viewer)
229
239
  if direct?
230
- other = other_participants(viewer).includes(:messager).first
231
- other ? Chats.display_name_for(other.messager) : I18n.t("chats.conversation.empty_title")
240
+ other = counterpart_for(viewer)
241
+ other ? Chats.display_name_for(other) : I18n.t("chats.conversation.empty_title")
232
242
  else
233
243
  title.presence || participants.active.includes(:messager).limit(4).map do |p|
234
244
  Chats.display_name_for(p.messager)
@@ -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.2.0"
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)
@@ -219,6 +220,14 @@ module Chats
219
220
  messager_option(messager, :chat_grouped_inbox?, default: false)
220
221
  end
221
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
+
222
231
  # The polymorphic type names of every registered messager class that
223
232
  # stacks (`inbox: :grouped`). Empty in an ordinary app — which is what
224
233
  # keeps the inbox query there byte-identical to 0.1.x. Used as a SQL
@@ -19,8 +19,10 @@ Chats.configure do |config|
19
19
  # class SupportDesk < ApplicationRecord
20
20
  # acts_as_messager notifications: false, # never notifiable
21
21
  # blockable: false, # no block/report affordances
22
- # inbox: :grouped # every thread with it is ONE
23
- # # inbox row (a "stack")
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
24
26
  # end
25
27
  #
26
28
  # `group_path:` says where that stacked row goes when it holds more than
@@ -200,6 +202,15 @@ Chats.configure do |config|
200
202
  # localized "— Agent Name":
201
203
  #
202
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
203
214
 
204
215
  # ==========================================================================
205
216
  # SLOTS — add one row or one button without ejecting a screen
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.2.0
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
@@ -151,6 +151,8 @@ files:
151
151
  - app/views/chats/messages/errors.turbo_stream.erb
152
152
  - app/views/chats/messages/locked.turbo_stream.erb
153
153
  - app/views/chats/shared/_unread_badge.html.erb
154
+ - app/views/chats/shared/_verified_badge.html.erb
155
+ - chats-0.3.1.gem
154
156
  - config/importmap.rb
155
157
  - config/locales/en.yml
156
158
  - config/locales/es.yml
@@ -176,6 +178,7 @@ files:
176
178
  - lib/chats/models/message.rb
177
179
  - lib/chats/models/participant.rb
178
180
  - lib/chats/models/reaction.rb
181
+ - lib/chats/send_rate_limited.rb
179
182
  - lib/chats/subscribers.rb
180
183
  - lib/chats/version.rb
181
184
  - lib/generators/chats/install_generator.rb