chats 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +478 -0
  3. data/README.md +150 -10
  4. data/app/assets/stylesheets/chats.css +599 -0
  5. data/app/controllers/chats/application_controller.rb +45 -4
  6. data/app/controllers/chats/conversations_controller.rb +273 -55
  7. data/app/helpers/chats/engine_helper.rb +102 -6
  8. data/app/javascript/chats/composer_controller.js +14 -1
  9. data/app/javascript/chats/dock_controller.js +1106 -0
  10. data/app/javascript/chats/recovery.js +539 -0
  11. data/app/javascript/chats/refresh_inbox_controller.js +55 -65
  12. data/app/javascript/chats/thread_controller.js +317 -60
  13. data/app/javascript/chats/unread_badge_controller.js +24 -0
  14. data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
  15. data/app/views/chats/conversations/_group.html.erb +6 -2
  16. data/app/views/chats/conversations/_history.html.erb +54 -0
  17. data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
  18. data/app/views/chats/conversations/_thread.html.erb +133 -0
  19. data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
  20. data/app/views/chats/conversations/dock.html.erb +40 -0
  21. data/app/views/chats/conversations/index.html.erb +24 -10
  22. data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
  23. data/app/views/chats/conversations/show.html.erb +11 -118
  24. data/app/views/chats/conversations/window.html.erb +26 -0
  25. data/app/views/chats/dock/_badge.html.erb +12 -0
  26. data/app/views/chats/dock/_dock.html.erb +138 -0
  27. data/app/views/chats/dock/_signal.html.erb +26 -0
  28. data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
  29. data/app/views/chats/dock/_window.html.erb +23 -0
  30. data/app/views/chats/dock/_window_header.html.erb +75 -0
  31. data/app/views/chats/dock/_window_shell.html.erb +17 -0
  32. data/app/views/chats/messages/_composer.html.erb +11 -1
  33. data/app/views/chats/messages/_message.html.erb +8 -6
  34. data/app/views/chats/shared/_unread_badge.html.erb +4 -1
  35. data/config/importmap.rb +7 -0
  36. data/config/locales/en.yml +17 -0
  37. data/config/locales/es.yml +17 -0
  38. data/config/routes.rb +8 -0
  39. data/docs/screenshots/dock-bar.png +0 -0
  40. data/docs/screenshots/dock-desktop.png +0 -0
  41. data/docs/screenshots/dock-phone-button.png +0 -0
  42. data/docs/screenshots/dock-phone-panel.png +0 -0
  43. data/docs/screenshots/dock-phone-window.png +0 -0
  44. data/gemfiles/rails_7.1.gemfile +2 -0
  45. data/gemfiles/rails_7.2.gemfile +2 -0
  46. data/gemfiles/rails_8.1.gemfile +2 -0
  47. data/lib/chats/broadcasts.rb +178 -17
  48. data/lib/chats/inbox.rb +68 -30
  49. data/lib/chats/inbox_refresh_job.rb +14 -0
  50. data/lib/chats/models/concerns/messager.rb +10 -2
  51. data/lib/chats/models/conversation.rb +57 -0
  52. data/lib/chats/models/message.rb +98 -9
  53. data/lib/chats/models/reaction.rb +13 -0
  54. data/lib/chats/version.rb +1 -1
  55. data/lib/generators/chats/templates/initializer.rb +1 -0
  56. data/lib/generators/chats/views_generator.rb +3 -2
  57. metadata +23 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2f76b973f012e2c86514932efaca3db25eaf8c70ed4709276d1f5f33d520de2
4
- data.tar.gz: 2b88c68ad0d239a9ba87b5e907193924926a3926715ed39b62f844e2d899bf19
3
+ metadata.gz: 233373c7319647cf267ec239c0227e7751deae6ea9a4afd8d94c578994c85a22
4
+ data.tar.gz: 02b7e37be81c69063969d202094430b1657640ea707a1f63e78c06876eb6877d
5
5
  SHA512:
6
- metadata.gz: be31c3ce5aad04e1c9d5d0ba141403514da0f547de42a4516ede78117025a12c7f3936060787a3935e477e4f2c03aa2fcfb7b7550c0fdca77a5c6d65a0c86db3
7
- data.tar.gz: f22af91cf0ccb50bd933659d0fdc549306ecb2bfad09ee87cfb0193499a0839c2f468cd8e109ad7266ddba35200126b28d5f8958b065420fd828a065b9f40b80
6
+ metadata.gz: 9bd561feaae573165ce631dac3e669b892d5d70271435a475bc36d92dc532fcd6f55b0b908415141845b47105600088dd739ae7adfeb520baf4cb4bcc0096127
7
+ data.tar.gz: ecd69642c82c827c53b5d038b8dd60f5853f5b012368c5d4db846b4549befbd4fd8b153b45d0468228cd77746b77b6c60dadd26ab5d36f73f2a31a4ec84a2edd
data/CHANGELOG.md CHANGED
@@ -4,6 +4,484 @@ 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.5.1] - 2026-10-18
8
+
9
+ A page that missed a broadcast is never left stale: every live surface
10
+ catches up on its own, without polling (fixes
11
+ [#1](https://github.com/rameerez/chats/issues/1)). Also: the dock's unread
12
+ count in every language's plural forms, one extension point for a
13
+ conversation that must open although the inbox hides it
14
+ (`conversation_lookup_scopes`), and real conversations told from
15
+ system-only threads. **No migration, nothing to configure.**
16
+
17
+ ### Added
18
+ - **Real conversations vs system-only threads.** `with_messages` asks whether
19
+ a conversation holds anything at all, system lines and tombstones
20
+ included; ops walls, moderation lists and analytics ask whether a messager
21
+ wrote something there that still stands. Four additive reads answer that:
22
+ `Chats::Message.human` (kind `text`, not deleted: written by a messager,
23
+ person or not, as opposed to posted by the app, so a bot's or a headless
24
+ desk's text message counts and a photo with no caption counts too), `Chats::Conversation.with_human_messages` and
25
+ `.without_human_messages` (one correlated `EXISTS` / `NOT EXISTS` built
26
+ from `Message.human`, so they compose with `alice.chats`, `inbox_for`,
27
+ `about`, `unread_by` and `with_messages` and never multiply rows),
28
+ `Conversation#last_human_message` (a query, not the `last_message`
29
+ pointer), and its batch twin `Chats::Message.last_human_in(conversations)`:
30
+ the newest human message of each conversation in ONE query, for a loaded
31
+ page, ids, a single record, or an unloaded relation, which is read as a
32
+ subquery and never loaded first. It returns a relation, so a `.select` can
33
+ narrow the columns. Viewer-facing lists start from `alice.chats`, which
34
+ applies blocks and `config.inbox_scope`; the bare `Chats::Conversation`
35
+ scopes see every conversation and belong behind staff authorization.
36
+ Nothing changes by default: system messages still bump `messages_count`
37
+ and `last_message`, and the inbox still lists system-only threads.
38
+ - **`Chats::ApplicationController#conversation_lookup_scopes`, the one
39
+ extension point for a conversation that must open although the inbox
40
+ hides it.** Every engine endpoint (the thread, a dock window, sending,
41
+ editing, reacting, reading) finds a conversation through
42
+ `find_conversation(id)`, which now asks each relation
43
+ `conversation_lookup_scopes` returns, in order (`find_by(id:)`), and
44
+ raises `ActiveRecord::RecordNotFound` when none has it. By default that is
45
+ `[chats_current_messager.chats]`, exactly the old lookup. A product gem
46
+ (support_desk: a requester's own ticket, which the host's `inbox_scope`
47
+ keeps out of the inbox) prepends a module whose
48
+ `conversation_lookup_scopes` returns `super + [its own scope]`, instead of
49
+ re-implementing the lookup and recognising a miss by the exception's
50
+ details. Later scopes are asked only on a miss; each scope must hold only
51
+ conversations the viewer may open, so nothing outside them becomes
52
+ reachable; and an error a scope raises is not rescued into a 404.
53
+
54
+ ### Fixed
55
+ - **The dock's window count used the right plural form only in two-form
56
+ languages.** `dock_controller.js` picked `one` for exactly 1 and `other`
57
+ for everything else, so Polish, Russian, Ukrainian, Croatian or Serbian
58
+ (one/few/many, with 21 read like 1 in the East Slavic ones) and
59
+ Arabic-style zero/two read wrong. The dock now renders every plural form
60
+ the locale provides for `chats.dock.window_unread` (`zero`, `one`, `two`,
61
+ `few`, `many`, `other`; new helper `chats_plural_forms`), and the
62
+ controller picks one with `Intl.PluralRules` in the page's language
63
+ (`<html lang>`, else the browser's), reading a missing form as `other`
64
+ and falling back to the old rule where `Intl` is unavailable. The gem's
65
+ `en` and `es` keep `one`/`other`; a host adds the forms its languages need
66
+ in its own locale files.
67
+ - **Every live surface catches up on the broadcasts it missed.** Action
68
+ Cable has no replay, so a broadcast sent while a page's socket was down
69
+ (a laptop asleep, a backgrounded tab or Hotwire Native WebView, a network
70
+ blip) never reached it, and the inbox, a thread, the unread badge or the
71
+ dock could sit on old content until the next navigation. One shared
72
+ module, `chats/recovery` (pinned for importmap hosts, like the
73
+ controllers), now watches each surface's own
74
+ `<turbo-cable-stream-source>` and the page, and asks for what was missed
75
+ when the stream reconnects, when the page comes back after 10 seconds or
76
+ more hidden, when the network comes back online (it asks Action Cable to
77
+ check its socket at once instead of after its backoff, and catches up
78
+ anyway: after a short outage the socket can look open while what was
79
+ sent during it is gone) and when the page
80
+ is restored from the back/forward cache or from Turbo's snapshot cache (a
81
+ back navigation, a Hotwire Native stack pop). Never otherwise: a healthy,
82
+ visible page fetches nothing. A hidden page never fetches; what it missed
83
+ waits until it is seen. Triggers that arrive together make one catch-up,
84
+ and catch-ups are at least five seconds apart.
85
+ - **A reconnect Action Cable never reported is seen.** When Action Cable
86
+ reopens a socket that went quiet (the half-open connection of a phone that
87
+ slept), it drops the old socket's handlers before its close event can
88
+ arrive: subscribers hear "connected" again and never "disconnected", and
89
+ the `reconnected: true` detail reaches only the first subscription
90
+ confirmed. Every confirmation after the first now counts.
91
+ - **A page restored from Turbo's snapshot cache catches up.** It came back
92
+ as old as when it was left, with no request: a thread restored by a back
93
+ navigation missed every message sent since. Its stream sources are marked
94
+ as they go into the cache, and a restored one catches up once it is live.
95
+ - **The inbox** reruns a refresh that arrived while another visit was
96
+ loading, which Turbo ignores (`Session#refresh` skips one while
97
+ `navigator.currentVisit` is set): a message landing mid-refresh was lost
98
+ with a healthy socket. A refresh no longer rewrites the field you're
99
+ typing in, no longer drops the search (see Changed), and a morph no longer
100
+ strips the stream source's `connected` attribute.
101
+ - **The inbox refresh is durable.** `refresh_inbox_of` enqueues
102
+ `Chats::InboxRefreshJob` straight from the after_commit instead of calling
103
+ `broadcast_refresh_later_to`, which parked the refresh for half a second
104
+ in an in-process timer (`Turbo::Debouncer`) before enqueueing it, so a
105
+ deploy or a crash inside that window dropped it. At most one is queued
106
+ per recipient: while one is queued and hasn't started, it stands for
107
+ every write that commits before it runs (a short-lived marker in
108
+ `Rails.cache`, removed by the job as it starts, before it broadcasts), so
109
+ a burst is one job per recipient. A lost marker costs an extra job, never
110
+ a missed refresh, and the marker expires after two seconds. Only a
111
+ marker that is really there counts as "queued": a store that can't hold
112
+ it (RedisCacheStore answers nil when a write fails; any store may raise)
113
+ means the write queues its own refresh. Since another
114
+ write may count on a refresh whose enqueue is still in flight, a failed
115
+ enqueue is retried once, then the refresh is broadcast on the spot. Every
116
+ refusal goes to `Rails.error` (raised, `false`, or a job the adapter
117
+ declined), never up through the write that committed. The refresh
118
+ tag carries `method="morph" scroll="preserve"`, so the inbox morphs
119
+ whatever the host layout's refresh meta says, and no request id, since
120
+ it may stand for several requests' writes.
121
+ - **The thread's catch-up asks on two cursors.** It sent only the newest
122
+ `updated_at` it had rendered and the server appended what was created
123
+ after it, so a message created at 10:15 and missed, while a bubble on
124
+ screen was edited at 10:20, never came. Now `after=` (the newest creation
125
+ on screen) decides what is appended and `changed=` (the newest change)
126
+ what is replaced, and both scopes split on the same creation cursor
127
+ (`created_after` / `created_through(…).updated_after(…)`). Each cursor is
128
+ a moment to the microsecond plus the ids on screen at it (`after_ids=`,
129
+ `changed_ids=`): a message missed in the very instant of one on screen,
130
+ or a change missed inside the same millisecond as one on screen (the old
131
+ cursor rounded up to the millisecond), is no longer lost. The cursors
132
+ are reconciled moments: a complete render sets them (the page, a window,
133
+ a history re-render), and so does a catch-up that arrived (its
134
+ `chats_caught_up` action); a live bubble never moves them, since it can
135
+ arrive after one that was missed (a message missed at 10:15, then one
136
+ live at 10:20). What arrived live is named instead, at most 200 ids
137
+ (`after_ids`, `changed_ids`, each id at most 64 characters); past that,
138
+ or with `full=1`, the history is re-rendered. Kept on the element
139
+ (`data-chats-reconciled`), so a dock window moved by a Turbo visit, or a
140
+ restored snapshot, keeps them. Each catch-up is bound to one render of
141
+ the thread (`render=`, echoed by the answer): an answer that arrives
142
+ after the page or a dock window rendered the conversation again is
143
+ dropped unrendered, and its cursors ignored — checked again by every
144
+ stream of it at the moment Turbo applies it, a repaint later, so a visit
145
+ or a window reload in between can't hand the old streams the new render.
146
+ A history re-render is followed by one more catch-up from its own
147
+ cursors once Turbo has actually applied it, however late that is, so a
148
+ message written while that page was being built isn't lost. Every
149
+ catch-up is bounded: a request with no answer after 15 seconds is
150
+ aborted (a fetch can hang across a network interruption, and would
151
+ otherwise stand in for every later one), a thread has at most one answer
152
+ waiting for Turbo (a newer one, or the thread going away, drops the
153
+ older one's leftover streams and stops watching for them — going away
154
+ for good: a dock window Turbo carries across a visit is off the page
155
+ until that render ends, which a host's asynchronous `turbo:before-render`
156
+ can make last many tasks, and keeps its answer, decided only once every
157
+ `turbo:before-render` has had its `turbo:render`, paired first in, first
158
+ out; a render that runs past 10 seconds stops holding things up but stays
159
+ paired until its own end, and one that never ends is forgotten after a
160
+ minute), and only the
161
+ answer's own streams are guarded (`data-chats-answer`), never a host's
162
+ stream rendered at the same moment. A thread whose catch-up didn't apply,
163
+ for any reason (its page rendered it away, the request failed, Turbo
164
+ never got to it), is marked behind (`data-chats-stale`, on the thread
165
+ element itself, so the mark travels with the dock and can't be
166
+ forgotten while the thread exists) and catches up again when it
167
+ connects, on its stream's confirmation and after every page render,
168
+ through the same coalescing and 5-second spacing as every other trigger;
169
+ only the answer of the newest catch-up clears it (they are numbered on
170
+ the element, `data-chats-catch-up`), so an older answer that applies
171
+ after a newer one failed leaves the thread marked; and nothing older
172
+ than the newest catch-up that has applied (`data-chats-applied`) may
173
+ apply after it, so an older answer arriving last can't put back older
174
+ bubbles, read state or cursors. A catch-up always gets an answer built
175
+ after it was asked: the same request asked in the same instant is made
176
+ once, but asked while it is already on its way, it is made once more as
177
+ soon as that one has its answer from the network (never waiting on
178
+ Turbo to apply it), for everyone who asked meanwhile, each keeping its
179
+ own `when` and its own outcome. An answer Turbo hasn't applied 10
180
+ seconds after it joined the page is cancelled (its callers settle, and a
181
+ thread stays marked behind), and neither a caller's hook that throws nor
182
+ a stream that fails to render can leave an answer half-applied or its
183
+ callers waiting. An empty
184
+ thread, which used to ask nothing, asks from the epoch: its first message
185
+ may be the one it missed.
186
+ - **The thread's catch-up is complete.** `GET /:id/refresh` now also
187
+ replaces the read state (a read the stream missed left "Sent" where it
188
+ should say "Seen"), catches reactions (a reaction now moves its message's
189
+ `updated_at`, with `update_columns`, so the bubble is not broadcast twice),
190
+ and, under `deletion: :hard`, lists the messages that still exist from the
191
+ oldest rendered bubble on (`from=`), so the thread removes the ones that
192
+ are gone (the `chats_prune` stream action). Catching up still reads
193
+ nothing.
194
+ - **The unread badge and the dock catch up.** The viewer's own
195
+ `chats_unread_badge` is wrapped in a `chats--unread-badge` controller (a
196
+ `span.chats-unread-badge-live`, `display: contents`, so it changes no
197
+ layout) that asks the new `GET /badge` for every unread badge on the page:
198
+ the nav badge and the dock's, with the count the badge streams send
199
+ (`unread_chats_count`). Someone else's badge stays live but doesn't catch
200
+ up. The dock fetches the same, and reloads its recent list; each open
201
+ window's thread catches up by itself. A message missed while offline
202
+ never pops a window open late.
203
+
204
+ - **Both search forms are search landmarks.** `form_with … role: "search"`
205
+ rendered no `role` at all (`form_with` drops HTML attributes it doesn't
206
+ know unless they go through `html:`), on the inbox and in the dock.
207
+
208
+ ### Changed
209
+ - A page now counts as having been away after 10 seconds hidden, not 60
210
+ (the inbox and the thread used 60).
211
+ - A thread more than a page behind no longer gets a page refresh, which
212
+ cleared the composer's draft and, in a dock window, refreshed the host's
213
+ whole page: its history (`chats/conversations/_history`, inside
214
+ `#history_conversation_<id>`) is re-rendered at the latest page, the
215
+ composer untouched. An ejected thread whose scroller has no
216
+ `id="<%= dom_id(conversation, :history) %>"` keeps getting the page
217
+ refresh.
218
+ - The inbox's search keeps its query in the URL (`data-turbo-action=
219
+ "replace"`, submitting to the inbox's own path), so a refresh re-renders
220
+ the same results instead of the unfiltered list. The dock's recent list
221
+ frame has `refresh="morph"`, so a reload keeps its scroll and its search.
222
+ - New: the `GET /badge` collection route, the `chats--unread-badge`
223
+ controller, `Chats::InboxRefreshJob`, and `data-message-id`,
224
+ `data-created-at-us` and `data-updated-at-us` on every bubble, which no
225
+ longer carries `data-updated-at-ms`.
226
+ - **If you wrapped the inbox in a reconciler of your own** (a
227
+ stream-reconciler controller around `turbo_stream_from … :chats_inbox`),
228
+ remove it: two on one page both fire, and the gem's covers what yours did.
229
+ **If you ejected the inbox**, keep the
230
+ `data-controller="chats--refresh-inbox"` element around its
231
+ `turbo_stream_from chats_current_messager, :chats_inbox` (an inbox without
232
+ it doesn't catch up), and add `turbo_action: "replace"` to the search
233
+ form's `data` and `url: request.original_fullpath.split("?").first`. An
234
+ ejected message partial (with only `data-updated-at-ms`) keeps the one
235
+ cursor it had (`since=`), and a hard delete missed while offline stays on
236
+ screen until the next load.
237
+
238
+ ## [0.5.0] - 2026-10-18
239
+
240
+ The messaging dock. LinkedIn and Steam keep your conversations in small
241
+ windows in the bottom-right corner while you go on using the app; one line
242
+ in a layout, `<%= chats_dock %>`, now does that. **No migration.** The dock
243
+ is opt-in, but a few things change without asking, all under Changed: a
244
+ conversation with no messages is no longer listed, a direct thread the
245
+ host's policy no longer allows says so up front, a hover prefetch no longer
246
+ reads a thread, and the thread body moved into a partial.
247
+
248
+ ### Added
249
+ - **`chats_dock`**, the messaging dock: `aside.chats-dock`, fixed to the
250
+ bottom-right corner, holding the floating windows (`#chats_dock_windows`,
251
+ newest nearest the panel) and the panel. The panel is a `<details>`: its
252
+ header is the bar (the viewer's avatar, "Messages", the unread badge),
253
+ and open it has the inbox's search, the viewer's most recent inbox rows
254
+ (stacks, verified badges, a bell-slash on muted ones, previews, unread
255
+ counts) and "See all messages". It opens without JavaScript, and its
256
+ rows, search and links are real links and forms. Options: `windows:` (3),
257
+ `recent:` (10), `launcher:` (true; false drops the phone's round button
258
+ for a host whose own nav opens the sheet) and `everywhere:` (see below).
259
+ Renders nothing for a signed-out visitor and nothing on the engine's own
260
+ pages.
261
+ - **It survives Turbo visits, per viewer.** The element is
262
+ `data-turbo-permanent`, so its windows, drafts, scroll positions and
263
+ subscriptions ride along from page to page. Its id names the viewer
264
+ (`chats_dock_<messager key>`), so a visit that lands on another
265
+ account's page (a sign-out and sign-in, an account switcher) never
266
+ matches it, and every window carries its viewer and is dropped on connect
267
+ if it names someone else. Because the server can't remove a permanent
268
+ element from the next page, the dock also hides itself on any page with
269
+ `data-chats-dock-hide`; the engine's inbox and thread roots carry it.
270
+ `everywhere: true` renders the dock on the engine's pages and ignores the
271
+ attribute.
272
+ - **Floating windows.** A window is a turbo frame
273
+ (`dom_id(conversation, :window)`) loading `GET /:id?window=1`, which,
274
+ from inside a frame, renders the same thread body as the page, rooted at
275
+ `.chats-thread.chats-thread--window` (never `.chats`, the full-page
276
+ shell), under a compact header: a title button (avatar, name, verified
277
+ badge, subject, the waiting count) that minimizes it, the
278
+ `conversation_header_actions` slot in a ⋯ menu with the page's locals
279
+ (`blockable:` included), ⤢ for the full page, – and ×. Until the thread
280
+ arrives, and if it can't, the window shows the same header over
281
+ "Loading…" or "Couldn't load this conversation." with "Try again"; a 404
282
+ (left, blocked) closes it, read from the response before Turbo acts on
283
+ it (an error page carrying `turbo-visit-control`, as Rails' own do, would
284
+ otherwise take the whole page with it), keeping what was typed in it; a
285
+ 5xx shows the failed shell. An empty conversation's window says "Say
286
+ hello to …". The dock never opens a window for a conversation the page itself
287
+ shows (the streams address the thread's DOM ids, which must exist once).
288
+ - **What fits.** `min(windows, floor((viewport − 312) / 344))` windows beside
289
+ the panel: two at 1280px, one at 768px, three from 1344px. Opening one
290
+ more closes the least recently used window that isn't in use. A window in
291
+ use (it has the focus, or unsent text) is never closed to make room: it
292
+ folds to a 104px header (avatar and ×, `--chats-dock-squeezed-width`)
293
+ beside the new one. Two limits are absolute: never more than `windows:`
294
+ windows, folded ones included, and never wider than the viewport beside
295
+ the panel. An open that can't fit even with every window in use folded is
296
+ declined, changing nothing, and the row's link (or the `dock: true`
297
+ button) opens the conversation's page instead; a pop-up just doesn't
298
+ happen. Unfolding a folded window (its title, a row, a repeated `dock:
299
+ true`) makes room exactly as an open does, and when it can't, the window
300
+ stays folded. Narrowing the viewport closes what isn't in use and folds
301
+ what is, the focused window last; when even a row of folded headers is
302
+ too wide, it closes the least recently used windows in use too, keeping
303
+ their unsent text, and announces nothing. A window the viewer closes
304
+ keeps its unsent text for when it comes back. Every way a window leaves
305
+ the dock (closed, evicted, declined, gone for the viewer, shown by the
306
+ page, another viewer's) goes through one path that keeps the draft and
307
+ resyncs the rows and the phone sheet.
308
+ - **Reading waits for the viewer.** A window never reads on render. A
309
+ minimized window never marks anything read, and a window that popped
310
+ open, or came back after a reload, marks messages read only once the
311
+ viewer focuses it, presses in it or scrolls it: the dock sets
312
+ `data-chats-minimized` and `data-chats-active` on the window and the
313
+ thread controller obeys them, checking again when its delayed read fires
314
+ (state, not geometry: a folded window's scroller also loses its padding,
315
+ but a size check alone is never trusted). A window the viewer opened
316
+ is engaged from the start. The thread page has no gate and is unchanged.
317
+ - **Live, server-driven.** A new per-messager stream,
318
+ **`[messager, :chats_dock]`**, gets one render per participant per event
319
+ (`chats/dock/_update`): the dock's badges (the nav badge's count) when
320
+ the count moves, and a signal template (`chats/dock/_signal`). The signal
321
+ reloads the recent list, debounced, for every participant, the sender and
322
+ edits and deletes included. For a message the participant would be
323
+ notified about (`Participant#notifiable_for?`: not theirs, not muted, not
324
+ a headless seat) it also names the conversation, the sender and the
325
+ unread count there. The dock then pops that conversation's window open,
326
+ waiting with the accent header and its count, and announces "New message
327
+ from …" in a polite live region (at most once per conversation every 30
328
+ seconds, never for the window you're typing in). The pop-up only happens
329
+ in dock mode, never moves the focus, never for a conversation the viewer
330
+ closed in the last five minutes, and never by closing a window someone is
331
+ using; with no room, there's no pop. **The signal names a conversation
332
+ only to a participant who can see it** (`messager.chats`, so blocks and
333
+ `inbox_scope` apply), because the dock's list and window refuse anything
334
+ else. The badges stay truthful either way. No polling.
335
+ - **Memory.** Per viewer in `localStorage`: `chats:dock:v1:<messager key>`
336
+ (which windows, minimized or folded or not, when each was used, the
337
+ recently closed ones and the panel) and `chats:draft:<messager key>:
338
+ <conversation id>` (unsent text, saved as you type and whenever a window
339
+ goes, by any path, a refused or missing one included, and before a
340
+ failure (5xx, network) replaces the window's content; restored the moment
341
+ the window's composer appears, cleared on send, kept seven days). A saved
342
+ draft goes back only into an EMPTY composer: if something was already
343
+ typed there, it stays, and the saved draft is never replaced by anything
344
+ shorter, so neither text is lost. A reload restores the
345
+ saved arrangement as it was, folded windows included, up to the cap, then
346
+ fits it to the width there is now by the resize rules; none of them is
347
+ engaged. The panel comes back in dock mode only, and the phone's sheet
348
+ always starts closed. Every access is guarded,
349
+ so a browser that refuses storage only means forgetting.
350
+ - **Phones.** Under 700px wide or 500px tall the closed bar is a 56px round
351
+ launcher, the open panel a full-screen sheet, and a conversation one
352
+ full-screen window with ← back to the list, sized to the visual viewport
353
+ so the composer sits on the keyboard. The list behind a window is `inert`
354
+ until Back, which returns the focus to it. The sheet makes the rest of the page
355
+ `inert` (the siblings of the dock and of each of its ancestors, so a
356
+ layout that wraps the dock never inerts the sheet) and stops it scrolling. Inputs are 16px, so iOS doesn't zoom.
357
+ Messages never pop a window on a phone. The layout is one media query, so
358
+ it applies before JavaScript connects and without it; the controller reads
359
+ the same query.
360
+ - **Keyboard and screen readers.** Escape unwinds from the inside out: the
361
+ thread's popup or photo, an edit (the composer now cancels one on
362
+ Escape), the ⋯ menu, then the window (minimized, focus to its title), the
363
+ panel (closed, focus to the bar); on a phone, the window back to the list,
364
+ then the sheet closed. Focus goes into a window only when the viewer
365
+ opened it: its composer on a desktop, ← on a phone. Close returns the
366
+ focus to whatever opened the window. Focus rings are inset, the history
367
+ scroller is focusable, the badge is not a live region, and the bar reads
368
+ "Messages, 3 unread" from its own text.
369
+ - **`chat_button_to(other, dock: true)`** posts `dock=1`, and
370
+ `ConversationsController#create` answers a Turbo Stream that appends the
371
+ conversation's window to `#chats_dock_windows`. When that conversation is
372
+ already in a window, the dock focuses it instead of letting the append
373
+ replace it, so nothing typed is lost. It belongs with `chats_dock`: on a
374
+ page without the dock the stream has no target and does nothing. Without
375
+ Turbo, and without `dock`, the redirect is unchanged.
376
+ - **`chats_dock_opener(conversation)`** and **`chats_dock_toggle`**: data for
377
+ any link on the page to open that conversation's window, or the dock's
378
+ panel (the sheet on a phone). They stay plain links without a dock or
379
+ JavaScript. **`chats_muted?(conversation, viewer)`** reads the viewer's
380
+ seat.
381
+ - **`GET /dock`** (`dock_conversations_path`), the panel's list:
382
+ `Chats::Inbox` with `?q=` and `?recent=` (1 to 50), rendered into the
383
+ `chats_dock_list` frame without a layout. The inbox answers a request from
384
+ that frame (the dock's search, which submits to the inbox so that it
385
+ lands on the full inbox's results without JavaScript) with the same list.
386
+ - **`Chats::Inbox.for(viewer, limit:)`**: how many rows come back
387
+ (`config.inbox_limit` by default, never more). A smaller limit is a
388
+ smaller query; a search still looks through the whole `inbox_limit`
389
+ window.
390
+ - **`Chats::Conversation.with_messages`**: conversations that have at least
391
+ one message, system messages and tombstones included (`last_message_id`
392
+ set, which the denormalized pointer already gives). It doesn't mean a
393
+ person wrote there: a thread holding only a system note is listed.
394
+ - **The `dock_panel_footer` slot** (receives `viewer:`): your own link next
395
+ to "See all messages".
396
+ - **Theming.** The dock's variables are declared on `:where(.chats-dock)`,
397
+ not `:root`, so a host re-pointing `--chats-bg` or `--chats-accent` on the
398
+ dock reaches them: `--chats-dock-z` (40), `-offset` (bottom clearance),
399
+ `-inset` (right edge), `-gap`, `-bar-height` (48px), `-panel-width`
400
+ (288px), `-panel-height`, `-window-width` (336px), `-window-height`
401
+ (480px), `-radius`, `-bg`, `-header-bg`, `-hover` (`#f9fafb`: muted text
402
+ at 4.63:1, where `--chats-surface` gives 4.39), `-edge`, `-shadow`,
403
+ `-unread-bg` / `-unread-text` (the accent pair), `-focus`,
404
+ `-title-font`, `-launcher-size`, `-duration`, `-ease`, plus
405
+ `--chats-safe-top/right/bottom/left` on `:root`. Nothing leaves a
406
+ transform, filter or `contain` on the dock or an expanded window, which
407
+ would trap the thread's fixed popup and photo preview inside it; entrance
408
+ animations end at `transform: none`, and `prefers-reduced-motion` turns
409
+ them all off. Strings under `chats.dock.*` in `en` and `es`. The
410
+ controller is pinned as `controllers/chats/dock_controller`, and
411
+ `rails generate chats:views` has a `dock` group (`app/views/chats/dock/`).
412
+
413
+ ### Changed
414
+ - **A conversation with no messages is never listed.** Opening a
415
+ conversation (`chat_with`, the Message button) creates it, and until now
416
+ the inbox listed it straight away as a "No messages yet" row. Every list
417
+ now reads through `Chats::Conversation.with_messages`: the inbox rows,
418
+ a stack's rows and its `open_count` / `unread_count`, search, the
419
+ `?with=` view and the dock. **If your inbox showed rows for threads
420
+ that were opened and never used, it no longer does.** The conversation
421
+ still opens when addressed directly (the thread page and its dock
422
+ window work), because `Messager#chats`, the relation every engine lookup
423
+ authorizes through, is unchanged; use `chats.with_messages` for lists of
424
+ your own. `unread_chats_count` already agreed (an empty conversation has
425
+ nothing unread).
426
+ - **A direct thread nobody may write in says so up front.** When the host's
427
+ `can_message` policy no longer allows the pair, or the other account is
428
+ gone, the thread (page and window) shows "You can't message this person."
429
+ in the composer's place, through the locked-composer box (same id; the
430
+ `locked_composer` slot gets it as `notice:`), instead of a composer whose
431
+ sends fail. `_locked_composer` takes an optional `notice:` local.
432
+ - **A hover prefetch never reads a thread.** Turbo 8 prefetches a link when
433
+ the pointer rests on it, and `ConversationsController#show` used to read
434
+ the thread on that request, so hovering an inbox row cleared its unread
435
+ badge. A request with `X-Sec-Purpose: prefetch` no longer reads; the
436
+ visit does. The dock's list turns prefetch off altogether.
437
+ - **The thread body moved into `chats/conversations/_thread.html.erb`.**
438
+ It holds the `chats--thread` element and everything inside it except the
439
+ header: the `turbo_stream_from`, the scroller with the older-pages frame
440
+ and the «new messages» line, read state, typing, the long-press popup,
441
+ the photo preview and the composer. It renders as a partial layout whose
442
+ `yield` is the header, with locals `conversation:`, `messages:`, `more:`,
443
+ `first_unread_id:`, `counterpart:` and `window:`. `show.html.erb` is now
444
+ its header wrapped in that partial. The page's DOM gains
445
+ `data-chats-dock-hide` and `data-chats--thread-read-pending-value="false"`
446
+ on its root and `tabindex="0"` on the scroller. **If you ejected
447
+ `show.html.erb`**, your copy keeps rendering the page as before; add
448
+ `data-chats-dock-hide` to its root if you render the dock everywhere.
449
+ Dock windows render the gem's `_thread` (or yours, if you eject it), so a
450
+ restyle that lives in your `show.html.erb` doesn't reach them: move it
451
+ into an ejected `_thread.html.erb` to style both.
452
+ - **Grouped inbox rows cost two queries in all.** A stack's `open_count` and
453
+ `unread_count` were two aggregates per stack; they are now two grouped
454
+ queries for every stack the inbox shows, so an inbox (or a dock) with many
455
+ stacked counterparts stays constant-query.
456
+ - The unread badge partial (`chats/shared/_unread_badge`) carries
457
+ `data-chats-unread-badge`, the mark every unread badge has, the dock's
458
+ included. Its broadcast still targets `#chats_unread_badge`, so a badge
459
+ ejected earlier keeps updating unchanged. Add the attribute if your own
460
+ CSS or JS wants to find every badge.
461
+ - The inbox row partials (`_conversation_row`, `_group`) take an optional
462
+ `dock:` local that marks their link as a dock opener, and a direct or
463
+ group row shows the bell-slash when the viewer muted it. Rows ejected
464
+ earlier still work in the dock; they navigate instead of opening a
465
+ window.
466
+ - **The composer's fields have per-conversation ids**
467
+ (`dom_id(conversation, :message_body)`, `:message_files`), instead of
468
+ `form_with`'s `message_body` / `message_files`. A page can now hold
469
+ several composers, and Turbo restores focus by id after rendering a
470
+ stream, so a shared id moved the cursor into another window's composer.
471
+ CSS or JS that selected `#message_body` should select `.chats-composer__input`.
472
+ - The long-press Edit event (`chats:edit-message`) now carries its thread
473
+ element, and a composer only starts editing for a message of its own
474
+ thread, since a page can hold several threads now. The check lives in
475
+ the controller, so ejected composer views get it too; a host that pinned
476
+ its own `composer_controller` reacts to every thread's Edit, as before.
477
+ - The dummy app has a real importmap setup (Turbo, Stimulus, the stock
478
+ eager loader) and a host home page with the dock, which is how the
479
+ screenshots were taken. `test/system` measures the dock's rendered layout
480
+ in a headless Chrome (capybara and selenium-webdriver join the test
481
+ bundle). Under CI (`CI=true`) Chrome is a prerequisite: the tests fail
482
+ when it can't start, and the workflow checks for it first; on a machine
483
+ without Chrome they skip. The suite prints how many ran.
484
+
7
485
  ## [0.4.2] - 2026-10-17
8
486
 
9
487
  ### Changed