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/CHANGELOG.md CHANGED
@@ -4,6 +4,1043 @@ 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.6.2] - 2026-10-18
8
+
9
+ ### Fixed
10
+ - **A moderation removal that removed nothing said it had.**
11
+ `Chats::Message#remove_reported_field!(:body)`, the seam moderate calls to
12
+ take reported content down, returned true for a message that was already
13
+ deleted, so moderate recorded a second "content removed" with a statement
14
+ of reasons that was false. It now returns whether anything was removed:
15
+ false for a message already deleted (by its sender or an earlier
16
+ decision), one whose body is already blank, and one already gone
17
+ (`deletion: :hard`); true only when it actually removed something (the
18
+ tombstone, or the row under `deletion: :hard`). It decides under the
19
+ message's row lock and reads the message again under it, so two
20
+ decisions taken at once cannot both see it standing. moderate
21
+ 1.0.0.beta3 and later refuse a removal that removed nothing; earlier
22
+ versions ignore the return value. A group's `remove_reported_field!(:title)`
23
+ keeps the same contract (false for a title already removed).
24
+
25
+ ## [0.6.1] - 2026-10-18
26
+
27
+ The package is the gem again, and three fixes to the two-pane page.
28
+ **No migration, nothing to configure.**
29
+
30
+ ### Fixed
31
+ - **The package shipped seven old packages.** The gemspec takes its files
32
+ from `git ls-files`, and the built `chats-0.3.2.gem` … `chats-0.5.1.gem`
33
+ had been committed (`chats-0.3.1.gem` since 0.3.1), so 0.6.0 weighed
34
+ 3.5 MB. They are untracked, `*.gem` is in `.gitignore`, and the gemspec
35
+ leaves out any `*.gem` it is handed anyway; `test/gemspec_test.rb` keeps
36
+ it so. The README's screenshots still ship, since the README links them.
37
+ - **The tab kept the previous page's title after a row click.** Turbo
38
+ leaves `<head>` alone when it promotes a frame load to a visit, so from
39
+ `/messages/A` clicking B left A's title. The messaging page's title now
40
+ has one source, the new `chats_page_title` helper ("Messages", "Bob ·
41
+ Messages": `chats.page_title.inbox` / `.conversation`, localized), which
42
+ `config.page_title = ->(conversation, viewer) { … }` rewords. The bundled
43
+ `index` and `show` give it to `content_for :title` (a layout doing
44
+ `yield(:title)` shows it on a full page), and the pane carries it
45
+ (`data-title`), which `chats--split` gives the tab when a row opens it.
46
+ The pane's answer stays in turbo-rails' minimal frame layout, now even
47
+ under a `config.layout`. A layout that titles pages from what the
48
+ controller loaded keeps its title on full pages and shows
49
+ `chats_page_title` after a row click: say it the same way in
50
+ `config.page_title`.
51
+ - **A tapped row kept its hover tint on touch screens.** Every hover rule
52
+ on inbox rows and on the dock (rows, the bar's chevron, "See all
53
+ messages", the window buttons) now sits inside `@media (hover: hover)`.
54
+ - **The open row kept its unread count until the list's live refresh.** The
55
+ pane says when the server read the conversation as it rendered it
56
+ (`data-read`), and the thread now dispatches `chats:read` once a read it
57
+ posted lands; on either, `chats--split` clears that row's count and bold
58
+ at once. The refresh that follows renders the same.
59
+
60
+ ## [0.6.0] - 2026-10-18
61
+
62
+ The messaging page is two panes, LinkedIn-style: on a wide screen
63
+ `/messages` is the conversation list beside the open conversation, and
64
+ `/messages/:id` is that same page with its conversation open. Phones keep
65
+ the separate pages. **No migration; `config.inbox_layout = :stacked` keeps
66
+ the old pages everywhere.**
67
+
68
+ ### Added
69
+ - **Two panes (`config.inbox_layout = :split`, the default).** From 700px
70
+ wide (the width at which the dock turns into a phone sheet) the list sits
71
+ on the left in a `<nav>` named "Messages", `--chats-list-width` (320px)
72
+ wide, and the open conversation on the right in a
73
+ `turbo_frame_tag "chats_conversation"`; with none open, the pane says
74
+ "Select a conversation" (`chats.inbox.select_hint`), and with no
75
+ conversations at all it holds the inbox's empty state and the
76
+ `inbox_empty` slot (rendered once, in the pane, no longer in the list
77
+ too). Both panes are as tall as `--chats-split-height` (the screen,
78
+ `100dvh`, by default; a host with its own header above the page sets what
79
+ is left, `calc(100dvh - 4rem)`, which a phone's list page also takes as
80
+ its least height) and scroll on their own; the page is as
81
+ wide as the list plus a thread page (`--chats-max-width`).
82
+ Below 700px it is one column, exactly the separate pages: `/messages` is
83
+ the list, `/messages/:id` the thread with its back link. One media query
84
+ decides, so the layout holds before JavaScript and without it.
85
+ - **A row opens its conversation in the pane.** Each row is a real link to
86
+ `/messages/:id` aimed at the frame (`data-turbo-frame`); the frame is
87
+ `data-turbo-action="advance"`, so the address bar and history follow, and
88
+ the server answers the frame with the pane alone (new `pane.html.erb`: the
89
+ header over `chats/conversations/_thread`, no list, no layout). A reload, a
90
+ deep link or back/forward renders the whole page, the row current
91
+ (`.chats-row--current`, `aria-current="page"`). The new `chats--split`
92
+ controller (`controllers/chats/split_controller`, pinned like the others)
93
+ enables the frame only on a wide screen; on a phone, before it connects
94
+ and without JavaScript, the same link opens the page. It also skips the
95
+ hover prefetch of a row while the frame is disabled (Turbo would ask for
96
+ the pane alone and then render it as the page), marks the row the pane
97
+ opened current at once, and takes the viewer into it, as LinkedIn does:
98
+ the focus goes to its composer, inside the pane a screen reader names
99
+ after the conversation; on a touch screen (`pointer: coarse`), and in a
100
+ locked conversation, to the pane's title (`tabindex="-1"`), so no
101
+ keyboard rises over a conversation not read yet.
102
+ - **A filtered list stays filtered.** A row of a search's results or of a
103
+ stack (`?with=`) links to `/messages/:id?q=…` / `?with=…`: the address
104
+ bar names the list the page shows, so the list's refreshes, which
105
+ re-request it, keep it, and the pane's back link (phones) returns to it.
106
+ - **The open conversation survives the list's refreshes.** The inbox's live
107
+ updates are Turbo 8 morphs of the whole page; the pane's thread sits in a
108
+ `data-turbo-permanent` element keyed by the conversation and the viewer
109
+ (`chats_conversation_pane_<id>_<viewer key>`), so a refresh re-renders the
110
+ rows and leaves the draft, the scroll position, the read state and the
111
+ stream subscription alone, while another conversation (another id)
112
+ replaces it. The pane's header (avatar, name, verified badge, subject, the
113
+ ⋯ menu) stays outside it, so a mute or a renamed group still shows. The
114
+ list and the pane each catch up on missed broadcasts once: a page refresh
115
+ for the list, the thread catch-up for the conversation.
116
+ - **`config.inbox_layout`**, `:split` or `:stacked`, checked at boot (a
117
+ wrong value fails the initializer), in the generated initializer and the
118
+ README.
119
+
120
+ ### Changed
121
+ - **When `/messages/:id` reads.** Opening a conversation still reads it: a
122
+ row clicked (the pane's frame), a link from elsewhere, a reload, a page
123
+ loaded without JavaScript. A Turbo visit of the two-pane page does not
124
+ read on the server, because the inbox's own refresh is one: it may arrive
125
+ in a background tab, and a read there said "Seen" while nobody looked and
126
+ refreshed the inbox again, in a loop. The pane renders `read-pending`
127
+ instead and its thread reads once it is on screen, as a dock window does.
128
+ A prefetched thread page (`:stacked`), which nothing used to read when
129
+ Turbo showed it, now reads itself the same way.
130
+ - **The inbox's search form has no `action`**, so it searches whatever page
131
+ it is on (an HTML form without one submits to the page's URL): after a row
132
+ opened a conversation and moved the address bar to `/messages/:id`, a
133
+ search keeps that conversation open (`/messages/:id?q=…`) instead of
134
+ going back to `/messages`.
135
+ - **The dock** drops a window whose conversation a frame of the page loads
136
+ (`turbo:frame-load`), as it did after a visit: one copy of a thread's DOM
137
+ ids per page, with `chats_dock everywhere: true` too.
138
+ - **Templates.** Hosts with ejected views are unaffected: an app whose own
139
+ `index` or `show` came from a `rails generate chats:views` before 0.6
140
+ (no `_conversation_pane` of its own) keeps its separate pages, rendered
141
+ and read exactly as before; re-eject to get the panes. The bundled
142
+ templates that changed:
143
+ - `chats/conversations/index.html.erb`: the two-pane page (the list in
144
+ `nav.chats-split__list`, the `chats_conversation` frame), rendered for
145
+ `/messages/:id` too; the list alone under `:stacked`. The search form is
146
+ a bare `<form>` with no `action`.
147
+ - `chats/conversations/show.html.erb`: the thread page, now only under
148
+ `:stacked` (or for views ejected before 0.6); its header moved to the new
149
+ `chats/conversations/_thread_header.html.erb`, shared with the pane.
150
+ - new `chats/conversations/_conversation_pane.html.erb` (the pane:
151
+ `section.chats-split__pane`, labelled by its `h2` title, the header, and
152
+ the permanent `.chats-split__thread` around the thread),
153
+ `chats/conversations/_empty_pane.html.erb` (`.chats-split__empty`,
154
+ `--inbox` for an empty inbox) and `chats/conversations/pane.html.erb`
155
+ (the frame's answer).
156
+ - `chats/conversations/_thread.html.erb`: `pane:` (rooted at
157
+ `.chats-thread--pane`, inside the page's `.chats` shell) and
158
+ `read_pending:` locals.
159
+ - `chats/conversations/_conversation_row.html.erb` and `_group.html.erb`:
160
+ with `pane: true` the link carries
161
+ `data-turbo-frame="chats_conversation"` (a stack only when it holds one
162
+ conversation) and the list's `filter:` (`q`, `with`) in its URL, and
163
+ with `current:` the current row its class and `aria-current="page"`;
164
+ the `<li>`'s classes come from `class_names` (no trailing space).
165
+ - `chats/conversations/_thread_header.html.erb` takes `back_path:` (the
166
+ pane's keeps the list's filter) and gives a titled pane's heading
167
+ `tabindex="-1"`.
168
+ - `chats/dock/_dock.html.erb`: one more action,
169
+ `turbo:frame-load@document->chats--dock#pageFrameLoaded`.
170
+ - `chats.css`: the `chats-split`, `chats-split__list`,
171
+ `chats-split__conversation`, `chats-split__pane`, `chats-split__thread`,
172
+ `chats-split__empty` and `chats-thread--pane` rules and
173
+ `--chats-list-width`; `--chats-split-height` is read with a `100dvh`
174
+ fallback and never declared by the gem, so a host's declaration wins
175
+ wherever it is. The existing class names are unchanged, so a theme on
176
+ `.chats`, `.chats-row` or `.chats-thread` keeps applying.
177
+
178
+ ## [0.5.1] - 2026-10-18
179
+
180
+ A page that missed a broadcast is never left stale: every live surface
181
+ catches up on its own, without polling (fixes
182
+ [#1](https://github.com/rameerez/chats/issues/1)). Also: the dock's unread
183
+ count in every language's plural forms, one extension point for a
184
+ conversation that must open although the inbox hides it
185
+ (`conversation_lookup_scopes`), and real conversations told from
186
+ system-only threads. **No migration, nothing to configure.**
187
+
188
+ ### Added
189
+ - **Real conversations vs system-only threads.** `with_messages` asks whether
190
+ a conversation holds anything at all, system lines and tombstones
191
+ included; ops walls, moderation lists and analytics ask whether a messager
192
+ wrote something there that still stands. Four additive reads answer that:
193
+ `Chats::Message.human` (kind `text`, not deleted: written by a messager,
194
+ person or not, as opposed to posted by the app, so a bot's or a headless
195
+ desk's text message counts and a photo with no caption counts too), `Chats::Conversation.with_human_messages` and
196
+ `.without_human_messages` (one correlated `EXISTS` / `NOT EXISTS` built
197
+ from `Message.human`, so they compose with `alice.chats`, `inbox_for`,
198
+ `about`, `unread_by` and `with_messages` and never multiply rows),
199
+ `Conversation#last_human_message` (a query, not the `last_message`
200
+ pointer), and its batch twin `Chats::Message.last_human_in(conversations)`:
201
+ the newest human message of each conversation in ONE query, for a loaded
202
+ page, ids, a single record, or an unloaded relation, which is read as a
203
+ subquery and never loaded first. It returns a relation, so a `.select` can
204
+ narrow the columns. Viewer-facing lists start from `alice.chats`, which
205
+ applies blocks and `config.inbox_scope`; the bare `Chats::Conversation`
206
+ scopes see every conversation and belong behind staff authorization.
207
+ Nothing changes by default: system messages still bump `messages_count`
208
+ and `last_message`, and the inbox still lists system-only threads.
209
+ - **`Chats::ApplicationController#conversation_lookup_scopes`, the one
210
+ extension point for a conversation that must open although the inbox
211
+ hides it.** Every engine endpoint (the thread, a dock window, sending,
212
+ editing, reacting, reading) finds a conversation through
213
+ `find_conversation(id)`, which now asks each relation
214
+ `conversation_lookup_scopes` returns, in order (`find_by(id:)`), and
215
+ raises `ActiveRecord::RecordNotFound` when none has it. By default that is
216
+ `[chats_current_messager.chats]`, exactly the old lookup. A product gem
217
+ (support_desk: a requester's own ticket, which the host's `inbox_scope`
218
+ keeps out of the inbox) prepends a module whose
219
+ `conversation_lookup_scopes` returns `super + [its own scope]`, instead of
220
+ re-implementing the lookup and recognising a miss by the exception's
221
+ details. Later scopes are asked only on a miss; each scope must hold only
222
+ conversations the viewer may open, so nothing outside them becomes
223
+ reachable; and an error a scope raises is not rescued into a 404.
224
+
225
+ ### Fixed
226
+ - **The dock's window count used the right plural form only in two-form
227
+ languages.** `dock_controller.js` picked `one` for exactly 1 and `other`
228
+ for everything else, so Polish, Russian, Ukrainian, Croatian or Serbian
229
+ (one/few/many, with 21 read like 1 in the East Slavic ones) and
230
+ Arabic-style zero/two read wrong. The dock now renders every plural form
231
+ the locale provides for `chats.dock.window_unread` (`zero`, `one`, `two`,
232
+ `few`, `many`, `other`; new helper `chats_plural_forms`), and the
233
+ controller picks one with `Intl.PluralRules` in the page's language
234
+ (`<html lang>`, else the browser's), reading a missing form as `other`
235
+ and falling back to the old rule where `Intl` is unavailable. The gem's
236
+ `en` and `es` keep `one`/`other`; a host adds the forms its languages need
237
+ in its own locale files.
238
+ - **Every live surface catches up on the broadcasts it missed.** Action
239
+ Cable has no replay, so a broadcast sent while a page's socket was down
240
+ (a laptop asleep, a backgrounded tab or Hotwire Native WebView, a network
241
+ blip) never reached it, and the inbox, a thread, the unread badge or the
242
+ dock could sit on old content until the next navigation. One shared
243
+ module, `chats/recovery` (pinned for importmap hosts, like the
244
+ controllers), now watches each surface's own
245
+ `<turbo-cable-stream-source>` and the page, and asks for what was missed
246
+ when the stream reconnects, when the page comes back after 10 seconds or
247
+ more hidden, when the network comes back online (it asks Action Cable to
248
+ check its socket at once instead of after its backoff, and catches up
249
+ anyway: after a short outage the socket can look open while what was
250
+ sent during it is gone) and when the page
251
+ is restored from the back/forward cache or from Turbo's snapshot cache (a
252
+ back navigation, a Hotwire Native stack pop). Never otherwise: a healthy,
253
+ visible page fetches nothing. A hidden page never fetches; what it missed
254
+ waits until it is seen. Triggers that arrive together make one catch-up,
255
+ and catch-ups are at least five seconds apart.
256
+ - **A reconnect Action Cable never reported is seen.** When Action Cable
257
+ reopens a socket that went quiet (the half-open connection of a phone that
258
+ slept), it drops the old socket's handlers before its close event can
259
+ arrive: subscribers hear "connected" again and never "disconnected", and
260
+ the `reconnected: true` detail reaches only the first subscription
261
+ confirmed. Every confirmation after the first now counts.
262
+ - **A page restored from Turbo's snapshot cache catches up.** It came back
263
+ as old as when it was left, with no request: a thread restored by a back
264
+ navigation missed every message sent since. Its stream sources are marked
265
+ as they go into the cache, and a restored one catches up once it is live.
266
+ - **The inbox** reruns a refresh that arrived while another visit was
267
+ loading, which Turbo ignores (`Session#refresh` skips one while
268
+ `navigator.currentVisit` is set): a message landing mid-refresh was lost
269
+ with a healthy socket. A refresh no longer rewrites the field you're
270
+ typing in, no longer drops the search (see Changed), and a morph no longer
271
+ strips the stream source's `connected` attribute.
272
+ - **The inbox refresh is durable.** `refresh_inbox_of` enqueues
273
+ `Chats::InboxRefreshJob` straight from the after_commit instead of calling
274
+ `broadcast_refresh_later_to`, which parked the refresh for half a second
275
+ in an in-process timer (`Turbo::Debouncer`) before enqueueing it, so a
276
+ deploy or a crash inside that window dropped it. At most one is queued
277
+ per recipient: while one is queued and hasn't started, it stands for
278
+ every write that commits before it runs (a short-lived marker in
279
+ `Rails.cache`, removed by the job as it starts, before it broadcasts), so
280
+ a burst is one job per recipient. A lost marker costs an extra job, never
281
+ a missed refresh, and the marker expires after two seconds. Only a
282
+ marker that is really there counts as "queued": a store that can't hold
283
+ it (RedisCacheStore answers nil when a write fails; any store may raise)
284
+ means the write queues its own refresh. Since another
285
+ write may count on a refresh whose enqueue is still in flight, a failed
286
+ enqueue is retried once, then the refresh is broadcast on the spot. Every
287
+ refusal goes to `Rails.error` (raised, `false`, or a job the adapter
288
+ declined), never up through the write that committed. The refresh
289
+ tag carries `method="morph" scroll="preserve"`, so the inbox morphs
290
+ whatever the host layout's refresh meta says, and no request id, since
291
+ it may stand for several requests' writes.
292
+ - **The thread's catch-up asks on two cursors.** It sent only the newest
293
+ `updated_at` it had rendered and the server appended what was created
294
+ after it, so a message created at 10:15 and missed, while a bubble on
295
+ screen was edited at 10:20, never came. Now `after=` (the newest creation
296
+ on screen) decides what is appended and `changed=` (the newest change)
297
+ what is replaced, and both scopes split on the same creation cursor
298
+ (`created_after` / `created_through(…).updated_after(…)`). Each cursor is
299
+ a moment to the microsecond plus the ids on screen at it (`after_ids=`,
300
+ `changed_ids=`): a message missed in the very instant of one on screen,
301
+ or a change missed inside the same millisecond as one on screen (the old
302
+ cursor rounded up to the millisecond), is no longer lost. The cursors
303
+ are reconciled moments: a complete render sets them (the page, a window,
304
+ a history re-render), and so does a catch-up that arrived (its
305
+ `chats_caught_up` action); a live bubble never moves them, since it can
306
+ arrive after one that was missed (a message missed at 10:15, then one
307
+ live at 10:20). What arrived live is named instead, at most 200 ids
308
+ (`after_ids`, `changed_ids`, each id at most 64 characters); past that,
309
+ or with `full=1`, the history is re-rendered. Kept on the element
310
+ (`data-chats-reconciled`), so a dock window moved by a Turbo visit, or a
311
+ restored snapshot, keeps them. Each catch-up is bound to one render of
312
+ the thread (`render=`, echoed by the answer): an answer that arrives
313
+ after the page or a dock window rendered the conversation again is
314
+ dropped unrendered, and its cursors ignored — checked again by every
315
+ stream of it at the moment Turbo applies it, a repaint later, so a visit
316
+ or a window reload in between can't hand the old streams the new render.
317
+ A history re-render is followed by one more catch-up from its own
318
+ cursors once Turbo has actually applied it, however late that is, so a
319
+ message written while that page was being built isn't lost. Every
320
+ catch-up is bounded: a request with no answer after 15 seconds is
321
+ aborted (a fetch can hang across a network interruption, and would
322
+ otherwise stand in for every later one), a thread has at most one answer
323
+ waiting for Turbo (a newer one, or the thread going away, drops the
324
+ older one's leftover streams and stops watching for them — going away
325
+ for good: a dock window Turbo carries across a visit is off the page
326
+ until that render ends, which a host's asynchronous `turbo:before-render`
327
+ can make last many tasks, and keeps its answer, decided only once every
328
+ `turbo:before-render` has had its `turbo:render`, paired first in, first
329
+ out; a render that runs past 10 seconds stops holding things up but stays
330
+ paired until its own end, and one that never ends is forgotten after a
331
+ minute), and only the
332
+ answer's own streams are guarded (`data-chats-answer`), never a host's
333
+ stream rendered at the same moment. A thread whose catch-up didn't apply,
334
+ for any reason (its page rendered it away, the request failed, Turbo
335
+ never got to it), is marked behind (`data-chats-stale`, on the thread
336
+ element itself, so the mark travels with the dock and can't be
337
+ forgotten while the thread exists) and catches up again when it
338
+ connects, on its stream's confirmation and after every page render,
339
+ through the same coalescing and 5-second spacing as every other trigger;
340
+ only the answer of the newest catch-up clears it (they are numbered on
341
+ the element, `data-chats-catch-up`), so an older answer that applies
342
+ after a newer one failed leaves the thread marked; and nothing older
343
+ than the newest catch-up that has applied (`data-chats-applied`) may
344
+ apply after it, so an older answer arriving last can't put back older
345
+ bubbles, read state or cursors. A catch-up always gets an answer built
346
+ after it was asked: the same request asked in the same instant is made
347
+ once, but asked while it is already on its way, it is made once more as
348
+ soon as that one has its answer from the network (never waiting on
349
+ Turbo to apply it), for everyone who asked meanwhile, each keeping its
350
+ own `when` and its own outcome. An answer Turbo hasn't applied 10
351
+ seconds after it joined the page is cancelled (its callers settle, and a
352
+ thread stays marked behind), and neither a caller's hook that throws nor
353
+ a stream that fails to render can leave an answer half-applied or its
354
+ callers waiting. An empty
355
+ thread, which used to ask nothing, asks from the epoch: its first message
356
+ may be the one it missed.
357
+ - **The thread's catch-up is complete.** `GET /:id/refresh` now also
358
+ replaces the read state (a read the stream missed left "Sent" where it
359
+ should say "Seen"), catches reactions (a reaction now moves its message's
360
+ `updated_at`, with `update_columns`, so the bubble is not broadcast twice),
361
+ and, under `deletion: :hard`, lists the messages that still exist from the
362
+ oldest rendered bubble on (`from=`), so the thread removes the ones that
363
+ are gone (the `chats_prune` stream action). Catching up still reads
364
+ nothing.
365
+ - **The unread badge and the dock catch up.** The viewer's own
366
+ `chats_unread_badge` is wrapped in a `chats--unread-badge` controller (a
367
+ `span.chats-unread-badge-live`, `display: contents`, so it changes no
368
+ layout) that asks the new `GET /badge` for every unread badge on the page:
369
+ the nav badge and the dock's, with the count the badge streams send
370
+ (`unread_chats_count`). Someone else's badge stays live but doesn't catch
371
+ up. The dock fetches the same, and reloads its recent list; each open
372
+ window's thread catches up by itself. A message missed while offline
373
+ never pops a window open late.
374
+
375
+ - **Both search forms are search landmarks.** `form_with … role: "search"`
376
+ rendered no `role` at all (`form_with` drops HTML attributes it doesn't
377
+ know unless they go through `html:`), on the inbox and in the dock.
378
+
379
+ ### Changed
380
+ - A page now counts as having been away after 10 seconds hidden, not 60
381
+ (the inbox and the thread used 60).
382
+ - A thread more than a page behind no longer gets a page refresh, which
383
+ cleared the composer's draft and, in a dock window, refreshed the host's
384
+ whole page: its history (`chats/conversations/_history`, inside
385
+ `#history_conversation_<id>`) is re-rendered at the latest page, the
386
+ composer untouched. An ejected thread whose scroller has no
387
+ `id="<%= dom_id(conversation, :history) %>"` keeps getting the page
388
+ refresh.
389
+ - The inbox's search keeps its query in the URL (`data-turbo-action=
390
+ "replace"`, submitting to the inbox's own path), so a refresh re-renders
391
+ the same results instead of the unfiltered list. The dock's recent list
392
+ frame has `refresh="morph"`, so a reload keeps its scroll and its search.
393
+ - New: the `GET /badge` collection route, the `chats--unread-badge`
394
+ controller, `Chats::InboxRefreshJob`, and `data-message-id`,
395
+ `data-created-at-us` and `data-updated-at-us` on every bubble, which no
396
+ longer carries `data-updated-at-ms`.
397
+ - **If you wrapped the inbox in a reconciler of your own** (a
398
+ stream-reconciler controller around `turbo_stream_from … :chats_inbox`),
399
+ remove it: two on one page both fire, and the gem's covers what yours did.
400
+ **If you ejected the inbox**, keep the
401
+ `data-controller="chats--refresh-inbox"` element around its
402
+ `turbo_stream_from chats_current_messager, :chats_inbox` (an inbox without
403
+ it doesn't catch up), and add `turbo_action: "replace"` to the search
404
+ form's `data` and `url: request.original_fullpath.split("?").first`. An
405
+ ejected message partial (with only `data-updated-at-ms`) keeps the one
406
+ cursor it had (`since=`), and a hard delete missed while offline stays on
407
+ screen until the next load.
408
+
409
+ ## [0.5.0] - 2026-10-18
410
+
411
+ The messaging dock. LinkedIn and Steam keep your conversations in small
412
+ windows in the bottom-right corner while you go on using the app; one line
413
+ in a layout, `<%= chats_dock %>`, now does that. **No migration.** The dock
414
+ is opt-in, but a few things change without asking, all under Changed: a
415
+ conversation with no messages is no longer listed, a direct thread the
416
+ host's policy no longer allows says so up front, a hover prefetch no longer
417
+ reads a thread, and the thread body moved into a partial.
418
+
419
+ ### Added
420
+ - **`chats_dock`**, the messaging dock: `aside.chats-dock`, fixed to the
421
+ bottom-right corner, holding the floating windows (`#chats_dock_windows`,
422
+ newest nearest the panel) and the panel. The panel is a `<details>`: its
423
+ header is the bar (the viewer's avatar, "Messages", the unread badge),
424
+ and open it has the inbox's search, the viewer's most recent inbox rows
425
+ (stacks, verified badges, a bell-slash on muted ones, previews, unread
426
+ counts) and "See all messages". It opens without JavaScript, and its
427
+ rows, search and links are real links and forms. Options: `windows:` (3),
428
+ `recent:` (10), `launcher:` (true; false drops the phone's round button
429
+ for a host whose own nav opens the sheet) and `everywhere:` (see below).
430
+ Renders nothing for a signed-out visitor and nothing on the engine's own
431
+ pages.
432
+ - **It survives Turbo visits, per viewer.** The element is
433
+ `data-turbo-permanent`, so its windows, drafts, scroll positions and
434
+ subscriptions ride along from page to page. Its id names the viewer
435
+ (`chats_dock_<messager key>`), so a visit that lands on another
436
+ account's page (a sign-out and sign-in, an account switcher) never
437
+ matches it, and every window carries its viewer and is dropped on connect
438
+ if it names someone else. Because the server can't remove a permanent
439
+ element from the next page, the dock also hides itself on any page with
440
+ `data-chats-dock-hide`; the engine's inbox and thread roots carry it.
441
+ `everywhere: true` renders the dock on the engine's pages and ignores the
442
+ attribute.
443
+ - **Floating windows.** A window is a turbo frame
444
+ (`dom_id(conversation, :window)`) loading `GET /:id?window=1`, which,
445
+ from inside a frame, renders the same thread body as the page, rooted at
446
+ `.chats-thread.chats-thread--window` (never `.chats`, the full-page
447
+ shell), under a compact header: a title button (avatar, name, verified
448
+ badge, subject, the waiting count) that minimizes it, the
449
+ `conversation_header_actions` slot in a ⋯ menu with the page's locals
450
+ (`blockable:` included), ⤢ for the full page, – and ×. Until the thread
451
+ arrives, and if it can't, the window shows the same header over
452
+ "Loading…" or "Couldn't load this conversation." with "Try again"; a 404
453
+ (left, blocked) closes it, read from the response before Turbo acts on
454
+ it (an error page carrying `turbo-visit-control`, as Rails' own do, would
455
+ otherwise take the whole page with it), keeping what was typed in it; a
456
+ 5xx shows the failed shell. An empty conversation's window says "Say
457
+ hello to …". The dock never opens a window for a conversation the page itself
458
+ shows (the streams address the thread's DOM ids, which must exist once).
459
+ - **What fits.** `min(windows, floor((viewport − 312) / 344))` windows beside
460
+ the panel: two at 1280px, one at 768px, three from 1344px. Opening one
461
+ more closes the least recently used window that isn't in use. A window in
462
+ use (it has the focus, or unsent text) is never closed to make room: it
463
+ folds to a 104px header (avatar and ×, `--chats-dock-squeezed-width`)
464
+ beside the new one. Two limits are absolute: never more than `windows:`
465
+ windows, folded ones included, and never wider than the viewport beside
466
+ the panel. An open that can't fit even with every window in use folded is
467
+ declined, changing nothing, and the row's link (or the `dock: true`
468
+ button) opens the conversation's page instead; a pop-up just doesn't
469
+ happen. Unfolding a folded window (its title, a row, a repeated `dock:
470
+ true`) makes room exactly as an open does, and when it can't, the window
471
+ stays folded. Narrowing the viewport closes what isn't in use and folds
472
+ what is, the focused window last; when even a row of folded headers is
473
+ too wide, it closes the least recently used windows in use too, keeping
474
+ their unsent text, and announces nothing. A window the viewer closes
475
+ keeps its unsent text for when it comes back. Every way a window leaves
476
+ the dock (closed, evicted, declined, gone for the viewer, shown by the
477
+ page, another viewer's) goes through one path that keeps the draft and
478
+ resyncs the rows and the phone sheet.
479
+ - **Reading waits for the viewer.** A window never reads on render. A
480
+ minimized window never marks anything read, and a window that popped
481
+ open, or came back after a reload, marks messages read only once the
482
+ viewer focuses it, presses in it or scrolls it: the dock sets
483
+ `data-chats-minimized` and `data-chats-active` on the window and the
484
+ thread controller obeys them, checking again when its delayed read fires
485
+ (state, not geometry: a folded window's scroller also loses its padding,
486
+ but a size check alone is never trusted). A window the viewer opened
487
+ is engaged from the start. The thread page has no gate and is unchanged.
488
+ - **Live, server-driven.** A new per-messager stream,
489
+ **`[messager, :chats_dock]`**, gets one render per participant per event
490
+ (`chats/dock/_update`): the dock's badges (the nav badge's count) when
491
+ the count moves, and a signal template (`chats/dock/_signal`). The signal
492
+ reloads the recent list, debounced, for every participant, the sender and
493
+ edits and deletes included. For a message the participant would be
494
+ notified about (`Participant#notifiable_for?`: not theirs, not muted, not
495
+ a headless seat) it also names the conversation, the sender and the
496
+ unread count there. The dock then pops that conversation's window open,
497
+ waiting with the accent header and its count, and announces "New message
498
+ from …" in a polite live region (at most once per conversation every 30
499
+ seconds, never for the window you're typing in). The pop-up only happens
500
+ in dock mode, never moves the focus, never for a conversation the viewer
501
+ closed in the last five minutes, and never by closing a window someone is
502
+ using; with no room, there's no pop. **The signal names a conversation
503
+ only to a participant who can see it** (`messager.chats`, so blocks and
504
+ `inbox_scope` apply), because the dock's list and window refuse anything
505
+ else. The badges stay truthful either way. No polling.
506
+ - **Memory.** Per viewer in `localStorage`: `chats:dock:v1:<messager key>`
507
+ (which windows, minimized or folded or not, when each was used, the
508
+ recently closed ones and the panel) and `chats:draft:<messager key>:
509
+ <conversation id>` (unsent text, saved as you type and whenever a window
510
+ goes, by any path, a refused or missing one included, and before a
511
+ failure (5xx, network) replaces the window's content; restored the moment
512
+ the window's composer appears, cleared on send, kept seven days). A saved
513
+ draft goes back only into an EMPTY composer: if something was already
514
+ typed there, it stays, and the saved draft is never replaced by anything
515
+ shorter, so neither text is lost. A reload restores the
516
+ saved arrangement as it was, folded windows included, up to the cap, then
517
+ fits it to the width there is now by the resize rules; none of them is
518
+ engaged. The panel comes back in dock mode only, and the phone's sheet
519
+ always starts closed. Every access is guarded,
520
+ so a browser that refuses storage only means forgetting.
521
+ - **Phones.** Under 700px wide or 500px tall the closed bar is a 56px round
522
+ launcher, the open panel a full-screen sheet, and a conversation one
523
+ full-screen window with ← back to the list, sized to the visual viewport
524
+ so the composer sits on the keyboard. The list behind a window is `inert`
525
+ until Back, which returns the focus to it. The sheet makes the rest of the page
526
+ `inert` (the siblings of the dock and of each of its ancestors, so a
527
+ layout that wraps the dock never inerts the sheet) and stops it scrolling. Inputs are 16px, so iOS doesn't zoom.
528
+ Messages never pop a window on a phone. The layout is one media query, so
529
+ it applies before JavaScript connects and without it; the controller reads
530
+ the same query.
531
+ - **Keyboard and screen readers.** Escape unwinds from the inside out: the
532
+ thread's popup or photo, an edit (the composer now cancels one on
533
+ Escape), the ⋯ menu, then the window (minimized, focus to its title), the
534
+ panel (closed, focus to the bar); on a phone, the window back to the list,
535
+ then the sheet closed. Focus goes into a window only when the viewer
536
+ opened it: its composer on a desktop, ← on a phone. Close returns the
537
+ focus to whatever opened the window. Focus rings are inset, the history
538
+ scroller is focusable, the badge is not a live region, and the bar reads
539
+ "Messages, 3 unread" from its own text.
540
+ - **`chat_button_to(other, dock: true)`** posts `dock=1`, and
541
+ `ConversationsController#create` answers a Turbo Stream that appends the
542
+ conversation's window to `#chats_dock_windows`. When that conversation is
543
+ already in a window, the dock focuses it instead of letting the append
544
+ replace it, so nothing typed is lost. It belongs with `chats_dock`: on a
545
+ page without the dock the stream has no target and does nothing. Without
546
+ Turbo, and without `dock`, the redirect is unchanged.
547
+ - **`chats_dock_opener(conversation)`** and **`chats_dock_toggle`**: data for
548
+ any link on the page to open that conversation's window, or the dock's
549
+ panel (the sheet on a phone). They stay plain links without a dock or
550
+ JavaScript. **`chats_muted?(conversation, viewer)`** reads the viewer's
551
+ seat.
552
+ - **`GET /dock`** (`dock_conversations_path`), the panel's list:
553
+ `Chats::Inbox` with `?q=` and `?recent=` (1 to 50), rendered into the
554
+ `chats_dock_list` frame without a layout. The inbox answers a request from
555
+ that frame (the dock's search, which submits to the inbox so that it
556
+ lands on the full inbox's results without JavaScript) with the same list.
557
+ - **`Chats::Inbox.for(viewer, limit:)`**: how many rows come back
558
+ (`config.inbox_limit` by default, never more). A smaller limit is a
559
+ smaller query; a search still looks through the whole `inbox_limit`
560
+ window.
561
+ - **`Chats::Conversation.with_messages`**: conversations that have at least
562
+ one message, system messages and tombstones included (`last_message_id`
563
+ set, which the denormalized pointer already gives). It doesn't mean a
564
+ person wrote there: a thread holding only a system note is listed.
565
+ - **The `dock_panel_footer` slot** (receives `viewer:`): your own link next
566
+ to "See all messages".
567
+ - **Theming.** The dock's variables are declared on `:where(.chats-dock)`,
568
+ not `:root`, so a host re-pointing `--chats-bg` or `--chats-accent` on the
569
+ dock reaches them: `--chats-dock-z` (40), `-offset` (bottom clearance),
570
+ `-inset` (right edge), `-gap`, `-bar-height` (48px), `-panel-width`
571
+ (288px), `-panel-height`, `-window-width` (336px), `-window-height`
572
+ (480px), `-radius`, `-bg`, `-header-bg`, `-hover` (`#f9fafb`: muted text
573
+ at 4.63:1, where `--chats-surface` gives 4.39), `-edge`, `-shadow`,
574
+ `-unread-bg` / `-unread-text` (the accent pair), `-focus`,
575
+ `-title-font`, `-launcher-size`, `-duration`, `-ease`, plus
576
+ `--chats-safe-top/right/bottom/left` on `:root`. Nothing leaves a
577
+ transform, filter or `contain` on the dock or an expanded window, which
578
+ would trap the thread's fixed popup and photo preview inside it; entrance
579
+ animations end at `transform: none`, and `prefers-reduced-motion` turns
580
+ them all off. Strings under `chats.dock.*` in `en` and `es`. The
581
+ controller is pinned as `controllers/chats/dock_controller`, and
582
+ `rails generate chats:views` has a `dock` group (`app/views/chats/dock/`).
583
+
584
+ ### Changed
585
+ - **A conversation with no messages is never listed.** Opening a
586
+ conversation (`chat_with`, the Message button) creates it, and until now
587
+ the inbox listed it straight away as a "No messages yet" row. Every list
588
+ now reads through `Chats::Conversation.with_messages`: the inbox rows,
589
+ a stack's rows and its `open_count` / `unread_count`, search, the
590
+ `?with=` view and the dock. **If your inbox showed rows for threads
591
+ that were opened and never used, it no longer does.** The conversation
592
+ still opens when addressed directly (the thread page and its dock
593
+ window work), because `Messager#chats`, the relation every engine lookup
594
+ authorizes through, is unchanged; use `chats.with_messages` for lists of
595
+ your own. `unread_chats_count` already agreed (an empty conversation has
596
+ nothing unread).
597
+ - **A direct thread nobody may write in says so up front.** When the host's
598
+ `can_message` policy no longer allows the pair, or the other account is
599
+ gone, the thread (page and window) shows "You can't message this person."
600
+ in the composer's place, through the locked-composer box (same id; the
601
+ `locked_composer` slot gets it as `notice:`), instead of a composer whose
602
+ sends fail. `_locked_composer` takes an optional `notice:` local.
603
+ - **A hover prefetch never reads a thread.** Turbo 8 prefetches a link when
604
+ the pointer rests on it, and `ConversationsController#show` used to read
605
+ the thread on that request, so hovering an inbox row cleared its unread
606
+ badge. A request with `X-Sec-Purpose: prefetch` no longer reads; the
607
+ visit does. The dock's list turns prefetch off altogether.
608
+ - **The thread body moved into `chats/conversations/_thread.html.erb`.**
609
+ It holds the `chats--thread` element and everything inside it except the
610
+ header: the `turbo_stream_from`, the scroller with the older-pages frame
611
+ and the «new messages» line, read state, typing, the long-press popup,
612
+ the photo preview and the composer. It renders as a partial layout whose
613
+ `yield` is the header, with locals `conversation:`, `messages:`, `more:`,
614
+ `first_unread_id:`, `counterpart:` and `window:`. `show.html.erb` is now
615
+ its header wrapped in that partial. The page's DOM gains
616
+ `data-chats-dock-hide` and `data-chats--thread-read-pending-value="false"`
617
+ on its root and `tabindex="0"` on the scroller. **If you ejected
618
+ `show.html.erb`**, your copy keeps rendering the page as before; add
619
+ `data-chats-dock-hide` to its root if you render the dock everywhere.
620
+ Dock windows render the gem's `_thread` (or yours, if you eject it), so a
621
+ restyle that lives in your `show.html.erb` doesn't reach them: move it
622
+ into an ejected `_thread.html.erb` to style both.
623
+ - **Grouped inbox rows cost two queries in all.** A stack's `open_count` and
624
+ `unread_count` were two aggregates per stack; they are now two grouped
625
+ queries for every stack the inbox shows, so an inbox (or a dock) with many
626
+ stacked counterparts stays constant-query.
627
+ - The unread badge partial (`chats/shared/_unread_badge`) carries
628
+ `data-chats-unread-badge`, the mark every unread badge has, the dock's
629
+ included. Its broadcast still targets `#chats_unread_badge`, so a badge
630
+ ejected earlier keeps updating unchanged. Add the attribute if your own
631
+ CSS or JS wants to find every badge.
632
+ - The inbox row partials (`_conversation_row`, `_group`) take an optional
633
+ `dock:` local that marks their link as a dock opener, and a direct or
634
+ group row shows the bell-slash when the viewer muted it. Rows ejected
635
+ earlier still work in the dock; they navigate instead of opening a
636
+ window.
637
+ - **The composer's fields have per-conversation ids**
638
+ (`dom_id(conversation, :message_body)`, `:message_files`), instead of
639
+ `form_with`'s `message_body` / `message_files`. A page can now hold
640
+ several composers, and Turbo restores focus by id after rendering a
641
+ stream, so a shared id moved the cursor into another window's composer.
642
+ CSS or JS that selected `#message_body` should select `.chats-composer__input`.
643
+ - The long-press Edit event (`chats:edit-message`) now carries its thread
644
+ element, and a composer only starts editing for a message of its own
645
+ thread, since a page can hold several threads now. The check lives in
646
+ the controller, so ejected composer views get it too; a host that pinned
647
+ its own `composer_controller` reacts to every thread's Edit, as before.
648
+ - The dummy app has a real importmap setup (Turbo, Stimulus, the stock
649
+ eager loader) and a host home page with the dock, which is how the
650
+ screenshots were taken. `test/system` measures the dock's rendered layout
651
+ in a headless Chrome (capybara and selenium-webdriver join the test
652
+ bundle). Under CI (`CI=true`) Chrome is a prerequisite: the tests fail
653
+ when it can't start, and the workflow checks for it first; on a machine
654
+ without Chrome they skip. The suite prints how many ran.
655
+
656
+ ## [0.4.2] - 2026-10-17
657
+
658
+ ### Changed
659
+ **Upgrade note: your `can_message` policy now runs on every direct send,
660
+ not only when a thread is opened.** Read yours before upgrading. If it
661
+ means "who may OPEN a conversation" (a listing must be live, a ride must be
662
+ accepted), existing threads will stop taking messages the moment it turns
663
+ false for the pair — which is usually what you want (a block already
664
+ behaves this way), but is a change. If it is expensive, it now costs that
665
+ on every direct message; make it cheap or cache what it reads. It must not
666
+ raise: an exception escapes the send like any other host error. Group
667
+ conversations and system messages are unchanged.
668
+
669
+ - **`can_message` holds on every direct send.** A block placed
670
+ mid-conversation already stopped the next message; the host's policy
671
+ turning false did not, so a pair allowed when the thread was opened kept
672
+ talking after an account was banned or a membership ended. Every text
673
+ message into a direct conversation now re-runs `Chats.can_message?`
674
+ exactly like the block check: `:blocked` when either side blocked the
675
+ other, `:not_allowed` (new, in `en` and `es`) when the policy says no.
676
+ The policy is asked last, only of a send that is otherwise valid (a
677
+ stranger, a locked thread or an empty body is refused before it runs).
678
+ The composer answers with its errors stream as before.
679
+ - **A direct thread with nobody on the other side takes no messages.** When
680
+ the counterpart's account was destroyed, its seat went with it (the
681
+ gem's `dependent: :destroy` on participations); a text message into that
682
+ thread is refused with `:not_allowed` instead of slipping past both the
683
+ block and the policy checks. The thread stays readable.
684
+
685
+ ## [0.4.1] - 2026-10-17
686
+
687
+ ### Fixed
688
+ - **An action's strings are frozen copies.** `Data` freezes the action but
689
+ not the strings it was handed, so `action.url.replace("javascript:…")`,
690
+ or the caller mutating the string they passed in, could rewrite a
691
+ validated action in memory before the bubble rendered it. `Chats::Action`
692
+ now keeps a frozen copy of both members: what the validator saw is what
693
+ renders.
694
+ - **An empty port suffix is refused.** `https://host:/x` parses as the
695
+ default port and slipped past the 1..65535 rule; a named port must have
696
+ digits.
697
+ - **An action's label is a plain String.** A label handed over as an
698
+ html_safe string (a host's `t("…_html")`) kept the mark through the copy
699
+ and rendered unescaped in the button. The copy is now a plain `String`,
700
+ so the bubble escapes it like any other content.
701
+ - **`config.inbox_scope` is one rule, applied once.** `Messager#chats` now
702
+ applies it, so the inbox relation, `unread_chats_count`, the inbox screen
703
+ and the live badge all agree. The badge broadcast used to count through
704
+ the raw `inbox_for` relation and could light up for a conversation the
705
+ inbox would never show (a host scoping game records out of the inbox saw
706
+ the badge count them). `Chats::Inbox` builds on `viewer.chats`, so a
707
+ host's own override of `chats` reaches the inbox screen too.
708
+ - The README said the inbox preview still shows a soft-deleted message's
709
+ body; it reads "Message deleted", as for any tombstone.
710
+
711
+ ## [0.4.0] - 2026-10-17
712
+
713
+ Action buttons. A support desk writes "Your invoice is ready" and wants a
714
+ *Pay now* button under it; a game writes "Let's play!" and wants *Join my
715
+ room*. Until now a host pasted a URL into the body. **0.4.0 needs one
716
+ migration** (`rails generate chats:upgrade && rails db:migrate`, one
717
+ nullable JSON column) and otherwise changes nothing until you pass
718
+ `action:`.
719
+
720
+ ### Added
721
+ - **One action per message.** `alice.message!(bob, "Let's play!", action:
722
+ { label: "Join my room", url: "/play/abcd" })`, and the same `action:` on
723
+ `Conversation#post_system_message!`. `message.action` is a
724
+ `Chats::Action` (an immutable `Data` value: `#label`, `#url`, `#to_h`) or
725
+ nil, and `message.action?` says which. `Chats::Action.wrap` accepts a
726
+ Hash with string or symbol keys, an Action, or nil, and raises
727
+ `ArgumentError` for anything else, including a key an action doesn't have
728
+ (`method: :post` would otherwise have turned into a GET link silently).
729
+ Assigning goes through `wrap` immediately, so a mistake raises on the line
730
+ that made it.
731
+ - **Validated at the write.** The label is present and at most 80
732
+ characters. The url is at most 2048 characters and is either an absolute
733
+ http(s) URL with a host (and a port from 1 to 65535, if it names one) or
734
+ a path on this site starting with a single `/`. `javascript:`, `data:`,
735
+ other schemes and relative paths are refused, and so is everything a
736
+ browser would send off-site: `//host`, `///host` (which URI.parse reads
737
+ as a hostless path), `/\host` and `/<tab>/host`. Anything starting with
738
+ `//` or `/\` is refused before the url is even parsed. The error is
739
+ `:action, :invalid`, in `en` and `es`. It is checked only when the action
740
+ changes, so editing an old message never fails because of an action
741
+ nobody touched.
742
+ - **The bubble renders it** as `a.chats-message__action` under the text and
743
+ attachments, and inside the note for system messages
744
+ (`.chats-message__system--with-action`). The link has
745
+ `data-turbo-frame="_top"`: older pages of a thread load inside lazy
746
+ turbo-frames, and without it a link there would navigate only the frame.
747
+ It is a link and never a form because the bubble is rendered once for
748
+ every viewer and cloned into the long-press popup, so it cannot hold a
749
+ per-viewer CSRF token. An action is a destination; a host that needs a
750
+ POST points it at a page that asks first. The markup lives in its own
751
+ partial, `chats/messages/_action`. The button uses the accent pair: filled
752
+ with the accent on foreign bubbles and system notes, reversed on your own
753
+ accent bubble.
754
+ - **Tombstones drop it.** Soft deletion clears the action along with the
755
+ body, and so does moderation's `remove_reported_field!`, which goes
756
+ through the same path. `edit!` keeps it.
757
+ - **The composer can never set one.** `Chats::MessagesController` still
758
+ permits only `body` and `files`, the same as with `author:`, and a test
759
+ checks that a posted `message[action]` is ignored.
760
+ - **`chats_messages.action`** (jsonb on PostgreSQL, json elsewhere, nullable,
761
+ no default), created by the install migration and added to existing
762
+ installs by `rails generate chats:upgrade`. The generator doesn't write
763
+ that migration where the column already exists (it asks the database; a
764
+ fresh 0.4.0 install has it), its `up` is guarded, and rolling it back
765
+ KEEPS the column: nothing a migration can see records whether it or the
766
+ create migration added it, and it must never drop a column, with every
767
+ message's button in it, that it did not add.
768
+ - **The `composer_actions` slot.** `app/views/chats/slots/_composer_actions.
769
+ html.erb` (receives `conversation:`) renders a row of your own buttons
770
+ above the composer's input ("Create a game", "Send my location") without
771
+ ejecting it. The row is outside the composer's `<form>`, so `button_to`
772
+ works there. It goes away with the composer when the thread locks, whether
773
+ on page load or through the live 422 swap. Unstyled links and buttons get
774
+ a chip look applied with zero specificity, so any class you add wins.
775
+
776
+ ### Changed
777
+ - **The composer partial's DOM.** `chats/messages/_composer` is now a dock
778
+ wrapping the form:
779
+ `div#<dom_id(conversation, :composer)>.chats-composer` contains the
780
+ `.chats-composer__actions` row (only when the slot exists) and then
781
+ `form.chats-composer__form`, which keeps the `chats--composer` controller,
782
+ its data-actions and everything inside it. In 0.3.x the form itself was
783
+ `form#…composer.chats-composer`. The swap id moved to the dock so the live
784
+ lock replaces the host's buttons along with the form. **If you ejected the
785
+ composer** (CarHey did), your copy keeps working unchanged:
786
+ `.chats-composer` still styles whatever element carries it, and the
787
+ locked-composer swap still targets your form's id. You only need to
788
+ re-eject to use `composer_actions`. If your own CSS or JS selects
789
+ `form.chats-composer`, change it to `form.chats-composer__form` (or
790
+ `.chats-composer form`) once you're on the bundled partial.
791
+ - The Trust & Safety report-link recipe in the README referred to the
792
+ inline actions row that 0.1.1 removed, and to its `chats-message__action`
793
+ class, which now names the action button. It now puts the link in the
794
+ long-press menu as a `chats-popup__item` with `data-chats-other-only`. The
795
+ bundled CSS styles `.chats-message__action` only as a direct child of a
796
+ bubble, so links in views ejected from 0.1.0 that still use the class are
797
+ left alone.
798
+
799
+ ## [0.3.2] - 2026-09-17
800
+
801
+ ### Fixed
802
+
803
+ - Grouped inbox limits preserve other counterparts even when one desk has many recent conversations.
804
+ - Conversation creation and participant departure events run after commit and remain silent on rollback. Direct conversation creation includes its roster in the transaction.
805
+ - A message author may be any persisted model; staff need not become messagers to sign a desk's reply.
806
+
807
+ ### Added
808
+
809
+ - `Chats::SendRateLimited`, a controller concern sharing the configured sender budget across chat and product-specific composers, using the host's cache store on all supported Rails versions.
810
+
811
+ ## [0.3.1] - 2026-09-16
812
+
813
+ ### Fixed
814
+ - **The verified badge was a star, not a check.** 0.3.0 shipped a
815
+ five-pointed star as the "official account" mark, copied from a host icon
816
+ that was named `verified_badge` but drawn as a star. A star reads as
817
+ "favourite" or "rated", not "this account is really us", and at 14px a
818
+ solid star and a solid rosette are the same blob. It is now heroicons
819
+ `check-badge`: a scalloped rosette with a tick knocked out of it, which
820
+ is the mark people already read as verified. `fill-rule="evenodd"` is
821
+ what knocks the tick out — without it the rosette fills solid.
822
+
823
+ ## [0.3.0] - 2026-09-16
824
+
825
+ Official accounts. Some counterparts are not people you met — they are a
826
+ support desk, an organization, a shop, a brand — and a person should be able
827
+ to tell at a glance. **Nothing changes until a model says so**: 0.2.x
828
+ installs upgrade by bumping the gem, with no migration and no new
829
+ configuration.
830
+
831
+ ### Added
832
+ - **`acts_as_messager verified: true`.** A messager declares itself an
833
+ OFFICIAL account, and every bundled view that shows its name marks it: the
834
+ inbox row, the stacked inbox row, and the thread header. `Klass.
835
+ chat_verified?` is the class predicate and `Chats.verified?(messager)`
836
+ reads it duck-typed — false for a plain model, a nil, a non-messager — so
837
+ hosts can badge their own screens without a class check. The option is
838
+ independent of the headless ones: a desk is usually headless *and*
839
+ official, a shop is usually official and nothing else. Unlike its boolean
840
+ neighbours it refuses to coerce — `verified: "false"` raises
841
+ `Chats::ConfigurationError` at boot rather than quietly verifying an
842
+ account, because a badge is a trust claim and not a display preference.
843
+ - **The badge itself**, `chats/shared/_verified_badge` — an inline rosette
844
+ that sizes itself from the text it sits beside. It is an image with a
845
+ name, not decoration: `role="img"` plus a localized label
846
+ (`chats.verified.label`, "Official account" / "Cuenta oficial"), and the
847
+ glyph is `aria-hidden` so a screen reader never announces it twice.
848
+ - **`config.verified_badge`** `->(messager) { markup }` — swap the glyph for
849
+ your design system's own, vary it per messager, or return nil for no badge.
850
+ The default (nil) renders the gem's rosette.
851
+ - **`--chats-verified`** (`#0284c7`) — the badge colour, a CSS custom
852
+ property like the rest of the gem's theming, inherited through
853
+ `currentColor`. Not the familiar `#1d9bf0`: the badge is a meaningful
854
+ graphic, so WCAG 1.4.11 asks 3:1 of it, and `#1d9bf0` is 3.00:1 on
855
+ `--chats-bg` but 2.73:1 on `--chats-surface` — the inbox row's HOVER
856
+ background, so it failed exactly while somebody was pointing at it.
857
+ `#0284c7` clears the bar on both grounds and on a dark one, so a host
858
+ inverting the palette inherits a badge that still passes.
859
+ `test/verified_badge_contrast_test.rb` computes it rather than trusting a
860
+ swatch.
861
+ - **`chats_verified_badge(messager)`** — the view helper behind all three
862
+ surfaces, available in host views too. Nil for everyone who hasn't
863
+ declared `verified: true`, so it is safe to drop next to any name
864
+ unconditionally.
865
+ - **`Chats::Conversation#counterpart_for(viewer)`** — the other messager in a
866
+ direct thread (nil for a group, and for a thread whose other seat left).
867
+ The title, the avatar and the badge on an inbox row now resolve the
868
+ counterpart through this one method, memoized per viewer, so a row that
869
+ cost one query in 0.2.0 still costs one.
870
+
871
+ ### Changed
872
+ - The bundled inbox row wraps its title in `.chats-row__name`, and the thread
873
+ header wraps its name in `.chats-thread__name`, so a long name still
874
+ ellipsizes while the badge beside it stays visible. If you ejected these
875
+ views with `rails generate chats:views`, your copies are untouched and keep
876
+ working; re-run the generator only if you want the badge.
877
+
878
+ ## [0.2.0] - 2026-09-16
879
+
880
+ The release that makes `chats` a foundation other products can be built on:
881
+ a messager that isn't a person, a conversation whose openness belongs to its
882
+ subject, a message someone wrote on someone else's behalf, and extension
883
+ points that don't require ejecting a screen. **Nothing here changes existing
884
+ behaviour until you set an option** — 0.1.1 installs upgrade by running
885
+ `rails generate chats:upgrade && rails db:migrate`.
886
+
887
+ ### Added
888
+ - **Headless messagers.** `acts_as_messager notifications: false, blockable:
889
+ false, inbox: :grouped` — a support desk, a bot, an org mailbox. Class
890
+ predicates (`chat_notifications?`, `chat_blockable?`, `chat_inbox_mode`,
891
+ `chat_group_path`) are read duck-typed everywhere, `Participant#
892
+ notifiable_for?` honours them, and the bundled views hide block/report
893
+ affordances against a non-blockable counterpart. Hosts stop writing
894
+ `is_a?(User)` in every notifier and view.
895
+ - **Subject-owned locks.** `Chats::ChatSubject#chat_locked?` /
896
+ `#chat_locked_notice` (both inert by default) decide whether a conversation
897
+ still accepts messages; `Conversation#locked?` / `#locked_notice` read
898
+ them, and `Chats::Message` refuses non-system writes with an `:locked`
899
+ error, and every OTHER write refuses too — `Message#edit!`,
900
+ `#soft_delete!` and `Reaction.toggle!` raise `Chats::LockedError` (a
901
+ `NotAllowedError` subclass), and the edit/delete/react endpoints answer 422
902
+ with the notice. The bundled bubble stops offering what would only fail:
903
+ no Edit, no Delete, no reaction toggles, while existing reactions still
904
+ render as plain counts and Copy still works. Moderation is the one
905
+ exception — `remove_reported_field!` removes reported content from a locked
906
+ conversation, because a product lock must never shield it. The thread stays
907
+ readable: the composer is replaced by the notice
908
+ (`chats/conversations/_locked_composer`, overridable through the
909
+ `locked_composer` slot), and a send that lands on a freshly locked
910
+ conversation gets a **422 that swaps the composer** instead of an
911
+ exception. System messages are exempt, so your app can always explain the
912
+ lock in the thread it just closed.
913
+ - **Message authorship.** `chats_messages.author_type/author_id` (nullable,
914
+ polymorphic, indexed) plus `Message#author`, `#signed?`, `#authored_by?`
915
+ and `Messager#message!(…, author:)`. `sender` stays the seat; `author` is
916
+ who wrote it. Signed bubbles render a signature line ("— Lucía G."),
917
+ rewritable with `config.message_signature`. New generator: **`rails
918
+ generate chats:upgrade`** writes the migration (guarded, so it is a no-op
919
+ on a fresh 0.2.0 install, which already has the columns).
920
+ - **Grouped inbox rows.** `Chats::Inbox.for(viewer)` returns
921
+ `Chats::Conversation | Chats::InboxGroup` rows sorted by activity; every
922
+ direct thread with an `inbox: :grouped` counterpart folds into one stack
923
+ (`#messager`, `#conversations`, `#unread_count`, `#last_message`,
924
+ `#last_message_at`, `#open_count`). A stack of one links straight to its
925
+ thread, which gains a "see all" link back; a deeper stack opens
926
+ `GET /conversations?with=<signed gid>` (purpose `:chats_inbox_with`, minted
927
+ by `Chats.inbox_with_sgid`) or wherever `group_path:` points. Grouping
928
+ happens in ONE place. `config.inbox_limit` bounds ROWS, not
929
+ conversations: stacked threads are queried separately from ordinary ones,
930
+ so a desk with hundreds of open threads can never evict the rest of the
931
+ inbox, and a stack's `open_count`/`unread_count` are GLOBAL — two indexed
932
+ aggregates per stack, never per conversation and never by loading the
933
+ stack to count it. `Chats::Inbox#unread_count` is the stack-aware badge number;
934
+ `unread_chats_count` is unchanged.
935
+ - **`config.inbox_limit`** (200, replacing a literal in the controller) and
936
+ **`config.inbox_scope`** `->(relation, viewer) { relation }`, composed into
937
+ the inbox query before the limit.
938
+ - **View slots.** The bundled views render `chats/slots/_inbox_top`,
939
+ `_inbox_empty`, `_conversation_header_actions`, `_locked_composer` and
940
+ `_message_meta` when such a partial exists — one memoized lookup when it
941
+ doesn't. Hosts (and engines mounted on top of chats) add a row or a button
942
+ without ejecting a screen.
943
+ - **Subscribers.** `Chats.on(:message_created | :conversation_created |
944
+ :participant_left | :conversation_read)` replaces the single notifier
945
+ proc: many subscribers per event, each isolated through
946
+ `Rails.error.report(e, handled: true, context: { event: })` so a failing
947
+ one is *visible* and never stops the others or the write that emitted
948
+ them. Registration is reload-safe (`key:` replaces in place;
949
+ `Chats.reset_subscribers!` clears). Two NEW events:
950
+ `:conversation_created` (once per conversation, never on resume) and
951
+ `:participant_left`.
952
+ - **`config.messager_url`** `->(messager) { nil }` — the bundled views link
953
+ names and titles to it, and render plain text when it returns nil. The gem
954
+ no longer assumes a host has `user_path`.
955
+ - **`Participant#reseat!(new_messager)`** — hand a seat to another messager
956
+ inside a transaction, keeping the read horizon, the role and the history,
957
+ and re-indexing a direct thread's `direct_key` so `chat_with` keeps
958
+ resolving to it instead of stranding a duplicate. Refuses with
959
+ `Chats::NotAllowedError` when the resulting pair already has a direct
960
+ conversation, checked BEFORE the write so a unique-index violation can
961
+ never poison a host's transaction.
962
+ - **Inbox missed-broadcast recovery** (`chats--refresh-inbox` controller): the
963
+ inbox already receives Turbo 8 page *refreshes*, but Action Cable has no
964
+ replay — a refresh broadcast sent while the client's socket was down
965
+ (backgrounded tab/app, network blip) was lost and the inbox sat stale until
966
+ the user navigated. The new controller re-runs the same page refresh on
967
+ cable reconnect and on return-to-visible, extending the thread's
968
+ stale-catch-up doctrine (`docs/campfire_review.md`) to the inbox. It reuses
969
+ the thread's channel-free reconnect detection (observing the
970
+ `<turbo-cable-stream-source>` `connected` attribute), so no new Action Cable
971
+ channel is introduced. Auto-registered via the engine importmap pin; hosts
972
+ need zero changes.
973
+
974
+ ### Changed
975
+ - `config.notifier` is **deprecated** (removed in 1.0). It still works and
976
+ receives `:message_created` and `:conversation_read` — the two events 0.1.1
977
+ had — and ONLY those: the events added in 0.2.0 are `Chats.on`-only, so a
978
+ 0.1.x hook written `->(event, message:, **)` can never be handed an event
979
+ it has no keyword for. It registers as a subscriber under a reserved key,
980
+ so re-assigning it replaces rather than stacks, and warns through
981
+ `Chats.deprecator`, which the engine registers with
982
+ `Rails.application.deprecators`.
983
+ **If your test environment sets `config.active_support.deprecation =
984
+ :raise`** (a common default) and you still assign `config.notifier`, that
985
+ warning now raises at boot, because the engine registers the gem's
986
+ deprecator with the app. Either move the hook to `Chats.on` — the migration
987
+ is one line — or silence just this one:
988
+
989
+ ```ruby
990
+ # config/initializers/chats.rb
991
+ Chats.deprecator.silence do
992
+ Chats.configure { |config| config.notifier = ->(event, **payload) { … } }
993
+ end
994
+ ```
995
+ - The install migration now creates the `author` columns, so a fresh install
996
+ needs no upgrade step.
997
+ - **If you ejected the inbox or the composer under 0.1.x**, nothing breaks:
998
+ `ConversationsController#index` still assigns `@conversations` (the flat,
999
+ unstacked list an ejected inbox loops over), and an ejected composer simply
1000
+ misses the DOM id the locked-composer swap targets — the 422 is then a
1001
+ no-op instead of a replace. Re-eject (or delete) those two files to pick up
1002
+ stacked rows and locked composers.
1003
+
1004
+ ### Fixed
1005
+ - **A host's own locale file no longer loses to the gem's.** The engine
1006
+ appended its `config/locales` onto the application's `i18n.load_path` on
1007
+ top of Rails' own `:add_locales`. Railtie paths are unshifted ahead of
1008
+ everything, so that second copy landed *after* the host's files and
1009
+ silently overrode them — a host rewording `chats.flashes.blocked` in its
1010
+ own `es.yml` kept reading ours, with no error to see. Gem first, host last,
1011
+ pinned by a test that ships a host override in the dummy app.
1012
+ - **Migrations name every adapter they actually run on.** `json_column_type`
1013
+ matched `"postgresql"`, which activerecord-postgis-adapter never reports
1014
+ (it answers `"PostGIS"`), so PostGIS hosts silently got `json` where the
1015
+ gem meant `jsonb`. `json_column_default` matched `/mysql/`, which misses
1016
+ Trilogy (Rails reports `"Trilogy"`), handing those hosts a default MySQL
1017
+ rejects. Both now match by prefix and by both spellings.
1018
+ - **The thread's missed-broadcast recovery never took effect for a deep
1019
+ backlog.** `ConversationsController#refresh` answered with `render html:
1020
+ … content_type: "text/vnd.turbo-stream.html"`, and `render html:` forces
1021
+ `text/html` and ignores the content type — so the response said
1022
+ `<turbo-stream action="refresh">` in a body nothing would treat as a
1023
+ stream. It now renders `turbo_stream.refresh(request_id: nil)`; the nil
1024
+ request id matters, because Turbo skips a refresh tagged with a request id
1025
+ it recognizes as its own, and this response answers the client's own
1026
+ catch-up fetch. The failure was invisible by construction: a recovery path
1027
+ that does nothing looks exactly like the staleness it exists to fix.
1028
+ - `:participant_added` was documented as a notifier event but never emitted.
1029
+ The event catalogue is now exactly what the gem fires, and registering for
1030
+ anything else raises at boot with the valid list.
1031
+ - **A signed message is its author's to answer for.** `Message#reported_owner`
1032
+ now returns `author || sender`. With authorship, an answer sent from a
1033
+ headless seat (a support desk) carries a human author, and a host's
1034
+ moderation `owner` is typed to its user class — a desk there raised an
1035
+ association type mismatch from inside the agent's own reply the first time
1036
+ a text filter tripped, and the moderation screens then asked the desk for
1037
+ an avatar it does not have.
1038
+ - **`jsonb` on PostGIS.** The install migration decided jsonb-or-json with
1039
+ `adapter_name.downcase.include?("postgresql")`, and activerecord-postgis-
1040
+ adapter answers `"PostGIS"`, so PostGIS hosts silently got plain `json`
1041
+ columns. The template now matches the prefix (`/\Apostg/i`). Existing
1042
+ installs are unaffected; a host that wants jsonb can `change_column` it.
1043
+
7
1044
  ## [0.1.1] - 2026-06-10
8
1045
 
9
1046
  Reliability + UX patterns adopted after a deep review of Basecamp's