chats 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c51ebc5c0e2da96363bb5b2e85d9a9c2a91e41f7ddb9d351ff10001654a48325
4
- data.tar.gz: a06cde14d3953972e7061b2950b7a52c696f4292e25a2a91c9c7bb6ce7d269cb
3
+ metadata.gz: a0e116038dc60b864f51b65edb4f5b879305da0cabef4c8aa2db73e23464c674
4
+ data.tar.gz: '0179846f4e07b60cb1d07a9d50909a836456794636da584a6178843f0d39f8ff'
5
5
  SHA512:
6
- metadata.gz: 5d23c31395edb382ec05fc6b7478a4e219b1878dc29f67092451101ef720bf2df556ae92976384665c0e108ca4607557ed9d1badf0af9357143182f1fdb57e6d
7
- data.tar.gz: 3f02985c90410059af142a03fc3a3298540f71fb05d9b46991f39ea22999bfab64738f86beb92db7355e98c68e7962c2b6e79925335cf409d67842c1b1a7a5eb
6
+ metadata.gz: 1c3dc69a2bdba881bc7fc6543bc768885797e121f8d23b619e72a578cb3e7c6555c9b19c6dde52bec3085efe52d98e40dc277af932015bb08f65a7aaf67ca5c1
7
+ data.tar.gz: 4ab1a0ae08133bbe02240e12b1efa8efccb6dfe5bfbf53a1662acce5542a74ec4bb3adfc1eeaf10e6db94a961b17dc81523e8b2ac5044fa02ff22bba6904e303
data/CHANGELOG.md CHANGED
@@ -4,6 +4,73 @@ 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.1] - 2026-09-16
8
+
9
+ ### Fixed
10
+ - **The verified badge was a star, not a check.** 0.3.0 shipped a
11
+ five-pointed star as the "official account" mark, copied from a host icon
12
+ that was named `verified_badge` but drawn as a star. A star reads as
13
+ "favourite" or "rated", not "this account is really us", and at 14px a
14
+ solid star and a solid rosette are the same blob. It is now heroicons
15
+ `check-badge`: a scalloped rosette with a tick knocked out of it, which
16
+ is the mark people already read as verified. `fill-rule="evenodd"` is
17
+ what knocks the tick out — without it the rosette fills solid.
18
+
19
+ ## [0.3.0] - 2026-09-16
20
+
21
+ Official accounts. Some counterparts are not people you met — they are a
22
+ support desk, an organization, a shop, a brand — and a person should be able
23
+ to tell at a glance. **Nothing changes until a model says so**: 0.2.x
24
+ installs upgrade by bumping the gem, with no migration and no new
25
+ configuration.
26
+
27
+ ### Added
28
+ - **`acts_as_messager verified: true`.** A messager declares itself an
29
+ OFFICIAL account, and every bundled view that shows its name marks it: the
30
+ inbox row, the stacked inbox row, and the thread header. `Klass.
31
+ chat_verified?` is the class predicate and `Chats.verified?(messager)`
32
+ reads it duck-typed — false for a plain model, a nil, a non-messager — so
33
+ hosts can badge their own screens without a class check. The option is
34
+ independent of the headless ones: a desk is usually headless *and*
35
+ official, a shop is usually official and nothing else. Unlike its boolean
36
+ neighbours it refuses to coerce — `verified: "false"` raises
37
+ `Chats::ConfigurationError` at boot rather than quietly verifying an
38
+ account, because a badge is a trust claim and not a display preference.
39
+ - **The badge itself**, `chats/shared/_verified_badge` — an inline rosette
40
+ that sizes itself from the text it sits beside. It is an image with a
41
+ name, not decoration: `role="img"` plus a localized label
42
+ (`chats.verified.label`, "Official account" / "Cuenta oficial"), and the
43
+ glyph is `aria-hidden` so a screen reader never announces it twice.
44
+ - **`config.verified_badge`** `->(messager) { markup }` — swap the glyph for
45
+ your design system's own, vary it per messager, or return nil for no badge.
46
+ The default (nil) renders the gem's rosette.
47
+ - **`--chats-verified`** (`#0284c7`) — the badge colour, a CSS custom
48
+ property like the rest of the gem's theming, inherited through
49
+ `currentColor`. Not the familiar `#1d9bf0`: the badge is a meaningful
50
+ graphic, so WCAG 1.4.11 asks 3:1 of it, and `#1d9bf0` is 3.00:1 on
51
+ `--chats-bg` but 2.73:1 on `--chats-surface` — the inbox row's HOVER
52
+ background, so it failed exactly while somebody was pointing at it.
53
+ `#0284c7` clears the bar on both grounds and on a dark one, so a host
54
+ inverting the palette inherits a badge that still passes.
55
+ `test/verified_badge_contrast_test.rb` computes it rather than trusting a
56
+ swatch.
57
+ - **`chats_verified_badge(messager)`** — the view helper behind all three
58
+ surfaces, available in host views too. Nil for everyone who hasn't
59
+ declared `verified: true`, so it is safe to drop next to any name
60
+ unconditionally.
61
+ - **`Chats::Conversation#counterpart_for(viewer)`** — the other messager in a
62
+ direct thread (nil for a group, and for a thread whose other seat left).
63
+ The title, the avatar and the badge on an inbox row now resolve the
64
+ counterpart through this one method, memoized per viewer, so a row that
65
+ cost one query in 0.2.0 still costs one.
66
+
67
+ ### Changed
68
+ - The bundled inbox row wraps its title in `.chats-row__name`, and the thread
69
+ header wraps its name in `.chats-thread__name`, so a long name still
70
+ ellipsizes while the badge beside it stays visible. If you ejected these
71
+ views with `rails generate chats:views`, your copies are untouched and keep
72
+ working; re-run the generator only if you want the badge.
73
+
7
74
  ## [0.2.0] - 2026-09-16
8
75
 
9
76
  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:
@@ -368,6 +415,7 @@ The bundled UI is intentionally framework-free (semantic `chats-*` classes + one
368
415
  :root {
369
416
  --chats-accent: #facc15; /* own bubbles, send button, badges */
370
417
  --chats-accent-contrast: #111827;
418
+ --chats-verified: #0284c7; /* the "official account" badge */
371
419
  }
372
420
  ```
373
421
 
@@ -429,6 +477,7 @@ Chats.configure do |config|
429
477
  config.messager_avatar = ->(messager) { messager.avatar } # URL/attachment/variant or nil
430
478
  config.messager_url = ->(messager) { nil } # nil ⇒ names render as plain text
431
479
  config.message_signature = nil # ->(message) { } for signed bubbles
480
+ config.verified_badge = nil # ->(messager) { markup } for verified: true
432
481
  end
433
482
  ```
434
483
 
@@ -445,10 +494,12 @@ alice.chats # inbox relation, newest first
445
494
  alice.unread_chats_count # conversations with unread messages
446
495
  alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
447
496
  Chats::Inbox.for(alice) # [Conversation | InboxGroup] + #unread_count
497
+ Chats.verified?(desk) # official account? (acts_as_messager verified: true)
448
498
 
449
499
  # Conversations
450
500
  conversation.participant?(user) # active membership
451
501
  conversation.other_participants(user)
502
+ conversation.counterpart_for(viewer) # the other messager (nil for groups)
452
503
  conversation.title_for(viewer) # counterpart name / group title
453
504
  conversation.subject_label # "Madrid → Barcelona"
454
505
  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
@@ -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>
@@ -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/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
@@ -222,13 +222,30 @@ module Chats
222
222
  )
223
223
  end
224
224
 
225
+ # The OTHER messager in a direct thread, from +viewer+'s seat — nil for a
226
+ # group, and nil for a direct thread whose other seat has left. The one
227
+ # place the counterpart is resolved, so the title, the avatar and the
228
+ # verified badge on an inbox row always name the same person.
229
+ #
230
+ # Memoized per viewer: an inbox row asks for it two or three times, and a
231
+ # row that cost one query in 0.2.0 must not start costing three.
232
+ def counterpart_for(viewer)
233
+ return nil unless direct?
234
+
235
+ @counterparts ||= {}
236
+ key = viewer && Chats.messager_key(viewer)
237
+ return @counterparts[key] if @counterparts.key?(key)
238
+
239
+ @counterparts[key] = other_participants(viewer).includes(:messager).first&.messager
240
+ end
241
+
225
242
  # What this conversation is called from +viewer+'s seat: a direct thread
226
243
  # is named after the counterpart; a group after its title (or its
227
244
  # members, when untitled).
228
245
  def title_for(viewer)
229
246
  if direct?
230
- other = other_participants(viewer).includes(:messager).first
231
- other ? Chats.display_name_for(other.messager) : I18n.t("chats.conversation.empty_title")
247
+ other = counterpart_for(viewer)
248
+ other ? Chats.display_name_for(other) : I18n.t("chats.conversation.empty_title")
232
249
  else
233
250
  title.presence || participants.active.includes(:messager).limit(4).map do |p|
234
251
  Chats.display_name_for(p.messager)
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.1"
5
5
  end
data/lib/chats.rb CHANGED
@@ -219,6 +219,14 @@ module Chats
219
219
  messager_option(messager, :chat_grouped_inbox?, default: false)
220
220
  end
221
221
 
222
+ # Whether +messager+ is an OFFICIAL account (`acts_as_messager verified:
223
+ # true`) — a support desk, an organization, a brand. The bundled views
224
+ # badge its name; hosts can read it to do the same on their own screens.
225
+ # Defaults to false: nothing is verified until a model says so.
226
+ def verified?(messager)
227
+ messager_option(messager, :chat_verified?, default: false)
228
+ end
229
+
222
230
  # The polymorphic type names of every registered messager class that
223
231
  # stacks (`inbox: :grouped`). Empty in an ordinary app — which is what
224
232
  # 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,7 +1,7 @@
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
@@ -151,6 +151,7 @@ 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
154
155
  - config/importmap.rb
155
156
  - config/locales/en.yml
156
157
  - config/locales/es.yml