chats 0.1.1 → 0.6.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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1037 -0
  3. data/README.md +621 -40
  4. data/app/assets/stylesheets/chats.css +906 -2
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +396 -86
  7. data/app/controllers/chats/messages_controller.rb +35 -13
  8. data/app/controllers/chats/reactions_controller.rb +14 -0
  9. data/app/helpers/chats/engine_helper.rb +230 -7
  10. data/app/javascript/chats/composer_controller.js +14 -1
  11. data/app/javascript/chats/dock_controller.js +1120 -0
  12. data/app/javascript/chats/recovery.js +539 -0
  13. data/app/javascript/chats/refresh_inbox_controller.js +76 -0
  14. data/app/javascript/chats/split_controller.js +131 -0
  15. data/app/javascript/chats/thread_controller.js +322 -60
  16. data/app/javascript/chats/unread_badge_controller.js +24 -0
  17. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  18. data/app/views/chats/conversations/_conversation_row.html.erb +37 -4
  19. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  20. data/app/views/chats/conversations/_group.html.erb +61 -0
  21. data/app/views/chats/conversations/_history.html.erb +54 -0
  22. data/app/views/chats/conversations/_locked_composer.html.erb +20 -0
  23. data/app/views/chats/conversations/_thread.html.erb +147 -0
  24. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  25. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  26. data/app/views/chats/conversations/dock.html.erb +40 -0
  27. data/app/views/chats/conversations/index.html.erb +142 -15
  28. data/app/views/chats/conversations/pane.html.erb +14 -0
  29. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  30. data/app/views/chats/conversations/show.html.erb +18 -134
  31. data/app/views/chats/conversations/window.html.erb +26 -0
  32. data/app/views/chats/dock/_badge.html.erb +12 -0
  33. data/app/views/chats/dock/_dock.html.erb +139 -0
  34. data/app/views/chats/dock/_signal.html.erb +26 -0
  35. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  36. data/app/views/chats/dock/_window.html.erb +23 -0
  37. data/app/views/chats/dock/_window_header.html.erb +75 -0
  38. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  39. data/app/views/chats/messages/_action.html.erb +15 -0
  40. data/app/views/chats/messages/_composer.html.erb +84 -56
  41. data/app/views/chats/messages/_message.html.erb +43 -15
  42. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  43. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  44. data/app/views/chats/shared/_verified_badge.html.erb +32 -0
  45. data/config/importmap.rb +10 -1
  46. data/config/locales/en.yml +40 -0
  47. data/config/locales/es.yml +40 -0
  48. data/config/routes.rb +8 -0
  49. data/context7.json +4 -0
  50. data/docs/PRD.md +1 -1
  51. data/docs/campfire_review.md +1 -1
  52. data/docs/screenshots/composer-actions.png +0 -0
  53. data/docs/screenshots/dock-bar.png +0 -0
  54. data/docs/screenshots/dock-desktop.png +0 -0
  55. data/docs/screenshots/dock-phone-button.png +0 -0
  56. data/docs/screenshots/dock-phone-panel.png +0 -0
  57. data/docs/screenshots/dock-phone-window.png +0 -0
  58. data/docs/screenshots/inbox.png +0 -0
  59. data/docs/screenshots/split-inbox-empty.png +0 -0
  60. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  61. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  62. data/docs/screenshots/split-inbox.png +0 -0
  63. data/docs/screenshots/system-note-action.png +0 -0
  64. data/docs/screenshots/thread-actions-phone.png +0 -0
  65. data/gemfiles/rails_7.1.gemfile +3 -0
  66. data/gemfiles/rails_7.2.gemfile +3 -0
  67. data/gemfiles/rails_8.1.gemfile +3 -0
  68. data/lib/chats/action.rb +85 -0
  69. data/lib/chats/broadcasts.rb +178 -17
  70. data/lib/chats/configuration.rb +115 -1
  71. data/lib/chats/engine.rb +29 -7
  72. data/lib/chats/errors.rb +16 -0
  73. data/lib/chats/inbox.rb +363 -0
  74. data/lib/chats/inbox_group.rb +75 -0
  75. data/lib/chats/inbox_refresh_job.rb +14 -0
  76. data/lib/chats/macros.rb +33 -1
  77. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  78. data/lib/chats/models/concerns/messager.rb +122 -6
  79. data/lib/chats/models/conversation.rb +153 -19
  80. data/lib/chats/models/message.rb +292 -21
  81. data/lib/chats/models/participant.rb +67 -0
  82. data/lib/chats/models/reaction.rb +18 -0
  83. data/lib/chats/send_rate_limited.rb +30 -0
  84. data/lib/chats/subscribers.rb +156 -0
  85. data/lib/chats/version.rb +1 -1
  86. data/lib/chats.rb +123 -15
  87. data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
  88. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  89. data/lib/generators/chats/templates/create_chats_tables.rb.erb +22 -2
  90. data/lib/generators/chats/templates/initializer.rb +125 -14
  91. data/lib/generators/chats/upgrade_generator.rb +79 -0
  92. data/lib/generators/chats/views_generator.rb +3 -2
  93. metadata +51 -2
data/README.md CHANGED
@@ -7,10 +7,12 @@
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
 
14
+ **Contents:** [Example](#-example) · [Quickstart](#quickstart) · [Data model](#-the-data-model) · [The macros](#-the-macros) · [Real-time](#-real-time-the-hotwire-way) · [Trust & Safety](#%EF%B8%8F-trust--safety-snaps-onto-the-moderate-gem) · [Events](#-events-subscribe-to-the-domain-moments) · [Headless messagers](#-headless-messagers-desks-bots-storefronts) · [Official accounts](#-official-accounts-the-verified-badge) · [Grouped inbox](#%EF%B8%8F-grouped-inbox-rows) · [Locked conversations](#-locked-conversations) · [Signed messages](#%EF%B8%8F-signed-messages) · [Action buttons](#-action-buttons) · [View slots](#-view-slots) · [Two panes](#-two-panes) · [Messaging dock](#-messaging-dock) · [Profile links](#-profile-links) · [Theming](#-make-it-yours) · [Configuration reference](#configuration-reference) · [The full Ruby API](#-the-full-ruby-api) · [View helpers](#-view-helpers) · [Errors](#errors) · [Locales](#locales) · [Upgrading](#upgrading) · [Testing](#testing)
15
+
14
16
  ## 👨‍💻 Example
15
17
 
16
18
  `chats` reads like plain English:
@@ -66,7 +68,9 @@ end
66
68
  mount Chats::Engine => "/messages"
67
69
  ```
68
70
 
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.
71
+ That's it. `/messages` is now a working, real-time inbox: threads, bubbles, reactions, read receipts, typing indicators — on a wide screen, the conversation list and the open conversation side by side, as on LinkedIn ([two panes](#-two-panes)). 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.
72
+
73
+ An inbox lists the conversations that have **at least one message** (a system message or a deleted one counts). Opening a conversation (`chat_with`, the Message button) creates it, but until a first message exists it appears nowhere — not in the inbox, its stacks, search, the dock or any count — so nobody's inbox fills with "No messages yet" rows. It still opens when you go to it, which is where the first message gets written.
70
74
 
71
75
  Drop a "Message" button anywhere — it renders only when the viewer is allowed to message that person:
72
76
 
@@ -80,12 +84,23 @@ And a live unread badge in your nav:
80
84
  <%= chats_unread_badge %>
81
85
  ```
82
86
 
87
+ Or give every page LinkedIn-style floating chat windows with one line in your layout — see [the messaging dock](#-messaging-dock):
88
+
89
+ ```erb
90
+ <%= chats_dock %>
91
+ ```
92
+
83
93
  ## What `chats` does (and doesn't) do
84
94
 
85
- **Does:** direct 1:1 threads, group conversations, image (or any) attachments via ActiveStorage, emoji reactions, read receipts ("Seen"), unread counts and live badges, typing indicators, message editing, soft deletion (WhatsApp-style tombstones), system messages your app posts into threads, per-sender rate limiting, inbox search, infinite scroll-up pagination, optional encryption at rest, and small adapter seams for moderation + notifications.
95
+ **Does:** direct 1:1 threads, group conversations, image (or any) attachments via ActiveStorage, emoji reactions, read receipts ("Seen"), unread counts and live badges, typing indicators, message editing, soft deletion (WhatsApp-style tombstones), system messages your app posts into threads, action buttons under messages ("Pay now", "Join my room"), a LinkedIn-style messaging dock of floating chat windows, per-sender rate limiting, inbox search, infinite scroll-up pagination, optional encryption at rest, and small adapter seams for moderation + notifications.
86
96
 
87
97
  **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
98
 
99
+ > [!NOTE]
100
+ > **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.
101
+ >
102
+ > A product on chats may need a conversation to open although your `inbox_scope` keeps it out of the inbox (a requester's own ticket). `Chats::ApplicationController#conversation_lookup_scopes` is the one extension point for that: every engine endpoint looks a conversation up through it, in order (by default `[viewer.chats]`), and the product appends its own relation, `super + [own_cases_scope]`, from a prepended module. Each scope must hold only conversations the viewer may open: nothing outside them becomes reachable, a miss is still a plain 404, and an error inside a scope is not rescued. The host's word is final: a host that must refuse a viewer entirely returns no scopes, and a product only ever adds a scope beside the host's, returning `super` unchanged when it is empty. A product page that renders a conversation on its own screen resolves it through this same lookup, so a host refusal holds there too.
103
+
89
104
  ## 🧱 The data model
90
105
 
91
106
  Five concepts, namespaced and polymorphic from day one (no hard `User` coupling anywhere):
@@ -94,22 +109,106 @@ Five concepts, namespaced and polymorphic from day one (no hard `User` coupling
94
109
  - **`Chats::Participant`** — a messager's seat in a conversation. Holds role, read horizon, mute, soft-leave, and notification bookkeeping.
95
110
  - **`Chats::Message`** — `text` (human) or `system` (posted by your app). Soft-deletes to a tombstone. Attachments via ActiveStorage.
96
111
  - **`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.
112
+ - **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
113
 
99
114
  Two deliberate design decisions worth knowing:
100
115
 
101
116
  1. **Read state is a horizon, not per-message receipts.** A participant has ONE `last_read_at`; a message is unread iff it's newer. That's unread counts, badges, and "Seen" indicators with zero extra writes per message (a receipts table writes N rows per message — the classic chat-schema scaling trap), and it's exactly how Basecamp's Campfire models it.
102
117
  2. **Direct conversations have a deterministic identity** (`direct_key`, unique-indexed): two people DMing each other in the same instant race into the SAME conversation, guaranteed by the database, not by hope.
103
118
 
119
+ ### Real conversations vs system-only threads
120
+
121
+ A system message is a message: `post_system_message!` bumps `messages_count`, moves `last_message` and re-sorts the inbox like a human one, and a deleted message stays behind as a tombstone. That is on purpose: "Your ride was cancelled" matters to the passenger, so the inbox lists a thread only your app has spoken in. Two questions, two scopes:
122
+
123
+ - **`with_messages`** asks *is there anything here?*: any message, system lines and tombstones included. Every list the gem renders reads through it, so a thread that was opened and never used is not listed.
124
+ - **`with_human_messages`** asks *did a messager write something here that still stands?*: at least one `text` message that isn't deleted. "Human" is the gem's word for a `text` message: written by a messager, person or not, as opposed to posted by the app. So a bot's or a headless desk's message counts, and so does a photo with no caption. It is for ops walls, moderation lists and "conversations started this week" charts, which must not count a thread holding only "Alice joined".
125
+
126
+ ```ruby
127
+ # What a viewer sees: start from alice.chats, so blocks and config.inbox_scope apply
128
+ alice.chats.with_human_messages # threads where somebody actually wrote
129
+ alice.chats.with_messages.without_human_messages # listed, but only the app has spoken
130
+ conversation.last_human_message # the newest human message, or nil (a query, not the pointer)
131
+ Chats::Message.last_human_in(alice.chats.limit(20)) # the same for a page of threads, in ONE query
132
+
133
+ # Admin-wide: no viewer, so no blocks and no inbox_scope. Only behind your staff authorization.
134
+ Chats::Conversation.with_human_messages.count
135
+ Chats::Conversation.about(ride).without_human_messages
136
+ Chats::Message.last_human_in(page) # page: records, ids, or a relation (read as a subquery, see below)
137
+ .select(:id, :conversation_id, :body) # narrow the columns if you like
138
+ .index_by(&:conversation_id) # { id => message }; no key means nobody wrote
139
+ ```
140
+
141
+ Both conversation scopes are a single correlated `EXISTS` built from `Chats::Message.human` (kind `text`, not deleted), so they compose with anything (`about`, `unread_by`, `with_messages`) and never multiply rows. `last_human_in` reads an unloaded relation as a subquery without its `includes` (they only load records), so a relation that filters on an associated table needs that table joined explicitly, with `joins` or `left_joins`; `includes(:participants).references(:participants).where(chats_participants: …)` loses the join and raises. Or pass the loaded page, or its ids. Counting what messagers wrote per conversation is the plain `Chats::Message.human.where(conversation_id: ids).group(:conversation_id).count`. None of this changes the inbox, unread counts, `messages_count` or `last_message`.
142
+
143
+ ## 🧩 The macros
144
+
145
+ ### `acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil, verified: false)`
146
+
147
+ Anyone (or anything) that can hold a seat in a conversation. Every option is a
148
+ class-level fact the gem reads duck-typed, so an ordinary model behaves exactly
149
+ as before and nothing asks `is_a?(User)`:
150
+
151
+ | option | default | what it means |
152
+ |---|---|---|
153
+ | `notifications:` | `true` | `false` for a headless seat: `Participant#notifiable_for?` is never true for it, and `Chats.notifications_for?(messager)` says so |
154
+ | `blockable:` | `true` | `false` hides block/report affordances against it and makes `Chats.blockable?(messager)` false |
155
+ | `inbox:` | `:default` | `:grouped` folds every direct thread with this messager into ONE inbox row (`Chats::InboxGroup`) |
156
+ | `group_path:` | `nil` | `->(viewer) { path }` — where a deep stack opens; default is chats' own filtered inbox |
157
+ | `verified:` | `false` | the official-account badge wherever the name renders; refuses to coerce (`"false"` raises at boot) |
158
+
159
+ Class predicates: `Klass.chat_notifications?`, `.chat_blockable?`, `.chat_inbox_mode`,
160
+ `.chat_group_path`, `.chat_verified?`. Instance API below.
161
+
162
+ ### `acts_as_chat_subject`
163
+
164
+ A domain record conversations can be *about*. Adds `chat_conversations` (`has_many`)
165
+ and three overridable readers — the contract a product built on chats (a ticket, an
166
+ order) implements:
167
+
168
+ ```ruby
169
+ class Ticket < ApplicationRecord
170
+ acts_as_chat_subject
171
+
172
+ def chat_subject_label = "Ticket #{reference}" # the context line in inbox rows and the thread header
173
+ def chat_locked? = closed? # whether the conversation still takes messages
174
+ def chat_locked_notice = "This ticket is closed." # what replaces the composer when it doesn't
175
+ end
176
+ ```
177
+
178
+ All three are inert by default (the label falls back to the record's `to_s`, nothing is locked).
179
+
104
180
  ## ⚡ Real-time, the Hotwire way
105
181
 
106
182
  - **The thread** subscribes to one conversation stream. New messages append surgically; edits/deletes replace bubbles in place; the sender's own bubble comes straight back in the form response (no cable round-trip), and Turbo's same-id dedup makes the echo broadcast a no-op.
107
183
  - **Bubbles are broadcast viewer-agnostic** — one render shared by every subscriber. A tiny Stimulus controller aligns own-vs-other client-side by comparing sender keys. This is what makes single-render broadcasts possible at all.
108
- - **The inbox** receives Turbo 8 page *refreshes* (morphing, scroll-preserving) instead of surgically patched rows: inbox rows are intensely per-viewer (unread badges, bold states, ordering), so each client re-requests and gets a correct, personalized render. Refreshes are debounced and tagged so the tab that caused the change skips its own.
184
+ - **The inbox** receives Turbo 8 page *refreshes* (morphing, scroll-preserving) instead of surgically patched rows: inbox rows are intensely per-viewer (unread badges, bold states, ordering), so each client re-requests and gets a correct, personalized render. Each refresh carries `method="morph" scroll="preserve"` itself, so it morphs whatever your layout's refresh meta says. A search keeps its query in the URL, so a refresh re-renders the same results, and the field you're typing in is never rewritten under you.
109
185
  - **Unread badges** get their own stream (`chats_unread_badge` helper) so any page can host a live badge without inheriting inbox refreshes.
186
+ - **The messaging dock** has one more per-messager stream (`[messager, :chats_dock]`): its badges, and a signal naming the conversation that changed and whether its window may pop open. The signal only names a conversation the participant can see (blocks and `inbox_scope` applied). No polling, no channel of its own.
110
187
  - **Typing indicators** are a Turbo Stream *custom action* — ephemeral, nothing persisted, no Action Cable channel class, no connection identification requirements.
111
188
  - **Cable down?** Everything still works request/response. Real-time is an enhancement, not a requirement.
112
189
 
190
+ ### What happens when a broadcast is missed
191
+
192
+ Action Cable has no replay: a broadcast sent while a page's socket is down (a laptop asleep, a phone in a pocket, a backgrounded Hotwire Native WebView, an elevator) never reaches it. So every live surface catches up by itself, with no polling and nothing to configure. It fetches what it missed at the moments it may have missed something, and only then:
193
+
194
+ - **its stream reconnects.** Every confirmation of the subscription after the first counts, including a socket Action Cable reopened without ever reporting it closed (the half-open TCP of a phone that slept);
195
+ - **the page comes back after 10 seconds or more hidden** (an app foregrounded, a tab returned to). A hidden page never fetches; what it missed waits until it is seen;
196
+ - **the network comes back online.** Action Cable is asked to check its socket right away instead of waiting out its backoff (a dead one reconnects: the first trigger), and the surface catches up anyway, because after a short outage the socket can still look open while what was sent during it is gone;
197
+ - **the page is restored**, from the browser's back/forward cache or from Turbo's own snapshot cache (a back navigation, a Hotwire Native stack pop).
198
+
199
+ Triggers that arrive together make one catch-up, and catch-ups are at least five seconds apart, so a flapping connection can't turn into a fetch loop. While the socket is healthy and the page is on screen, nothing is fetched. What each surface fetches:
200
+
201
+ | surface | catches up with |
202
+ |---|---|
203
+ | the inbox | the same morphing page refresh a broadcast triggers. A refresh that arrives while another visit is still loading, which Turbo would otherwise ignore, runs when that visit completes. On the [two-pane page](#-two-panes) the list and the open conversation are two surfaces: one refresh for the list, one thread catch-up for the conversation, which the refresh leaves alone |
204
+ | a thread (page or dock window) | `GET /:id/refresh?after=…&changed=…`, on two cursors: messages created after the newest one on screen are appended (an empty thread asks from the beginning), and edits, tombstones and reactions made to the ones on screen since the newest change it saw are replaced. Two, because a message created at 10:15 and missed is older than a 10:20 edit that did arrive. Each cursor is a moment to the microsecond up to which the thread knows it has everything: set by a complete render or by a catch-up that arrived, never by a live bubble, which can arrive after one that was missed. What arrived live since is named instead (at most 200 ids; past that the history is re-rendered), so it isn't sent twice, and two messages that share a timestamp are never mistaken for each other. Each catch-up belongs to one render of the thread: an answer that arrives after the page or the window rendered the conversation again is dropped unrendered. The read receipts are updated too, the read receipts updated, and, under `deletion: :hard`, bubbles whose messages are gone removed. The composer, its draft and your scroll position are never touched. A thread more than a page behind gets its history re-rendered at the latest page |
205
+ | `chats_unread_badge` and the dock's badges | `GET /badge`, every unread badge on the page in one Turbo Stream |
206
+ | the dock's recent list | a reload of its frame, morphed in place so it keeps its scroll position and your search |
207
+
208
+ None of these requests marks anything read, and none can be prefetched (they are not links). On the server, the inbox refresh is enqueued straight into your job queue when the write commits (`Chats::InboxRefreshJob`), and not parked first in Turbo's in-process half-second debounce, where a deploy or a crash inside that window would drop it. A burst is one job per recipient, not one per write: a refresh carries no data (the inbox re-requests itself), so while one is queued and hasn't started it stands for every write that commits before it runs. A short-lived marker in `Rails.cache` says one is queued, and the job removes it as it starts, before it broadcasts, so a write that still finds the marker is covered and a write after it queues the next job. Because another write may already be counting on it, an enqueue that fails is tried once more, and if the queue refuses again the refresh is broadcast on the spot. Every refusal goes to `Rails.error`, whether the adapter raised, returned `false` or declined the job. Whatever happens to the marker costs an extra job, never a missed refresh: a cache that can't hold it (a null store, a per-process memory store, a Redis that refuses the write) only means no coalescing, and it expires after two seconds, so a job lost from the queue holds nothing back for longer than that. The refresh names no request id, because it may stand for several requests' writes. Refreshes that still arrive close together merge in the browser: Turbo runs one page refresh for those within 150 ms of each other.
209
+
210
+ **Hotwire Native** needs nothing. The catch-up is the same code path a broadcast takes (the inbox's is a Turbo page refresh, which Turbo hands to the native adapter as it does any refresh), and a back navigation is a Turbo restore, which catches up like any other. A WebView that lives for the whole app session is the case all of this is for: when the app comes back to the foreground, the WebView reports the page visible again, and in any case the socket that died in the background reconnects, and that reconnect is a trigger of its own. **If you wrapped the inbox in a reconciler of your own** (a stream-reconciler controller around `turbo_stream_from … :chats_inbox`), remove it: two reconcilers on one page both fire, and the gem's now covers everything yours did.
211
+
113
212
  ## 🛡️ Trust & Safety: snaps onto the [`moderate`](https://github.com/rameerez/moderate) gem
114
213
 
115
214
  Messages are user-generated content — App Store Guideline 1.2, Google Play's UGC policy, and the EU DSA all expect **report**, **block**, and **filter** capabilities before you ship a chat. Instead of re-implementing any of that, `chats` exposes the exact seams the `moderate` gem expects, with **no hard dependency in either direction**: each gem runs standalone, and together they behave like one system. This section is the complete recipe.
@@ -129,7 +228,7 @@ That single hook makes moderate's bidirectional block table the law everywhere c
129
228
  - **can't send** into an existing one (a block placed mid-conversation stops the very next message),
130
229
  - and **stop seeing** each other's direct threads in the inbox and unread counts — *hidden, never deleted*: lift the block and the history reappears.
131
230
 
132
- Two semantics worth knowing: blocking is enforced **beneath** your `can_message` policy (a permissive or buggy policy can never let a blocked pair talk), and **group conversations are exempt from pair blocks** — the industry standard: blocking someone removes your private line, not your seat in shared spaces. If your domain should eject blocked members from groups, do it in moderate's `on_block` hook by tearing down whatever domain relationship feeds the group membership.
231
+ Two semantics worth knowing: blocking is enforced **beneath** your `can_message` policy (a permissive or buggy policy can never let a blocked pair talk), both are checked on **every direct send**, not only when the thread is opened (a block placed, or a policy turning false, mid-conversation stops the very next message with `:blocked` / `:not_allowed`), and **group conversations are exempt from pair blocks** — the industry standard: blocking someone removes your private line, not your seat in shared spaces. If your domain should eject blocked members from groups, do it in moderate's `on_block` hook by tearing down whatever domain relationship feeds the group membership.
133
232
 
134
233
  ### 2. Reportable + filtered messages
135
234
 
@@ -154,7 +253,7 @@ Use **`:flag`, never `:block`** for chat: you don't gag someone mid-conversation
154
253
  |---|---|
155
254
  | `reported_owner` | the sender (who a decision notifies, who a ban targets) |
156
255
  | `moderation_snapshot(:body)` | the body — frozen as evidence at report time, surviving later edits/deletes |
157
- | `remove_reported_field!(:body)` | the **soft-delete tombstone** — a moderator's removal looks exactly like a user deletion ("Message deleted"), no special admin rendering path |
256
+ | `remove_reported_field!(:body)` | the **soft-delete tombstone** — a moderator's removal looks exactly like a user deletion ("Message deleted"), no special admin rendering path. Returns whether anything was removed: `true` only when it actually removed something (the tombstone, or the row under `deletion: :hard`), `false` for a message already deleted (by its sender or an earlier decision), already gone, or whose body is already blank, decided under the message's row lock so two decisions at once can't both remove it. moderate 1.0.0.beta3 and later refuse a removal that removed nothing; earlier versions ignore the return value. A group's `remove_reported_field!(:title)` keeps the same contract |
158
257
  | `report_visible_to?(viewer)` | participants only (a DM isn't public content), and never the author |
159
258
  | `moderation_field_value(:files)` / change detection | attachment-aware seams so image filters classify what actually changed |
160
259
 
@@ -163,16 +262,19 @@ Use **`:flag`, never `:block`** for chat: you don't gag someone mid-conversation
163
262
  Put a report link on every bubble **someone else** sent. One nuance matters: `chats` renders each bubble **once per broadcast, viewer-agnostically** (that's what makes real-time fan-out cheap), so anything depending on `current_user` at render time — like moderate's `report_link` helper, which checks `report_visible_to?(viewer)` — would silently vanish from live-appended bubbles. Use the **signed-target URL** instead (viewer-independent), and hide it on own bubbles with the same client-side mechanism the gem uses for edit/delete:
164
263
 
165
264
  ```erb
166
- <%# in your ejected chats/messages/_message.html.erb, inside the actions row %>
265
+ <%# in your ejected chats/messages/_message.html.erb, inside .chats-popup__menu
266
+ in the <template data-chats-message-menu> (the long-press menu) %>
167
267
  <% if message.sender %>
168
268
  <%= link_to "Report",
169
269
  main_app.new_abuse_report_path(
170
270
  target: message.to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE),
171
271
  field: "body"
172
272
  ),
173
- class: "chats-message__action in-own-hidden" %>
174
- <%# hide on .chats-message--own via your CSS; moderate's controller
175
- re-checks report_visible_to? server-side, so hiding is cosmetic %>
273
+ class: "chats-popup__item", role: "menuitem",
274
+ data: { chats_other_only: true } %>
275
+ <%# data-chats-other-only: the popup drops it from your own bubbles;
276
+ moderate's controller re-checks report_visible_to? server-side,
277
+ so hiding is cosmetic %>
176
278
  <% end %>
177
279
  ```
178
280
 
@@ -201,30 +303,40 @@ Moderate::Flag.flag!(
201
303
  - [ ] admin queue handles chat flags/reports (it does, automatically — verify with one test)
202
304
  - [ ] a test that a block placed mid-conversation stops the next send
203
305
 
204
- ## 🔔 Notifications: one hook, fan out anywhere
306
+ ## 🔔 Events: subscribe to the domain moments
205
307
 
206
- `chats` fires domain moments through a single no-op-default notifier — it does **not** build its own notification bus:
308
+ `chats` fires domain moments at subscribers — it does **not** build its own notification bus:
207
309
 
208
310
  ```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
- }
311
+ # config/initializers/chats.rb (or anywhere that runs at boot)
312
+ Chats.on(:message_created) { |message| NewMessageNotifier.with(record: message).deliver }
313
+ Chats.on(:conversation_created) { |conversation| Analytics.track("chat_started", conversation) }
314
+ Chats.on(:participant_left) { |participant| AuditLog.log("chat_left", participant) }
315
+ Chats.on(:conversation_read) { |conversation:, participant:| Bell.mark_read(participant.messager, conversation) }
222
316
  ```
223
317
 
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.
318
+ | event | block arguments | when |
319
+ |---|---|---|
320
+ | `:message_created` | `message` | after a **text** message commits (system messages never notify) |
321
+ | `:conversation_created` | `conversation` | once per conversation, never when an existing one is resumed |
322
+ | `:participant_left` | `participant` | someone left a group |
323
+ | `:conversation_read` | `conversation:, participant:` | a read advanced the horizon past unread content |
324
+
325
+
326
+ Four properties, all of which matter the first time something goes wrong at 3am:
327
+
328
+ - **Many subscribers per event.** Your mailer, your analytics and your audit log don't have to share one `case` statement.
329
+ - **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.
330
+ - **Reload-safe.** Registering from reloadable code? Pass a key and a reload replaces the subscriber instead of stacking a second one:
331
+
332
+ ```ruby
333
+ Rails.application.config.to_prepare do
334
+ Chats.on(:message_created, key: :unread_email) { |message| … }
335
+ end
336
+ ```
337
+ - **Unknown events fail loudly**, at boot, naming the ones that exist.
338
+
339
+ > **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
340
 
229
341
  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
342
 
@@ -232,7 +344,7 @@ The etiquette helpers every messaging product needs ship on the participant, so
232
344
  class ChatsUnreadEmailJob < ApplicationJob
233
345
  def perform(message)
234
346
  message.conversation.participants.active.each do |participant|
235
- next unless participant.notifiable_for?(message) # not the sender, not muted, not departed
347
+ next unless participant.notifiable_for?(message) # not the sender, not muted, not departed, not headless
236
348
  next unless participant.should_notify? # unread + not already notified this burst
237
349
 
238
350
  ChatsMailer.with(participant: participant).unread_messages.deliver_now
@@ -241,9 +353,7 @@ class ChatsUnreadEmailJob < ApplicationJob
241
353
  end
242
354
  end
243
355
 
244
- config.notifier = ->(event, message:, **) {
245
- ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) if event == :message_created
246
- }
356
+ Chats.on(:message_created) { |message| ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) }
247
357
  ```
248
358
 
249
359
  And it works in the other direction too — your app can post **into** conversations:
@@ -252,6 +362,342 @@ And it works in the other direction too — your app can post **into** conversat
252
362
  ride.chat_conversations.find_each { |c| c.post_system_message!("Your ride was cancelled") }
253
363
  ```
254
364
 
365
+ ## 🤖 Headless messagers: desks, bots, storefronts
366
+
367
+ 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:
368
+
369
+ ```ruby
370
+ class SupportDesk < ApplicationRecord
371
+ acts_as_messager notifications: false, # Participant#notifiable_for? says no, always
372
+ blockable: false, # the views hide block/report affordances
373
+ inbox: :grouped # every thread with it is ONE inbox row
374
+ end
375
+ ```
376
+
377
+ 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.
378
+
379
+ ## ✅ Official accounts: the verified badge
380
+
381
+ 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:
382
+
383
+ ```ruby
384
+ class SupportDesk < ApplicationRecord
385
+ acts_as_messager verified: true
386
+ end
387
+ ```
388
+
389
+ 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.
390
+
391
+ `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:
392
+
393
+ ```ruby
394
+ acts_as_messager verified: true # official, notifiable, blockable
395
+ acts_as_messager notifications: false, inbox: :grouped, verified: true # an official desk
396
+ ```
397
+
398
+ 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.
399
+
400
+ Read it anywhere you render your own screens — duck-typed, never a class check:
401
+
402
+ ```ruby
403
+ SupportDesk.chat_verified? # the class predicate
404
+ Chats.verified?(messager) # false for a plain model, a nil, a non-messager
405
+ chats_verified_badge(messager) # the view helper: markup, or nil for everyone else
406
+ ```
407
+
408
+ **Change the colour** with one CSS variable (the badge inherits it through `currentColor`):
409
+
410
+ ```css
411
+ :root { --chats-verified: #0284c7; }
412
+ ```
413
+
414
+ 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.
415
+
416
+ **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):
417
+
418
+ ```ruby
419
+ config.verified_badge = lambda do |messager|
420
+ ApplicationController.helpers.image_tag("official.svg", class: "badge", alt: "Official account")
421
+ end
422
+ ```
423
+
424
+ Or eject `app/views/chats/shared/_verified_badge.html.erb` with `rails generate chats:views` and rewrite it.
425
+
426
+ ## 🗂️ Grouped inbox rows
427
+
428
+ With `inbox: :grouped`, every direct conversation a viewer has with that messager folds into a single inbox row — a stack:
429
+
430
+ ```ruby
431
+ inbox = Chats::Inbox.for(current_user) # [Chats::Conversation | Chats::InboxGroup], newest activity first
432
+ inbox.unread_count # the stack-aware badge number
433
+
434
+ group = inbox.rows.first
435
+ group.messager # the desk
436
+ group.conversations # the stacked threads, freshest first
437
+ group.unread_count # aggregated across the stack
438
+ group.open_count # how many are in it
439
+ ```
440
+
441
+ - `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.
442
+ - A stack of one links **straight to the thread**, which then carries a small "see all" link back to the stack.
443
+ - 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 }`.
444
+ - Two knobs shape the whole query: `config.inbox_limit` (200) and `config.inbox_scope = ->(relation, viewer) { relation }`.
445
+
446
+ `user.unread_chats_count` is unchanged (it counts conversations); `Chats::Inbox#unread_count` is the stack-aware number for badges.
447
+
448
+ ## 🔒 Locked conversations
449
+
450
+ 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:
451
+
452
+ ```ruby
453
+ class Ticket < ApplicationRecord
454
+ acts_as_chat_subject
455
+
456
+ def chat_locked? = closed?
457
+ def chat_locked_notice = "This ticket is closed. Reply to reopen it."
458
+ end
459
+ ```
460
+
461
+ - `Chats::Message` refuses new messages with an `:locked` error; `Conversation#locked?` and `#locked_notice` read the subject.
462
+ - **System messages are exempt**: your app can always post "This ticket was closed" into the thread it just closed.
463
+ - 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.
464
+ - 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.
465
+
466
+ Every other write refuses too: `Message#edit!`, `#soft_delete!` and
467
+ `Chats::Reaction.toggle!` raise `Chats::LockedError` (a `NotAllowedError`
468
+ subclass), the edit/delete/react endpoints answer 422 with the notice, and the
469
+ bundled bubble stops offering them. Moderation is the one exception —
470
+ `remove_reported_field!` removes reported content from a locked conversation,
471
+ because a product lock must never shield it.
472
+
473
+ ## ✍️ Signed messages
474
+
475
+ `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:
476
+
477
+ ```ruby
478
+ desk.message!(alice, "On it!", author: lucia) # sender: the desk, author: Lucía
479
+ message.signed? # true — author present and not the sender
480
+ message.authored_by?(lucia)
481
+ ```
482
+
483
+ 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.
484
+
485
+ An author must be persisted, but needs neither `acts_as_messager` nor a conversation
486
+ seat. The host authorizes who may send on behalf of a shared identity; the HTTP
487
+ message controller never accepts an author from request parameters.
488
+
489
+ Existing installs get the columns with one command:
490
+
491
+ ```bash
492
+ rails generate chats:upgrade && rails db:migrate
493
+ ```
494
+
495
+
496
+ ## 🎯 Action buttons
497
+
498
+ A support desk writes "Your invoice is ready" and wants a **Pay now** button under it. A game writes "Let's play!" and wants **Join my room**. A marketplace posts "Your ride was cancelled" with **Book another**. Pasting a URL into the body is the wrong answer: one message can carry **one action**, a label and a url, and the bundled bubble renders it as a button.
499
+
500
+ ```ruby
501
+ alice.message!(bob, "Let's play!", action: { label: "Join my room", url: play_room_path(room) })
502
+ desk.message!(user, "Your invoice is ready", author: lucia, action: { label: "Pay now", url: invoice.payment_url })
503
+ conversation.post_system_message!("Your ride was cancelled", action: { label: "Book another", url: new_ride_path })
504
+ ```
505
+
506
+ <img src="docs/screenshots/thread-actions-phone.png" alt="A thread on a phone: a received message with a dark Join my room button, and your own dark bubble with a white Open my lobby button" width="320">
507
+
508
+ *One action per message: accent-filled under someone else's message, reversed in your own bubble.*
509
+
510
+ ```ruby
511
+ message.action # => #<data Chats::Action label="Join my room", url="/play/abcd">, or nil
512
+ message.action? # => true
513
+ message.action.label # => "Join my room"
514
+ message.action.to_h # => { label: "Join my room", url: "/play/abcd" }
515
+
516
+ Chats::Action.new(label: "Pay now", url: "/pay") # accepted anywhere a Hash is
517
+ ```
518
+
519
+ `Chats::Action` is an immutable value (`Data`), compared by value. Assign a Hash (string or symbol keys), an Action, or `nil`; anything else raises `ArgumentError` where you assigned it. A key an action doesn't have raises too: `{ label:, url:, method: :post }` would otherwise turn into a plain link without telling you.
520
+
521
+ **An action is a place to go, not something that happens.** It renders as a link (`data-turbo-frame="_top"`, so it opens the page even from an older page of the thread, which loads inside a frame). It is never a form, and that is on purpose: every bubble is rendered **once** and broadcast to everyone in the thread, and the long-press popup clones it, so nothing that belongs to one viewer can live inside it. A form would need that viewer's CSRF token. If the button should *do* something (accept an offer, pay), point it at a page that asks first and does the work there.
522
+
523
+ **It is validated when it is written**, so a bad one fails loudly in your code and never renders as a broken or dangerous link:
524
+
525
+ - `label`: present, at most 80 characters.
526
+ - `url`: at most 2048 characters, and EITHER an absolute `http://` / `https://` URL with a host (and, if it names one, a port from 1 to 65535), OR a path on your site that starts with a single `/`. Everything else is refused: `javascript:`, `data:`, `mailto:`, relative paths, and anything a browser would send to another site: `//elsewhere.example`, `///elsewhere.example`, `/\elsewhere.example`, `/<tab>/…`. URLs must be escaped, as Rails' url helpers already produce them.
527
+ - The error is `errors.of_kind?(:action, :invalid)`, with a readable message in `en` and `es`.
528
+
529
+ Body rules don't change: an action goes with a message, it is never a message by itself. Both text and system messages can carry one. `edit!` keeps the action. **A tombstone shows neither the body nor the button**: soft deletion clears the action, and so does a moderator's `remove_reported_field!`, because it goes through the same tombstone. The inbox preview reads “Message deleted”, as for any tombstone.
530
+
531
+ **Only your Ruby code sets an action.** The composer can't: `Chats::MessagesController` permits `body` and `files` only, the same as with `author:`. Restyle the button with `.chats-message__action` (eject `chats/messages/_action.html.erb` to change its markup). It uses your `--chats-accent` / `--chats-accent-contrast` colours: accent-filled under someone else's message or a system note, reversed inside your own accent-coloured bubble.
532
+
533
+ ### Buttons next to the composer: the `composer_actions` slot
534
+
535
+ Actions are the buttons your app puts under a message. The `composer_actions` [slot](#-view-slots) is for buttons *you* put next to the composer, such as "Create a game" or "Send my location", without ejecting it:
536
+
537
+ ```erb
538
+ <%# app/views/chats/slots/_composer_actions.html.erb %>
539
+ <%= button_to "🎮 Create a game", games_path, params: { conversation_id: conversation.id } %>
540
+ <%= link_to "📍 Send my location", new_location_path(conversation_id: conversation.id) %>
541
+ ```
542
+
543
+ The row appears above the input, **outside** the composer's `<form>`, so `button_to` forms are fine there: a form inside a form is invalid HTML. If the conversation locks, the row goes away together with the composer. Unstyled buttons and links get a small chip look by default. It is applied with zero specificity, so any class you add overrides it.
544
+
545
+ ## ⏱️ One send budget for every composer
546
+
547
+ `config.send_rate_limit` (60 per minute per sender by default) is enforced by
548
+ the gem's own message controller. A product with its **own** composer that
549
+ still ends in a chats message — a support form, an order note — shares the
550
+ same budget by including one concern, so nobody gets two allowances:
551
+
552
+ ```ruby
553
+ class Support::TicketsController < ApplicationController
554
+ include Chats::SendRateLimited
555
+ before_action :enforce_chat_send_rate_limit, only: :create
556
+
557
+ private
558
+
559
+ def chat_rate_limit_messager = current_user # whose budget; defaults to the configured current messager
560
+ end
561
+ ```
562
+
563
+ It counts in the host's cache store (atomic increment, so it works on Rails 7.1
564
+ too) and answers `429` with `chats.flashes.rate_limited` when the budget is spent.
565
+
566
+ ## 🔌 View slots
567
+
568
+ 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:
569
+
570
+ | slot | where it renders |
571
+ |---|---|
572
+ | `inbox_top` | above the first inbox row |
573
+ | `inbox_empty` | inside the empty state |
574
+ | `conversation_header_actions` | the thread's menu (gets `blockable:`) |
575
+ | `composer_actions` | a row of your own buttons above the composer's input, outside its form (gets `conversation:`; gone when the thread locks) |
576
+ | `locked_composer` | the locked composer's body |
577
+ | `message_meta` | after each bubble's timestamp |
578
+ | `dock_panel_footer` | beside "See all messages" in the [messaging dock](#-messaging-dock)'s panel (gets `viewer:`) |
579
+
580
+ ```erb
581
+ <%# app/views/chats/slots/_inbox_top.html.erb %>
582
+ <%= link_to "Need help? Write to us", support_path, class: "support-door" %>
583
+ ```
584
+
585
+ 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.
586
+
587
+ ## 🪟 Two panes
588
+
589
+ `/messages` is LinkedIn's messaging page: the conversation list on the left, the open conversation on the right. `/messages/:id` is that same page with its conversation open and its row highlighted, so a link to a conversation, a reload, back and forward all land where they should.
590
+
591
+ ![Two panes](docs/screenshots/split-inbox.png)
592
+
593
+ Each row is a real link to `/messages/:id` (keeping the list's search or stack in its query, so the page's URL always names the list it shows). On a wide screen it opens the conversation in the right pane, a `turbo_frame_tag "chats_conversation"` whose `data-turbo-action="advance"` moves the address bar and history with it, and the server answers that frame with the pane alone. The pane's thread sits in a `data-turbo-permanent` element keyed by the conversation (and the viewer), so the inbox's live refreshes (Turbo 8 morphs of the whole page) re-render the list and leave the conversation alone: its draft, scroll position, read state and stream subscription. Opening one takes you into it, as on LinkedIn: the focus goes to its composer (on a touch screen, to its title, so the keyboard waits until you ask for it), inside a pane a screen reader names after the conversation.
594
+
595
+ **On a phone** (under 700px wide, where the dock turns into a full-screen sheet) it is one column, exactly the separate pages as before: `/messages` is the list, and a row opens the conversation's page, with its back link to the list. One media query decides, so the layout holds before JavaScript runs and without it; with JavaScript off a row always opens the page.
596
+
597
+ <img src="docs/screenshots/split-inbox-phone-list.png" alt="A phone: the messaging page is the conversation list" width="240"> <img src="docs/screenshots/split-inbox-phone-thread.png" alt="A phone: a conversation fills the screen, with its back link to the list" width="240">
598
+
599
+ **Reads** happen when you open a conversation: a row clicked, a link, a reload. The page's own refreshes never read (one may arrive in a background tab), and a conversation the page renders unread reads itself as soon as it's on screen.
600
+
601
+ **The page's title** comes from one place, `chats_page_title`: "Messages" for the inbox, "Bob · Messages" with a conversation open (`chats.page_title.*`, localized). The bundled pages hand it to `content_for :title`, so a layout that shows `<title><%= content_for(:title) || "My app" %></title>` (the Rails default) gets it, and the pane carries it, so the tab follows when a row opens a conversation (Turbo leaves `<head>` alone on a frame visit). Word it your way, for both, with `config.page_title = ->(conversation, viewer) { … }` (`conversation` is nil on the inbox). A layout that builds its own title from what the controller loaded (a `@page_title`) keeps it on full page loads, and shows `chats_page_title` after a row click: say it the same way in `config.page_title`.
602
+
603
+ The [messaging dock](#-messaging-dock) stays off this page, as on every engine page; its "See all messages" opens it, and a window's ⤢ opens its conversation here.
604
+
605
+ ```ruby
606
+ config.inbox_layout = :split # the default: two panes from 700px wide
607
+ config.inbox_layout = :stacked # separate pages at every width, as before 0.6
608
+ ```
609
+
610
+ The list pane is `--chats-list-width` wide (320px), and the page as wide as the list plus a thread (`--chats-max-width`). It is as tall as the screen, so each pane scrolls on its own and the composer stays at the bottom. If your layout puts a header above it, say how much is left, or the page will scroll by the header's height:
611
+
612
+ ```css
613
+ :root { --chats-split-height: calc(100dvh - 4rem); } /* 4rem: your header */
614
+ ``` Views ejected with `rails generate chats:views` before 0.6 keep their own separate pages; re-eject to get the panes.
615
+
616
+ ## 🪟 Messaging dock
617
+
618
+ LinkedIn and Steam don't send you to another page to answer a message: chats live in small windows in the bottom-right corner while you keep doing what you were doing. One line in your layout, for signed-in viewers, gives your app the same:
619
+
620
+ ```erb
621
+ <%# app/views/layouts/application.html.erb, before </body> %>
622
+ <%= chats_dock %>
623
+ ```
624
+
625
+ ![The messaging dock](docs/screenshots/dock-desktop.png)
626
+
627
+ *Two conversations in floating windows (one with an action button) and the panel of recent conversations. The rows open in a window carry the accent bar.*
628
+
629
+ - **The bar** sits flush with the bottom-right corner: your avatar, "Messages" and the live unread badge (the same number as `chats_unread_badge`).
630
+ - **The panel** opens from it: the inbox's search, your ten most recent conversations exactly as the inbox renders them (stacks, verified badges, a bell-slash on muted ones, previews, unread counts) and "See all messages". It is a `<details>`: it opens without JavaScript, and every row, the search and the link are real links and forms.
631
+ - **A window** is the same thread the full page shows (live messages, read receipts, typing, the long-press menu, attachments, action buttons, your `composer_actions`, the locked composer) under a compact header: the name (click it to minimize, as on LinkedIn), the `conversation_header_actions` slot in a ⋯ menu, ⤢ for the full page, – and ×. As many windows open as fit beside the panel, up to three: two at 1280px, one at 768px. Opening one more closes the least recently used window that isn't **in use**. A window in use (it has the focus, or text you haven't sent) is never closed to make room: it folds to a narrow header (its avatar and ×) beside the new window instead. Two limits always hold: never more than `windows:` windows, folded ones included, and never wider than the screen beside the panel. When a conversation can't fit even with every window in use folded, the dock doesn't open it: the row's link opens the conversation's page instead (a pop-up simply doesn't happen). Unfolding a folded window makes room the same way; when it can't, the window stays folded. Narrowing the browser closes idle windows and folds the ones in use, the focused one last; if even a row of folded headers is too wide, the least recently used windows close too, their unsent text kept for when you reopen them. Text in a window that goes away (you close it, it makes room, its conversation becomes unreachable, its load fails) is kept for when you reopen it, and goes back into the composer the moment it appears, unless you've already started typing there.
632
+ - **Live**: a new message updates the badge and the list, and opens its conversation's window without taking the focus. That never happens for your own messages, for a conversation you muted, on a phone, for one you closed in the last five minutes, or by closing a window you're using. The window waits with the accent header and its count, and "New message from …" is announced politely. Edits, deletes and your own sends from another tab keep the list in order. A message that arrived while you were offline doesn't pop a window late: the badge and the list [catch up](#what-happens-when-a-broadcast-is-missed), and so does every open window's thread.
633
+ - **Reading** waits for you. A minimized window never marks anything read (the thread checks the window's state, not just its size), and a window that popped open marks messages read once you focus it, press in it or scroll it. One you opened yourself reads right away.
634
+ - **It stays put**: the dock is `data-turbo-permanent`, so Turbo Drive carries its windows, drafts and scroll positions from page to page. Its id names the viewer, so a visit that lands on another account's page never inherits it. Open windows, minimized ones, recently closed ones, the panel and unsent drafts are kept in `localStorage` under keys that name the viewer (`chats:dock:v1:<key>`, `chats:draft:<key>:<id>`), so a reload brings them back as they were, folded ones included (fitted to the width there is). A browser that refuses storage only means the dock forgets.
635
+ - **Keyboard**: Escape unwinds from the inside out (the long-press popup, an edit, the ⋯ menu, then the window minimizes, then the panel closes). Focus rings are inset; the history scrolls with the arrow keys.
636
+
637
+ On a phone (under 700px wide or 500px tall) the bar becomes a round button with the badge, the panel a full-screen sheet, and a conversation **one full-screen window**, with ← back to the list. While a window is up, the list behind it is out of the tab order; Back returns the focus to it. The sheet holds the focus while it's open (everything around it is made `inert`, siblings of the dock and of each of its ancestors, so a layout that wraps the dock works too), the page behind it doesn't scroll, and the composer sits on the keyboard. This layout is one media query, so it applies before JavaScript runs and without it.
638
+
639
+ <img src="docs/screenshots/dock-phone-button.png" alt="A phone: the dock is a round button with an unread badge in the corner" width="240"> <img src="docs/screenshots/dock-phone-panel.png" alt="A phone: the dock's panel fills the screen with the recent conversations" width="240"> <img src="docs/screenshots/dock-phone-window.png" alt="A phone: one conversation fills the screen, with the composer at the bottom" width="240">
640
+
641
+ **Open a window from your own pages.** `dock: true` on the Message button opens the conversation in the dock instead of navigating to it (a conversation already open in a window is focused, with its draft intact):
642
+
643
+ ```erb
644
+ <%= chat_button_to @player, dock: true %>
645
+ ```
646
+
647
+ It needs `chats_dock` on the same page: without the dock, the button's response has nowhere to go and nothing happens. Any other link can do the same, or open the panel, with two data helpers. Both stay real links when the dock isn't there:
648
+
649
+ ```erb
650
+ <%= link_to "Chat", chats.conversation_path(conversation), data: chats_dock_opener(conversation) %>
651
+ <%= link_to "Messages", chats.conversations_path, data: chats_dock_toggle %>
652
+ ```
653
+
654
+ **Options:**
655
+
656
+ ```erb
657
+ <%= chats_dock windows: 2, recent: 8 %> <%# at most this many windows (3), conversations in the panel (10) %>
658
+ <%= chats_dock launcher: false %> <%# no round button on phones: your nav opens the sheet (chats_dock_toggle) %>
659
+ <%= chats_dock everywhere: true %> <%# also on the engine's own pages (see below) %>
660
+ ```
661
+
662
+ **Not on your messaging pages.** `chats_dock` renders nothing on the engine's own pages (the inbox and the thread already are the messaging UI), and the dock never opens a window for a conversation the page itself shows: the live updates find a thread by its DOM ids, which must exist once. Because the element survives Turbo visits, the server can't remove it from the next page, so the dock also hides itself on any page carrying `data-chats-dock-hide`. The engine's inbox and thread carry it; add it to your own messaging pages. `everywhere: true` renders the dock on the engine's pages too and ignores the attribute.
663
+
664
+ **Theme it** like the rest of chats. The variables are declared on `.chats-dock`, so re-pointing `--chats-bg` or `--chats-accent` there reaches the dock too:
665
+
666
+ ```css
667
+ .chats-dock {
668
+ --chats-dock-z: 40; /* above your page, below your modals */
669
+ --chats-dock-offset: 0px; /* bottom clearance: a cookie banner, a tab bar */
670
+ --chats-dock-inset: 16px; /* from the right edge */
671
+ --chats-dock-gap: 8px;
672
+ --chats-dock-bar-height: 48px; /* the bar and every window header */
673
+ --chats-dock-panel-width: 288px;
674
+ --chats-dock-panel-height: 560px;
675
+ --chats-dock-window-width: 336px;
676
+ --chats-dock-window-height: 480px;
677
+ --chats-dock-radius: 12px;
678
+ --chats-dock-hover: #f9fafb; /* row hover: muted text stays at 4.63:1 */
679
+ --chats-dock-unread-bg: var(--chats-accent); /* a window waiting to be read */
680
+ --chats-dock-unread-text: var(--chats-accent-contrast);
681
+ --chats-dock-title-font: inherit;
682
+ }
683
+ ```
684
+
685
+ The `dock_panel_footer` [slot](#-view-slots) puts your own link next to "See all messages". `rails generate chats:views` ejects the dock's partials (`chats/dock/`) along with everything else. All labels are localized under `chats.dock.*`. The window's unread count (`chats.dock.window_unread`) is pluralized in the browser with `Intl.PluralRules` in the page's `<html lang>`: the gem ships `one`/`other`, so add the forms your languages need (`zero`, `two`, `few`, `many`) in your own locale file, e.g. Polish `few: "%{count} nowe wiadomości"`, `many: "%{count} nowych wiadomości"`.
686
+
687
+ ## 🔗 Profile links
688
+
689
+ `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:
690
+
691
+ ```ruby
692
+ config.messager_url = lambda do |messager|
693
+ routes = Rails.application.routes.url_helpers
694
+
695
+ case messager
696
+ when User then routes.user_path(messager) # a desk or a bot has no profile: nil
697
+ end
698
+ end
699
+ ```
700
+
255
701
  ## 🎨 Make it yours
256
702
 
257
703
  The bundled UI is intentionally framework-free (semantic `chats-*` classes + one self-contained stylesheet, themed with CSS variables):
@@ -260,6 +706,9 @@ The bundled UI is intentionally framework-free (semantic `chats-*` classes + one
260
706
  :root {
261
707
  --chats-accent: #facc15; /* own bubbles, send button, badges */
262
708
  --chats-accent-contrast: #111827;
709
+ --chats-verified: #0284c7; /* the "official account" badge */
710
+ --chats-list-width: 320px; /* the list beside an open conversation */
711
+ --chats-split-height: calc(100dvh - 4rem); /* the two-pane page under a 4rem header (default 100dvh) */
263
712
  }
264
713
  ```
265
714
 
@@ -269,7 +718,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
269
718
  rails generate chats:views
270
719
  ```
271
720
 
272
- Override the two Stimulus controllers by pinning the same importmap keys (`controllers/chats/thread_controller`, `controllers/chats/composer_controller`) — host pins win.
721
+ 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`, `controllers/chats/refresh_inbox_controller`, `controllers/chats/unread_badge_controller`, `controllers/chats/dock_controller`, and `controllers/chats/split_controller`. What the inbox, thread, badge and dock controllers share to catch up on missed broadcasts is `chats/recovery` (`recoverMissedBroadcasts`, `renderStreamFrom`); a controller of your own can import it too.
273
722
 
274
723
  ## Configuration reference
275
724
 
@@ -301,20 +750,29 @@ Chats.configure do |config|
301
750
  config.max_group_size = 32
302
751
  config.max_attachment_size = 10.megabytes
303
752
  config.max_attachments_per_message = 4
304
- config.send_rate_limit = { to: 60, within: 1.minute } # Rails 8 rate_limit; nil disables
753
+ config.send_rate_limit = { to: 60, within: 1.minute } # shared sender budget; nil disables
305
754
  config.encrypt_messages = false # ActiveRecord Encryption on bodies
306
755
 
307
756
  # Policies (on top of — never instead of — block enforcement)
308
757
  config.can_message = ->(sender, recipient) { true }
309
758
  config.can_create_group = ->(creator) { true }
310
759
 
760
+ # Inbox shaping
761
+ config.inbox_limit = 200
762
+ config.inbox_scope = ->(relation, viewer) { relation }
763
+ config.inbox_layout = :split # :split (two panes from 700px wide) | :stacked
764
+
311
765
  # Ecosystem seams (no-op defaults; chats runs standalone)
312
766
  config.blocked_messager_ids = ->(messager) { [] }
313
- config.notifier = ->(event, **payload) {}
767
+ config.notifier = ->(event, **payload) {} # DEPRECATED — use Chats.on
314
768
 
315
769
  # Display (used by the bundled views)
316
770
  config.messager_display_name = ->(messager) { messager.display_name }
317
771
  config.messager_avatar = ->(messager) { messager.avatar } # URL/attachment/variant or nil
772
+ config.messager_url = ->(messager) { nil } # nil ⇒ names render as plain text
773
+ config.message_signature = nil # ->(message) { } for signed bubbles
774
+ config.verified_badge = nil # ->(messager) { markup } for verified: true
775
+ config.page_title = nil # ->(conversation, viewer) { String }; nil ⇒ "Bob · Messages"
318
776
  end
319
777
  ```
320
778
 
@@ -327,23 +785,37 @@ alice.chat_with(bob, about: ride) # the DM about that ride
327
785
  alice.chat_with(bob, carol, title: "Trip") # a group (alice is owner)
328
786
  alice.message!(bob, "hi", about: ride) # send (resolves the thread)
329
787
  alice.message!(conversation, "hi", files: []) # send into a conversation
330
- alice.chats # inbox relation, newest first
788
+ alice.chats # every conversation she's in (what she may open), newest first
789
+ alice.chats.with_messages # ...the ones with a message: what lists show
790
+ alice.chats.with_human_messages # ...the ones a messager wrote in (system lines and tombstones don't count)
331
791
  alice.unread_chats_count # conversations with unread messages
792
+ alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
793
+ alice.message!(bob, "Let's play!", # with one button under it
794
+ action: { label: "Join my room", url: "/play/abcd" })
795
+ Chats::Inbox.for(alice) # [Conversation | InboxGroup] + #unread_count
796
+ Chats.verified?(desk) # official account? (acts_as_messager verified: true)
332
797
 
333
798
  # Conversations
334
799
  conversation.participant?(user) # active membership
335
800
  conversation.other_participants(user)
801
+ conversation.counterpart_for(viewer) # the other messager (nil for groups)
336
802
  conversation.title_for(viewer) # counterpart name / group title
337
803
  conversation.subject_label # "Madrid → Barcelona"
338
804
  conversation.unread_count_for(user)
339
805
  conversation.mark_read_by!(user)
340
806
  conversation.post_system_message!("Ride cancelled")
807
+ conversation.post_system_message!("Ride cancelled", action: { label: "Book another", url: "/rides/new" })
341
808
  conversation.add_participant!(user) # idempotent, race-safe
809
+ conversation.locked? # the SUBJECT decides (chat_locked?)
810
+ conversation.locked_notice # why, in words
342
811
 
343
812
  # Messages
344
813
  message.edit!("fixed") # stamps edited_at
345
814
  message.soft_delete! # tombstone (or destroy, per config)
346
815
  message.read_by?(user)
816
+ message.signed? / message.authored_by?(lucia) # authorship
817
+ message.action / message.action? # the button: a Chats::Action, or nil
818
+ Chats::Action.new(label: "Pay now", url: "/pay") # .label .url .to_h; Chats::Action.wrap(hash_or_action_or_nil)
347
819
  Chats::Reaction.toggle!(message:, reactor:, emoji: "👍")
348
820
 
349
821
  # Participants (the per-member state)
@@ -352,9 +824,118 @@ participant.mute! / participant.unmute!
352
824
  participant.leave! # groups
353
825
  participant.notifiable_for?(message) # notification etiquette
354
826
  participant.should_notify? / participant.mark_notified!
827
+ participant.reseat!(new_messager) # hand the seat over, read horizon intact
828
+
829
+ # Events
830
+ Chats.on(:message_created) { |message| } # also :conversation_created,
831
+ # :participant_left, :conversation_read
832
+
833
+ # Conversations — finding and creating
834
+ Chats::Conversation.direct_between(alice, bob, about: ride) # nil when none exists
835
+ Chats::Conversation.direct_between!(alice, bob, about: ride) # find-or-create; checks blocks and can_message(alice, bob)
836
+ Chats::Conversation.group!(alice, [bob, carol], title: "Trip")
837
+ Chats::Conversation.direct .groups .about(ride) .recent_first
838
+ Chats::Conversation.inbox_for(alice) .excluding_blocked_for(alice) .unread_by(alice)
839
+ Chats::Conversation.with_messages # has at least one message, system messages and tombstones included (`last_message_id` set); every list reads through this
840
+ Chats::Conversation.with_human_messages .without_human_messages # a messager wrote / only system lines, tombstones or nothing
841
+ conversation.last_human_message # newest text, non-deleted message, or nil (a query)
842
+ Chats::Conversation.unread_counts_for(alice, conversations) # { id => count } in one query
843
+ conversation.direct? / conversation.group?
844
+ conversation.participant_for(alice) # the seat, or nil
845
+ ride.chat_conversations # every conversation about a subject
846
+
847
+ # Messages — reading
848
+ message.system? / message.text? / message.deleted? / message.edited?
849
+ message.visible_body # body, or the tombstone text
850
+ message.attachments?
851
+ message.sent_by?(alice) message.sender_key
852
+ Chats::Message.visible .oldest_first .recent_first .before_message(message) # keyset pagination
853
+ Chats::Message.created_since(time) .updated_since(time) # what the stale-thread catch-up reads
854
+ Chats::Message.human # kind text, not deleted: what messagers wrote
855
+ Chats::Message.last_human_in(conversations) # newest human message per conversation, one query
856
+
857
+ # Participants — reading
858
+ participant.active? / participant.left? / participant.muted? / participant.owner?
859
+ participant.unread? / participant.unread_count / participant.unread_messages
860
+ participant.display_name
861
+ Chats::Participant.active .muted
862
+
863
+ # The inbox
864
+ inbox = Chats::Inbox.for(alice, query: "trip", with: sgid) # search, or filtered to one counterpart's stack
865
+ Chats::Inbox.for(alice, limit: 10) # the first rows only (the dock's recent list)
866
+ inbox.rows inbox.each inbox.to_a inbox.size inbox.any? inbox.empty? inbox.filtered?
867
+ inbox.conversations # the flat, unstacked list
868
+ inbox.unread_counts inbox.unread_count_for(conversation) inbox.unread_count
869
+ group = inbox.rows.first # a Chats::InboxGroup when the counterpart is inbox: :grouped
870
+ group.messager group.conversations group.conversation group.single?
871
+ group.unread_count group.unread? group.open_count group.last_message group.last_message_at
872
+ group.title_for(viewer) group.dom_id group.with_sgid # the signed token GET /conversations?with= takes
873
+
874
+ # Module-level
875
+ Chats.display_name_for(messager) Chats.avatar_for(messager) Chats.messager_url_for(messager)
876
+ Chats.message_signature_for(message) # "— Lucía G." or nil
877
+ Chats.verified?(m) Chats.notifications_for?(m) Chats.blockable?(m) Chats.grouped_inbox?(m)
878
+ Chats.blocked_ids_for(messager) Chats.blocked_between?(a, b) Chats.can_message?(sender, recipient)
879
+ Chats.inbox_with_sgid(messager) # the token behind a stack's "see all"
880
+ Chats.messager_key(messager) # "User:42" — what bubbles carry to align own-vs-other
881
+ Chats.messager_class?(klass) Chats.chat_subject_class?(klass)
882
+ Chats.on(event, key: nil) { … } Chats.reset_subscribers! Chats.reset! Chats.deprecator
883
+ ```
884
+
885
+ Errors are namespaced and meaningful, all under `Chats::Error`:
886
+ `Chats::BlockedError` (a blocked pair opening or sending), `Chats::NotAllowedError`
887
+ (`can_message` / `can_create_group` said no, groups disabled, a reseat that would
888
+ collide) and its subclass `Chats::LockedError` (any non-system write into a
889
+ locked conversation), `Chats::ConfigurationError` (boot).
890
+
891
+ ## 🧰 View helpers
892
+
893
+ All available in the bundled views **and** in your own templates:
894
+
895
+ | helper | renders |
896
+ |---|---|
897
+ | `chat_button_to(other, about:, label:, dock:, **html)` | a "Message" button, only when the viewer may message them; `dock: true` opens a dock window instead of navigating |
898
+ | `chats_unread_badge(messager = viewer)` | the live unread badge with its own stream; the viewer's own also [catches up](#what-happens-when-a-broadcast-is-missed) on updates it missed |
899
+ | `chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)` | the [messaging dock](#-messaging-dock): bar, panel and floating windows |
900
+ | `chats_dock_opener(conversation)` / `chats_dock_toggle` | data for your own links: open that conversation's dock window, or open the dock's panel |
901
+ | `chats_muted?(conversation, viewer)` | whether the viewer muted it (read from the preloaded seats) |
902
+ | `chats_messager_name(messager, css_class:)` | the name, linked through `messager_url`, badged when verified |
903
+ | `chats_messager_avatar(messager)` / `chats_conversation_avatar(conversation, viewer)` | avatar (URL, attachment, variant or initials) |
904
+ | `chats_verified_badge(messager)` | the official-account mark, or nil |
905
+ | `chats_message_signature(message)` | the "— Lucía G." line for a signed message |
906
+ | `chats_preview_for(conversation, viewer)` | the inbox row's last-message preview with its speaker |
907
+ | `chats_timestamp(time)` | "12:04" · "Yesterday" · "12 Sep" |
908
+ | `chats_slot(name, **locals)` / `chats_slot?(name)` | render a host slot partial when it exists |
909
+ | `chats_blockable?(messager)` / `chats_messager_url(messager)` | the duck-typed reads, for your own screens |
910
+ | `chats_group_path(group)` / `chats_group_path_for(messager)` | where a stack opens |
911
+ | `chats_styles` | the bundled stylesheet tag for your `<head>` |
912
+ | `chats_viewer` | the current messager, as the engine resolved it |
913
+
914
+ ## Errors
915
+
916
+ See the end of [the full Ruby API](#-the-full-ruby-api): `Chats::Error` → `BlockedError`, `NotAllowedError` → `LockedError`, `ConfigurationError`.
917
+
918
+ ## Locales
919
+
920
+ `en` and `es` ship with the gem under `chats.*` (`inbox`, `thread`, `conversation`, `verified`, `message`, `composer`, `dock`, `buttons`, `flashes`). Your own locale
921
+ files **outrank** the gem's — Rails loads every engine's locales first and the
922
+ app's last (a 0.2.0 fix; the gem used to re-append its files after yours and
923
+ silently win) — so override any key in your `es.yml` and the gem's copy loses.
924
+ Bubbles, flashes, the empty inbox, the locked composer and the verified label
925
+ (`chats.verified.label`) are all there, and so are the model errors
926
+ (`activerecord.errors.models.chats/message.…`, the action's included).
927
+
928
+
929
+ ## Upgrading
930
+
931
+ `chats` ships the migrations a version bump needs; your initializer and views stay yours:
932
+
933
+ ```bash
934
+ rails generate chats:upgrade # 0.2.0: message authorship columns · 0.4.0: the message action column
935
+ rails db:migrate
355
936
  ```
356
937
 
357
- Errors are namespaced and meaningful: `Chats::BlockedError`, `Chats::NotAllowedError`, `Chats::ConfigurationError` — all under `Chats::Error`.
938
+ 0.6.0 needs no migration: the messaging page becomes [two panes](#-two-panes) on wide screens by itself; `config.inbox_layout = :stacked` keeps the separate pages. Views you ejected before 0.6 keep rendering as they are (the separate pages); the [CHANGELOG](CHANGELOG.md) lists the templates that changed. 0.5.1 needs no migration and nothing to configure: every live surface now [catches up](#what-happens-when-a-broadcast-is-missed) on missed broadcasts by itself. If you wrapped the inbox in a reconciler of your own, remove it; if you ejected the inbox, the thread or the message partial, read the 0.5.1 [CHANGELOG](CHANGELOG.md) entry. 0.5.0 needs no migration: the messaging dock is opt-in (`chats_dock`). A few things change on their own: a conversation with no messages is no longer listed (it used to show as a "No messages yet" row), a thread the host's policy no longer allows shows the notice in the composer's place, a Turbo hover prefetch no longer marks a thread read, and the thread body moved into `chats/conversations/_thread.html.erb`. Read the 0.5.0 [CHANGELOG](CHANGELOG.md) entry if you ejected `show.html.erb` or the unread badge. 0.4.0 needs that migration (one nullable JSON column, `chats_messages.action`) and nothing else: actions and the `composer_actions` slot are opt-in. The generator is guarded: it doesn't write the action migration where the column already exists (a fresh 0.4.0 install), and re-running it is harmless. Rolling that migration back keeps the column, because a migration must never drop a column it may not have added. If you ejected the composer, read the 0.4.0 [CHANGELOG](CHANGELOG.md) entry: its DOM changed shape. 0.3.x needs no migration and no new configuration: `verified: true` (0.3.0) and `Chats::SendRateLimited` (0.3.2) are opt-in. Nothing in 0.2.0 changes behaviour until you set an option.
358
939
 
359
940
  ## Database support
360
941
 
@@ -362,7 +943,7 @@ PostgreSQL, MySQL, and SQLite. The migration adapts automatically: it honors you
362
943
 
363
944
  ## Testing
364
945
 
365
- The gem is tested with Minitest against a real dummy host app — models, broadcasts (over the Action Cable test adapter), full request cycles, generators, and every authorization negative (outsiders, leavers, and blocked pairs all get plain 404s; existence never leaks).
946
+ The gem is tested with Minitest against a real dummy host app — models, broadcasts (over the Action Cable test adapter), full request cycles, generators, and every authorization negative (outsiders, leavers, and blocked pairs all get plain 404s; existence never leaks). The Stimulus controllers' decisions run in node, and the dock's layout is measured in a headless Chrome (`test/system`). Under CI (`CI=true`) Chrome is required and its absence fails the run; locally the layout tests skip without it, and the suite prints how many ran.
366
947
 
367
948
  ```bash
368
949
  bundle exec rake test # full suite