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 +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +57 -2
- data/app/assets/stylesheets/chats.css +42 -0
- data/app/controllers/chats/conversations_controller.rb +1 -4
- data/app/controllers/chats/messages_controller.rb +1 -13
- data/app/helpers/chats/engine_helper.rb +19 -2
- data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
- data/app/views/chats/conversations/_group.html.erb +4 -1
- data/app/views/chats/conversations/show.html.erb +6 -2
- data/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/chats-0.3.1.gem +0 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/es.yml +2 -0
- data/lib/chats/configuration.rb +16 -0
- data/lib/chats/engine.rb +1 -1
- data/lib/chats/inbox.rb +30 -4
- data/lib/chats/macros.rb +12 -3
- data/lib/chats/models/concerns/messager.rb +21 -8
- data/lib/chats/models/conversation.rb +32 -22
- data/lib/chats/models/message.rb +7 -7
- data/lib/chats/models/participant.rb +9 -1
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +9 -0
- data/lib/generators/chats/templates/initializer.rb +13 -2
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80fff570dabbff3e99a5a5f37c43b43acd334a94aae30c23b8c76bfbab5441e8
|
|
4
|
+
data.tar.gz: 72efe83f785fe99876d6b5922a5d834897f1771f3880bead3b43ae6f342ba96d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 } #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
data/config/locales/en.yml
CHANGED
data/config/locales/es.yml
CHANGED
data/lib/chats/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
155
|
-
# the
|
|
156
|
-
#
|
|
157
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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 =
|
|
231
|
-
other ? Chats.display_name_for(other
|
|
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)
|
data/lib/chats/models/message.rb
CHANGED
|
@@ -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 :
|
|
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
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
def
|
|
319
|
-
return if author.nil? ||
|
|
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, :
|
|
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
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
|
-
#
|
|
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
|
|
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-
|
|
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
|