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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +118 -0
- data/README.md +36 -6
- data/app/assets/stylesheets/chats.css +111 -0
- data/app/controllers/chats/conversations_controller.rb +101 -11
- data/app/javascript/chats/dock_controller.js +15 -1
- data/app/javascript/chats/split_controller.js +97 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +20 -4
- data/app/views/chats/conversations/_thread.html.erb +22 -8
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/index.html.erb +77 -14
- data/app/views/chats/conversations/pane.html.erb +12 -0
- data/app/views/chats/conversations/show.html.erb +10 -65
- data/app/views/chats/dock/_dock.html.erb +2 -1
- data/chats-0.3.2.gem +0 -0
- data/chats-0.4.0.gem +0 -0
- data/chats-0.4.1.gem +0 -0
- data/chats-0.4.2.gem +0 -0
- data/chats-0.5.0.gem +0 -0
- data/chats-0.5.1.gem +0 -0
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/lib/chats/configuration.rb +23 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +9 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0635f1e1c801684a95223db2e59dbb843a67fd9edfe858973b4e9ac6bb32c2a
|
|
4
|
+
data.tar.gz: 9273751eca09bf387e640a45760f00dbd9875af8b8a75a1c2c4ae0de9ff2f527
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+

|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|