chats 0.1.1 → 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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +233 -0
  3. data/README.md +214 -28
  4. data/app/assets/stylesheets/chats.css +86 -0
  5. data/app/controllers/chats/conversations_controller.rb +39 -42
  6. data/app/controllers/chats/messages_controller.rb +34 -0
  7. data/app/controllers/chats/reactions_controller.rb +14 -0
  8. data/app/helpers/chats/engine_helper.rb +113 -2
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
  11. data/app/views/chats/conversations/_group.html.erb +41 -0
  12. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  13. data/app/views/chats/conversations/index.html.erb +39 -9
  14. data/app/views/chats/conversations/show.html.erb +48 -3
  15. data/app/views/chats/messages/_composer.html.erb +4 -0
  16. data/app/views/chats/messages/_message.html.erb +29 -8
  17. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  18. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  19. data/config/importmap.rb +2 -1
  20. data/config/locales/en.yml +13 -0
  21. data/config/locales/es.yml +13 -0
  22. data/context7.json +4 -0
  23. data/docs/PRD.md +1 -1
  24. data/docs/campfire_review.md +1 -1
  25. data/gemfiles/rails_7.1.gemfile +1 -0
  26. data/gemfiles/rails_7.2.gemfile +1 -0
  27. data/gemfiles/rails_8.1.gemfile +1 -0
  28. data/lib/chats/configuration.rb +79 -1
  29. data/lib/chats/engine.rb +29 -7
  30. data/lib/chats/errors.rb +16 -0
  31. data/lib/chats/inbox.rb +303 -0
  32. data/lib/chats/inbox_group.rb +75 -0
  33. data/lib/chats/macros.rb +33 -1
  34. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  35. data/lib/chats/models/concerns/messager.rb +99 -2
  36. data/lib/chats/models/conversation.rb +78 -7
  37. data/lib/chats/models/message.rb +67 -3
  38. data/lib/chats/models/participant.rb +59 -0
  39. data/lib/chats/models/reaction.rb +5 -0
  40. data/lib/chats/subscribers.rb +156 -0
  41. data/lib/chats/version.rb +1 -1
  42. data/lib/chats.rb +122 -15
  43. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  44. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  45. data/lib/generators/chats/templates/initializer.rb +106 -14
  46. data/lib/generators/chats/upgrade_generator.rb +48 -0
  47. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7e254b33cba3c656807db12e1ca491be50335584cd5824b32d7514ffcacc1bf
4
- data.tar.gz: 227ad7e2280ad374bc9cb1bf48ff67d164a2ad0893e9be676dfc2a3af0bcfe67
3
+ metadata.gz: a0e116038dc60b864f51b65edb4f5b879305da0cabef4c8aa2db73e23464c674
4
+ data.tar.gz: '0179846f4e07b60cb1d07a9d50909a836456794636da584a6178843f0d39f8ff'
5
5
  SHA512:
6
- metadata.gz: feb3c7419b117cd252b35c4c3202ec1ab5dfb85aa3c9da26488d490102cabc33c177ea03d2cbad1251d1af29405e023718552f1a210fb8f69207e2657d8b940b
7
- data.tar.gz: f68dc5a7814bbe56f8bad71e3495c5c1d4258fcc364e284fb118b9faee455124e041612eed1f4f38ed43a255513501430e0b5d6aa13646cc8e8438979598b4c7
6
+ metadata.gz: 1c3dc69a2bdba881bc7fc6543bc768885797e121f8d23b619e72a578cb3e7c6555c9b19c6dde52bec3085efe52d98e40dc277af932015bb08f65a7aaf67ca5c1
7
+ data.tar.gz: 4ab1a0ae08133bbe02240e12b1efa8efccb6dfe5bfbf53a1662acce5542a74ec4bb3adfc1eeaf10e6db94a961b17dc81523e8b2ac5044fa02ff22bba6904e303
data/CHANGELOG.md CHANGED
@@ -4,6 +4,239 @@ 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
+
74
+ ## [0.2.0] - 2026-09-16
75
+
76
+ The release that makes `chats` a foundation other products can be built on:
77
+ a messager that isn't a person, a conversation whose openness belongs to its
78
+ subject, a message someone wrote on someone else's behalf, and extension
79
+ points that don't require ejecting a screen. **Nothing here changes existing
80
+ behaviour until you set an option** — 0.1.1 installs upgrade by running
81
+ `rails generate chats:upgrade && rails db:migrate`.
82
+
83
+ ### Added
84
+ - **Headless messagers.** `acts_as_messager notifications: false, blockable:
85
+ false, inbox: :grouped` — a support desk, a bot, an org mailbox. Class
86
+ predicates (`chat_notifications?`, `chat_blockable?`, `chat_inbox_mode`,
87
+ `chat_group_path`) are read duck-typed everywhere, `Participant#
88
+ notifiable_for?` honours them, and the bundled views hide block/report
89
+ affordances against a non-blockable counterpart. Hosts stop writing
90
+ `is_a?(User)` in every notifier and view.
91
+ - **Subject-owned locks.** `Chats::ChatSubject#chat_locked?` /
92
+ `#chat_locked_notice` (both inert by default) decide whether a conversation
93
+ still accepts messages; `Conversation#locked?` / `#locked_notice` read
94
+ them, and `Chats::Message` refuses non-system writes with an `:locked`
95
+ error, and every OTHER write refuses too — `Message#edit!`,
96
+ `#soft_delete!` and `Reaction.toggle!` raise `Chats::LockedError` (a
97
+ `NotAllowedError` subclass), and the edit/delete/react endpoints answer 422
98
+ with the notice. The bundled bubble stops offering what would only fail:
99
+ no Edit, no Delete, no reaction toggles, while existing reactions still
100
+ render as plain counts and Copy still works. Moderation is the one
101
+ exception — `remove_reported_field!` removes reported content from a locked
102
+ conversation, because a product lock must never shield it. The thread stays
103
+ readable: the composer is replaced by the notice
104
+ (`chats/conversations/_locked_composer`, overridable through the
105
+ `locked_composer` slot), and a send that lands on a freshly locked
106
+ conversation gets a **422 that swaps the composer** instead of an
107
+ exception. System messages are exempt, so your app can always explain the
108
+ lock in the thread it just closed.
109
+ - **Message authorship.** `chats_messages.author_type/author_id` (nullable,
110
+ polymorphic, indexed) plus `Message#author`, `#signed?`, `#authored_by?`
111
+ and `Messager#message!(…, author:)`. `sender` stays the seat; `author` is
112
+ who wrote it. Signed bubbles render a signature line ("— Lucía G."),
113
+ rewritable with `config.message_signature`. New generator: **`rails
114
+ generate chats:upgrade`** writes the migration (guarded, so it is a no-op
115
+ on a fresh 0.2.0 install, which already has the columns).
116
+ - **Grouped inbox rows.** `Chats::Inbox.for(viewer)` returns
117
+ `Chats::Conversation | Chats::InboxGroup` rows sorted by activity; every
118
+ direct thread with an `inbox: :grouped` counterpart folds into one stack
119
+ (`#messager`, `#conversations`, `#unread_count`, `#last_message`,
120
+ `#last_message_at`, `#open_count`). A stack of one links straight to its
121
+ thread, which gains a "see all" link back; a deeper stack opens
122
+ `GET /conversations?with=<signed gid>` (purpose `:chats_inbox_with`, minted
123
+ by `Chats.inbox_with_sgid`) or wherever `group_path:` points. Grouping
124
+ happens in ONE place. `config.inbox_limit` bounds ROWS, not
125
+ conversations: stacked threads are queried separately from ordinary ones,
126
+ so a desk with hundreds of open threads can never evict the rest of the
127
+ inbox, and a stack's `open_count`/`unread_count` are GLOBAL — two indexed
128
+ aggregates per stack, never per conversation and never by loading the
129
+ stack to count it. `Chats::Inbox#unread_count` is the stack-aware badge number;
130
+ `unread_chats_count` is unchanged.
131
+ - **`config.inbox_limit`** (200, replacing a literal in the controller) and
132
+ **`config.inbox_scope`** `->(relation, viewer) { relation }`, composed into
133
+ the inbox query before the limit.
134
+ - **View slots.** The bundled views render `chats/slots/_inbox_top`,
135
+ `_inbox_empty`, `_conversation_header_actions`, `_locked_composer` and
136
+ `_message_meta` when such a partial exists — one memoized lookup when it
137
+ doesn't. Hosts (and engines mounted on top of chats) add a row or a button
138
+ without ejecting a screen.
139
+ - **Subscribers.** `Chats.on(:message_created | :conversation_created |
140
+ :participant_left | :conversation_read)` replaces the single notifier
141
+ proc: many subscribers per event, each isolated through
142
+ `Rails.error.report(e, handled: true, context: { event: })` so a failing
143
+ one is *visible* and never stops the others or the write that emitted
144
+ them. Registration is reload-safe (`key:` replaces in place;
145
+ `Chats.reset_subscribers!` clears). Two NEW events:
146
+ `:conversation_created` (once per conversation, never on resume) and
147
+ `:participant_left`.
148
+ - **`config.messager_url`** `->(messager) { nil }` — the bundled views link
149
+ names and titles to it, and render plain text when it returns nil. The gem
150
+ no longer assumes a host has `user_path`.
151
+ - **`Participant#reseat!(new_messager)`** — hand a seat to another messager
152
+ inside a transaction, keeping the read horizon, the role and the history,
153
+ and re-indexing a direct thread's `direct_key` so `chat_with` keeps
154
+ resolving to it instead of stranding a duplicate. Refuses with
155
+ `Chats::NotAllowedError` when the resulting pair already has a direct
156
+ conversation, checked BEFORE the write so a unique-index violation can
157
+ never poison a host's transaction.
158
+ - **Inbox missed-broadcast recovery** (`chats--refresh-inbox` controller): the
159
+ inbox already receives Turbo 8 page *refreshes*, but Action Cable has no
160
+ replay — a refresh broadcast sent while the client's socket was down
161
+ (backgrounded tab/app, network blip) was lost and the inbox sat stale until
162
+ the user navigated. The new controller re-runs the same page refresh on
163
+ cable reconnect and on return-to-visible, extending the thread's
164
+ stale-catch-up doctrine (`docs/campfire_review.md`) to the inbox. It reuses
165
+ the thread's channel-free reconnect detection (observing the
166
+ `<turbo-cable-stream-source>` `connected` attribute), so no new Action Cable
167
+ channel is introduced. Auto-registered via the engine importmap pin; hosts
168
+ need zero changes.
169
+
170
+ ### Changed
171
+ - `config.notifier` is **deprecated** (removed in 1.0). It still works and
172
+ receives `:message_created` and `:conversation_read` — the two events 0.1.1
173
+ had — and ONLY those: the events added in 0.2.0 are `Chats.on`-only, so a
174
+ 0.1.x hook written `->(event, message:, **)` can never be handed an event
175
+ it has no keyword for. It registers as a subscriber under a reserved key,
176
+ so re-assigning it replaces rather than stacks, and warns through
177
+ `Chats.deprecator`, which the engine registers with
178
+ `Rails.application.deprecators`.
179
+ **If your test environment sets `config.active_support.deprecation =
180
+ :raise`** (a common default) and you still assign `config.notifier`, that
181
+ warning now raises at boot, because the engine registers the gem's
182
+ deprecator with the app. Either move the hook to `Chats.on` — the migration
183
+ is one line — or silence just this one:
184
+
185
+ ```ruby
186
+ # config/initializers/chats.rb
187
+ Chats.deprecator.silence do
188
+ Chats.configure { |config| config.notifier = ->(event, **payload) { … } }
189
+ end
190
+ ```
191
+ - The install migration now creates the `author` columns, so a fresh install
192
+ needs no upgrade step.
193
+ - **If you ejected the inbox or the composer under 0.1.x**, nothing breaks:
194
+ `ConversationsController#index` still assigns `@conversations` (the flat,
195
+ unstacked list an ejected inbox loops over), and an ejected composer simply
196
+ misses the DOM id the locked-composer swap targets — the 422 is then a
197
+ no-op instead of a replace. Re-eject (or delete) those two files to pick up
198
+ stacked rows and locked composers.
199
+
200
+ ### Fixed
201
+ - **A host's own locale file no longer loses to the gem's.** The engine
202
+ appended its `config/locales` onto the application's `i18n.load_path` on
203
+ top of Rails' own `:add_locales`. Railtie paths are unshifted ahead of
204
+ everything, so that second copy landed *after* the host's files and
205
+ silently overrode them — a host rewording `chats.flashes.blocked` in its
206
+ own `es.yml` kept reading ours, with no error to see. Gem first, host last,
207
+ pinned by a test that ships a host override in the dummy app.
208
+ - **Migrations name every adapter they actually run on.** `json_column_type`
209
+ matched `"postgresql"`, which activerecord-postgis-adapter never reports
210
+ (it answers `"PostGIS"`), so PostGIS hosts silently got `json` where the
211
+ gem meant `jsonb`. `json_column_default` matched `/mysql/`, which misses
212
+ Trilogy (Rails reports `"Trilogy"`), handing those hosts a default MySQL
213
+ rejects. Both now match by prefix and by both spellings.
214
+ - **The thread's missed-broadcast recovery never took effect for a deep
215
+ backlog.** `ConversationsController#refresh` answered with `render html:
216
+ … content_type: "text/vnd.turbo-stream.html"`, and `render html:` forces
217
+ `text/html` and ignores the content type — so the response said
218
+ `<turbo-stream action="refresh">` in a body nothing would treat as a
219
+ stream. It now renders `turbo_stream.refresh(request_id: nil)`; the nil
220
+ request id matters, because Turbo skips a refresh tagged with a request id
221
+ it recognizes as its own, and this response answers the client's own
222
+ catch-up fetch. The failure was invisible by construction: a recovery path
223
+ that does nothing looks exactly like the staleness it exists to fix.
224
+ - `:participant_added` was documented as a notifier event but never emitted.
225
+ The event catalogue is now exactly what the gem fires, and registering for
226
+ anything else raises at boot with the valid list.
227
+ - **A signed message is its author's to answer for.** `Message#reported_owner`
228
+ now returns `author || sender`. With authorship, an answer sent from a
229
+ headless seat (a support desk) carries a human author, and a host's
230
+ moderation `owner` is typed to its user class — a desk there raised an
231
+ association type mismatch from inside the agent's own reply the first time
232
+ a text filter tripped, and the moderation screens then asked the desk for
233
+ an avatar it does not have.
234
+ - **`jsonb` on PostGIS.** The install migration decided jsonb-or-json with
235
+ `adapter_name.downcase.include?("postgresql")`, and activerecord-postgis-
236
+ adapter answers `"PostGIS"`, so PostGIS hosts silently got plain `json`
237
+ columns. The template now matches the prefix (`/\Apostg/i`). Existing
238
+ installs are unaffected; a host that wants jsonb can `change_column` it.
239
+
7
240
  ## [0.1.1] - 2026-06-10
8
241
 
9
242
  Reliability + UX patterns adopted after a deep review of Basecamp's
data/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  `chats` gives your Rails app **Instagram-class user-to-user messaging**: direct messages, group chats, image attachments, emoji reactions, read receipts, unread badges, and typing indicators — all real-time, all server-rendered.
9
9
 
10
- It's **Hotwire-native**: messages stream live over Turbo Streams + Action Cable, the inbox refreshes itself with Turbo 8 morphing, and the only JavaScript is two tiny Stimulus controllers the gem ships and registers for you. No SPA, no build step, no custom WebSocket code — and everything degrades gracefully to plain request/response when WebSockets are down.
10
+ It's **Hotwire-native**: messages stream live over Turbo Streams + Action Cable, the inbox refreshes itself with Turbo 8 morphing, and the only JavaScript is a few tiny Stimulus controllers the gem ships and registers for you. No SPA, no build step, no custom WebSocket code — and everything degrades gracefully to plain request/response when WebSockets are down. Both the thread and the inbox self-heal after a missed broadcast (cable reconnect / return-to-visible), so a client that slept through a WebSocket drop still catches up.
11
11
 
12
12
  Every consumer app eventually needs DMs, and everyone rebuilds the same conversation/participant/message schema, the same Action Cable plumbing, and the same "report this message, block this user" story. `chats` is that whole rebuild, done once, done right.
13
13
 
@@ -66,7 +66,7 @@ end
66
66
  mount Chats::Engine => "/messages"
67
67
  ```
68
68
 
69
- That's it. `/messages` is now a working, real-time inbox: threads, bubbles, reactions, read receipts, typing indicators. The engine inherits your `ApplicationController` (so your auth, layout, and locale apply automatically — Devise works out of the box), and its two Stimulus controllers register themselves through your existing importmap setup. Zero JavaScript changes.
69
+ That's it. `/messages` is now a working, real-time inbox: threads, bubbles, reactions, read receipts, typing indicators. The engine inherits your `ApplicationController` (so your auth, layout, and locale apply automatically — Devise works out of the box), and its bundled Stimulus controllers register themselves through your existing importmap setup. Zero JavaScript changes.
70
70
 
71
71
  Drop a "Message" button anywhere — it renders only when the viewer is allowed to message that person:
72
72
 
@@ -86,6 +86,9 @@ And a live unread badge in your nav:
86
86
 
87
87
  **Doesn't:** chatbots/LLM agents, workspaces/tenancy, voice/video, public channels, federation. It's peer-to-peer (and group) human messaging — not a Slack clone, not a support-ticketing tool.
88
88
 
89
+ > [!NOTE]
90
+ > **Want customer support?** Ticketing stays out of `chats` on purpose — queues, assignment and SLAs are not messaging. [`support_desk`](https://github.com/rameerez/support_desk) is the product gem that adds them ON TOP of this one: tickets that are real conversations, a support desk that sends while your staff sign, and a BYOUI agent console. It uses the seams below (headless messagers, subject locks, message authorship, grouped inbox rows), so you get the same threads, attachments and read state you already have.
91
+
89
92
  ## 🧱 The data model
90
93
 
91
94
  Five concepts, namespaced and polymorphic from day one (no hard `User` coupling anywhere):
@@ -94,7 +97,7 @@ Five concepts, namespaced and polymorphic from day one (no hard `User` coupling
94
97
  - **`Chats::Participant`** — a messager's seat in a conversation. Holds role, read horizon, mute, soft-leave, and notification bookkeeping.
95
98
  - **`Chats::Message`** — `text` (human) or `system` (posted by your app). Soft-deletes to a tombstone. Attachments via ActiveStorage.
96
99
  - **`Chats::Reaction`** — one row per (message, reactor, emoji); tap-to-toggle, race-safe.
97
- - **Any model with `acts_as_messager`** — users, organizations, support agents: participants and senders are polymorphic.
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.
98
101
 
99
102
  Two deliberate design decisions worth knowing:
100
103
 
@@ -201,30 +204,32 @@ Moderate::Flag.flag!(
201
204
  - [ ] admin queue handles chat flags/reports (it does, automatically — verify with one test)
202
205
  - [ ] a test that a block placed mid-conversation stops the next send
203
206
 
204
- ## 🔔 Notifications: one hook, fan out anywhere
207
+ ## 🔔 Events: subscribe to the domain moments
205
208
 
206
- `chats` fires domain moments through a single no-op-default notifier — it does **not** build its own notification bus:
209
+ `chats` fires domain moments at subscribers — it does **not** build its own notification bus:
207
210
 
208
211
  ```ruby
209
- config.notifier = ->(event, **payload) {
210
- case event
211
- when :message_created
212
- # payload: message:
213
- NewMessageNotifier.with(record: payload[:message]).deliver # Noticed, email, push…
214
- when :conversation_read
215
- # payload: conversation:, participant: — fired when a read actually
216
- # consumed unread content. Use it to keep EXTERNAL notification
217
- # surfaces truthful: e.g. mark this chat's rows read in your
218
- # notification center the moment the thread is read, so a bell badge
219
- # doesn't keep advertising messages the user has already seen.
220
- end
221
- }
212
+ # config/initializers/chats.rb (or anywhere that runs at boot)
213
+ Chats.on(:message_created) { |message| NewMessageNotifier.with(record: message).deliver }
214
+ Chats.on(:conversation_created) { |conversation| Analytics.track("chat_started", conversation) }
215
+ Chats.on(:participant_left) { |participant| AuditLog.log("chat_left", participant) }
216
+ Chats.on(:conversation_read) { |conversation:, participant:| Bell.mark_read(participant.messager, conversation) }
222
217
  ```
223
218
 
224
- > Write the lambda as `->(event, **payload)` (not `->(event, message:, **)`):
225
- > events carry different payloads, and a keyword the event doesn't include
226
- > would raise harmlessly (the hook is error-isolated and logged), but
227
- > noisily.
219
+ Four properties, all of which matter the first time something goes wrong at 3am:
220
+
221
+ - **Many subscribers per event.** Your mailer, your analytics and your audit log don't have to share one `case` statement.
222
+ - **Each one is isolated.** A raising subscriber is reported through `Rails.error.report(e, handled: true, context: { event: })` — *visible*, not swallowed — and the next subscriber still runs. The message is already committed; notifications are best-effort fan-out.
223
+ - **Reload-safe.** Registering from reloadable code? Pass a key and a reload replaces the subscriber instead of stacking a second one:
224
+
225
+ ```ruby
226
+ Rails.application.config.to_prepare do
227
+ Chats.on(:message_created, key: :unread_email) { |message| … }
228
+ end
229
+ ```
230
+ - **Unknown events fail loudly**, at boot, naming the ones that exist.
231
+
232
+ > **Deprecated:** `config.notifier = ->(event, **payload) {}` still works and will be removed in 1.0. It receives `:message_created` and `:conversation_read` — the two events that existed in 0.1.1 — and *only* those, so an old `->(event, message:, **)` hook can never start raising on an event it was never written for. The events added in 0.2.0 are `Chats.on`-only. Move it to `Chats.on` — that's the whole migration.
228
233
 
229
234
  The etiquette helpers every messaging product needs ship on the participant, so a debounced "email me only once until I come back" digest is a tiny host job:
230
235
 
@@ -232,7 +237,7 @@ The etiquette helpers every messaging product needs ship on the participant, so
232
237
  class ChatsUnreadEmailJob < ApplicationJob
233
238
  def perform(message)
234
239
  message.conversation.participants.active.each do |participant|
235
- next unless participant.notifiable_for?(message) # not the sender, not muted, not departed
240
+ next unless participant.notifiable_for?(message) # not the sender, not muted, not departed, not headless
236
241
  next unless participant.should_notify? # unread + not already notified this burst
237
242
 
238
243
  ChatsMailer.with(participant: participant).unread_messages.deliver_now
@@ -241,9 +246,7 @@ class ChatsUnreadEmailJob < ApplicationJob
241
246
  end
242
247
  end
243
248
 
244
- config.notifier = ->(event, message:, **) {
245
- ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) if event == :message_created
246
- }
249
+ Chats.on(:message_created) { |message| ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) }
247
250
  ```
248
251
 
249
252
  And it works in the other direction too — your app can post **into** conversations:
@@ -252,6 +255,158 @@ And it works in the other direction too — your app can post **into** conversat
252
255
  ride.chat_conversations.find_each { |c| c.post_system_message!("Your ride was cancelled") }
253
256
  ```
254
257
 
258
+ ## 🤖 Headless messagers: desks, bots, storefronts
259
+
260
+ Not every messager is a person. A support desk, an order bot or an organization mailbox converses like anyone else but must never be notified, can't meaningfully be blocked, and shouldn't fill the inbox with one row per thread. Say so once, on the model:
261
+
262
+ ```ruby
263
+ class SupportDesk < ApplicationRecord
264
+ acts_as_messager notifications: false, # Participant#notifiable_for? says no, always
265
+ blockable: false, # the views hide block/report affordances
266
+ inbox: :grouped # every thread with it is ONE inbox row
267
+ end
268
+ ```
269
+
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
+
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
+
319
+ ## 🗂️ Grouped inbox rows
320
+
321
+ With `inbox: :grouped`, every direct conversation a viewer has with that messager folds into a single inbox row — a stack:
322
+
323
+ ```ruby
324
+ inbox = Chats::Inbox.for(current_user) # [Chats::Conversation | Chats::InboxGroup], newest activity first
325
+ inbox.unread_count # the stack-aware badge number
326
+
327
+ group = inbox.rows.first
328
+ group.messager # the desk
329
+ group.conversations # the stacked threads, freshest first
330
+ group.unread_count # aggregated across the stack
331
+ group.open_count # how many are in it
332
+ ```
333
+
334
+ - `config.inbox_limit` bounds **rows**, not conversations: stacked threads are queried separately from ordinary ones, so a desk with 500 open tickets can never evict your friends from the inbox. A stack's `open_count` and `unread_count` are **global** — two indexed aggregates per stack, however deep it runs — so stacking neither goes N+1 nor loads a stack to count it.
335
+ - A stack of one links **straight to the thread**, which then carries a small "see all" link back to the stack.
336
+ - The stack list is chats' own filtered inbox — `GET /conversations?with=<signed gid>` — unless you point it somewhere else with `group_path: ->(viewer) { support_path }`.
337
+ - Two knobs shape the whole query: `config.inbox_limit` (200) and `config.inbox_scope = ->(relation, viewer) { relation }`.
338
+
339
+ `user.unread_chats_count` is unchanged (it counts conversations); `Chats::Inbox#unread_count` is the stack-aware number for badges.
340
+
341
+ ## 🔒 Locked conversations
342
+
343
+ Whether a conversation still takes messages belongs to the thing it's **about** — a closed ticket, a delivered order, an archived listing. The subject already owns the conversation's meaning; it owns its openness too:
344
+
345
+ ```ruby
346
+ class Ticket < ApplicationRecord
347
+ acts_as_chat_subject
348
+
349
+ def chat_locked? = closed?
350
+ def chat_locked_notice = "This ticket is closed. Reply to reopen it."
351
+ end
352
+ ```
353
+
354
+ - `Chats::Message` refuses new messages with an `:locked` error; `Conversation#locked?` and `#locked_notice` read the subject.
355
+ - **System messages are exempt**: your app can always post "This ticket was closed" into the thread it just closed.
356
+ - The thread **stays readable**. Only the composer changes: it's replaced by the notice (the `locked_composer` slot overrides the body). Gate the action, never hide the explanation.
357
+ - A send that lands on a conversation locked since the page loaded gets a **422 that swaps the composer for the notice** — no raise, no lying screen.
358
+
359
+ ## ✍️ Signed messages
360
+
361
+ `sender` is the seat a message came from; `author` is who **wrote** it on that seat's behalf. That's how a shared desk answers as itself while the human stays visible:
362
+
363
+ ```ruby
364
+ desk.message!(alice, "On it!", author: lucia) # sender: the desk, author: Lucía
365
+ message.signed? # true — author present and not the sender
366
+ message.authored_by?(lucia)
367
+ ```
368
+
369
+ The bundled bubble renders a signature line ("— Lucía G.") via `Chats.display_name_for`; `config.message_signature = ->(message) { … }` rewrites it. Ordinary messages have no author and render exactly as before.
370
+
371
+ Existing installs get the columns with one command:
372
+
373
+ ```bash
374
+ rails generate chats:upgrade && rails db:migrate
375
+ ```
376
+
377
+ ## 🔌 View slots
378
+
379
+ Ejecting a whole screen to add one row or one button is too coarse. The bundled views render a partial named `chats/slots/_<slot>` **when it exists** — no configuration, no registration, and an absent slot costs one memoized lookup:
380
+
381
+ | slot | where it renders |
382
+ |---|---|
383
+ | `inbox_top` | above the first inbox row |
384
+ | `inbox_empty` | inside the empty state |
385
+ | `conversation_header_actions` | the thread's menu (gets `blockable:`) |
386
+ | `locked_composer` | the locked composer's body |
387
+ | `message_meta` | after each bubble's timestamp |
388
+
389
+ ```erb
390
+ <%# app/views/chats/slots/_inbox_top.html.erb %>
391
+ <%= link_to "Need help? Write to us", support_path, class: "support-door" %>
392
+ ```
393
+
394
+ An engine mounted on top of chats ships its own `app/views/chats/slots/…`; the host's file wins by view-path order. `rails generate chats:views` is still there for wholesale restyling.
395
+
396
+ ## 🔗 Profile links
397
+
398
+ `chats` never assumes your app has a `user_path`. Tell it where a messager lives and names become links; leave it alone and they render as plain text:
399
+
400
+ ```ruby
401
+ config.messager_url = lambda do |messager|
402
+ routes = Rails.application.routes.url_helpers
403
+
404
+ case messager
405
+ when User then routes.user_path(messager) # a desk or a bot has no profile: nil
406
+ end
407
+ end
408
+ ```
409
+
255
410
  ## 🎨 Make it yours
256
411
 
257
412
  The bundled UI is intentionally framework-free (semantic `chats-*` classes + one self-contained stylesheet, themed with CSS variables):
@@ -260,6 +415,7 @@ The bundled UI is intentionally framework-free (semantic `chats-*` classes + one
260
415
  :root {
261
416
  --chats-accent: #facc15; /* own bubbles, send button, badges */
262
417
  --chats-accent-contrast: #111827;
418
+ --chats-verified: #0284c7; /* the "official account" badge */
263
419
  }
264
420
  ```
265
421
 
@@ -269,7 +425,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
269
425
  rails generate chats:views
270
426
  ```
271
427
 
272
- Override the two Stimulus controllers by pinning the same importmap keys (`controllers/chats/thread_controller`, `controllers/chats/composer_controller`) host pins win.
428
+ Override any bundled Stimulus controller by pinning the same importmap key — host pins win. The current keys are `controllers/chats/thread_controller`, `controllers/chats/composer_controller`, `controllers/chats/debounced_submit_controller`, and `controllers/chats/refresh_inbox_controller`.
273
429
 
274
430
  ## Configuration reference
275
431
 
@@ -308,13 +464,20 @@ Chats.configure do |config|
308
464
  config.can_message = ->(sender, recipient) { true }
309
465
  config.can_create_group = ->(creator) { true }
310
466
 
467
+ # Inbox shaping
468
+ config.inbox_limit = 200
469
+ config.inbox_scope = ->(relation, viewer) { relation }
470
+
311
471
  # Ecosystem seams (no-op defaults; chats runs standalone)
312
472
  config.blocked_messager_ids = ->(messager) { [] }
313
- config.notifier = ->(event, **payload) {}
473
+ config.notifier = ->(event, **payload) {} # DEPRECATED — use Chats.on
314
474
 
315
475
  # Display (used by the bundled views)
316
476
  config.messager_display_name = ->(messager) { messager.display_name }
317
477
  config.messager_avatar = ->(messager) { messager.avatar } # URL/attachment/variant or nil
478
+ config.messager_url = ->(messager) { nil } # nil ⇒ names render as plain text
479
+ config.message_signature = nil # ->(message) { } for signed bubbles
480
+ config.verified_badge = nil # ->(messager) { markup } for verified: true
318
481
  end
319
482
  ```
320
483
 
@@ -329,21 +492,28 @@ alice.message!(bob, "hi", about: ride) # send (resolves the thread)
329
492
  alice.message!(conversation, "hi", files: []) # send into a conversation
330
493
  alice.chats # inbox relation, newest first
331
494
  alice.unread_chats_count # conversations with unread messages
495
+ alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
496
+ Chats::Inbox.for(alice) # [Conversation | InboxGroup] + #unread_count
497
+ Chats.verified?(desk) # official account? (acts_as_messager verified: true)
332
498
 
333
499
  # Conversations
334
500
  conversation.participant?(user) # active membership
335
501
  conversation.other_participants(user)
502
+ conversation.counterpart_for(viewer) # the other messager (nil for groups)
336
503
  conversation.title_for(viewer) # counterpart name / group title
337
504
  conversation.subject_label # "Madrid → Barcelona"
338
505
  conversation.unread_count_for(user)
339
506
  conversation.mark_read_by!(user)
340
507
  conversation.post_system_message!("Ride cancelled")
341
508
  conversation.add_participant!(user) # idempotent, race-safe
509
+ conversation.locked? # the SUBJECT decides (chat_locked?)
510
+ conversation.locked_notice # why, in words
342
511
 
343
512
  # Messages
344
513
  message.edit!("fixed") # stamps edited_at
345
514
  message.soft_delete! # tombstone (or destroy, per config)
346
515
  message.read_by?(user)
516
+ message.signed? / message.authored_by?(lucia) # authorship
347
517
  Chats::Reaction.toggle!(message:, reactor:, emoji: "👍")
348
518
 
349
519
  # Participants (the per-member state)
@@ -352,10 +522,26 @@ participant.mute! / participant.unmute!
352
522
  participant.leave! # groups
353
523
  participant.notifiable_for?(message) # notification etiquette
354
524
  participant.should_notify? / participant.mark_notified!
525
+ participant.reseat!(new_messager) # hand the seat over, read horizon intact
526
+
527
+ # Events
528
+ Chats.on(:message_created) { |message| } # also :conversation_created,
529
+ # :participant_left, :conversation_read
355
530
  ```
356
531
 
357
532
  Errors are namespaced and meaningful: `Chats::BlockedError`, `Chats::NotAllowedError`, `Chats::ConfigurationError` — all under `Chats::Error`.
358
533
 
534
+ ## Upgrading
535
+
536
+ `chats` ships the migrations a version bump needs; your initializer and views stay yours:
537
+
538
+ ```bash
539
+ rails generate chats:upgrade # 0.2.0: message authorship columns
540
+ rails db:migrate
541
+ ```
542
+
543
+ Nothing in 0.2.0 changes behaviour until you set an option — see the [CHANGELOG](CHANGELOG.md).
544
+
359
545
  ## Database support
360
546
 
361
547
  PostgreSQL, MySQL, and SQLite. The migration adapts automatically: it honors your app's configured primary key type (**uuid or bigint** — same detection `rails g model` uses), picks `jsonb` on Postgres / `json` elsewhere, and handles MySQL's no-defaults-on-JSON rule. Works on Rails 7.1+ and shines on the Rails 8 omakase.