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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +171 -0
- data/README.md +40 -7
- data/app/assets/stylesheets/chats.css +131 -6
- data/app/controllers/chats/conversations_controller.rb +106 -11
- data/app/helpers/chats/engine_helper.rb +15 -0
- data/app/javascript/chats/dock_controller.js +15 -1
- data/app/javascript/chats/split_controller.js +131 -0
- data/app/javascript/chats/thread_controller.js +5 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +53 -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 +81 -14
- data/app/views/chats/conversations/pane.html.erb +14 -0
- data/app/views/chats/conversations/show.html.erb +11 -65
- data/app/views/chats/dock/_dock.html.erb +2 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/es.yml +4 -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 +36 -0
- data/lib/chats/models/conversation.rb +10 -2
- data/lib/chats/models/message.rb +19 -3
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +17 -0
- metadata +11 -3
- data/chats-0.3.1.gem +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa521d419a2dc6b1d672e2919c4db795674c744fd97df02027ce33b81f582675
|
|
4
|
+
data.tar.gz: c64cf6cfad1d3f41b9eaf9938936017ecb3af6840feb74f342699c7e0d5d6a01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+

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