chats 0.5.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +171 -0
  3. data/README.md +40 -7
  4. data/app/assets/stylesheets/chats.css +131 -6
  5. data/app/controllers/chats/conversations_controller.rb +106 -11
  6. data/app/helpers/chats/engine_helper.rb +15 -0
  7. data/app/javascript/chats/dock_controller.js +15 -1
  8. data/app/javascript/chats/split_controller.js +131 -0
  9. data/app/javascript/chats/thread_controller.js +5 -0
  10. data/app/views/chats/conversations/_conversation_pane.html.erb +53 -0
  11. data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
  12. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  13. data/app/views/chats/conversations/_group.html.erb +20 -4
  14. data/app/views/chats/conversations/_thread.html.erb +22 -8
  15. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  16. data/app/views/chats/conversations/index.html.erb +81 -14
  17. data/app/views/chats/conversations/pane.html.erb +14 -0
  18. data/app/views/chats/conversations/show.html.erb +11 -65
  19. data/app/views/chats/dock/_dock.html.erb +2 -1
  20. data/config/importmap.rb +1 -0
  21. data/config/locales/en.yml +4 -0
  22. data/config/locales/es.yml +4 -0
  23. data/docs/screenshots/split-inbox-empty.png +0 -0
  24. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  25. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  26. data/docs/screenshots/split-inbox.png +0 -0
  27. data/lib/chats/configuration.rb +36 -0
  28. data/lib/chats/models/conversation.rb +10 -2
  29. data/lib/chats/models/message.rb +19 -3
  30. data/lib/chats/version.rb +1 -1
  31. data/lib/generators/chats/templates/initializer.rb +17 -0
  32. metadata +11 -3
  33. data/chats-0.3.1.gem +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 233373c7319647cf267ec239c0227e7751deae6ea9a4afd8d94c578994c85a22
4
- data.tar.gz: 02b7e37be81c69063969d202094430b1657640ea707a1f63e78c06876eb6877d
3
+ metadata.gz: fa521d419a2dc6b1d672e2919c4db795674c744fd97df02027ce33b81f582675
4
+ data.tar.gz: c64cf6cfad1d3f41b9eaf9938936017ecb3af6840feb74f342699c7e0d5d6a01
5
5
  SHA512:
6
- metadata.gz: 9bd561feaae573165ce631dac3e669b892d5d70271435a475bc36d92dc532fcd6f55b0b908415141845b47105600088dd739ae7adfeb520baf4cb4bcc0096127
7
- data.tar.gz: ecd69642c82c827c53b5d038b8dd60f5853f5b012368c5d4db846b4549befbd4fd8b153b45d0468228cd77746b77b6c60dadd26ab5d36f73f2a31a4ec84a2edd
6
+ metadata.gz: 99f45e5ce16f754d2e32a39bffa6664c4205fd401882b2794152a339eacfc6bb155514303c3e483e68f48f0e7dc52028ea0a7ce9180d776c3083df1b6ff3dd29
7
+ data.tar.gz: 660e92ea36bc25cea9ad374479495c32f5fcbd1f04f661cfff561eb9f6ce300e8afecd0b5a8294d30630e7577cd35a1f81395ae2848a806be60046b729f25f46
data/CHANGELOG.md CHANGED
@@ -4,6 +4,177 @@ 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
+
7
178
  ## [0.5.1] - 2026-10-18
8
179
 
9
180
  A page that missed a broadcast is never left stale: every live surface
data/README.md CHANGED
@@ -11,7 +11,7 @@ It's **Hotwire-native**: messages stream live over Turbo Streams + Action Cable,
11
11
 
12
12
  Every consumer app eventually needs DMs, and everyone rebuilds the same conversation/participant/message schema, the same Action Cable plumbing, and the same "report this message, block this user" story. `chats` is that whole rebuild, done once, done right.
13
13
 
14
- **Contents:** [Example](#-example) · [Quickstart](#quickstart) · [Data model](#-the-data-model) · [The macros](#-the-macros) · [Real-time](#-real-time-the-hotwire-way) · [Trust & Safety](#%EF%B8%8F-trust--safety-snaps-onto-the-moderate-gem) · [Events](#-events-subscribe-to-the-domain-moments) · [Headless messagers](#-headless-messagers-desks-bots-storefronts) · [Official accounts](#-official-accounts-the-verified-badge) · [Grouped inbox](#%EF%B8%8F-grouped-inbox-rows) · [Locked conversations](#-locked-conversations) · [Signed messages](#%EF%B8%8F-signed-messages) · [Action buttons](#-action-buttons) · [View slots](#-view-slots) · [Messaging dock](#-messaging-dock) · [Profile links](#-profile-links) · [Theming](#-make-it-yours) · [Configuration reference](#configuration-reference) · [The full Ruby API](#-the-full-ruby-api) · [View helpers](#-view-helpers) · [Errors](#errors) · [Locales](#locales) · [Upgrading](#upgrading) · [Testing](#testing)
14
+ **Contents:** [Example](#-example) · [Quickstart](#quickstart) · [Data model](#-the-data-model) · [The macros](#-the-macros) · [Real-time](#-real-time-the-hotwire-way) · [Trust & Safety](#%EF%B8%8F-trust--safety-snaps-onto-the-moderate-gem) · [Events](#-events-subscribe-to-the-domain-moments) · [Headless messagers](#-headless-messagers-desks-bots-storefronts) · [Official accounts](#-official-accounts-the-verified-badge) · [Grouped inbox](#%EF%B8%8F-grouped-inbox-rows) · [Locked conversations](#-locked-conversations) · [Signed messages](#%EF%B8%8F-signed-messages) · [Action buttons](#-action-buttons) · [View slots](#-view-slots) · [Two panes](#-two-panes) · [Messaging dock](#-messaging-dock) · [Profile links](#-profile-links) · [Theming](#-make-it-yours) · [Configuration reference](#configuration-reference) · [The full Ruby API](#-the-full-ruby-api) · [View helpers](#-view-helpers) · [Errors](#errors) · [Locales](#locales) · [Upgrading](#upgrading) · [Testing](#testing)
15
15
 
16
16
  ## 👨‍💻 Example
17
17
 
@@ -68,7 +68,7 @@ end
68
68
  mount Chats::Engine => "/messages"
69
69
  ```
70
70
 
71
- That's it. `/messages` is now a working, real-time inbox: threads, bubbles, reactions, read receipts, typing indicators. The engine inherits your `ApplicationController` (so your auth, layout, and locale apply automatically — Devise works out of the box), and its bundled Stimulus controllers register themselves through your existing importmap setup. Zero JavaScript changes.
71
+ That's it. `/messages` is now a working, real-time inbox: threads, bubbles, reactions, read receipts, typing indicators — on a wide screen, the conversation list and the open conversation side by side, as on LinkedIn ([two panes](#-two-panes)). The engine inherits your `ApplicationController` (so your auth, layout, and locale apply automatically — Devise works out of the box), and its bundled Stimulus controllers register themselves through your existing importmap setup. Zero JavaScript changes.
72
72
 
73
73
  An inbox lists the conversations that have **at least one message** (a system message or a deleted one counts). Opening a conversation (`chat_with`, the Message button) creates it, but until a first message exists it appears nowhere — not in the inbox, its stacks, search, the dock or any count — so nobody's inbox fills with "No messages yet" rows. It still opens when you go to it, which is where the first message gets written.
74
74
 
@@ -99,7 +99,7 @@ Or give every page LinkedIn-style floating chat windows with one line in your la
99
99
  > [!NOTE]
100
100
  > **Want customer support?** Ticketing stays out of `chats` on purpose — queues, assignment and SLAs are not messaging. [`support_desk`](https://github.com/rameerez/support_desk) is the product gem that adds them ON TOP of this one: tickets that are real conversations, a support desk that sends while your staff sign, and a BYOUI agent console. It uses the seams below (headless messagers, subject locks, message authorship, grouped inbox rows), so you get the same threads, attachments and read state you already have.
101
101
  >
102
- > A product on chats may need a conversation to open although your `inbox_scope` keeps it out of the inbox (a requester's own ticket). `Chats::ApplicationController#conversation_lookup_scopes` is the one extension point for that: every engine endpoint looks a conversation up through it, in order (by default `[viewer.chats]`), and the product appends its own relation, `super + [own_cases_scope]`, from a prepended module. Each scope must hold only conversations the viewer may open: nothing outside them becomes reachable, a miss is still a plain 404, and an error inside a scope is not rescued.
102
+ > A product on chats may need a conversation to open although your `inbox_scope` keeps it out of the inbox (a requester's own ticket). `Chats::ApplicationController#conversation_lookup_scopes` is the one extension point for that: every engine endpoint looks a conversation up through it, in order (by default `[viewer.chats]`), and the product appends its own relation, `super + [own_cases_scope]`, from a prepended module. Each scope must hold only conversations the viewer may open: nothing outside them becomes reachable, a miss is still a plain 404, and an error inside a scope is not rescued. The host's word is final: a host that must refuse a viewer entirely returns no scopes, and a product only ever adds a scope beside the host's, returning `super` unchanged when it is empty. A product page that renders a conversation on its own screen resolves it through this same lookup, so a host refusal holds there too.
103
103
 
104
104
  ## 🧱 The data model
105
105
 
@@ -200,7 +200,7 @@ Triggers that arrive together make one catch-up, and catch-ups are at least five
200
200
 
201
201
  | surface | catches up with |
202
202
  |---|---|
203
- | the inbox | the same morphing page refresh a broadcast triggers. A refresh that arrives while another visit is still loading, which Turbo would otherwise ignore, runs when that visit completes |
203
+ | the inbox | the same morphing page refresh a broadcast triggers. A refresh that arrives while another visit is still loading, which Turbo would otherwise ignore, runs when that visit completes. On the [two-pane page](#-two-panes) the list and the open conversation are two surfaces: one refresh for the list, one thread catch-up for the conversation, which the refresh leaves alone |
204
204
  | a thread (page or dock window) | `GET /:id/refresh?after=…&changed=…`, on two cursors: messages created after the newest one on screen are appended (an empty thread asks from the beginning), and edits, tombstones and reactions made to the ones on screen since the newest change it saw are replaced. Two, because a message created at 10:15 and missed is older than a 10:20 edit that did arrive. Each cursor is a moment to the microsecond up to which the thread knows it has everything: set by a complete render or by a catch-up that arrived, never by a live bubble, which can arrive after one that was missed. What arrived live since is named instead (at most 200 ids; past that the history is re-rendered), so it isn't sent twice, and two messages that share a timestamp are never mistaken for each other. Each catch-up belongs to one render of the thread: an answer that arrives after the page or the window rendered the conversation again is dropped unrendered. The read receipts are updated too, the read receipts updated, and, under `deletion: :hard`, bubbles whose messages are gone removed. The composer, its draft and your scroll position are never touched. A thread more than a page behind gets its history re-rendered at the latest page |
205
205
  | `chats_unread_badge` and the dock's badges | `GET /badge`, every unread badge on the page in one Turbo Stream |
206
206
  | the dock's recent list | a reload of its frame, morphed in place so it keeps its scroll position and your search |
@@ -253,7 +253,7 @@ Use **`:flag`, never `:block`** for chat: you don't gag someone mid-conversation
253
253
  |---|---|
254
254
  | `reported_owner` | the sender (who a decision notifies, who a ban targets) |
255
255
  | `moderation_snapshot(:body)` | the body — frozen as evidence at report time, surviving later edits/deletes |
256
- | `remove_reported_field!(:body)` | the **soft-delete tombstone** — a moderator's removal looks exactly like a user deletion ("Message deleted"), no special admin rendering path |
256
+ | `remove_reported_field!(:body)` | the **soft-delete tombstone** — a moderator's removal looks exactly like a user deletion ("Message deleted"), no special admin rendering path. Returns whether anything was removed: `true` only when it actually removed something (the tombstone, or the row under `deletion: :hard`), `false` for a message already deleted (by its sender or an earlier decision), already gone, or whose body is already blank, decided under the message's row lock so two decisions at once can't both remove it. moderate 1.0.0.beta3 and later refuse a removal that removed nothing; earlier versions ignore the return value. A group's `remove_reported_field!(:title)` keeps the same contract |
257
257
  | `report_visible_to?(viewer)` | participants only (a DM isn't public content), and never the author |
258
258
  | `moderation_field_value(:files)` / change detection | attachment-aware seams so image filters classify what actually changed |
259
259
 
@@ -584,6 +584,35 @@ Ejecting a whole screen to add one row or one button is too coarse. The bundled
584
584
 
585
585
  An engine mounted on top of chats ships its own `app/views/chats/slots/…`; the host's file wins by view-path order. `rails generate chats:views` is still there for wholesale restyling.
586
586
 
587
+ ## 🪟 Two panes
588
+
589
+ `/messages` is LinkedIn's messaging page: the conversation list on the left, the open conversation on the right. `/messages/:id` is that same page with its conversation open and its row highlighted, so a link to a conversation, a reload, back and forward all land where they should.
590
+
591
+ ![Two panes](docs/screenshots/split-inbox.png)
592
+
593
+ Each row is a real link to `/messages/:id` (keeping the list's search or stack in its query, so the page's URL always names the list it shows). On a wide screen it opens the conversation in the right pane, a `turbo_frame_tag "chats_conversation"` whose `data-turbo-action="advance"` moves the address bar and history with it, and the server answers that frame with the pane alone. The pane's thread sits in a `data-turbo-permanent` element keyed by the conversation (and the viewer), so the inbox's live refreshes (Turbo 8 morphs of the whole page) re-render the list and leave the conversation alone: its draft, scroll position, read state and stream subscription. Opening one takes you into it, as on LinkedIn: the focus goes to its composer (on a touch screen, to its title, so the keyboard waits until you ask for it), inside a pane a screen reader names after the conversation.
594
+
595
+ **On a phone** (under 700px wide, where the dock turns into a full-screen sheet) it is one column, exactly the separate pages as before: `/messages` is the list, and a row opens the conversation's page, with its back link to the list. One media query decides, so the layout holds before JavaScript runs and without it; with JavaScript off a row always opens the page.
596
+
597
+ <img src="docs/screenshots/split-inbox-phone-list.png" alt="A phone: the messaging page is the conversation list" width="240"> <img src="docs/screenshots/split-inbox-phone-thread.png" alt="A phone: a conversation fills the screen, with its back link to the list" width="240">
598
+
599
+ **Reads** happen when you open a conversation: a row clicked, a link, a reload. The page's own refreshes never read (one may arrive in a background tab), and a conversation the page renders unread reads itself as soon as it's on screen.
600
+
601
+ **The page's title** comes from one place, `chats_page_title`: "Messages" for the inbox, "Bob · Messages" with a conversation open (`chats.page_title.*`, localized). The bundled pages hand it to `content_for :title`, so a layout that shows `<title><%= content_for(:title) || "My app" %></title>` (the Rails default) gets it, and the pane carries it, so the tab follows when a row opens a conversation (Turbo leaves `<head>` alone on a frame visit). Word it your way, for both, with `config.page_title = ->(conversation, viewer) { … }` (`conversation` is nil on the inbox). A layout that builds its own title from what the controller loaded (a `@page_title`) keeps it on full page loads, and shows `chats_page_title` after a row click: say it the same way in `config.page_title`.
602
+
603
+ The [messaging dock](#-messaging-dock) stays off this page, as on every engine page; its "See all messages" opens it, and a window's ⤢ opens its conversation here.
604
+
605
+ ```ruby
606
+ config.inbox_layout = :split # the default: two panes from 700px wide
607
+ config.inbox_layout = :stacked # separate pages at every width, as before 0.6
608
+ ```
609
+
610
+ The list pane is `--chats-list-width` wide (320px), and the page as wide as the list plus a thread (`--chats-max-width`). It is as tall as the screen, so each pane scrolls on its own and the composer stays at the bottom. If your layout puts a header above it, say how much is left, or the page will scroll by the header's height:
611
+
612
+ ```css
613
+ :root { --chats-split-height: calc(100dvh - 4rem); } /* 4rem: your header */
614
+ ``` Views ejected with `rails generate chats:views` before 0.6 keep their own separate pages; re-eject to get the panes.
615
+
587
616
  ## 🪟 Messaging dock
588
617
 
589
618
  LinkedIn and Steam don't send you to another page to answer a message: chats live in small windows in the bottom-right corner while you keep doing what you were doing. One line in your layout, for signed-in viewers, gives your app the same:
@@ -678,6 +707,8 @@ The bundled UI is intentionally framework-free (semantic `chats-*` classes + one
678
707
  --chats-accent: #facc15; /* own bubbles, send button, badges */
679
708
  --chats-accent-contrast: #111827;
680
709
  --chats-verified: #0284c7; /* the "official account" badge */
710
+ --chats-list-width: 320px; /* the list beside an open conversation */
711
+ --chats-split-height: calc(100dvh - 4rem); /* the two-pane page under a 4rem header (default 100dvh) */
681
712
  }
682
713
  ```
683
714
 
@@ -687,7 +718,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
687
718
  rails generate chats:views
688
719
  ```
689
720
 
690
- Override any bundled Stimulus controller by pinning the same importmap key — host pins win. The current keys are `controllers/chats/thread_controller`, `controllers/chats/composer_controller`, `controllers/chats/debounced_submit_controller`, `controllers/chats/refresh_inbox_controller`, `controllers/chats/unread_badge_controller`, and `controllers/chats/dock_controller`. What the inbox, thread, badge and dock controllers share to catch up on missed broadcasts is `chats/recovery` (`recoverMissedBroadcasts`, `renderStreamFrom`); a controller of your own can import it too.
721
+ Override any bundled Stimulus controller by pinning the same importmap key — host pins win. The current keys are `controllers/chats/thread_controller`, `controllers/chats/composer_controller`, `controllers/chats/debounced_submit_controller`, `controllers/chats/refresh_inbox_controller`, `controllers/chats/unread_badge_controller`, `controllers/chats/dock_controller`, and `controllers/chats/split_controller`. What the inbox, thread, badge and dock controllers share to catch up on missed broadcasts is `chats/recovery` (`recoverMissedBroadcasts`, `renderStreamFrom`); a controller of your own can import it too.
691
722
 
692
723
  ## Configuration reference
693
724
 
@@ -729,6 +760,7 @@ Chats.configure do |config|
729
760
  # Inbox shaping
730
761
  config.inbox_limit = 200
731
762
  config.inbox_scope = ->(relation, viewer) { relation }
763
+ config.inbox_layout = :split # :split (two panes from 700px wide) | :stacked
732
764
 
733
765
  # Ecosystem seams (no-op defaults; chats runs standalone)
734
766
  config.blocked_messager_ids = ->(messager) { [] }
@@ -740,6 +772,7 @@ Chats.configure do |config|
740
772
  config.messager_url = ->(messager) { nil } # nil ⇒ names render as plain text
741
773
  config.message_signature = nil # ->(message) { } for signed bubbles
742
774
  config.verified_badge = nil # ->(messager) { markup } for verified: true
775
+ config.page_title = nil # ->(conversation, viewer) { String }; nil ⇒ "Bob · Messages"
743
776
  end
744
777
  ```
745
778
 
@@ -902,7 +935,7 @@ rails generate chats:upgrade # 0.2.0: message authorship columns · 0.4.0: the
902
935
  rails db:migrate
903
936
  ```
904
937
 
905
- 0.5.1 needs no migration and nothing to configure: every live surface now [catches up](#what-happens-when-a-broadcast-is-missed) on missed broadcasts by itself. If you wrapped the inbox in a reconciler of your own, remove it; if you ejected the inbox, the thread or the message partial, read the 0.5.1 [CHANGELOG](CHANGELOG.md) entry. 0.5.0 needs no migration: the messaging dock is opt-in (`chats_dock`). A few things change on their own: a conversation with no messages is no longer listed (it used to show as a "No messages yet" row), a thread the host's policy no longer allows shows the notice in the composer's place, a Turbo hover prefetch no longer marks a thread read, and the thread body moved into `chats/conversations/_thread.html.erb`. Read the 0.5.0 [CHANGELOG](CHANGELOG.md) entry if you ejected `show.html.erb` or the unread badge. 0.4.0 needs that migration (one nullable JSON column, `chats_messages.action`) and nothing else: actions and the `composer_actions` slot are opt-in. The generator is guarded: it doesn't write the action migration where the column already exists (a fresh 0.4.0 install), and re-running it is harmless. Rolling that migration back keeps the column, because a migration must never drop a column it may not have added. If you ejected the composer, read the 0.4.0 [CHANGELOG](CHANGELOG.md) entry: its DOM changed shape. 0.3.x needs no migration and no new configuration: `verified: true` (0.3.0) and `Chats::SendRateLimited` (0.3.2) are opt-in. Nothing in 0.2.0 changes behaviour until you set an option.
938
+ 0.6.0 needs no migration: the messaging page becomes [two panes](#-two-panes) on wide screens by itself; `config.inbox_layout = :stacked` keeps the separate pages. Views you ejected before 0.6 keep rendering as they are (the separate pages); the [CHANGELOG](CHANGELOG.md) lists the templates that changed. 0.5.1 needs no migration and nothing to configure: every live surface now [catches up](#what-happens-when-a-broadcast-is-missed) on missed broadcasts by itself. If you wrapped the inbox in a reconciler of your own, remove it; if you ejected the inbox, the thread or the message partial, read the 0.5.1 [CHANGELOG](CHANGELOG.md) entry. 0.5.0 needs no migration: the messaging dock is opt-in (`chats_dock`). A few things change on their own: a conversation with no messages is no longer listed (it used to show as a "No messages yet" row), a thread the host's policy no longer allows shows the notice in the composer's place, a Turbo hover prefetch no longer marks a thread read, and the thread body moved into `chats/conversations/_thread.html.erb`. Read the 0.5.0 [CHANGELOG](CHANGELOG.md) entry if you ejected `show.html.erb` or the unread badge. 0.4.0 needs that migration (one nullable JSON column, `chats_messages.action`) and nothing else: actions and the `composer_actions` slot are opt-in. The generator is guarded: it doesn't write the action migration where the column already exists (a fresh 0.4.0 install), and re-running it is harmless. Rolling that migration back keeps the column, because a migration must never drop a column it may not have added. If you ejected the composer, read the 0.4.0 [CHANGELOG](CHANGELOG.md) entry: its DOM changed shape. 0.3.x needs no migration and no new configuration: `verified: true` (0.3.0) and `Chats::SendRateLimited` (0.3.2) are opt-in. Nothing in 0.2.0 changes behaviour until you set an option.
906
939
 
907
940
  ## Database support
908
941
 
@@ -32,6 +32,13 @@
32
32
  --chats-bubble-text: #111827;
33
33
  --chats-radius: 1.1rem;
34
34
  --chats-max-width: 42rem;
35
+ /* The conversation list beside an open conversation (two panes). */
36
+ --chats-list-width: 320px;
37
+ /* --chats-split-height, how tall the two-pane page is, is NOT declared
38
+ here: the rules read it with the whole screen (100dvh) as the fallback,
39
+ so a host's own declaration wins wherever it is, even on a :root rule
40
+ loaded before this file (the bundled views link it in the body).
41
+ Under a host header: calc(100dvh - <the header's height>). */
35
42
 
36
43
  /* Safe areas, re-pointable by a host with its own source of truth. */
37
44
  --chats-safe-top: env(safe-area-inset-top, 0px);
@@ -79,7 +86,11 @@
79
86
  text-decoration: none;
80
87
  color: inherit;
81
88
  }
82
- .chats-row__link:hover { background: var(--chats-surface); }
89
+ /* Hover only where there is one: a touch screen "hovers" what it tapped and
90
+ keeps the tint after the finger is gone (the open row stayed grey). */
91
+ @media (hover: hover) {
92
+ .chats-row__link:hover { background: var(--chats-surface); }
93
+ }
83
94
  .chats-row__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }
84
95
  .chats-row__top, .chats-row__bottom {
85
96
  display: flex;
@@ -1007,6 +1018,110 @@ turbo-frame[id^="chats_page_"] {
1007
1018
  }
1008
1019
 
1009
1020
 
1021
+ /* ---------------------------------------------------------------------------
1022
+ 0.6.0 — two panes (config.inbox_layout = :split)
1023
+ ---------------------------------------------------------------------------
1024
+
1025
+ The messaging page as LinkedIn has it: from 700px wide (the dock's phone
1026
+ rule, and chats--split's), the list on the left and the open conversation
1027
+ on the right, both as tall as the screen, each scrolling on its own. Below
1028
+ that, one column, exactly as the separate pages: /messages is the list,
1029
+ /messages/:id the thread. Every request renders both panes; this media
1030
+ query alone picks, so it holds before JavaScript runs and without it.
1031
+
1032
+ .chats-split the page (.chats.chats-inbox too)
1033
+ .chats-split__list the list's <nav>
1034
+ .chats-split__conversation the `chats_conversation` frame
1035
+ .chats-split__pane a conversation in it (section)
1036
+ .chats-split__thread its permanent wrapper, around .chats-thread--pane
1037
+ .chats-split__empty no conversation open (--inbox: no conversations at all)
1038
+
1039
+ The page is as wide as the list plus a thread page (--chats-max-width),
1040
+ so the conversation reads at the width it always had, and as tall as
1041
+ --chats-split-height (the screen; a host with its own header above the
1042
+ page sets what is left: calc(100dvh - 4rem)). */
1043
+ .chats-split {
1044
+ max-width: calc(var(--chats-list-width) + var(--chats-max-width));
1045
+ /* The list alone (a phone): at least what is left of the screen, never
1046
+ the shell's whole 100dvh, which would run past it under a host header. */
1047
+ min-height: var(--chats-split-height, 100dvh);
1048
+ }
1049
+ .chats-split__list { display: flex; flex-direction: column; min-width: 0; }
1050
+ .chats-split__conversation {
1051
+ display: flex;
1052
+ flex-direction: column;
1053
+ min-width: 0;
1054
+ min-height: 0;
1055
+ }
1056
+ .chats-split__pane,
1057
+ .chats-split__thread {
1058
+ flex: 1;
1059
+ min-width: 0;
1060
+ min-height: 0;
1061
+ display: flex;
1062
+ flex-direction: column;
1063
+ }
1064
+ /* The thread page's body, inside the pane: no shell of its own (the page is
1065
+ the .chats shell), and the pane's height, not the screen's. */
1066
+ .chats-thread--pane {
1067
+ flex: 1;
1068
+ min-height: 0;
1069
+ height: auto;
1070
+ display: flex;
1071
+ flex-direction: column;
1072
+ overflow: hidden;
1073
+ }
1074
+ .chats-split__empty { margin: auto; }
1075
+ .chats-row--current .chats-row__link {
1076
+ background: var(--chats-surface);
1077
+ box-shadow: inset 3px 0 0 var(--chats-accent);
1078
+ }
1079
+
1080
+ @media (min-width: 700px) {
1081
+ .chats-split {
1082
+ box-sizing: border-box;
1083
+ flex-direction: row;
1084
+ height: var(--chats-split-height, 100dvh);
1085
+ min-height: 0;
1086
+ overflow: hidden;
1087
+ /* Its edges, where a page wider than it shows the host's background. */
1088
+ border-inline: 1px solid var(--chats-border);
1089
+ }
1090
+ .chats-split__list {
1091
+ box-sizing: border-box;
1092
+ flex: 0 0 var(--chats-list-width);
1093
+ width: var(--chats-list-width);
1094
+ border-right: 1px solid var(--chats-border);
1095
+ }
1096
+ /* The rows scroll; the title and the search stay. */
1097
+ .chats-split__list > turbo-frame {
1098
+ display: block;
1099
+ flex: 1;
1100
+ min-height: 0;
1101
+ overflow-y: auto;
1102
+ overscroll-behavior-y: contain;
1103
+ }
1104
+ .chats-split__conversation { flex: 1 1 0; }
1105
+ /* The list is right there: no way back to it. */
1106
+ .chats-split .chats-thread__back { display: none; }
1107
+ }
1108
+
1109
+ @media (max-width: 699.98px) {
1110
+ /* A conversation open: the page is the thread, as tall as the screen. */
1111
+ .chats-split:has(.chats-split__pane) {
1112
+ height: var(--chats-split-height, 100dvh);
1113
+ min-height: 0;
1114
+ overflow: hidden;
1115
+ }
1116
+ .chats-split:has(.chats-split__pane) > .chats-split__list { display: none; }
1117
+ .chats-split:has(.chats-split__pane) > .chats-split__conversation { flex: 1; }
1118
+ /* None: the page is the list. The empty pane shows under it only to say
1119
+ there are no conversations at all (the inbox's empty state lives there). */
1120
+ .chats-split:not(:has(.chats-split__pane, .chats-split__empty--inbox)) > .chats-split__conversation {
1121
+ display: none;
1122
+ }
1123
+ }
1124
+
1010
1125
  /* ---------------------------------------------------------------------------
1011
1126
  0.5.0 — the messaging dock (chats_dock)
1012
1127
  ---------------------------------------------------------------------------
@@ -1201,7 +1316,9 @@ turbo-frame[id^="chats_page_"] {
1201
1316
  height: 18px;
1202
1317
  transition: transform var(--chats-dock-duration) var(--chats-dock-ease);
1203
1318
  }
1204
- .chats-dock__toggle:hover .chats-dock__chevron { background: var(--chats-dock-hover); color: var(--chats-text); }
1319
+ @media (hover: hover) {
1320
+ .chats-dock__toggle:hover .chats-dock__chevron { background: var(--chats-dock-hover); color: var(--chats-text); }
1321
+ }
1205
1322
  .chats-dock__panel[open] .chats-dock__chevron svg { transform: rotate(180deg); }
1206
1323
  .chats-dock__close-glyph { display: none; }
1207
1324
 
@@ -1239,7 +1356,9 @@ turbo-frame[id^="chats_page_"] {
1239
1356
  }
1240
1357
  .chats-dock__list { list-style: none; margin: 0; padding: 4px 0; }
1241
1358
  .chats-dock__list .chats-row__link { padding: 9px 12px; gap: 10px; }
1242
- .chats-dock__list .chats-row__link:hover { background: var(--chats-dock-hover); }
1359
+ @media (hover: hover) {
1360
+ .chats-dock__list .chats-row__link:hover { background: var(--chats-dock-hover); }
1361
+ }
1243
1362
  .chats-dock__list .chats-avatar { width: 40px; height: 40px; font-size: 0.85rem; }
1244
1363
  .chats-dock__list .chats-row__title { font-size: 0.875rem; }
1245
1364
  .chats-dock__list .chats-row__subject { font-size: 0.6875rem; }
@@ -1260,7 +1379,9 @@ turbo-frame[id^="chats_page_"] {
1260
1379
  font-weight: 650;
1261
1380
  text-decoration: none;
1262
1381
  }
1263
- .chats-dock__see-all:hover { background: var(--chats-dock-hover); text-decoration: underline; }
1382
+ @media (hover: hover) {
1383
+ .chats-dock__see-all:hover { background: var(--chats-dock-hover); text-decoration: underline; }
1384
+ }
1264
1385
 
1265
1386
  .chats-dock .chats-empty { padding: 40px 20px; }
1266
1387
  .chats-dock .chats-empty__icon { font-size: 2rem; margin-bottom: 0.5rem; }
@@ -1342,7 +1463,9 @@ turbo-frame[id^="chats_page_"] {
1342
1463
  }
1343
1464
  .chats-window__icon::-webkit-details-marker { display: none; }
1344
1465
  .chats-window__icon svg { width: 18px; height: 18px; }
1345
- .chats-window__icon:hover { background: var(--chats-dock-hover); color: var(--chats-text); }
1466
+ @media (hover: hover) {
1467
+ .chats-window__icon:hover { background: var(--chats-dock-hover); color: var(--chats-text); }
1468
+ }
1346
1469
  .chats-window__back { display: none; }
1347
1470
  /* The host's thread actions (conversation_header_actions). */
1348
1471
  .chats-window__menu { flex: 0 0 auto; }
@@ -1364,7 +1487,9 @@ turbo-frame[id^="chats_page_"] {
1364
1487
  box-shadow: none;
1365
1488
  }
1366
1489
  .chats-window--unread :is(.chats-window__subject, .chats-window__icon, .chats-verified) { color: inherit; }
1367
- .chats-window--unread .chats-window__icon:hover { background: color-mix(in srgb, currentColor 16%, transparent); color: inherit; }
1490
+ @media (hover: hover) {
1491
+ .chats-window--unread .chats-window__icon:hover { background: color-mix(in srgb, currentColor 16%, transparent); color: inherit; }
1492
+ }
1368
1493
  .chats-window--unread .chats-window__header .chats-badge {
1369
1494
  background: var(--chats-dock-unread-text);
1370
1495
  color: var(--chats-dock-unread-bg);