chats 0.4.2 → 0.5.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 +247 -0
- data/README.md +95 -8
- data/app/assets/stylesheets/chats.css +596 -0
- data/app/controllers/chats/conversations_controller.rb +71 -5
- data/app/helpers/chats/engine_helper.rb +75 -2
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1070 -0
- data/app/javascript/chats/thread_controller.js +72 -6
- data/app/views/chats/conversations/_conversation_row.html.erb +13 -2
- data/app/views/chats/conversations/_group.html.erb +6 -2
- data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +172 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +2 -1
- data/app/views/chats/conversations/show.html.erb +11 -118
- data/app/views/chats/conversations/window.html.erb +26 -0
- data/app/views/chats/dock/_badge.html.erb +12 -0
- data/app/views/chats/dock/_dock.html.erb +130 -0
- data/app/views/chats/dock/_signal.html.erb +26 -0
- data/app/views/chats/dock/_update.turbo_stream.erb +10 -0
- data/app/views/chats/dock/_window.html.erb +23 -0
- data/app/views/chats/dock/_window_header.html.erb +75 -0
- data/app/views/chats/dock/_window_shell.html.erb +17 -0
- data/app/views/chats/messages/_composer.html.erb +11 -1
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +17 -0
- data/config/routes.rb +5 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/gemfiles/rails_7.1.gemfile +2 -0
- data/gemfiles/rails_7.2.gemfile +2 -0
- data/gemfiles/rails_8.1.gemfile +2 -0
- data/lib/chats/broadcasts.rb +76 -10
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +19 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 709f9d9d48f89eaac706a8c974cdfdb05ac89ed02a15677874be21dcd069c951
|
|
4
|
+
data.tar.gz: b5fcf75df095a3aaf34016e056fbc662def488efcfe502fa797745ac807d0183
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '09ef3d73a35fb2276770e14cf032925c2d4ebc80c4890e4bc21f3df72cc0ef6ef2ea11cb482ae570bba694dd1aa3047cac22b63b64bd99095bfcc738fe04b377'
|
|
7
|
+
data.tar.gz: 424502a1634c16786c512f62f246e71f7655fdb11dc13a6386bd5767474b4fdb483e409d4c988258bce54d494eb71eecb52314d469c79ddaa96e0b2cbc8d26b0
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,253 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - 2026-10-18
|
|
8
|
+
|
|
9
|
+
The messaging dock. LinkedIn and Steam keep your conversations in small
|
|
10
|
+
windows in the bottom-right corner while you go on using the app; one line
|
|
11
|
+
in a layout, `<%= chats_dock %>`, now does that. **No migration.** The dock
|
|
12
|
+
is opt-in, but a few things change without asking, all under Changed: a
|
|
13
|
+
conversation with no messages is no longer listed, a direct thread the
|
|
14
|
+
host's policy no longer allows says so up front, a hover prefetch no longer
|
|
15
|
+
reads a thread, and the thread body moved into a partial.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **`chats_dock`**, the messaging dock: `aside.chats-dock`, fixed to the
|
|
19
|
+
bottom-right corner, holding the floating windows (`#chats_dock_windows`,
|
|
20
|
+
newest nearest the panel) and the panel. The panel is a `<details>`: its
|
|
21
|
+
header is the bar (the viewer's avatar, "Messages", the unread badge),
|
|
22
|
+
and open it has the inbox's search, the viewer's most recent inbox rows
|
|
23
|
+
(stacks, verified badges, a bell-slash on muted ones, previews, unread
|
|
24
|
+
counts) and "See all messages". It opens without JavaScript, and its
|
|
25
|
+
rows, search and links are real links and forms. Options: `windows:` (3),
|
|
26
|
+
`recent:` (10), `launcher:` (true; false drops the phone's round button
|
|
27
|
+
for a host whose own nav opens the sheet) and `everywhere:` (see below).
|
|
28
|
+
Renders nothing for a signed-out visitor and nothing on the engine's own
|
|
29
|
+
pages.
|
|
30
|
+
- **It survives Turbo visits, per viewer.** The element is
|
|
31
|
+
`data-turbo-permanent`, so its windows, drafts, scroll positions and
|
|
32
|
+
subscriptions ride along from page to page. Its id names the viewer
|
|
33
|
+
(`chats_dock_<messager key>`), so a visit that lands on another
|
|
34
|
+
account's page (a sign-out and sign-in, an account switcher) never
|
|
35
|
+
matches it, and every window carries its viewer and is dropped on connect
|
|
36
|
+
if it names someone else. Because the server can't remove a permanent
|
|
37
|
+
element from the next page, the dock also hides itself on any page with
|
|
38
|
+
`data-chats-dock-hide`; the engine's inbox and thread roots carry it.
|
|
39
|
+
`everywhere: true` renders the dock on the engine's pages and ignores the
|
|
40
|
+
attribute.
|
|
41
|
+
- **Floating windows.** A window is a turbo frame
|
|
42
|
+
(`dom_id(conversation, :window)`) loading `GET /:id?window=1`, which,
|
|
43
|
+
from inside a frame, renders the same thread body as the page, rooted at
|
|
44
|
+
`.chats-thread.chats-thread--window` (never `.chats`, the full-page
|
|
45
|
+
shell), under a compact header: a title button (avatar, name, verified
|
|
46
|
+
badge, subject, the waiting count) that minimizes it, the
|
|
47
|
+
`conversation_header_actions` slot in a ⋯ menu with the page's locals
|
|
48
|
+
(`blockable:` included), ⤢ for the full page, – and ×. Until the thread
|
|
49
|
+
arrives, and if it can't, the window shows the same header over
|
|
50
|
+
"Loading…" or "Couldn't load this conversation." with "Try again"; a 404
|
|
51
|
+
(left, blocked) closes it, read from the response before Turbo acts on
|
|
52
|
+
it (an error page carrying `turbo-visit-control`, as Rails' own do, would
|
|
53
|
+
otherwise take the whole page with it), keeping what was typed in it; a
|
|
54
|
+
5xx shows the failed shell. An empty conversation's window says "Say
|
|
55
|
+
hello to …". The dock never opens a window for a conversation the page itself
|
|
56
|
+
shows (the streams address the thread's DOM ids, which must exist once).
|
|
57
|
+
- **What fits.** `min(windows, floor((viewport − 312) / 344))` windows beside
|
|
58
|
+
the panel: two at 1280px, one at 768px, three from 1344px. Opening one
|
|
59
|
+
more closes the least recently used window that isn't in use. A window in
|
|
60
|
+
use (it has the focus, or unsent text) is never closed to make room: it
|
|
61
|
+
folds to a 104px header (avatar and ×, `--chats-dock-squeezed-width`)
|
|
62
|
+
beside the new one. Two limits are absolute: never more than `windows:`
|
|
63
|
+
windows, folded ones included, and never wider than the viewport beside
|
|
64
|
+
the panel. An open that can't fit even with every window in use folded is
|
|
65
|
+
declined, changing nothing, and the row's link (or the `dock: true`
|
|
66
|
+
button) opens the conversation's page instead; a pop-up just doesn't
|
|
67
|
+
happen. Unfolding a folded window (its title, a row, a repeated `dock:
|
|
68
|
+
true`) makes room exactly as an open does, and when it can't, the window
|
|
69
|
+
stays folded. Narrowing the viewport closes what isn't in use and folds
|
|
70
|
+
what is, the focused window last; when even a row of folded headers is
|
|
71
|
+
too wide, it closes the least recently used windows in use too, keeping
|
|
72
|
+
their unsent text, and announces nothing. A window the viewer closes
|
|
73
|
+
keeps its unsent text for when it comes back. Every way a window leaves
|
|
74
|
+
the dock (closed, evicted, declined, gone for the viewer, shown by the
|
|
75
|
+
page, another viewer's) goes through one path that keeps the draft and
|
|
76
|
+
resyncs the rows and the phone sheet.
|
|
77
|
+
- **Reading waits for the viewer.** A window never reads on render. A
|
|
78
|
+
minimized window never marks anything read, and a window that popped
|
|
79
|
+
open, or came back after a reload, marks messages read only once the
|
|
80
|
+
viewer focuses it, presses in it or scrolls it: the dock sets
|
|
81
|
+
`data-chats-minimized` and `data-chats-active` on the window and the
|
|
82
|
+
thread controller obeys them, checking again when its delayed read fires
|
|
83
|
+
(state, not geometry: a folded window's scroller also loses its padding,
|
|
84
|
+
but a size check alone is never trusted). A window the viewer opened
|
|
85
|
+
is engaged from the start. The thread page has no gate and is unchanged.
|
|
86
|
+
- **Live, server-driven.** A new per-messager stream,
|
|
87
|
+
**`[messager, :chats_dock]`**, gets one render per participant per event
|
|
88
|
+
(`chats/dock/_update`): the dock's badges (the nav badge's count) when
|
|
89
|
+
the count moves, and a signal template (`chats/dock/_signal`). The signal
|
|
90
|
+
reloads the recent list, debounced, for every participant, the sender and
|
|
91
|
+
edits and deletes included. For a message the participant would be
|
|
92
|
+
notified about (`Participant#notifiable_for?`: not theirs, not muted, not
|
|
93
|
+
a headless seat) it also names the conversation, the sender and the
|
|
94
|
+
unread count there. The dock then pops that conversation's window open,
|
|
95
|
+
waiting with the accent header and its count, and announces "New message
|
|
96
|
+
from …" in a polite live region (at most once per conversation every 30
|
|
97
|
+
seconds, never for the window you're typing in). The pop-up only happens
|
|
98
|
+
in dock mode, never moves the focus, never for a conversation the viewer
|
|
99
|
+
closed in the last five minutes, and never by closing a window someone is
|
|
100
|
+
using; with no room, there's no pop. **The signal names a conversation
|
|
101
|
+
only to a participant who can see it** (`messager.chats`, so blocks and
|
|
102
|
+
`inbox_scope` apply), because the dock's list and window refuse anything
|
|
103
|
+
else. The badges stay truthful either way. No polling.
|
|
104
|
+
- **Memory.** Per viewer in `localStorage`: `chats:dock:v1:<messager key>`
|
|
105
|
+
(which windows, minimized or folded or not, when each was used, the
|
|
106
|
+
recently closed ones and the panel) and `chats:draft:<messager key>:
|
|
107
|
+
<conversation id>` (unsent text, saved as you type and whenever a window
|
|
108
|
+
goes, by any path, a refused or missing one included, and before a
|
|
109
|
+
failure (5xx, network) replaces the window's content; restored the moment
|
|
110
|
+
the window's composer appears, cleared on send, kept seven days). A saved
|
|
111
|
+
draft goes back only into an EMPTY composer: if something was already
|
|
112
|
+
typed there, it stays, and the saved draft is never replaced by anything
|
|
113
|
+
shorter, so neither text is lost. A reload restores the
|
|
114
|
+
saved arrangement as it was, folded windows included, up to the cap, then
|
|
115
|
+
fits it to the width there is now by the resize rules; none of them is
|
|
116
|
+
engaged. The panel comes back in dock mode only, and the phone's sheet
|
|
117
|
+
always starts closed. Every access is guarded,
|
|
118
|
+
so a browser that refuses storage only means forgetting.
|
|
119
|
+
- **Phones.** Under 700px wide or 500px tall the closed bar is a 56px round
|
|
120
|
+
launcher, the open panel a full-screen sheet, and a conversation one
|
|
121
|
+
full-screen window with ← back to the list, sized to the visual viewport
|
|
122
|
+
so the composer sits on the keyboard. The list behind a window is `inert`
|
|
123
|
+
until Back, which returns the focus to it. The sheet makes the rest of the page
|
|
124
|
+
`inert` (the siblings of the dock and of each of its ancestors, so a
|
|
125
|
+
layout that wraps the dock never inerts the sheet) and stops it scrolling. Inputs are 16px, so iOS doesn't zoom.
|
|
126
|
+
Messages never pop a window on a phone. The layout is one media query, so
|
|
127
|
+
it applies before JavaScript connects and without it; the controller reads
|
|
128
|
+
the same query.
|
|
129
|
+
- **Keyboard and screen readers.** Escape unwinds from the inside out: the
|
|
130
|
+
thread's popup or photo, an edit (the composer now cancels one on
|
|
131
|
+
Escape), the ⋯ menu, then the window (minimized, focus to its title), the
|
|
132
|
+
panel (closed, focus to the bar); on a phone, the window back to the list,
|
|
133
|
+
then the sheet closed. Focus goes into a window only when the viewer
|
|
134
|
+
opened it: its composer on a desktop, ← on a phone. Close returns the
|
|
135
|
+
focus to whatever opened the window. Focus rings are inset, the history
|
|
136
|
+
scroller is focusable, the badge is not a live region, and the bar reads
|
|
137
|
+
"Messages, 3 unread" from its own text.
|
|
138
|
+
- **`chat_button_to(other, dock: true)`** posts `dock=1`, and
|
|
139
|
+
`ConversationsController#create` answers a Turbo Stream that appends the
|
|
140
|
+
conversation's window to `#chats_dock_windows`. When that conversation is
|
|
141
|
+
already in a window, the dock focuses it instead of letting the append
|
|
142
|
+
replace it, so nothing typed is lost. It belongs with `chats_dock`: on a
|
|
143
|
+
page without the dock the stream has no target and does nothing. Without
|
|
144
|
+
Turbo, and without `dock`, the redirect is unchanged.
|
|
145
|
+
- **`chats_dock_opener(conversation)`** and **`chats_dock_toggle`**: data for
|
|
146
|
+
any link on the page to open that conversation's window, or the dock's
|
|
147
|
+
panel (the sheet on a phone). They stay plain links without a dock or
|
|
148
|
+
JavaScript. **`chats_muted?(conversation, viewer)`** reads the viewer's
|
|
149
|
+
seat.
|
|
150
|
+
- **`GET /dock`** (`dock_conversations_path`), the panel's list:
|
|
151
|
+
`Chats::Inbox` with `?q=` and `?recent=` (1 to 50), rendered into the
|
|
152
|
+
`chats_dock_list` frame without a layout. The inbox answers a request from
|
|
153
|
+
that frame (the dock's search, which submits to the inbox so that it
|
|
154
|
+
lands on the full inbox's results without JavaScript) with the same list.
|
|
155
|
+
- **`Chats::Inbox.for(viewer, limit:)`**: how many rows come back
|
|
156
|
+
(`config.inbox_limit` by default, never more). A smaller limit is a
|
|
157
|
+
smaller query; a search still looks through the whole `inbox_limit`
|
|
158
|
+
window.
|
|
159
|
+
- **`Chats::Conversation.with_messages`**: conversations that have at least
|
|
160
|
+
one message, system messages and tombstones included (`last_message_id`
|
|
161
|
+
set, which the denormalized pointer already gives). It doesn't mean a
|
|
162
|
+
person wrote there: a thread holding only a system note is listed.
|
|
163
|
+
- **The `dock_panel_footer` slot** (receives `viewer:`): your own link next
|
|
164
|
+
to "See all messages".
|
|
165
|
+
- **Theming.** The dock's variables are declared on `:where(.chats-dock)`,
|
|
166
|
+
not `:root`, so a host re-pointing `--chats-bg` or `--chats-accent` on the
|
|
167
|
+
dock reaches them: `--chats-dock-z` (40), `-offset` (bottom clearance),
|
|
168
|
+
`-inset` (right edge), `-gap`, `-bar-height` (48px), `-panel-width`
|
|
169
|
+
(288px), `-panel-height`, `-window-width` (336px), `-window-height`
|
|
170
|
+
(480px), `-radius`, `-bg`, `-header-bg`, `-hover` (`#f9fafb`: muted text
|
|
171
|
+
at 4.63:1, where `--chats-surface` gives 4.39), `-edge`, `-shadow`,
|
|
172
|
+
`-unread-bg` / `-unread-text` (the accent pair), `-focus`,
|
|
173
|
+
`-title-font`, `-launcher-size`, `-duration`, `-ease`, plus
|
|
174
|
+
`--chats-safe-top/right/bottom/left` on `:root`. Nothing leaves a
|
|
175
|
+
transform, filter or `contain` on the dock or an expanded window, which
|
|
176
|
+
would trap the thread's fixed popup and photo preview inside it; entrance
|
|
177
|
+
animations end at `transform: none`, and `prefers-reduced-motion` turns
|
|
178
|
+
them all off. Strings under `chats.dock.*` in `en` and `es`. The
|
|
179
|
+
controller is pinned as `controllers/chats/dock_controller`, and
|
|
180
|
+
`rails generate chats:views` has a `dock` group (`app/views/chats/dock/`).
|
|
181
|
+
|
|
182
|
+
### Changed
|
|
183
|
+
- **A conversation with no messages is never listed.** Opening a
|
|
184
|
+
conversation (`chat_with`, the Message button) creates it, and until now
|
|
185
|
+
the inbox listed it straight away as a "No messages yet" row. Every list
|
|
186
|
+
now reads through `Chats::Conversation.with_messages`: the inbox rows,
|
|
187
|
+
a stack's rows and its `open_count` / `unread_count`, search, the
|
|
188
|
+
`?with=` view and the dock. **If your inbox showed rows for threads
|
|
189
|
+
that were opened and never used, it no longer does.** The conversation
|
|
190
|
+
still opens when addressed directly (the thread page and its dock
|
|
191
|
+
window work), because `Messager#chats`, the relation every engine lookup
|
|
192
|
+
authorizes through, is unchanged; use `chats.with_messages` for lists of
|
|
193
|
+
your own. `unread_chats_count` already agreed (an empty conversation has
|
|
194
|
+
nothing unread).
|
|
195
|
+
- **A direct thread nobody may write in says so up front.** When the host's
|
|
196
|
+
`can_message` policy no longer allows the pair, or the other account is
|
|
197
|
+
gone, the thread (page and window) shows "You can't message this person."
|
|
198
|
+
in the composer's place, through the locked-composer box (same id; the
|
|
199
|
+
`locked_composer` slot gets it as `notice:`), instead of a composer whose
|
|
200
|
+
sends fail. `_locked_composer` takes an optional `notice:` local.
|
|
201
|
+
- **A hover prefetch never reads a thread.** Turbo 8 prefetches a link when
|
|
202
|
+
the pointer rests on it, and `ConversationsController#show` used to read
|
|
203
|
+
the thread on that request, so hovering an inbox row cleared its unread
|
|
204
|
+
badge. A request with `X-Sec-Purpose: prefetch` no longer reads; the
|
|
205
|
+
visit does. The dock's list turns prefetch off altogether.
|
|
206
|
+
- **The thread body moved into `chats/conversations/_thread.html.erb`.**
|
|
207
|
+
It holds the `chats--thread` element and everything inside it except the
|
|
208
|
+
header: the `turbo_stream_from`, the scroller with the older-pages frame
|
|
209
|
+
and the «new messages» line, read state, typing, the long-press popup,
|
|
210
|
+
the photo preview and the composer. It renders as a partial layout whose
|
|
211
|
+
`yield` is the header, with locals `conversation:`, `messages:`, `more:`,
|
|
212
|
+
`first_unread_id:`, `counterpart:` and `window:`. `show.html.erb` is now
|
|
213
|
+
its header wrapped in that partial. The page's DOM gains
|
|
214
|
+
`data-chats-dock-hide` and `data-chats--thread-read-pending-value="false"`
|
|
215
|
+
on its root and `tabindex="0"` on the scroller. **If you ejected
|
|
216
|
+
`show.html.erb`**, your copy keeps rendering the page as before; add
|
|
217
|
+
`data-chats-dock-hide` to its root if you render the dock everywhere.
|
|
218
|
+
Dock windows render the gem's `_thread` (or yours, if you eject it), so a
|
|
219
|
+
restyle that lives in your `show.html.erb` doesn't reach them: move it
|
|
220
|
+
into an ejected `_thread.html.erb` to style both.
|
|
221
|
+
- **Grouped inbox rows cost two queries in all.** A stack's `open_count` and
|
|
222
|
+
`unread_count` were two aggregates per stack; they are now two grouped
|
|
223
|
+
queries for every stack the inbox shows, so an inbox (or a dock) with many
|
|
224
|
+
stacked counterparts stays constant-query.
|
|
225
|
+
- The unread badge partial (`chats/shared/_unread_badge`) carries
|
|
226
|
+
`data-chats-unread-badge`, the mark every unread badge has, the dock's
|
|
227
|
+
included. Its broadcast still targets `#chats_unread_badge`, so a badge
|
|
228
|
+
ejected earlier keeps updating unchanged. Add the attribute if your own
|
|
229
|
+
CSS or JS wants to find every badge.
|
|
230
|
+
- The inbox row partials (`_conversation_row`, `_group`) take an optional
|
|
231
|
+
`dock:` local that marks their link as a dock opener, and a direct or
|
|
232
|
+
group row shows the bell-slash when the viewer muted it. Rows ejected
|
|
233
|
+
earlier still work in the dock; they navigate instead of opening a
|
|
234
|
+
window.
|
|
235
|
+
- **The composer's fields have per-conversation ids**
|
|
236
|
+
(`dom_id(conversation, :message_body)`, `:message_files`), instead of
|
|
237
|
+
`form_with`'s `message_body` / `message_files`. A page can now hold
|
|
238
|
+
several composers, and Turbo restores focus by id after rendering a
|
|
239
|
+
stream, so a shared id moved the cursor into another window's composer.
|
|
240
|
+
CSS or JS that selected `#message_body` should select `.chats-composer__input`.
|
|
241
|
+
- The long-press Edit event (`chats:edit-message`) now carries its thread
|
|
242
|
+
element, and a composer only starts editing for a message of its own
|
|
243
|
+
thread, since a page can hold several threads now. The check lives in
|
|
244
|
+
the controller, so ejected composer views get it too; a host that pinned
|
|
245
|
+
its own `composer_controller` reacts to every thread's Edit, as before.
|
|
246
|
+
- The dummy app has a real importmap setup (Turbo, Stimulus, the stock
|
|
247
|
+
eager loader) and a host home page with the dock, which is how the
|
|
248
|
+
screenshots were taken. `test/system` measures the dock's rendered layout
|
|
249
|
+
in a headless Chrome (capybara and selenium-webdriver join the test
|
|
250
|
+
bundle). Under CI (`CI=true`) Chrome is a prerequisite: the tests fail
|
|
251
|
+
when it can't start, and the workflow checks for it first; on a machine
|
|
252
|
+
without Chrome they skip. The suite prints how many ran.
|
|
253
|
+
|
|
7
254
|
## [0.4.2] - 2026-10-17
|
|
8
255
|
|
|
9
256
|
### Changed
|
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) · [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) · [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
|
|
|
@@ -70,6 +70,8 @@ mount Chats::Engine => "/messages"
|
|
|
70
70
|
|
|
71
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.
|
|
72
72
|
|
|
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
|
+
|
|
73
75
|
Drop a "Message" button anywhere — it renders only when the viewer is allowed to message that person:
|
|
74
76
|
|
|
75
77
|
```erb
|
|
@@ -82,9 +84,15 @@ And a live unread badge in your nav:
|
|
|
82
84
|
<%= chats_unread_badge %>
|
|
83
85
|
```
|
|
84
86
|
|
|
87
|
+
Or give every page LinkedIn-style floating chat windows with one line in your layout — see [the messaging dock](#-messaging-dock):
|
|
88
|
+
|
|
89
|
+
```erb
|
|
90
|
+
<%= chats_dock %>
|
|
91
|
+
```
|
|
92
|
+
|
|
85
93
|
## What `chats` does (and doesn't) do
|
|
86
94
|
|
|
87
|
-
**Does:** direct 1:1 threads, group conversations, image (or any) attachments via ActiveStorage, emoji reactions, read receipts ("Seen"), unread counts and live badges, typing indicators, message editing, soft deletion (WhatsApp-style tombstones), system messages your app posts into threads, action buttons under messages ("Pay now", "Join my room"), per-sender rate limiting, inbox search, infinite scroll-up pagination, optional encryption at rest, and small adapter seams for moderation + notifications.
|
|
95
|
+
**Does:** direct 1:1 threads, group conversations, image (or any) attachments via ActiveStorage, emoji reactions, read receipts ("Seen"), unread counts and live badges, typing indicators, message editing, soft deletion (WhatsApp-style tombstones), system messages your app posts into threads, action buttons under messages ("Pay now", "Join my room"), a LinkedIn-style messaging dock of floating chat windows, per-sender rate limiting, inbox search, infinite scroll-up pagination, optional encryption at rest, and small adapter seams for moderation + notifications.
|
|
88
96
|
|
|
89
97
|
**Doesn't:** chatbots/LLM agents, workspaces/tenancy, voice/video, public channels, federation. It's peer-to-peer (and group) human messaging — not a Slack clone, not a support-ticketing tool.
|
|
90
98
|
|
|
@@ -149,6 +157,7 @@ All three are inert by default (the label falls back to the record's `to_s`, not
|
|
|
149
157
|
- **Bubbles are broadcast viewer-agnostic** — one render shared by every subscriber. A tiny Stimulus controller aligns own-vs-other client-side by comparing sender keys. This is what makes single-render broadcasts possible at all.
|
|
150
158
|
- **The inbox** receives Turbo 8 page *refreshes* (morphing, scroll-preserving) instead of surgically patched rows: inbox rows are intensely per-viewer (unread badges, bold states, ordering), so each client re-requests and gets a correct, personalized render. Refreshes are debounced and tagged so the tab that caused the change skips its own.
|
|
151
159
|
- **Unread badges** get their own stream (`chats_unread_badge` helper) so any page can host a live badge without inheriting inbox refreshes.
|
|
160
|
+
- **The messaging dock** has one more per-messager stream (`[messager, :chats_dock]`): its badges, and a signal naming the conversation that changed and whether its window may pop open. The signal only names a conversation the participant can see (blocks and `inbox_scope` applied). No polling, no channel of its own.
|
|
152
161
|
- **Typing indicators** are a Turbo Stream *custom action* — ephemeral, nothing persisted, no Action Cable channel class, no connection identification requirements.
|
|
153
162
|
- **Cable down?** Everything still works request/response. Real-time is an enhancement, not a requirement.
|
|
154
163
|
|
|
@@ -518,6 +527,7 @@ Ejecting a whole screen to add one row or one button is too coarse. The bundled
|
|
|
518
527
|
| `composer_actions` | a row of your own buttons above the composer's input, outside its form (gets `conversation:`; gone when the thread locks) |
|
|
519
528
|
| `locked_composer` | the locked composer's body |
|
|
520
529
|
| `message_meta` | after each bubble's timestamp |
|
|
530
|
+
| `dock_panel_footer` | beside "See all messages" in the [messaging dock](#-messaging-dock)'s panel (gets `viewer:`) |
|
|
521
531
|
|
|
522
532
|
```erb
|
|
523
533
|
<%# app/views/chats/slots/_inbox_top.html.erb %>
|
|
@@ -526,6 +536,77 @@ Ejecting a whole screen to add one row or one button is too coarse. The bundled
|
|
|
526
536
|
|
|
527
537
|
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.
|
|
528
538
|
|
|
539
|
+
## 🪟 Messaging dock
|
|
540
|
+
|
|
541
|
+
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:
|
|
542
|
+
|
|
543
|
+
```erb
|
|
544
|
+
<%# app/views/layouts/application.html.erb, before </body> %>
|
|
545
|
+
<%= chats_dock %>
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+

|
|
549
|
+
|
|
550
|
+
*Two conversations in floating windows (one with an action button) and the panel of recent conversations. The rows open in a window carry the accent bar.*
|
|
551
|
+
|
|
552
|
+
- **The bar** sits flush with the bottom-right corner: your avatar, "Messages" and the live unread badge (the same number as `chats_unread_badge`).
|
|
553
|
+
- **The panel** opens from it: the inbox's search, your ten most recent conversations exactly as the inbox renders them (stacks, verified badges, a bell-slash on muted ones, previews, unread counts) and "See all messages". It is a `<details>`: it opens without JavaScript, and every row, the search and the link are real links and forms.
|
|
554
|
+
- **A window** is the same thread the full page shows (live messages, read receipts, typing, the long-press menu, attachments, action buttons, your `composer_actions`, the locked composer) under a compact header: the name (click it to minimize, as on LinkedIn), the `conversation_header_actions` slot in a ⋯ menu, ⤢ for the full page, – and ×. As many windows open as fit beside the panel, up to three: two at 1280px, one at 768px. Opening one more closes the least recently used window that isn't **in use**. A window in use (it has the focus, or text you haven't sent) is never closed to make room: it folds to a narrow header (its avatar and ×) beside the new window instead. Two limits always hold: never more than `windows:` windows, folded ones included, and never wider than the screen beside the panel. When a conversation can't fit even with every window in use folded, the dock doesn't open it: the row's link opens the conversation's page instead (a pop-up simply doesn't happen). Unfolding a folded window makes room the same way; when it can't, the window stays folded. Narrowing the browser closes idle windows and folds the ones in use, the focused one last; if even a row of folded headers is too wide, the least recently used windows close too, their unsent text kept for when you reopen them. Text in a window that goes away (you close it, it makes room, its conversation becomes unreachable, its load fails) is kept for when you reopen it, and goes back into the composer the moment it appears, unless you've already started typing there.
|
|
555
|
+
- **Live**: a new message updates the badge and the list, and opens its conversation's window without taking the focus. That never happens for your own messages, for a conversation you muted, on a phone, for one you closed in the last five minutes, or by closing a window you're using. The window waits with the accent header and its count, and "New message from …" is announced politely. Edits, deletes and your own sends from another tab keep the list in order.
|
|
556
|
+
- **Reading** waits for you. A minimized window never marks anything read (the thread checks the window's state, not just its size), and a window that popped open marks messages read once you focus it, press in it or scroll it. One you opened yourself reads right away.
|
|
557
|
+
- **It stays put**: the dock is `data-turbo-permanent`, so Turbo Drive carries its windows, drafts and scroll positions from page to page. Its id names the viewer, so a visit that lands on another account's page never inherits it. Open windows, minimized ones, recently closed ones, the panel and unsent drafts are kept in `localStorage` under keys that name the viewer (`chats:dock:v1:<key>`, `chats:draft:<key>:<id>`), so a reload brings them back as they were, folded ones included (fitted to the width there is). A browser that refuses storage only means the dock forgets.
|
|
558
|
+
- **Keyboard**: Escape unwinds from the inside out (the long-press popup, an edit, the ⋯ menu, then the window minimizes, then the panel closes). Focus rings are inset; the history scrolls with the arrow keys.
|
|
559
|
+
|
|
560
|
+
On a phone (under 700px wide or 500px tall) the bar becomes a round button with the badge, the panel a full-screen sheet, and a conversation **one full-screen window**, with ← back to the list. While a window is up, the list behind it is out of the tab order; Back returns the focus to it. The sheet holds the focus while it's open (everything around it is made `inert`, siblings of the dock and of each of its ancestors, so a layout that wraps the dock works too), the page behind it doesn't scroll, and the composer sits on the keyboard. This layout is one media query, so it applies before JavaScript runs and without it.
|
|
561
|
+
|
|
562
|
+
<img src="docs/screenshots/dock-phone-button.png" alt="A phone: the dock is a round button with an unread badge in the corner" width="240"> <img src="docs/screenshots/dock-phone-panel.png" alt="A phone: the dock's panel fills the screen with the recent conversations" width="240"> <img src="docs/screenshots/dock-phone-window.png" alt="A phone: one conversation fills the screen, with the composer at the bottom" width="240">
|
|
563
|
+
|
|
564
|
+
**Open a window from your own pages.** `dock: true` on the Message button opens the conversation in the dock instead of navigating to it (a conversation already open in a window is focused, with its draft intact):
|
|
565
|
+
|
|
566
|
+
```erb
|
|
567
|
+
<%= chat_button_to @player, dock: true %>
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
It needs `chats_dock` on the same page: without the dock, the button's response has nowhere to go and nothing happens. Any other link can do the same, or open the panel, with two data helpers. Both stay real links when the dock isn't there:
|
|
571
|
+
|
|
572
|
+
```erb
|
|
573
|
+
<%= link_to "Chat", chats.conversation_path(conversation), data: chats_dock_opener(conversation) %>
|
|
574
|
+
<%= link_to "Messages", chats.conversations_path, data: chats_dock_toggle %>
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**Options:**
|
|
578
|
+
|
|
579
|
+
```erb
|
|
580
|
+
<%= chats_dock windows: 2, recent: 8 %> <%# at most this many windows (3), conversations in the panel (10) %>
|
|
581
|
+
<%= chats_dock launcher: false %> <%# no round button on phones: your nav opens the sheet (chats_dock_toggle) %>
|
|
582
|
+
<%= chats_dock everywhere: true %> <%# also on the engine's own pages (see below) %>
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
**Not on your messaging pages.** `chats_dock` renders nothing on the engine's own pages (the inbox and the thread already are the messaging UI), and the dock never opens a window for a conversation the page itself shows: the live updates find a thread by its DOM ids, which must exist once. Because the element survives Turbo visits, the server can't remove it from the next page, so the dock also hides itself on any page carrying `data-chats-dock-hide`. The engine's inbox and thread carry it; add it to your own messaging pages. `everywhere: true` renders the dock on the engine's pages too and ignores the attribute.
|
|
586
|
+
|
|
587
|
+
**Theme it** like the rest of chats. The variables are declared on `.chats-dock`, so re-pointing `--chats-bg` or `--chats-accent` there reaches the dock too:
|
|
588
|
+
|
|
589
|
+
```css
|
|
590
|
+
.chats-dock {
|
|
591
|
+
--chats-dock-z: 40; /* above your page, below your modals */
|
|
592
|
+
--chats-dock-offset: 0px; /* bottom clearance: a cookie banner, a tab bar */
|
|
593
|
+
--chats-dock-inset: 16px; /* from the right edge */
|
|
594
|
+
--chats-dock-gap: 8px;
|
|
595
|
+
--chats-dock-bar-height: 48px; /* the bar and every window header */
|
|
596
|
+
--chats-dock-panel-width: 288px;
|
|
597
|
+
--chats-dock-panel-height: 560px;
|
|
598
|
+
--chats-dock-window-width: 336px;
|
|
599
|
+
--chats-dock-window-height: 480px;
|
|
600
|
+
--chats-dock-radius: 12px;
|
|
601
|
+
--chats-dock-hover: #f9fafb; /* row hover: muted text stays at 4.63:1 */
|
|
602
|
+
--chats-dock-unread-bg: var(--chats-accent); /* a window waiting to be read */
|
|
603
|
+
--chats-dock-unread-text: var(--chats-accent-contrast);
|
|
604
|
+
--chats-dock-title-font: inherit;
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
The `dock_panel_footer` [slot](#-view-slots) puts your own link next to "See all messages". `rails generate chats:views` ejects the dock's partials (`chats/dock/`) along with everything else. All labels are localized under `chats.dock.*`.
|
|
609
|
+
|
|
529
610
|
## 🔗 Profile links
|
|
530
611
|
|
|
531
612
|
`chats` never assumes your app has a `user_path`. Tell it where a messager lives and names become links; leave it alone and they render as plain text:
|
|
@@ -558,7 +639,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
|
|
|
558
639
|
rails generate chats:views
|
|
559
640
|
```
|
|
560
641
|
|
|
561
|
-
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`, and `controllers/chats/
|
|
642
|
+
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`, and `controllers/chats/dock_controller`.
|
|
562
643
|
|
|
563
644
|
## Configuration reference
|
|
564
645
|
|
|
@@ -623,7 +704,8 @@ alice.chat_with(bob, about: ride) # the DM about that ride
|
|
|
623
704
|
alice.chat_with(bob, carol, title: "Trip") # a group (alice is owner)
|
|
624
705
|
alice.message!(bob, "hi", about: ride) # send (resolves the thread)
|
|
625
706
|
alice.message!(conversation, "hi", files: []) # send into a conversation
|
|
626
|
-
alice.chats #
|
|
707
|
+
alice.chats # every conversation she's in (what she may open), newest first
|
|
708
|
+
alice.chats.with_messages # ...the ones with a message: what lists show
|
|
627
709
|
alice.unread_chats_count # conversations with unread messages
|
|
628
710
|
alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
|
|
629
711
|
alice.message!(bob, "Let's play!", # with one button under it
|
|
@@ -672,6 +754,7 @@ Chats::Conversation.direct_between!(alice, bob, about: ride) # find-or-create;
|
|
|
672
754
|
Chats::Conversation.group!(alice, [bob, carol], title: "Trip")
|
|
673
755
|
Chats::Conversation.direct .groups .about(ride) .recent_first
|
|
674
756
|
Chats::Conversation.inbox_for(alice) .excluding_blocked_for(alice) .unread_by(alice)
|
|
757
|
+
Chats::Conversation.with_messages # has at least one message, system messages and tombstones included (`last_message_id` set); every list reads through this
|
|
675
758
|
Chats::Conversation.unread_counts_for(alice, conversations) # { id => count } in one query
|
|
676
759
|
conversation.direct? / conversation.group?
|
|
677
760
|
conversation.participant_for(alice) # the seat, or nil
|
|
@@ -693,6 +776,7 @@ Chats::Participant.active .muted
|
|
|
693
776
|
|
|
694
777
|
# The inbox
|
|
695
778
|
inbox = Chats::Inbox.for(alice, query: "trip", with: sgid) # search, or filtered to one counterpart's stack
|
|
779
|
+
Chats::Inbox.for(alice, limit: 10) # the first rows only (the dock's recent list)
|
|
696
780
|
inbox.rows inbox.each inbox.to_a inbox.size inbox.any? inbox.empty? inbox.filtered?
|
|
697
781
|
inbox.conversations # the flat, unstacked list
|
|
698
782
|
inbox.unread_counts inbox.unread_count_for(conversation) inbox.unread_count
|
|
@@ -724,8 +808,11 @@ All available in the bundled views **and** in your own templates:
|
|
|
724
808
|
|
|
725
809
|
| helper | renders |
|
|
726
810
|
|---|---|
|
|
727
|
-
| `chat_button_to(other, about:, label:, **html)` | a "Message" button, only when the viewer may message them |
|
|
811
|
+
| `chat_button_to(other, about:, label:, dock:, **html)` | a "Message" button, only when the viewer may message them; `dock: true` opens a dock window instead of navigating |
|
|
728
812
|
| `chats_unread_badge(messager = viewer)` | the live unread badge with its own stream |
|
|
813
|
+
| `chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)` | the [messaging dock](#-messaging-dock): bar, panel and floating windows |
|
|
814
|
+
| `chats_dock_opener(conversation)` / `chats_dock_toggle` | data for your own links: open that conversation's dock window, or open the dock's panel |
|
|
815
|
+
| `chats_muted?(conversation, viewer)` | whether the viewer muted it (read from the preloaded seats) |
|
|
729
816
|
| `chats_messager_name(messager, css_class:)` | the name, linked through `messager_url`, badged when verified |
|
|
730
817
|
| `chats_messager_avatar(messager)` / `chats_conversation_avatar(conversation, viewer)` | avatar (URL, attachment, variant or initials) |
|
|
731
818
|
| `chats_verified_badge(messager)` | the official-account mark, or nil |
|
|
@@ -744,7 +831,7 @@ See the end of [the full Ruby API](#-the-full-ruby-api): `Chats::Error` → `Blo
|
|
|
744
831
|
|
|
745
832
|
## Locales
|
|
746
833
|
|
|
747
|
-
`en` and `es` ship with the gem under `chats.*` (`inbox`, `thread`, `conversation`, `verified`, `message`, `composer`, `buttons`, `flashes`). Your own locale
|
|
834
|
+
`en` and `es` ship with the gem under `chats.*` (`inbox`, `thread`, `conversation`, `verified`, `message`, `composer`, `dock`, `buttons`, `flashes`). Your own locale
|
|
748
835
|
files **outrank** the gem's — Rails loads every engine's locales first and the
|
|
749
836
|
app's last (a 0.2.0 fix; the gem used to re-append its files after yours and
|
|
750
837
|
silently win) — so override any key in your `es.yml` and the gem's copy loses.
|
|
@@ -762,7 +849,7 @@ rails generate chats:upgrade # 0.2.0: message authorship columns · 0.4.0: the
|
|
|
762
849
|
rails db:migrate
|
|
763
850
|
```
|
|
764
851
|
|
|
765
|
-
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.
|
|
852
|
+
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.
|
|
766
853
|
|
|
767
854
|
## Database support
|
|
768
855
|
|
|
@@ -770,7 +857,7 @@ PostgreSQL, MySQL, and SQLite. The migration adapts automatically: it honors you
|
|
|
770
857
|
|
|
771
858
|
## Testing
|
|
772
859
|
|
|
773
|
-
The gem is tested with Minitest against a real dummy host app — models, broadcasts (over the Action Cable test adapter), full request cycles, generators, and every authorization negative (outsiders, leavers, and blocked pairs all get plain 404s; existence never leaks).
|
|
860
|
+
The gem is tested with Minitest against a real dummy host app — models, broadcasts (over the Action Cable test adapter), full request cycles, generators, and every authorization negative (outsiders, leavers, and blocked pairs all get plain 404s; existence never leaks). The Stimulus controllers' decisions run in node, and the dock's layout is measured in a headless Chrome (`test/system`). Under CI (`CI=true`) Chrome is required and its absence fails the run; locally the layout tests skip without it, and the suite prints how many ran.
|
|
774
861
|
|
|
775
862
|
```bash
|
|
776
863
|
bundle exec rake test # full suite
|