chats 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +36 -6
  4. data/app/assets/stylesheets/chats.css +111 -0
  5. data/app/controllers/chats/conversations_controller.rb +101 -11
  6. data/app/javascript/chats/dock_controller.js +15 -1
  7. data/app/javascript/chats/split_controller.js +97 -0
  8. data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
  9. data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
  10. data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
  11. data/app/views/chats/conversations/_group.html.erb +20 -4
  12. data/app/views/chats/conversations/_thread.html.erb +22 -8
  13. data/app/views/chats/conversations/_thread_header.html.erb +83 -0
  14. data/app/views/chats/conversations/index.html.erb +77 -14
  15. data/app/views/chats/conversations/pane.html.erb +12 -0
  16. data/app/views/chats/conversations/show.html.erb +10 -65
  17. data/app/views/chats/dock/_dock.html.erb +2 -1
  18. data/chats-0.3.2.gem +0 -0
  19. data/chats-0.4.0.gem +0 -0
  20. data/chats-0.4.1.gem +0 -0
  21. data/chats-0.4.2.gem +0 -0
  22. data/chats-0.5.0.gem +0 -0
  23. data/chats-0.5.1.gem +0 -0
  24. data/config/importmap.rb +1 -0
  25. data/config/locales/en.yml +1 -0
  26. data/config/locales/es.yml +1 -0
  27. data/docs/screenshots/split-inbox-empty.png +0 -0
  28. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  29. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  30. data/docs/screenshots/split-inbox.png +0 -0
  31. data/lib/chats/configuration.rb +23 -0
  32. data/lib/chats/version.rb +1 -1
  33. data/lib/generators/chats/templates/initializer.rb +9 -0
  34. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 233373c7319647cf267ec239c0227e7751deae6ea9a4afd8d94c578994c85a22
4
- data.tar.gz: 02b7e37be81c69063969d202094430b1657640ea707a1f63e78c06876eb6877d
3
+ metadata.gz: a0635f1e1c801684a95223db2e59dbb843a67fd9edfe858973b4e9ac6bb32c2a
4
+ data.tar.gz: 9273751eca09bf387e640a45760f00dbd9875af8b8a75a1c2c4ae0de9ff2f527
5
5
  SHA512:
6
- metadata.gz: 9bd561feaae573165ce631dac3e669b892d5d70271435a475bc36d92dc532fcd6f55b0b908415141845b47105600088dd739ae7adfeb520baf4cb4bcc0096127
7
- data.tar.gz: ecd69642c82c827c53b5d038b8dd60f5853f5b012368c5d4db846b4549befbd4fd8b153b45d0468228cd77746b77b6c60dadd26ab5d36f73f2a31a4ec84a2edd
6
+ metadata.gz: 94843453f041d4ff766b577b8ba9c28f4a04123f6532e7c27109836dae84f9c354d3c3051cf7f6ad6ec4b05abcbc46e78deacaaec131d63cd0c1af5631bdf4c1
7
+ data.tar.gz: 0b61d2704ea93dd153e0b5ff899b9029b561f710f117955dd82f971a232a2891edc903b526af2cfe4438dfd3e655237a9a31dd9512f9406409a75675df138b94
data/CHANGELOG.md CHANGED
@@ -4,6 +4,124 @@ 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.0] - 2026-10-18
8
+
9
+ The messaging page is two panes, LinkedIn-style: on a wide screen
10
+ `/messages` is the conversation list beside the open conversation, and
11
+ `/messages/:id` is that same page with its conversation open. Phones keep
12
+ the separate pages. **No migration; `config.inbox_layout = :stacked` keeps
13
+ the old pages everywhere.**
14
+
15
+ ### Added
16
+ - **Two panes (`config.inbox_layout = :split`, the default).** From 700px
17
+ wide (the width at which the dock turns into a phone sheet) the list sits
18
+ on the left in a `<nav>` named "Messages", `--chats-list-width` (320px)
19
+ wide, and the open conversation on the right in a
20
+ `turbo_frame_tag "chats_conversation"`; with none open, the pane says
21
+ "Select a conversation" (`chats.inbox.select_hint`), and with no
22
+ conversations at all it holds the inbox's empty state and the
23
+ `inbox_empty` slot (rendered once, in the pane, no longer in the list
24
+ too). Both panes are as tall as `--chats-split-height` (the screen,
25
+ `100dvh`, by default; a host with its own header above the page sets what
26
+ is left, `calc(100dvh - 4rem)`, which a phone's list page also takes as
27
+ its least height) and scroll on their own; the page is as
28
+ wide as the list plus a thread page (`--chats-max-width`).
29
+ Below 700px it is one column, exactly the separate pages: `/messages` is
30
+ the list, `/messages/:id` the thread with its back link. One media query
31
+ decides, so the layout holds before JavaScript and without it.
32
+ - **A row opens its conversation in the pane.** Each row is a real link to
33
+ `/messages/:id` aimed at the frame (`data-turbo-frame`); the frame is
34
+ `data-turbo-action="advance"`, so the address bar and history follow, and
35
+ the server answers the frame with the pane alone (new `pane.html.erb`: the
36
+ header over `chats/conversations/_thread`, no list, no layout). A reload, a
37
+ deep link or back/forward renders the whole page, the row current
38
+ (`.chats-row--current`, `aria-current="page"`). The new `chats--split`
39
+ controller (`controllers/chats/split_controller`, pinned like the others)
40
+ enables the frame only on a wide screen; on a phone, before it connects
41
+ and without JavaScript, the same link opens the page. It also skips the
42
+ hover prefetch of a row while the frame is disabled (Turbo would ask for
43
+ the pane alone and then render it as the page), marks the row the pane
44
+ opened current at once, and takes the viewer into it, as LinkedIn does:
45
+ the focus goes to its composer, inside the pane a screen reader names
46
+ after the conversation; on a touch screen (`pointer: coarse`), and in a
47
+ locked conversation, to the pane's title (`tabindex="-1"`), so no
48
+ keyboard rises over a conversation not read yet.
49
+ - **A filtered list stays filtered.** A row of a search's results or of a
50
+ stack (`?with=`) links to `/messages/:id?q=…` / `?with=…`: the address
51
+ bar names the list the page shows, so the list's refreshes, which
52
+ re-request it, keep it, and the pane's back link (phones) returns to it.
53
+ - **The open conversation survives the list's refreshes.** The inbox's live
54
+ updates are Turbo 8 morphs of the whole page; the pane's thread sits in a
55
+ `data-turbo-permanent` element keyed by the conversation and the viewer
56
+ (`chats_conversation_pane_<id>_<viewer key>`), so a refresh re-renders the
57
+ rows and leaves the draft, the scroll position, the read state and the
58
+ stream subscription alone, while another conversation (another id)
59
+ replaces it. The pane's header (avatar, name, verified badge, subject, the
60
+ ⋯ menu) stays outside it, so a mute or a renamed group still shows. The
61
+ list and the pane each catch up on missed broadcasts once: a page refresh
62
+ for the list, the thread catch-up for the conversation.
63
+ - **`config.inbox_layout`**, `:split` or `:stacked`, checked at boot (a
64
+ wrong value fails the initializer), in the generated initializer and the
65
+ README.
66
+
67
+ ### Changed
68
+ - **When `/messages/:id` reads.** Opening a conversation still reads it: a
69
+ row clicked (the pane's frame), a link from elsewhere, a reload, a page
70
+ loaded without JavaScript. A Turbo visit of the two-pane page does not
71
+ read on the server, because the inbox's own refresh is one: it may arrive
72
+ in a background tab, and a read there said "Seen" while nobody looked and
73
+ refreshed the inbox again, in a loop. The pane renders `read-pending`
74
+ instead and its thread reads once it is on screen, as a dock window does.
75
+ A prefetched thread page (`:stacked`), which nothing used to read when
76
+ Turbo showed it, now reads itself the same way.
77
+ - **The inbox's search form has no `action`**, so it searches whatever page
78
+ it is on (an HTML form without one submits to the page's URL): after a row
79
+ opened a conversation and moved the address bar to `/messages/:id`, a
80
+ search keeps that conversation open (`/messages/:id?q=…`) instead of
81
+ going back to `/messages`.
82
+ - **The dock** drops a window whose conversation a frame of the page loads
83
+ (`turbo:frame-load`), as it did after a visit: one copy of a thread's DOM
84
+ ids per page, with `chats_dock everywhere: true` too.
85
+ - **Templates.** Hosts with ejected views are unaffected: an app whose own
86
+ `index` or `show` came from a `rails generate chats:views` before 0.6
87
+ (no `_conversation_pane` of its own) keeps its separate pages, rendered
88
+ and read exactly as before; re-eject to get the panes. The bundled
89
+ templates that changed:
90
+ - `chats/conversations/index.html.erb`: the two-pane page (the list in
91
+ `nav.chats-split__list`, the `chats_conversation` frame), rendered for
92
+ `/messages/:id` too; the list alone under `:stacked`. The search form is
93
+ a bare `<form>` with no `action`.
94
+ - `chats/conversations/show.html.erb`: the thread page, now only under
95
+ `:stacked` (or for views ejected before 0.6); its header moved to the new
96
+ `chats/conversations/_thread_header.html.erb`, shared with the pane.
97
+ - new `chats/conversations/_conversation_pane.html.erb` (the pane:
98
+ `section.chats-split__pane`, labelled by its `h2` title, the header, and
99
+ the permanent `.chats-split__thread` around the thread),
100
+ `chats/conversations/_empty_pane.html.erb` (`.chats-split__empty`,
101
+ `--inbox` for an empty inbox) and `chats/conversations/pane.html.erb`
102
+ (the frame's answer).
103
+ - `chats/conversations/_thread.html.erb`: `pane:` (rooted at
104
+ `.chats-thread--pane`, inside the page's `.chats` shell) and
105
+ `read_pending:` locals.
106
+ - `chats/conversations/_conversation_row.html.erb` and `_group.html.erb`:
107
+ with `pane: true` the link carries
108
+ `data-turbo-frame="chats_conversation"` (a stack only when it holds one
109
+ conversation) and the list's `filter:` (`q`, `with`) in its URL, and
110
+ with `current:` the current row its class and `aria-current="page"`;
111
+ the `<li>`'s classes come from `class_names` (no trailing space).
112
+ - `chats/conversations/_thread_header.html.erb` takes `back_path:` (the
113
+ pane's keeps the list's filter) and gives a titled pane's heading
114
+ `tabindex="-1"`.
115
+ - `chats/dock/_dock.html.erb`: one more action,
116
+ `turbo:frame-load@document->chats--dock#pageFrameLoaded`.
117
+ - `chats.css`: the `chats-split`, `chats-split__list`,
118
+ `chats-split__conversation`, `chats-split__pane`, `chats-split__thread`,
119
+ `chats-split__empty` and `chats-thread--pane` rules and
120
+ `--chats-list-width`; `--chats-split-height` is read with a `100dvh`
121
+ fallback and never declared by the gem, so a host's declaration wins
122
+ wherever it is. The existing class names are unchanged, so a theme on
123
+ `.chats`, `.chats-row` or `.chats-thread` keeps applying.
124
+
7
125
  ## [0.5.1] - 2026-10-18
8
126
 
9
127
  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 |
@@ -584,6 +584,33 @@ 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 [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.
602
+
603
+ ```ruby
604
+ config.inbox_layout = :split # the default: two panes from 700px wide
605
+ config.inbox_layout = :stacked # separate pages at every width, as before 0.6
606
+ ```
607
+
608
+ 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:
609
+
610
+ ```css
611
+ :root { --chats-split-height: calc(100dvh - 4rem); } /* 4rem: your header */
612
+ ``` Views ejected with `rails generate chats:views` before 0.6 keep their own separate pages; re-eject to get the panes.
613
+
587
614
  ## 🪟 Messaging dock
588
615
 
589
616
  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 +705,8 @@ The bundled UI is intentionally framework-free (semantic `chats-*` classes + one
678
705
  --chats-accent: #facc15; /* own bubbles, send button, badges */
679
706
  --chats-accent-contrast: #111827;
680
707
  --chats-verified: #0284c7; /* the "official account" badge */
708
+ --chats-list-width: 320px; /* the list beside an open conversation */
709
+ --chats-split-height: calc(100dvh - 4rem); /* the two-pane page under a 4rem header (default 100dvh) */
681
710
  }
682
711
  ```
683
712
 
@@ -687,7 +716,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
687
716
  rails generate chats:views
688
717
  ```
689
718
 
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.
719
+ 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
720
 
692
721
  ## Configuration reference
693
722
 
@@ -729,6 +758,7 @@ Chats.configure do |config|
729
758
  # Inbox shaping
730
759
  config.inbox_limit = 200
731
760
  config.inbox_scope = ->(relation, viewer) { relation }
761
+ config.inbox_layout = :split # :split (two panes from 700px wide) | :stacked
732
762
 
733
763
  # Ecosystem seams (no-op defaults; chats runs standalone)
734
764
  config.blocked_messager_ids = ->(messager) { [] }
@@ -902,7 +932,7 @@ rails generate chats:upgrade # 0.2.0: message authorship columns · 0.4.0: the
902
932
  rails db:migrate
903
933
  ```
904
934
 
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.
935
+ 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
936
 
907
937
  ## Database support
908
938
 
@@ -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);
@@ -1007,6 +1014,110 @@ turbo-frame[id^="chats_page_"] {
1007
1014
  }
1008
1015
 
1009
1016
 
1017
+ /* ---------------------------------------------------------------------------
1018
+ 0.6.0 — two panes (config.inbox_layout = :split)
1019
+ ---------------------------------------------------------------------------
1020
+
1021
+ The messaging page as LinkedIn has it: from 700px wide (the dock's phone
1022
+ rule, and chats--split's), the list on the left and the open conversation
1023
+ on the right, both as tall as the screen, each scrolling on its own. Below
1024
+ that, one column, exactly as the separate pages: /messages is the list,
1025
+ /messages/:id the thread. Every request renders both panes; this media
1026
+ query alone picks, so it holds before JavaScript runs and without it.
1027
+
1028
+ .chats-split the page (.chats.chats-inbox too)
1029
+ .chats-split__list the list's <nav>
1030
+ .chats-split__conversation the `chats_conversation` frame
1031
+ .chats-split__pane a conversation in it (section)
1032
+ .chats-split__thread its permanent wrapper, around .chats-thread--pane
1033
+ .chats-split__empty no conversation open (--inbox: no conversations at all)
1034
+
1035
+ The page is as wide as the list plus a thread page (--chats-max-width),
1036
+ so the conversation reads at the width it always had, and as tall as
1037
+ --chats-split-height (the screen; a host with its own header above the
1038
+ page sets what is left: calc(100dvh - 4rem)). */
1039
+ .chats-split {
1040
+ max-width: calc(var(--chats-list-width) + var(--chats-max-width));
1041
+ /* The list alone (a phone): at least what is left of the screen, never
1042
+ the shell's whole 100dvh, which would run past it under a host header. */
1043
+ min-height: var(--chats-split-height, 100dvh);
1044
+ }
1045
+ .chats-split__list { display: flex; flex-direction: column; min-width: 0; }
1046
+ .chats-split__conversation {
1047
+ display: flex;
1048
+ flex-direction: column;
1049
+ min-width: 0;
1050
+ min-height: 0;
1051
+ }
1052
+ .chats-split__pane,
1053
+ .chats-split__thread {
1054
+ flex: 1;
1055
+ min-width: 0;
1056
+ min-height: 0;
1057
+ display: flex;
1058
+ flex-direction: column;
1059
+ }
1060
+ /* The thread page's body, inside the pane: no shell of its own (the page is
1061
+ the .chats shell), and the pane's height, not the screen's. */
1062
+ .chats-thread--pane {
1063
+ flex: 1;
1064
+ min-height: 0;
1065
+ height: auto;
1066
+ display: flex;
1067
+ flex-direction: column;
1068
+ overflow: hidden;
1069
+ }
1070
+ .chats-split__empty { margin: auto; }
1071
+ .chats-row--current .chats-row__link {
1072
+ background: var(--chats-surface);
1073
+ box-shadow: inset 3px 0 0 var(--chats-accent);
1074
+ }
1075
+
1076
+ @media (min-width: 700px) {
1077
+ .chats-split {
1078
+ box-sizing: border-box;
1079
+ flex-direction: row;
1080
+ height: var(--chats-split-height, 100dvh);
1081
+ min-height: 0;
1082
+ overflow: hidden;
1083
+ /* Its edges, where a page wider than it shows the host's background. */
1084
+ border-inline: 1px solid var(--chats-border);
1085
+ }
1086
+ .chats-split__list {
1087
+ box-sizing: border-box;
1088
+ flex: 0 0 var(--chats-list-width);
1089
+ width: var(--chats-list-width);
1090
+ border-right: 1px solid var(--chats-border);
1091
+ }
1092
+ /* The rows scroll; the title and the search stay. */
1093
+ .chats-split__list > turbo-frame {
1094
+ display: block;
1095
+ flex: 1;
1096
+ min-height: 0;
1097
+ overflow-y: auto;
1098
+ overscroll-behavior-y: contain;
1099
+ }
1100
+ .chats-split__conversation { flex: 1 1 0; }
1101
+ /* The list is right there: no way back to it. */
1102
+ .chats-split .chats-thread__back { display: none; }
1103
+ }
1104
+
1105
+ @media (max-width: 699.98px) {
1106
+ /* A conversation open: the page is the thread, as tall as the screen. */
1107
+ .chats-split:has(.chats-split__pane) {
1108
+ height: var(--chats-split-height, 100dvh);
1109
+ min-height: 0;
1110
+ overflow: hidden;
1111
+ }
1112
+ .chats-split:has(.chats-split__pane) > .chats-split__list { display: none; }
1113
+ .chats-split:has(.chats-split__pane) > .chats-split__conversation { flex: 1; }
1114
+ /* None: the page is the list. The empty pane shows under it only to say
1115
+ there are no conversations at all (the inbox's empty state lives there). */
1116
+ .chats-split:not(:has(.chats-split__pane, .chats-split__empty--inbox)) > .chats-split__conversation {
1117
+ display: none;
1118
+ }
1119
+ }
1120
+
1010
1121
  /* ---------------------------------------------------------------------------
1011
1122
  0.5.0 — the messaging dock (chats_dock)
1012
1123
  ---------------------------------------------------------------------------
@@ -14,9 +14,12 @@ module Chats
14
14
  # 36): a client past either gets its history re-rendered instead.
15
15
  CURSOR_IDS_LIMIT = 200
16
16
  CURSOR_ID_LENGTH = 64
17
+ # The two-pane messaging page's conversation frame (index.html.erb): a
18
+ # request from it is answered with the pane alone (pane.html.erb).
19
+ PANE_FRAME = "chats_conversation"
17
20
 
18
21
  before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
19
- helper_method :chats_counterpart
22
+ helper_method :chats_counterpart, :chats_two_panes?, :chats_inbox_filter
20
23
 
21
24
  # The inbox. Everything is preloaded/batched so rendering N rows costs a
22
25
  # constant number of queries (conversations + last messages + participants
@@ -29,14 +32,7 @@ module Chats
29
32
  # is the dock's own list.
30
33
  return dock if turbo_frame_request_id == "chats_dock_list"
31
34
 
32
- @inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
33
- @rows = @inbox.rows
34
- @unread_counts = @inbox.unread_counts
35
- # Back-compat for inboxes ejected under 0.1.x, which loop over
36
- # @conversations: they keep rendering the flat list (no stacking, i.e.
37
- # exactly what they rendered before). Re-eject or delete your copy to
38
- # get the stacked rows.
39
- @conversations = @inbox.conversations
35
+ load_inbox
40
36
  end
41
37
 
42
38
  # The messaging dock's recent list (`chats_dock`): the first rows of the
@@ -67,6 +63,12 @@ module Chats
67
63
  # `?window=1` from a Turbo frame is the same thread as a floating dock
68
64
  # window: the window template (its compact header around the same
69
65
  # thread body), no layout.
66
+ #
67
+ # With two panes (`config.inbox_layout = :split`, the default) the
68
+ # thread is the conversation pane of the messaging page: the whole page
69
+ # (the inbox's template, with the pane open), or, asked from that page's
70
+ # own conversation frame (a row clicked on a wide screen), the pane
71
+ # alone. `:stacked` renders the thread page.
70
72
  def show
71
73
  @participant = @conversation.participant_for(chats_current_messager)
72
74
 
@@ -90,10 +92,14 @@ module Chats
90
92
  # swallow the unread badge. The thread controller posts #read once
91
93
  # the window is actually on screen (`read-pending`).
92
94
  render :window, layout: false
93
- elsif !prefetch?
95
+ else
94
96
  # Opening the thread reads it. (Live appends while the thread
95
97
  # stays open are read via the thread controller's POST to #read.)
96
- @participant&.read!
98
+ # A render the server doesn't read is marked `read-pending`: the
99
+ # thread controller reads it once it is on screen.
100
+ @read_pending = !reads_on_render?
101
+ @participant&.read! unless @read_pending
102
+ render_split_page if chats_two_panes?
97
103
  end
98
104
  end
99
105
  end
@@ -226,6 +232,90 @@ module Chats
226
232
  @conversation = find_conversation
227
233
  end
228
234
 
235
+ # The inbox's rows, for the inbox page and for the two-pane page.
236
+ def load_inbox
237
+ @inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
238
+ @rows = @inbox.rows
239
+ @unread_counts = @inbox.unread_counts
240
+ # Back-compat for inboxes ejected under 0.1.x, which loop over
241
+ # @conversations: they keep rendering the flat list (no stacking, i.e.
242
+ # exactly what they rendered before). Re-eject or delete your copy to
243
+ # get the stacked rows.
244
+ @conversations = @inbox.conversations
245
+ end
246
+
247
+ # The open conversation on the two-pane page: the pane alone for the
248
+ # page's own conversation frame, else the whole page around it (any
249
+ # other frame of the page, the search's results among them, finds its
250
+ # own part in it).
251
+ def render_split_page
252
+ return render :pane, layout: false if pane_request?
253
+
254
+ load_inbox
255
+ render :index
256
+ end
257
+
258
+ def pane_request?
259
+ turbo_frame_request_id == PANE_FRAME
260
+ end
261
+
262
+ # Whether the messaging page is two panes: `config.inbox_layout = :split`
263
+ # (the default), unless this app ejected its views before there were
264
+ # two panes. `rails g chats:views` under 0.5 copied an inbox and a
265
+ # thread page and no conversation pane: that app keeps its own separate
266
+ # pages, rendered and read exactly as before, until it ejects again (or
267
+ # deletes its copies). Views ejected since 0.6 carry the pane, and are
268
+ # two panes like the gem's own.
269
+ def chats_two_panes?
270
+ return @chats_two_panes if defined?(@chats_two_panes)
271
+
272
+ @chats_two_panes = Chats.config.split_inbox? && !views_ejected_before_two_panes?
273
+ end
274
+
275
+ # The list's filter on the two-pane page: its search (`q`) or the stack
276
+ # it shows (`with`). A row opened in the pane carries it into its URL,
277
+ # /messages/:id?with=…, because that URL is what the list's live
278
+ # refreshes re-request: without it, the next refresh would bring back
279
+ # the whole inbox under a list that was filtered. The back link to the
280
+ # list keeps it too.
281
+ def chats_inbox_filter
282
+ { q: params[:q], with: params[:with] }.select { |_, value| value.is_a?(String) && value.present? }
283
+ end
284
+
285
+ def views_ejected_before_two_panes?
286
+ (own_view?("index") || own_view?("show")) && !own_view?("conversation_pane", partial: true)
287
+ end
288
+
289
+ # Whether the app renders its own copy of one of these views, not the gem's.
290
+ def own_view?(name, partial: false)
291
+ template = lookup_context.find_all(name, ["chats/conversations"], partial).first
292
+ template.present? && !template.identifier.to_s.start_with?(Chats::Engine.root.join("app/views").to_s)
293
+ end
294
+
295
+ # Whether showing the conversation reads it, there and then.
296
+ #
297
+ # Never a prefetch (Turbo 8 fetches a link the pointer rests on, which
298
+ # is nobody reading). On the thread page, everything else: opening it
299
+ # reads it.
300
+ #
301
+ # On the two-pane page, the viewer opening it: a row clicked (the
302
+ # pane's frame) or the page loaded by the browser itself (a link from
303
+ # elsewhere, a reload, no JavaScript). NOT a Turbo visit of the page,
304
+ # because one of those is the inbox's own refresh: every live update of
305
+ # the list re-requests this page, maybe in a hidden tab, and a read
306
+ # there would say "Seen" to the sender while nobody looks — and, since
307
+ # a read refreshes the reader's inbox, refresh the page again, forever.
308
+ # Turbo marks each of its requests (X-Turbo-Request-Id) and a refresh
309
+ # looks like any other visit, so a Turbo visit renders the pane unread
310
+ # and its thread reads it once it's on screen. A refresh's pane never
311
+ # gets that far: the page keeps its permanent one, and drops the new.
312
+ def reads_on_render?
313
+ return false if prefetch?
314
+ return true unless chats_two_panes?
315
+
316
+ pane_request? || request.headers["X-Turbo-Request-Id"].blank?
317
+ end
318
+
229
319
  def time_from_ms(value)
230
320
  Time.zone.at(0, value.to_i, :millisecond)
231
321
  end
@@ -44,7 +44,9 @@ import { recoverMissedBroadcasts, renderStreamFrom } from "chats/recovery"
44
44
  // Every access is guarded: no storage, no memory, no harm.
45
45
  // * hiding on any page with [data-chats-dock-hide] (the engine's
46
46
  // inbox and thread), since a permanent element is never
47
- // re-rendered by the server
47
+ // re-rendered by the server; and no window for a
48
+ // conversation the page renders, after a visit or after a
49
+ // frame of the page loads one
48
50
  // * keyboard Escape unwinds innermost first: the thread's popup or
49
51
  // photo, an edit, the window menu, then the window
50
52
  // (minimized), the panel (closed); on a phone, the window
@@ -782,6 +784,18 @@ export default class extends Controller {
782
784
  this.save()
783
785
  }
784
786
 
787
+ // A frame of the page itself loaded a conversation (the two-pane messaging
788
+ // page opens one beside its list without a visit): no window keeps a
789
+ // second copy of it.
790
+ pageFrameLoaded(event) {
791
+ if (this.element.contains(event.target)) return
792
+
793
+ this.windowTargets.forEach((frame) => {
794
+ if (this.renderedOnPage(frame.dataset.conversationId)) this.removeWindow(frame)
795
+ })
796
+ this.save()
797
+ }
798
+
785
799
  // A row whose conversation is open in a window says so.
786
800
  markOpenRows() {
787
801
  if (!this.hasListTarget) return