chats 0.1.1 → 0.2.0

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +163 -28
  4. data/app/assets/stylesheets/chats.css +44 -0
  5. data/app/controllers/chats/conversations_controller.rb +43 -43
  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 +94 -0
  9. data/app/javascript/chats/refresh_inbox_controller.js +86 -0
  10. data/app/views/chats/conversations/_group.html.erb +38 -0
  11. data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
  12. data/app/views/chats/conversations/index.html.erb +39 -9
  13. data/app/views/chats/conversations/show.html.erb +44 -3
  14. data/app/views/chats/messages/_composer.html.erb +4 -0
  15. data/app/views/chats/messages/_message.html.erb +29 -8
  16. data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
  17. data/config/importmap.rb +2 -1
  18. data/config/locales/en.yml +11 -0
  19. data/config/locales/es.yml +11 -0
  20. data/context7.json +4 -0
  21. data/docs/PRD.md +1 -1
  22. data/docs/campfire_review.md +1 -1
  23. data/gemfiles/rails_7.1.gemfile +1 -0
  24. data/gemfiles/rails_7.2.gemfile +1 -0
  25. data/gemfiles/rails_8.1.gemfile +1 -0
  26. data/lib/chats/configuration.rb +63 -1
  27. data/lib/chats/engine.rb +29 -7
  28. data/lib/chats/errors.rb +16 -0
  29. data/lib/chats/inbox.rb +303 -0
  30. data/lib/chats/inbox_group.rb +75 -0
  31. data/lib/chats/macros.rb +24 -1
  32. data/lib/chats/models/concerns/chat_subject.rb +23 -0
  33. data/lib/chats/models/concerns/messager.rb +81 -2
  34. data/lib/chats/models/conversation.rb +59 -5
  35. data/lib/chats/models/message.rb +67 -3
  36. data/lib/chats/models/participant.rb +59 -0
  37. data/lib/chats/models/reaction.rb +5 -0
  38. data/lib/chats/subscribers.rb +156 -0
  39. data/lib/chats/version.rb +1 -1
  40. data/lib/chats.rb +114 -15
  41. data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
  42. data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
  43. data/lib/generators/chats/templates/initializer.rb +95 -14
  44. data/lib/generators/chats/upgrade_generator.rb +48 -0
  45. metadata +12 -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: c51ebc5c0e2da96363bb5b2e85d9a9c2a91e41f7ddb9d351ff10001654a48325
4
+ data.tar.gz: a06cde14d3953972e7061b2950b7a52c696f4292e25a2a91c9c7bb6ce7d269cb
5
5
  SHA512:
6
- metadata.gz: feb3c7419b117cd252b35c4c3202ec1ab5dfb85aa3c9da26488d490102cabc33c177ea03d2cbad1251d1af29405e023718552f1a210fb8f69207e2657d8b940b
7
- data.tar.gz: f68dc5a7814bbe56f8bad71e3495c5c1d4258fcc364e284fb118b9faee455124e041612eed1f4f38ed43a255513501430e0b5d6aa13646cc8e8438979598b4c7
6
+ metadata.gz: 5d23c31395edb382ec05fc6b7478a4e219b1878dc29f67092451101ef720bf2df556ae92976384665c0e108ca4607557ed9d1badf0af9357143182f1fdb57e6d
7
+ data.tar.gz: 3f02985c90410059af142a03fc3a3298540f71fb05d9b46991f39ea22999bfab64738f86beb92db7355e98c68e7962c2b6e79925335cf409d67842c1b1a7a5eb
data/CHANGELOG.md CHANGED
@@ -4,6 +4,172 @@ 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.2.0] - 2026-09-16
8
+
9
+ The release that makes `chats` a foundation other products can be built on:
10
+ a messager that isn't a person, a conversation whose openness belongs to its
11
+ subject, a message someone wrote on someone else's behalf, and extension
12
+ points that don't require ejecting a screen. **Nothing here changes existing
13
+ behaviour until you set an option** — 0.1.1 installs upgrade by running
14
+ `rails generate chats:upgrade && rails db:migrate`.
15
+
16
+ ### Added
17
+ - **Headless messagers.** `acts_as_messager notifications: false, blockable:
18
+ false, inbox: :grouped` — a support desk, a bot, an org mailbox. Class
19
+ predicates (`chat_notifications?`, `chat_blockable?`, `chat_inbox_mode`,
20
+ `chat_group_path`) are read duck-typed everywhere, `Participant#
21
+ notifiable_for?` honours them, and the bundled views hide block/report
22
+ affordances against a non-blockable counterpart. Hosts stop writing
23
+ `is_a?(User)` in every notifier and view.
24
+ - **Subject-owned locks.** `Chats::ChatSubject#chat_locked?` /
25
+ `#chat_locked_notice` (both inert by default) decide whether a conversation
26
+ still accepts messages; `Conversation#locked?` / `#locked_notice` read
27
+ them, and `Chats::Message` refuses non-system writes with an `:locked`
28
+ error, and every OTHER write refuses too — `Message#edit!`,
29
+ `#soft_delete!` and `Reaction.toggle!` raise `Chats::LockedError` (a
30
+ `NotAllowedError` subclass), and the edit/delete/react endpoints answer 422
31
+ with the notice. The bundled bubble stops offering what would only fail:
32
+ no Edit, no Delete, no reaction toggles, while existing reactions still
33
+ render as plain counts and Copy still works. Moderation is the one
34
+ exception — `remove_reported_field!` removes reported content from a locked
35
+ conversation, because a product lock must never shield it. The thread stays
36
+ readable: the composer is replaced by the notice
37
+ (`chats/conversations/_locked_composer`, overridable through the
38
+ `locked_composer` slot), and a send that lands on a freshly locked
39
+ conversation gets a **422 that swaps the composer** instead of an
40
+ exception. System messages are exempt, so your app can always explain the
41
+ lock in the thread it just closed.
42
+ - **Message authorship.** `chats_messages.author_type/author_id` (nullable,
43
+ polymorphic, indexed) plus `Message#author`, `#signed?`, `#authored_by?`
44
+ and `Messager#message!(…, author:)`. `sender` stays the seat; `author` is
45
+ who wrote it. Signed bubbles render a signature line ("— Lucía G."),
46
+ rewritable with `config.message_signature`. New generator: **`rails
47
+ generate chats:upgrade`** writes the migration (guarded, so it is a no-op
48
+ on a fresh 0.2.0 install, which already has the columns).
49
+ - **Grouped inbox rows.** `Chats::Inbox.for(viewer)` returns
50
+ `Chats::Conversation | Chats::InboxGroup` rows sorted by activity; every
51
+ direct thread with an `inbox: :grouped` counterpart folds into one stack
52
+ (`#messager`, `#conversations`, `#unread_count`, `#last_message`,
53
+ `#last_message_at`, `#open_count`). A stack of one links straight to its
54
+ thread, which gains a "see all" link back; a deeper stack opens
55
+ `GET /conversations?with=<signed gid>` (purpose `:chats_inbox_with`, minted
56
+ by `Chats.inbox_with_sgid`) or wherever `group_path:` points. Grouping
57
+ happens in ONE place. `config.inbox_limit` bounds ROWS, not
58
+ conversations: stacked threads are queried separately from ordinary ones,
59
+ so a desk with hundreds of open threads can never evict the rest of the
60
+ inbox, and a stack's `open_count`/`unread_count` are GLOBAL — two indexed
61
+ aggregates per stack, never per conversation and never by loading the
62
+ stack to count it. `Chats::Inbox#unread_count` is the stack-aware badge number;
63
+ `unread_chats_count` is unchanged.
64
+ - **`config.inbox_limit`** (200, replacing a literal in the controller) and
65
+ **`config.inbox_scope`** `->(relation, viewer) { relation }`, composed into
66
+ the inbox query before the limit.
67
+ - **View slots.** The bundled views render `chats/slots/_inbox_top`,
68
+ `_inbox_empty`, `_conversation_header_actions`, `_locked_composer` and
69
+ `_message_meta` when such a partial exists — one memoized lookup when it
70
+ doesn't. Hosts (and engines mounted on top of chats) add a row or a button
71
+ without ejecting a screen.
72
+ - **Subscribers.** `Chats.on(:message_created | :conversation_created |
73
+ :participant_left | :conversation_read)` replaces the single notifier
74
+ proc: many subscribers per event, each isolated through
75
+ `Rails.error.report(e, handled: true, context: { event: })` so a failing
76
+ one is *visible* and never stops the others or the write that emitted
77
+ them. Registration is reload-safe (`key:` replaces in place;
78
+ `Chats.reset_subscribers!` clears). Two NEW events:
79
+ `:conversation_created` (once per conversation, never on resume) and
80
+ `:participant_left`.
81
+ - **`config.messager_url`** `->(messager) { nil }` — the bundled views link
82
+ names and titles to it, and render plain text when it returns nil. The gem
83
+ no longer assumes a host has `user_path`.
84
+ - **`Participant#reseat!(new_messager)`** — hand a seat to another messager
85
+ inside a transaction, keeping the read horizon, the role and the history,
86
+ and re-indexing a direct thread's `direct_key` so `chat_with` keeps
87
+ resolving to it instead of stranding a duplicate. Refuses with
88
+ `Chats::NotAllowedError` when the resulting pair already has a direct
89
+ conversation, checked BEFORE the write so a unique-index violation can
90
+ never poison a host's transaction.
91
+ - **Inbox missed-broadcast recovery** (`chats--refresh-inbox` controller): the
92
+ inbox already receives Turbo 8 page *refreshes*, but Action Cable has no
93
+ replay — a refresh broadcast sent while the client's socket was down
94
+ (backgrounded tab/app, network blip) was lost and the inbox sat stale until
95
+ the user navigated. The new controller re-runs the same page refresh on
96
+ cable reconnect and on return-to-visible, extending the thread's
97
+ stale-catch-up doctrine (`docs/campfire_review.md`) to the inbox. It reuses
98
+ the thread's channel-free reconnect detection (observing the
99
+ `<turbo-cable-stream-source>` `connected` attribute), so no new Action Cable
100
+ channel is introduced. Auto-registered via the engine importmap pin; hosts
101
+ need zero changes.
102
+
103
+ ### Changed
104
+ - `config.notifier` is **deprecated** (removed in 1.0). It still works and
105
+ receives `:message_created` and `:conversation_read` — the two events 0.1.1
106
+ had — and ONLY those: the events added in 0.2.0 are `Chats.on`-only, so a
107
+ 0.1.x hook written `->(event, message:, **)` can never be handed an event
108
+ it has no keyword for. It registers as a subscriber under a reserved key,
109
+ so re-assigning it replaces rather than stacks, and warns through
110
+ `Chats.deprecator`, which the engine registers with
111
+ `Rails.application.deprecators`.
112
+ **If your test environment sets `config.active_support.deprecation =
113
+ :raise`** (a common default) and you still assign `config.notifier`, that
114
+ warning now raises at boot, because the engine registers the gem's
115
+ deprecator with the app. Either move the hook to `Chats.on` — the migration
116
+ is one line — or silence just this one:
117
+
118
+ ```ruby
119
+ # config/initializers/chats.rb
120
+ Chats.deprecator.silence do
121
+ Chats.configure { |config| config.notifier = ->(event, **payload) { … } }
122
+ end
123
+ ```
124
+ - The install migration now creates the `author` columns, so a fresh install
125
+ needs no upgrade step.
126
+ - **If you ejected the inbox or the composer under 0.1.x**, nothing breaks:
127
+ `ConversationsController#index` still assigns `@conversations` (the flat,
128
+ unstacked list an ejected inbox loops over), and an ejected composer simply
129
+ misses the DOM id the locked-composer swap targets — the 422 is then a
130
+ no-op instead of a replace. Re-eject (or delete) those two files to pick up
131
+ stacked rows and locked composers.
132
+
133
+ ### Fixed
134
+ - **A host's own locale file no longer loses to the gem's.** The engine
135
+ appended its `config/locales` onto the application's `i18n.load_path` on
136
+ top of Rails' own `:add_locales`. Railtie paths are unshifted ahead of
137
+ everything, so that second copy landed *after* the host's files and
138
+ silently overrode them — a host rewording `chats.flashes.blocked` in its
139
+ own `es.yml` kept reading ours, with no error to see. Gem first, host last,
140
+ pinned by a test that ships a host override in the dummy app.
141
+ - **Migrations name every adapter they actually run on.** `json_column_type`
142
+ matched `"postgresql"`, which activerecord-postgis-adapter never reports
143
+ (it answers `"PostGIS"`), so PostGIS hosts silently got `json` where the
144
+ gem meant `jsonb`. `json_column_default` matched `/mysql/`, which misses
145
+ Trilogy (Rails reports `"Trilogy"`), handing those hosts a default MySQL
146
+ rejects. Both now match by prefix and by both spellings.
147
+ - **The thread's missed-broadcast recovery never took effect for a deep
148
+ backlog.** `ConversationsController#refresh` answered with `render html:
149
+ … content_type: "text/vnd.turbo-stream.html"`, and `render html:` forces
150
+ `text/html` and ignores the content type — so the response said
151
+ `<turbo-stream action="refresh">` in a body nothing would treat as a
152
+ stream. It now renders `turbo_stream.refresh(request_id: nil)`; the nil
153
+ request id matters, because Turbo skips a refresh tagged with a request id
154
+ it recognizes as its own, and this response answers the client's own
155
+ catch-up fetch. The failure was invisible by construction: a recovery path
156
+ that does nothing looks exactly like the staleness it exists to fix.
157
+ - `:participant_added` was documented as a notifier event but never emitted.
158
+ The event catalogue is now exactly what the gem fires, and registering for
159
+ anything else raises at boot with the valid list.
160
+ - **A signed message is its author's to answer for.** `Message#reported_owner`
161
+ now returns `author || sender`. With authorship, an answer sent from a
162
+ headless seat (a support desk) carries a human author, and a host's
163
+ moderation `owner` is typed to its user class — a desk there raised an
164
+ association type mismatch from inside the agent's own reply the first time
165
+ a text filter tripped, and the moderation screens then asked the desk for
166
+ an avatar it does not have.
167
+ - **`jsonb` on PostGIS.** The install migration decided jsonb-or-json with
168
+ `adapter_name.downcase.include?("postgresql")`, and activerecord-postgis-
169
+ adapter answers `"PostGIS"`, so PostGIS hosts silently got plain `json`
170
+ columns. The template now matches the prefix (`/\Apostg/i`). Existing
171
+ installs are unaffected; a host that wants jsonb can `change_column` it.
172
+
7
173
  ## [0.1.1] - 2026-06-10
8
174
 
9
175
  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. 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,111 @@ 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
+ ## 🗂️ Grouped inbox rows
273
+
274
+ With `inbox: :grouped`, every direct conversation a viewer has with that messager folds into a single inbox row — a stack:
275
+
276
+ ```ruby
277
+ inbox = Chats::Inbox.for(current_user) # [Chats::Conversation | Chats::InboxGroup], newest activity first
278
+ inbox.unread_count # the stack-aware badge number
279
+
280
+ group = inbox.rows.first
281
+ group.messager # the desk
282
+ group.conversations # the stacked threads, freshest first
283
+ group.unread_count # aggregated across the stack
284
+ group.open_count # how many are in it
285
+ ```
286
+
287
+ - `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.
288
+ - A stack of one links **straight to the thread**, which then carries a small "see all" link back to the stack.
289
+ - 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 }`.
290
+ - Two knobs shape the whole query: `config.inbox_limit` (200) and `config.inbox_scope = ->(relation, viewer) { relation }`.
291
+
292
+ `user.unread_chats_count` is unchanged (it counts conversations); `Chats::Inbox#unread_count` is the stack-aware number for badges.
293
+
294
+ ## 🔒 Locked conversations
295
+
296
+ 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:
297
+
298
+ ```ruby
299
+ class Ticket < ApplicationRecord
300
+ acts_as_chat_subject
301
+
302
+ def chat_locked? = closed?
303
+ def chat_locked_notice = "This ticket is closed. Reply to reopen it."
304
+ end
305
+ ```
306
+
307
+ - `Chats::Message` refuses new messages with an `:locked` error; `Conversation#locked?` and `#locked_notice` read the subject.
308
+ - **System messages are exempt**: your app can always post "This ticket was closed" into the thread it just closed.
309
+ - 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.
310
+ - 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.
311
+
312
+ ## ✍️ Signed messages
313
+
314
+ `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:
315
+
316
+ ```ruby
317
+ desk.message!(alice, "On it!", author: lucia) # sender: the desk, author: Lucía
318
+ message.signed? # true — author present and not the sender
319
+ message.authored_by?(lucia)
320
+ ```
321
+
322
+ The bundled bubble renders a signature line ("— Lucía G.") via `Chats.display_name_for`; `config.message_signature = ->(message) { … }` rewrites it. Ordinary messages have no author and render exactly as before.
323
+
324
+ Existing installs get the columns with one command:
325
+
326
+ ```bash
327
+ rails generate chats:upgrade && rails db:migrate
328
+ ```
329
+
330
+ ## 🔌 View slots
331
+
332
+ 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:
333
+
334
+ | slot | where it renders |
335
+ |---|---|
336
+ | `inbox_top` | above the first inbox row |
337
+ | `inbox_empty` | inside the empty state |
338
+ | `conversation_header_actions` | the thread's menu (gets `blockable:`) |
339
+ | `locked_composer` | the locked composer's body |
340
+ | `message_meta` | after each bubble's timestamp |
341
+
342
+ ```erb
343
+ <%# app/views/chats/slots/_inbox_top.html.erb %>
344
+ <%= link_to "Need help? Write to us", support_path, class: "support-door" %>
345
+ ```
346
+
347
+ 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.
348
+
349
+ ## 🔗 Profile links
350
+
351
+ `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:
352
+
353
+ ```ruby
354
+ config.messager_url = lambda do |messager|
355
+ routes = Rails.application.routes.url_helpers
356
+
357
+ case messager
358
+ when User then routes.user_path(messager) # a desk or a bot has no profile: nil
359
+ end
360
+ end
361
+ ```
362
+
255
363
  ## 🎨 Make it yours
256
364
 
257
365
  The bundled UI is intentionally framework-free (semantic `chats-*` classes + one self-contained stylesheet, themed with CSS variables):
@@ -269,7 +377,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
269
377
  rails generate chats:views
270
378
  ```
271
379
 
272
- Override the two Stimulus controllers by pinning the same importmap keys (`controllers/chats/thread_controller`, `controllers/chats/composer_controller`) host pins win.
380
+ 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
381
 
274
382
  ## Configuration reference
275
383
 
@@ -308,13 +416,19 @@ Chats.configure do |config|
308
416
  config.can_message = ->(sender, recipient) { true }
309
417
  config.can_create_group = ->(creator) { true }
310
418
 
419
+ # Inbox shaping
420
+ config.inbox_limit = 200
421
+ config.inbox_scope = ->(relation, viewer) { relation }
422
+
311
423
  # Ecosystem seams (no-op defaults; chats runs standalone)
312
424
  config.blocked_messager_ids = ->(messager) { [] }
313
- config.notifier = ->(event, **payload) {}
425
+ config.notifier = ->(event, **payload) {} # DEPRECATED — use Chats.on
314
426
 
315
427
  # Display (used by the bundled views)
316
428
  config.messager_display_name = ->(messager) { messager.display_name }
317
429
  config.messager_avatar = ->(messager) { messager.avatar } # URL/attachment/variant or nil
430
+ config.messager_url = ->(messager) { nil } # nil ⇒ names render as plain text
431
+ config.message_signature = nil # ->(message) { } for signed bubbles
318
432
  end
319
433
  ```
320
434
 
@@ -329,6 +443,8 @@ alice.message!(bob, "hi", about: ride) # send (resolves the thread)
329
443
  alice.message!(conversation, "hi", files: []) # send into a conversation
330
444
  alice.chats # inbox relation, newest first
331
445
  alice.unread_chats_count # conversations with unread messages
446
+ alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
447
+ Chats::Inbox.for(alice) # [Conversation | InboxGroup] + #unread_count
332
448
 
333
449
  # Conversations
334
450
  conversation.participant?(user) # active membership
@@ -339,11 +455,14 @@ conversation.unread_count_for(user)
339
455
  conversation.mark_read_by!(user)
340
456
  conversation.post_system_message!("Ride cancelled")
341
457
  conversation.add_participant!(user) # idempotent, race-safe
458
+ conversation.locked? # the SUBJECT decides (chat_locked?)
459
+ conversation.locked_notice # why, in words
342
460
 
343
461
  # Messages
344
462
  message.edit!("fixed") # stamps edited_at
345
463
  message.soft_delete! # tombstone (or destroy, per config)
346
464
  message.read_by?(user)
465
+ message.signed? / message.authored_by?(lucia) # authorship
347
466
  Chats::Reaction.toggle!(message:, reactor:, emoji: "👍")
348
467
 
349
468
  # Participants (the per-member state)
@@ -352,10 +471,26 @@ participant.mute! / participant.unmute!
352
471
  participant.leave! # groups
353
472
  participant.notifiable_for?(message) # notification etiquette
354
473
  participant.should_notify? / participant.mark_notified!
474
+ participant.reseat!(new_messager) # hand the seat over, read horizon intact
475
+
476
+ # Events
477
+ Chats.on(:message_created) { |message| } # also :conversation_created,
478
+ # :participant_left, :conversation_read
355
479
  ```
356
480
 
357
481
  Errors are namespaced and meaningful: `Chats::BlockedError`, `Chats::NotAllowedError`, `Chats::ConfigurationError` — all under `Chats::Error`.
358
482
 
483
+ ## Upgrading
484
+
485
+ `chats` ships the migrations a version bump needs; your initializer and views stay yours:
486
+
487
+ ```bash
488
+ rails generate chats:upgrade # 0.2.0: message authorship columns
489
+ rails db:migrate
490
+ ```
491
+
492
+ Nothing in 0.2.0 changes behaviour until you set an option — see the [CHANGELOG](CHANGELOG.md).
493
+
359
494
  ## Database support
360
495
 
361
496
  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.
@@ -816,3 +816,47 @@ turbo-frame[id^="chats_page_"] {
816
816
  border-radius: 0.5rem;
817
817
  }
818
818
  .chats-emoji:hover { background: var(--chats-surface); }
819
+
820
+ /* ---------------------------------------------------------------------------
821
+ 0.2.0 — stacked inbox rows, locked composers, signed messages
822
+ --------------------------------------------------------------------------- */
823
+
824
+ /* A stack (Chats::InboxGroup) reads as one row; the count line sits where a
825
+ single conversation shows its subject. */
826
+ .chats-row--group .chats-row__subject { font-weight: 600; }
827
+
828
+ /* The inbox filtered to one counterpart (?with=). */
829
+ .chats-inbox__filter {
830
+ display: flex;
831
+ align-items: baseline;
832
+ gap: 0.5rem;
833
+ margin: 0 0 0.5rem;
834
+ font-size: 0.85rem;
835
+ color: var(--chats-text-muted);
836
+ }
837
+ .chats-inbox__filter-clear { color: inherit; text-decoration: underline; }
838
+
839
+ /* "See all" back to the stack, under the thread title. */
840
+ .chats-thread__see-all { margin: 0; font-size: 0.72rem; }
841
+ .chats-thread__see-all-link { color: var(--chats-text-muted); text-decoration: underline; }
842
+
843
+ /* Who wrote it, when that isn't the seat it came from. */
844
+ .chats-message__signature {
845
+ margin-top: 0.25rem;
846
+ font-size: 0.7rem;
847
+ font-style: italic;
848
+ opacity: 0.75;
849
+ }
850
+
851
+ /* The composer, replaced by the reason the conversation is closed. Same box,
852
+ so the thread doesn't jump when a subject locks under an open composer. */
853
+ .chats-composer--locked {
854
+ display: block;
855
+ padding: 0.85rem 1rem;
856
+ text-align: center;
857
+ }
858
+ .chats-composer__locked-notice {
859
+ margin: 0;
860
+ font-size: 0.85rem;
861
+ color: var(--chats-text-muted);
862
+ }
@@ -5,16 +5,22 @@ module Chats
5
5
  # pages (create), and the per-member actions (read/typing/leave/mute).
6
6
  class ConversationsController < ApplicationController
7
7
  before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
8
+ helper_method :chats_counterpart
8
9
 
9
10
  # The inbox. Everything is preloaded/batched so rendering N rows costs a
10
11
  # constant number of queries (conversations + last messages + participants
11
12
  # + one grouped unread-count query — see Conversation.unread_counts_for).
13
+ # Rows are Conversation | InboxGroup: stacking, search and the `?with=`
14
+ # filter all live in Chats::Inbox, so this action stays three lines.
12
15
  def index
13
- @conversations = chats_current_messager.chats
14
- .includes(:last_message, :subject, participants: :messager)
15
- .limit(200)
16
- @conversations = apply_search(@conversations)
17
- @unread_counts = Chats::Conversation.unread_counts_for(chats_current_messager, @conversations)
16
+ @inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
17
+ @rows = @inbox.rows
18
+ @unread_counts = @inbox.unread_counts
19
+ # Back-compat for inboxes ejected under 0.1.x, which loop over
20
+ # @conversations: they keep rendering the flat list (no stacking, i.e.
21
+ # exactly what they rendered before). Re-eject or delete your copy to
22
+ # get the stacked rows.
23
+ @conversations = @inbox.conversations
18
24
  end
19
25
 
20
26
  # The thread. Renders the LATEST page of messages; older pages stream in
@@ -76,11 +82,18 @@ module Chats
76
82
  # A backlog deeper than one page would mean splicing an arbitrary
77
83
  # amount of history through surgical appends; a Turbo 8 page refresh
78
84
  # (morph + scroll preservation) re-renders the latest page + frame
79
- # chain correctly instead. Raw tag rather than `turbo_stream.refresh`
80
- # so we don't depend on turbo-rails ≥ 2.0 helpers.
85
+ # chain correctly instead.
86
+ #
87
+ # `render turbo_stream:`, NOT `render html: … content_type:` — the
88
+ # latter forces text/html and silently ignores the content type, so the
89
+ # body says <turbo-stream> while the response says it isn't one.
90
+ #
91
+ # `request_id: nil` on purpose: Turbo skips a refresh tagged with a
92
+ # request id it recognizes as its own, and this response is the answer
93
+ # to the client's OWN catch-up fetch — the one client that must not
94
+ # skip it.
81
95
  if @new_messages.size > Chats.config.messages_per_page
82
- render html: '<turbo-stream action="refresh"></turbo-stream>'.html_safe,
83
- content_type: "text/vnd.turbo-stream.html"
96
+ render turbo_stream: turbo_stream.refresh(request_id: nil)
84
97
  return
85
98
  end
86
99
 
@@ -156,43 +169,30 @@ module Chats
156
169
  GlobalID::Locator.locate_signed(sgid, for: purpose) || raise(ActiveRecord::RecordNotFound)
157
170
  end
158
171
 
159
- # Partial, case-insensitive matching across the inbox metadata users can
160
- # actually see: participant names, conversation titles, subject labels,
161
- # and message bodies. The inbox is capped at 200 rows, so metadata is
162
- # filtered portably in Ruby from the already-preloaded objects while the
163
- # potentially larger message-body set stays in SQL. No PostgreSQL-only
164
- # full-text dependency is needed for this scale.
165
- def apply_search(conversations)
166
- return conversations unless Chats.config.search
167
-
168
- query = params[:q].to_s.strip
169
- return conversations if query.empty?
170
-
171
- loaded = conversations.to_a
172
- normalized_query = query.downcase
173
- pattern = "%#{Chats::Conversation.sanitize_sql_like(query.downcase)}%"
174
- message_match_ids =
175
- if Chats.config.encrypt_messages
176
- []
177
- else
178
- Chats::Message.where(conversation_id: loaded.map(&:id), deleted_at: nil)
179
- .where("LOWER(chats_messages.body) LIKE ?", pattern)
180
- .distinct
181
- .pluck(:conversation_id)
182
- end
172
+ # `?with=<signed gid>` the inbox filtered to one counterpart (a stack's
173
+ # contents). Signed and purpose-scoped like every other polymorphic
174
+ # param the engine accepts; a tampered one is a plain 404.
175
+ def inbox_filter
176
+ return nil if params[:with].blank?
183
177
 
184
- loaded.select do |conversation|
185
- message_match_ids.include?(conversation.id) ||
186
- searchable_metadata(conversation).downcase.include?(normalized_query)
187
- end
188
- end
178
+ messager = locate_signed!(params[:with], purpose: :chats_inbox_with)
179
+ raise ActiveRecord::RecordNotFound unless Chats.messager_class?(messager.class)
189
180
 
190
- def searchable_metadata(conversation)
191
- participant_names = conversation.participants.filter_map do |participant|
192
- Chats.display_name_for(participant.messager) if participant.active?
193
- end
181
+ messager
182
+ end
194
183
 
195
- [conversation.title, conversation.subject_label, *participant_names].compact.join(" ")
184
+ # The other party of a direct thread (nil for groups) — the thread header
185
+ # names them, links to their profile, and decides whether to offer the
186
+ # "see all" link back to their stack. LAZY and memoized: a group thread
187
+ # never pays for it, and a direct one pays once no matter how many of
188
+ # those three things the rendered view asks for.
189
+ def chats_counterpart
190
+ return @chats_counterpart if defined?(@chats_counterpart)
191
+
192
+ @chats_counterpart =
193
+ if @conversation&.direct?
194
+ @conversation.other_participants(chats_current_messager).includes(:messager).first&.messager
195
+ end
196
196
  end
197
197
  end
198
198
  end