chats 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +349 -0
- data/README.md +91 -8
- data/app/assets/stylesheets/chats.css +114 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +305 -63
- data/app/helpers/chats/engine_helper.rb +27 -4
- data/app/javascript/chats/dock_controller.js +54 -4
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/split_controller.js +97 -0
- data/app/javascript/chats/thread_controller.js +245 -54
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- data/app/views/chats/conversations/_conversation_pane.html.erb +42 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +20 -4
- data/app/views/chats/conversations/_empty_pane.html.erb +23 -0
- data/app/views/chats/conversations/_group.html.erb +20 -4
- data/app/views/chats/conversations/_history.html.erb +54 -0
- data/app/views/chats/conversations/_thread.html.erb +29 -54
- data/app/views/chats/conversations/_thread_header.html.erb +83 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/index.html.erb +91 -15
- data/app/views/chats/conversations/pane.html.erb +12 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- data/app/views/chats/conversations/show.html.erb +10 -65
- data/app/views/chats/dock/_dock.html.erb +14 -5
- data/app/views/chats/messages/_message.html.erb +8 -6
- data/chats-0.3.2.gem +0 -0
- data/chats-0.4.0.gem +0 -0
- data/chats-0.4.1.gem +0 -0
- data/chats-0.4.2.gem +0 -0
- data/chats-0.5.0.gem +0 -0
- data/chats-0.5.1.gem +0 -0
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/config/routes.rb +3 -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/broadcasts.rb +102 -7
- data/lib/chats/configuration.rb +23 -0
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/conversation.rb +38 -0
- data/lib/chats/models/message.rb +98 -9
- data/lib/chats/models/reaction.rb +13 -0
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +9 -0
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0635f1e1c801684a95223db2e59dbb843a67fd9edfe858973b4e9ac6bb32c2a
|
|
4
|
+
data.tar.gz: 9273751eca09bf387e640a45760f00dbd9875af8b8a75a1c2c4ae0de9ff2f527
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94843453f041d4ff766b577b8ba9c28f4a04123f6532e7c27109836dae84f9c354d3c3051cf7f6ad6ec4b05abcbc46e78deacaaec131d63cd0c1af5631bdf4c1
|
|
7
|
+
data.tar.gz: 0b61d2704ea93dd153e0b5ff899b9029b561f710f117955dd82f971a232a2891edc903b526af2cfe4438dfd3e655237a9a31dd9512f9406409a75675df138b94
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,355 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.6.0] - 2026-10-18
|
|
8
|
+
|
|
9
|
+
The messaging page is two panes, LinkedIn-style: on a wide screen
|
|
10
|
+
`/messages` is the conversation list beside the open conversation, and
|
|
11
|
+
`/messages/:id` is that same page with its conversation open. Phones keep
|
|
12
|
+
the separate pages. **No migration; `config.inbox_layout = :stacked` keeps
|
|
13
|
+
the old pages everywhere.**
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Two panes (`config.inbox_layout = :split`, the default).** From 700px
|
|
17
|
+
wide (the width at which the dock turns into a phone sheet) the list sits
|
|
18
|
+
on the left in a `<nav>` named "Messages", `--chats-list-width` (320px)
|
|
19
|
+
wide, and the open conversation on the right in a
|
|
20
|
+
`turbo_frame_tag "chats_conversation"`; with none open, the pane says
|
|
21
|
+
"Select a conversation" (`chats.inbox.select_hint`), and with no
|
|
22
|
+
conversations at all it holds the inbox's empty state and the
|
|
23
|
+
`inbox_empty` slot (rendered once, in the pane, no longer in the list
|
|
24
|
+
too). Both panes are as tall as `--chats-split-height` (the screen,
|
|
25
|
+
`100dvh`, by default; a host with its own header above the page sets what
|
|
26
|
+
is left, `calc(100dvh - 4rem)`, which a phone's list page also takes as
|
|
27
|
+
its least height) and scroll on their own; the page is as
|
|
28
|
+
wide as the list plus a thread page (`--chats-max-width`).
|
|
29
|
+
Below 700px it is one column, exactly the separate pages: `/messages` is
|
|
30
|
+
the list, `/messages/:id` the thread with its back link. One media query
|
|
31
|
+
decides, so the layout holds before JavaScript and without it.
|
|
32
|
+
- **A row opens its conversation in the pane.** Each row is a real link to
|
|
33
|
+
`/messages/:id` aimed at the frame (`data-turbo-frame`); the frame is
|
|
34
|
+
`data-turbo-action="advance"`, so the address bar and history follow, and
|
|
35
|
+
the server answers the frame with the pane alone (new `pane.html.erb`: the
|
|
36
|
+
header over `chats/conversations/_thread`, no list, no layout). A reload, a
|
|
37
|
+
deep link or back/forward renders the whole page, the row current
|
|
38
|
+
(`.chats-row--current`, `aria-current="page"`). The new `chats--split`
|
|
39
|
+
controller (`controllers/chats/split_controller`, pinned like the others)
|
|
40
|
+
enables the frame only on a wide screen; on a phone, before it connects
|
|
41
|
+
and without JavaScript, the same link opens the page. It also skips the
|
|
42
|
+
hover prefetch of a row while the frame is disabled (Turbo would ask for
|
|
43
|
+
the pane alone and then render it as the page), marks the row the pane
|
|
44
|
+
opened current at once, and takes the viewer into it, as LinkedIn does:
|
|
45
|
+
the focus goes to its composer, inside the pane a screen reader names
|
|
46
|
+
after the conversation; on a touch screen (`pointer: coarse`), and in a
|
|
47
|
+
locked conversation, to the pane's title (`tabindex="-1"`), so no
|
|
48
|
+
keyboard rises over a conversation not read yet.
|
|
49
|
+
- **A filtered list stays filtered.** A row of a search's results or of a
|
|
50
|
+
stack (`?with=`) links to `/messages/:id?q=…` / `?with=…`: the address
|
|
51
|
+
bar names the list the page shows, so the list's refreshes, which
|
|
52
|
+
re-request it, keep it, and the pane's back link (phones) returns to it.
|
|
53
|
+
- **The open conversation survives the list's refreshes.** The inbox's live
|
|
54
|
+
updates are Turbo 8 morphs of the whole page; the pane's thread sits in a
|
|
55
|
+
`data-turbo-permanent` element keyed by the conversation and the viewer
|
|
56
|
+
(`chats_conversation_pane_<id>_<viewer key>`), so a refresh re-renders the
|
|
57
|
+
rows and leaves the draft, the scroll position, the read state and the
|
|
58
|
+
stream subscription alone, while another conversation (another id)
|
|
59
|
+
replaces it. The pane's header (avatar, name, verified badge, subject, the
|
|
60
|
+
⋯ menu) stays outside it, so a mute or a renamed group still shows. The
|
|
61
|
+
list and the pane each catch up on missed broadcasts once: a page refresh
|
|
62
|
+
for the list, the thread catch-up for the conversation.
|
|
63
|
+
- **`config.inbox_layout`**, `:split` or `:stacked`, checked at boot (a
|
|
64
|
+
wrong value fails the initializer), in the generated initializer and the
|
|
65
|
+
README.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
- **When `/messages/:id` reads.** Opening a conversation still reads it: a
|
|
69
|
+
row clicked (the pane's frame), a link from elsewhere, a reload, a page
|
|
70
|
+
loaded without JavaScript. A Turbo visit of the two-pane page does not
|
|
71
|
+
read on the server, because the inbox's own refresh is one: it may arrive
|
|
72
|
+
in a background tab, and a read there said "Seen" while nobody looked and
|
|
73
|
+
refreshed the inbox again, in a loop. The pane renders `read-pending`
|
|
74
|
+
instead and its thread reads once it is on screen, as a dock window does.
|
|
75
|
+
A prefetched thread page (`:stacked`), which nothing used to read when
|
|
76
|
+
Turbo showed it, now reads itself the same way.
|
|
77
|
+
- **The inbox's search form has no `action`**, so it searches whatever page
|
|
78
|
+
it is on (an HTML form without one submits to the page's URL): after a row
|
|
79
|
+
opened a conversation and moved the address bar to `/messages/:id`, a
|
|
80
|
+
search keeps that conversation open (`/messages/:id?q=…`) instead of
|
|
81
|
+
going back to `/messages`.
|
|
82
|
+
- **The dock** drops a window whose conversation a frame of the page loads
|
|
83
|
+
(`turbo:frame-load`), as it did after a visit: one copy of a thread's DOM
|
|
84
|
+
ids per page, with `chats_dock everywhere: true` too.
|
|
85
|
+
- **Templates.** Hosts with ejected views are unaffected: an app whose own
|
|
86
|
+
`index` or `show` came from a `rails generate chats:views` before 0.6
|
|
87
|
+
(no `_conversation_pane` of its own) keeps its separate pages, rendered
|
|
88
|
+
and read exactly as before; re-eject to get the panes. The bundled
|
|
89
|
+
templates that changed:
|
|
90
|
+
- `chats/conversations/index.html.erb`: the two-pane page (the list in
|
|
91
|
+
`nav.chats-split__list`, the `chats_conversation` frame), rendered for
|
|
92
|
+
`/messages/:id` too; the list alone under `:stacked`. The search form is
|
|
93
|
+
a bare `<form>` with no `action`.
|
|
94
|
+
- `chats/conversations/show.html.erb`: the thread page, now only under
|
|
95
|
+
`:stacked` (or for views ejected before 0.6); its header moved to the new
|
|
96
|
+
`chats/conversations/_thread_header.html.erb`, shared with the pane.
|
|
97
|
+
- new `chats/conversations/_conversation_pane.html.erb` (the pane:
|
|
98
|
+
`section.chats-split__pane`, labelled by its `h2` title, the header, and
|
|
99
|
+
the permanent `.chats-split__thread` around the thread),
|
|
100
|
+
`chats/conversations/_empty_pane.html.erb` (`.chats-split__empty`,
|
|
101
|
+
`--inbox` for an empty inbox) and `chats/conversations/pane.html.erb`
|
|
102
|
+
(the frame's answer).
|
|
103
|
+
- `chats/conversations/_thread.html.erb`: `pane:` (rooted at
|
|
104
|
+
`.chats-thread--pane`, inside the page's `.chats` shell) and
|
|
105
|
+
`read_pending:` locals.
|
|
106
|
+
- `chats/conversations/_conversation_row.html.erb` and `_group.html.erb`:
|
|
107
|
+
with `pane: true` the link carries
|
|
108
|
+
`data-turbo-frame="chats_conversation"` (a stack only when it holds one
|
|
109
|
+
conversation) and the list's `filter:` (`q`, `with`) in its URL, and
|
|
110
|
+
with `current:` the current row its class and `aria-current="page"`;
|
|
111
|
+
the `<li>`'s classes come from `class_names` (no trailing space).
|
|
112
|
+
- `chats/conversations/_thread_header.html.erb` takes `back_path:` (the
|
|
113
|
+
pane's keeps the list's filter) and gives a titled pane's heading
|
|
114
|
+
`tabindex="-1"`.
|
|
115
|
+
- `chats/dock/_dock.html.erb`: one more action,
|
|
116
|
+
`turbo:frame-load@document->chats--dock#pageFrameLoaded`.
|
|
117
|
+
- `chats.css`: the `chats-split`, `chats-split__list`,
|
|
118
|
+
`chats-split__conversation`, `chats-split__pane`, `chats-split__thread`,
|
|
119
|
+
`chats-split__empty` and `chats-thread--pane` rules and
|
|
120
|
+
`--chats-list-width`; `--chats-split-height` is read with a `100dvh`
|
|
121
|
+
fallback and never declared by the gem, so a host's declaration wins
|
|
122
|
+
wherever it is. The existing class names are unchanged, so a theme on
|
|
123
|
+
`.chats`, `.chats-row` or `.chats-thread` keeps applying.
|
|
124
|
+
|
|
125
|
+
## [0.5.1] - 2026-10-18
|
|
126
|
+
|
|
127
|
+
A page that missed a broadcast is never left stale: every live surface
|
|
128
|
+
catches up on its own, without polling (fixes
|
|
129
|
+
[#1](https://github.com/rameerez/chats/issues/1)). Also: the dock's unread
|
|
130
|
+
count in every language's plural forms, one extension point for a
|
|
131
|
+
conversation that must open although the inbox hides it
|
|
132
|
+
(`conversation_lookup_scopes`), and real conversations told from
|
|
133
|
+
system-only threads. **No migration, nothing to configure.**
|
|
134
|
+
|
|
135
|
+
### Added
|
|
136
|
+
- **Real conversations vs system-only threads.** `with_messages` asks whether
|
|
137
|
+
a conversation holds anything at all, system lines and tombstones
|
|
138
|
+
included; ops walls, moderation lists and analytics ask whether a messager
|
|
139
|
+
wrote something there that still stands. Four additive reads answer that:
|
|
140
|
+
`Chats::Message.human` (kind `text`, not deleted: written by a messager,
|
|
141
|
+
person or not, as opposed to posted by the app, so a bot's or a headless
|
|
142
|
+
desk's text message counts and a photo with no caption counts too), `Chats::Conversation.with_human_messages` and
|
|
143
|
+
`.without_human_messages` (one correlated `EXISTS` / `NOT EXISTS` built
|
|
144
|
+
from `Message.human`, so they compose with `alice.chats`, `inbox_for`,
|
|
145
|
+
`about`, `unread_by` and `with_messages` and never multiply rows),
|
|
146
|
+
`Conversation#last_human_message` (a query, not the `last_message`
|
|
147
|
+
pointer), and its batch twin `Chats::Message.last_human_in(conversations)`:
|
|
148
|
+
the newest human message of each conversation in ONE query, for a loaded
|
|
149
|
+
page, ids, a single record, or an unloaded relation, which is read as a
|
|
150
|
+
subquery and never loaded first. It returns a relation, so a `.select` can
|
|
151
|
+
narrow the columns. Viewer-facing lists start from `alice.chats`, which
|
|
152
|
+
applies blocks and `config.inbox_scope`; the bare `Chats::Conversation`
|
|
153
|
+
scopes see every conversation and belong behind staff authorization.
|
|
154
|
+
Nothing changes by default: system messages still bump `messages_count`
|
|
155
|
+
and `last_message`, and the inbox still lists system-only threads.
|
|
156
|
+
- **`Chats::ApplicationController#conversation_lookup_scopes`, the one
|
|
157
|
+
extension point for a conversation that must open although the inbox
|
|
158
|
+
hides it.** Every engine endpoint (the thread, a dock window, sending,
|
|
159
|
+
editing, reacting, reading) finds a conversation through
|
|
160
|
+
`find_conversation(id)`, which now asks each relation
|
|
161
|
+
`conversation_lookup_scopes` returns, in order (`find_by(id:)`), and
|
|
162
|
+
raises `ActiveRecord::RecordNotFound` when none has it. By default that is
|
|
163
|
+
`[chats_current_messager.chats]`, exactly the old lookup. A product gem
|
|
164
|
+
(support_desk: a requester's own ticket, which the host's `inbox_scope`
|
|
165
|
+
keeps out of the inbox) prepends a module whose
|
|
166
|
+
`conversation_lookup_scopes` returns `super + [its own scope]`, instead of
|
|
167
|
+
re-implementing the lookup and recognising a miss by the exception's
|
|
168
|
+
details. Later scopes are asked only on a miss; each scope must hold only
|
|
169
|
+
conversations the viewer may open, so nothing outside them becomes
|
|
170
|
+
reachable; and an error a scope raises is not rescued into a 404.
|
|
171
|
+
|
|
172
|
+
### Fixed
|
|
173
|
+
- **The dock's window count used the right plural form only in two-form
|
|
174
|
+
languages.** `dock_controller.js` picked `one` for exactly 1 and `other`
|
|
175
|
+
for everything else, so Polish, Russian, Ukrainian, Croatian or Serbian
|
|
176
|
+
(one/few/many, with 21 read like 1 in the East Slavic ones) and
|
|
177
|
+
Arabic-style zero/two read wrong. The dock now renders every plural form
|
|
178
|
+
the locale provides for `chats.dock.window_unread` (`zero`, `one`, `two`,
|
|
179
|
+
`few`, `many`, `other`; new helper `chats_plural_forms`), and the
|
|
180
|
+
controller picks one with `Intl.PluralRules` in the page's language
|
|
181
|
+
(`<html lang>`, else the browser's), reading a missing form as `other`
|
|
182
|
+
and falling back to the old rule where `Intl` is unavailable. The gem's
|
|
183
|
+
`en` and `es` keep `one`/`other`; a host adds the forms its languages need
|
|
184
|
+
in its own locale files.
|
|
185
|
+
- **Every live surface catches up on the broadcasts it missed.** Action
|
|
186
|
+
Cable has no replay, so a broadcast sent while a page's socket was down
|
|
187
|
+
(a laptop asleep, a backgrounded tab or Hotwire Native WebView, a network
|
|
188
|
+
blip) never reached it, and the inbox, a thread, the unread badge or the
|
|
189
|
+
dock could sit on old content until the next navigation. One shared
|
|
190
|
+
module, `chats/recovery` (pinned for importmap hosts, like the
|
|
191
|
+
controllers), now watches each surface's own
|
|
192
|
+
`<turbo-cable-stream-source>` and the page, and asks for what was missed
|
|
193
|
+
when the stream reconnects, when the page comes back after 10 seconds or
|
|
194
|
+
more hidden, when the network comes back online (it asks Action Cable to
|
|
195
|
+
check its socket at once instead of after its backoff, and catches up
|
|
196
|
+
anyway: after a short outage the socket can look open while what was
|
|
197
|
+
sent during it is gone) and when the page
|
|
198
|
+
is restored from the back/forward cache or from Turbo's snapshot cache (a
|
|
199
|
+
back navigation, a Hotwire Native stack pop). Never otherwise: a healthy,
|
|
200
|
+
visible page fetches nothing. A hidden page never fetches; what it missed
|
|
201
|
+
waits until it is seen. Triggers that arrive together make one catch-up,
|
|
202
|
+
and catch-ups are at least five seconds apart.
|
|
203
|
+
- **A reconnect Action Cable never reported is seen.** When Action Cable
|
|
204
|
+
reopens a socket that went quiet (the half-open connection of a phone that
|
|
205
|
+
slept), it drops the old socket's handlers before its close event can
|
|
206
|
+
arrive: subscribers hear "connected" again and never "disconnected", and
|
|
207
|
+
the `reconnected: true` detail reaches only the first subscription
|
|
208
|
+
confirmed. Every confirmation after the first now counts.
|
|
209
|
+
- **A page restored from Turbo's snapshot cache catches up.** It came back
|
|
210
|
+
as old as when it was left, with no request: a thread restored by a back
|
|
211
|
+
navigation missed every message sent since. Its stream sources are marked
|
|
212
|
+
as they go into the cache, and a restored one catches up once it is live.
|
|
213
|
+
- **The inbox** reruns a refresh that arrived while another visit was
|
|
214
|
+
loading, which Turbo ignores (`Session#refresh` skips one while
|
|
215
|
+
`navigator.currentVisit` is set): a message landing mid-refresh was lost
|
|
216
|
+
with a healthy socket. A refresh no longer rewrites the field you're
|
|
217
|
+
typing in, no longer drops the search (see Changed), and a morph no longer
|
|
218
|
+
strips the stream source's `connected` attribute.
|
|
219
|
+
- **The inbox refresh is durable.** `refresh_inbox_of` enqueues
|
|
220
|
+
`Chats::InboxRefreshJob` straight from the after_commit instead of calling
|
|
221
|
+
`broadcast_refresh_later_to`, which parked the refresh for half a second
|
|
222
|
+
in an in-process timer (`Turbo::Debouncer`) before enqueueing it, so a
|
|
223
|
+
deploy or a crash inside that window dropped it. At most one is queued
|
|
224
|
+
per recipient: while one is queued and hasn't started, it stands for
|
|
225
|
+
every write that commits before it runs (a short-lived marker in
|
|
226
|
+
`Rails.cache`, removed by the job as it starts, before it broadcasts), so
|
|
227
|
+
a burst is one job per recipient. A lost marker costs an extra job, never
|
|
228
|
+
a missed refresh, and the marker expires after two seconds. Only a
|
|
229
|
+
marker that is really there counts as "queued": a store that can't hold
|
|
230
|
+
it (RedisCacheStore answers nil when a write fails; any store may raise)
|
|
231
|
+
means the write queues its own refresh. Since another
|
|
232
|
+
write may count on a refresh whose enqueue is still in flight, a failed
|
|
233
|
+
enqueue is retried once, then the refresh is broadcast on the spot. Every
|
|
234
|
+
refusal goes to `Rails.error` (raised, `false`, or a job the adapter
|
|
235
|
+
declined), never up through the write that committed. The refresh
|
|
236
|
+
tag carries `method="morph" scroll="preserve"`, so the inbox morphs
|
|
237
|
+
whatever the host layout's refresh meta says, and no request id, since
|
|
238
|
+
it may stand for several requests' writes.
|
|
239
|
+
- **The thread's catch-up asks on two cursors.** It sent only the newest
|
|
240
|
+
`updated_at` it had rendered and the server appended what was created
|
|
241
|
+
after it, so a message created at 10:15 and missed, while a bubble on
|
|
242
|
+
screen was edited at 10:20, never came. Now `after=` (the newest creation
|
|
243
|
+
on screen) decides what is appended and `changed=` (the newest change)
|
|
244
|
+
what is replaced, and both scopes split on the same creation cursor
|
|
245
|
+
(`created_after` / `created_through(…).updated_after(…)`). Each cursor is
|
|
246
|
+
a moment to the microsecond plus the ids on screen at it (`after_ids=`,
|
|
247
|
+
`changed_ids=`): a message missed in the very instant of one on screen,
|
|
248
|
+
or a change missed inside the same millisecond as one on screen (the old
|
|
249
|
+
cursor rounded up to the millisecond), is no longer lost. The cursors
|
|
250
|
+
are reconciled moments: a complete render sets them (the page, a window,
|
|
251
|
+
a history re-render), and so does a catch-up that arrived (its
|
|
252
|
+
`chats_caught_up` action); a live bubble never moves them, since it can
|
|
253
|
+
arrive after one that was missed (a message missed at 10:15, then one
|
|
254
|
+
live at 10:20). What arrived live is named instead, at most 200 ids
|
|
255
|
+
(`after_ids`, `changed_ids`, each id at most 64 characters); past that,
|
|
256
|
+
or with `full=1`, the history is re-rendered. Kept on the element
|
|
257
|
+
(`data-chats-reconciled`), so a dock window moved by a Turbo visit, or a
|
|
258
|
+
restored snapshot, keeps them. Each catch-up is bound to one render of
|
|
259
|
+
the thread (`render=`, echoed by the answer): an answer that arrives
|
|
260
|
+
after the page or a dock window rendered the conversation again is
|
|
261
|
+
dropped unrendered, and its cursors ignored — checked again by every
|
|
262
|
+
stream of it at the moment Turbo applies it, a repaint later, so a visit
|
|
263
|
+
or a window reload in between can't hand the old streams the new render.
|
|
264
|
+
A history re-render is followed by one more catch-up from its own
|
|
265
|
+
cursors once Turbo has actually applied it, however late that is, so a
|
|
266
|
+
message written while that page was being built isn't lost. Every
|
|
267
|
+
catch-up is bounded: a request with no answer after 15 seconds is
|
|
268
|
+
aborted (a fetch can hang across a network interruption, and would
|
|
269
|
+
otherwise stand in for every later one), a thread has at most one answer
|
|
270
|
+
waiting for Turbo (a newer one, or the thread going away, drops the
|
|
271
|
+
older one's leftover streams and stops watching for them — going away
|
|
272
|
+
for good: a dock window Turbo carries across a visit is off the page
|
|
273
|
+
until that render ends, which a host's asynchronous `turbo:before-render`
|
|
274
|
+
can make last many tasks, and keeps its answer, decided only once every
|
|
275
|
+
`turbo:before-render` has had its `turbo:render`, paired first in, first
|
|
276
|
+
out; a render that runs past 10 seconds stops holding things up but stays
|
|
277
|
+
paired until its own end, and one that never ends is forgotten after a
|
|
278
|
+
minute), and only the
|
|
279
|
+
answer's own streams are guarded (`data-chats-answer`), never a host's
|
|
280
|
+
stream rendered at the same moment. A thread whose catch-up didn't apply,
|
|
281
|
+
for any reason (its page rendered it away, the request failed, Turbo
|
|
282
|
+
never got to it), is marked behind (`data-chats-stale`, on the thread
|
|
283
|
+
element itself, so the mark travels with the dock and can't be
|
|
284
|
+
forgotten while the thread exists) and catches up again when it
|
|
285
|
+
connects, on its stream's confirmation and after every page render,
|
|
286
|
+
through the same coalescing and 5-second spacing as every other trigger;
|
|
287
|
+
only the answer of the newest catch-up clears it (they are numbered on
|
|
288
|
+
the element, `data-chats-catch-up`), so an older answer that applies
|
|
289
|
+
after a newer one failed leaves the thread marked; and nothing older
|
|
290
|
+
than the newest catch-up that has applied (`data-chats-applied`) may
|
|
291
|
+
apply after it, so an older answer arriving last can't put back older
|
|
292
|
+
bubbles, read state or cursors. A catch-up always gets an answer built
|
|
293
|
+
after it was asked: the same request asked in the same instant is made
|
|
294
|
+
once, but asked while it is already on its way, it is made once more as
|
|
295
|
+
soon as that one has its answer from the network (never waiting on
|
|
296
|
+
Turbo to apply it), for everyone who asked meanwhile, each keeping its
|
|
297
|
+
own `when` and its own outcome. An answer Turbo hasn't applied 10
|
|
298
|
+
seconds after it joined the page is cancelled (its callers settle, and a
|
|
299
|
+
thread stays marked behind), and neither a caller's hook that throws nor
|
|
300
|
+
a stream that fails to render can leave an answer half-applied or its
|
|
301
|
+
callers waiting. An empty
|
|
302
|
+
thread, which used to ask nothing, asks from the epoch: its first message
|
|
303
|
+
may be the one it missed.
|
|
304
|
+
- **The thread's catch-up is complete.** `GET /:id/refresh` now also
|
|
305
|
+
replaces the read state (a read the stream missed left "Sent" where it
|
|
306
|
+
should say "Seen"), catches reactions (a reaction now moves its message's
|
|
307
|
+
`updated_at`, with `update_columns`, so the bubble is not broadcast twice),
|
|
308
|
+
and, under `deletion: :hard`, lists the messages that still exist from the
|
|
309
|
+
oldest rendered bubble on (`from=`), so the thread removes the ones that
|
|
310
|
+
are gone (the `chats_prune` stream action). Catching up still reads
|
|
311
|
+
nothing.
|
|
312
|
+
- **The unread badge and the dock catch up.** The viewer's own
|
|
313
|
+
`chats_unread_badge` is wrapped in a `chats--unread-badge` controller (a
|
|
314
|
+
`span.chats-unread-badge-live`, `display: contents`, so it changes no
|
|
315
|
+
layout) that asks the new `GET /badge` for every unread badge on the page:
|
|
316
|
+
the nav badge and the dock's, with the count the badge streams send
|
|
317
|
+
(`unread_chats_count`). Someone else's badge stays live but doesn't catch
|
|
318
|
+
up. The dock fetches the same, and reloads its recent list; each open
|
|
319
|
+
window's thread catches up by itself. A message missed while offline
|
|
320
|
+
never pops a window open late.
|
|
321
|
+
|
|
322
|
+
- **Both search forms are search landmarks.** `form_with … role: "search"`
|
|
323
|
+
rendered no `role` at all (`form_with` drops HTML attributes it doesn't
|
|
324
|
+
know unless they go through `html:`), on the inbox and in the dock.
|
|
325
|
+
|
|
326
|
+
### Changed
|
|
327
|
+
- A page now counts as having been away after 10 seconds hidden, not 60
|
|
328
|
+
(the inbox and the thread used 60).
|
|
329
|
+
- A thread more than a page behind no longer gets a page refresh, which
|
|
330
|
+
cleared the composer's draft and, in a dock window, refreshed the host's
|
|
331
|
+
whole page: its history (`chats/conversations/_history`, inside
|
|
332
|
+
`#history_conversation_<id>`) is re-rendered at the latest page, the
|
|
333
|
+
composer untouched. An ejected thread whose scroller has no
|
|
334
|
+
`id="<%= dom_id(conversation, :history) %>"` keeps getting the page
|
|
335
|
+
refresh.
|
|
336
|
+
- The inbox's search keeps its query in the URL (`data-turbo-action=
|
|
337
|
+
"replace"`, submitting to the inbox's own path), so a refresh re-renders
|
|
338
|
+
the same results instead of the unfiltered list. The dock's recent list
|
|
339
|
+
frame has `refresh="morph"`, so a reload keeps its scroll and its search.
|
|
340
|
+
- New: the `GET /badge` collection route, the `chats--unread-badge`
|
|
341
|
+
controller, `Chats::InboxRefreshJob`, and `data-message-id`,
|
|
342
|
+
`data-created-at-us` and `data-updated-at-us` on every bubble, which no
|
|
343
|
+
longer carries `data-updated-at-ms`.
|
|
344
|
+
- **If you wrapped the inbox in a reconciler of your own** (a
|
|
345
|
+
stream-reconciler controller around `turbo_stream_from … :chats_inbox`),
|
|
346
|
+
remove it: two on one page both fire, and the gem's covers what yours did.
|
|
347
|
+
**If you ejected the inbox**, keep the
|
|
348
|
+
`data-controller="chats--refresh-inbox"` element around its
|
|
349
|
+
`turbo_stream_from chats_current_messager, :chats_inbox` (an inbox without
|
|
350
|
+
it doesn't catch up), and add `turbo_action: "replace"` to the search
|
|
351
|
+
form's `data` and `url: request.original_fullpath.split("?").first`. An
|
|
352
|
+
ejected message partial (with only `data-updated-at-ms`) keeps the one
|
|
353
|
+
cursor it had (`since=`), and a hard delete missed while offline stays on
|
|
354
|
+
screen until the next load.
|
|
355
|
+
|
|
7
356
|
## [0.5.0] - 2026-10-18
|
|
8
357
|
|
|
9
358
|
The messaging dock. LinkedIn and Steam keep your conversations in small
|
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
|
|
|
@@ -98,6 +98,8 @@ Or give every page LinkedIn-style floating chat windows with one line in your la
|
|
|
98
98
|
|
|
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
|
+
>
|
|
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.
|
|
101
103
|
|
|
102
104
|
## 🧱 The data model
|
|
103
105
|
|
|
@@ -114,6 +116,30 @@ Two deliberate design decisions worth knowing:
|
|
|
114
116
|
1. **Read state is a horizon, not per-message receipts.** A participant has ONE `last_read_at`; a message is unread iff it's newer. That's unread counts, badges, and "Seen" indicators with zero extra writes per message (a receipts table writes N rows per message — the classic chat-schema scaling trap), and it's exactly how Basecamp's Campfire models it.
|
|
115
117
|
2. **Direct conversations have a deterministic identity** (`direct_key`, unique-indexed): two people DMing each other in the same instant race into the SAME conversation, guaranteed by the database, not by hope.
|
|
116
118
|
|
|
119
|
+
### Real conversations vs system-only threads
|
|
120
|
+
|
|
121
|
+
A system message is a message: `post_system_message!` bumps `messages_count`, moves `last_message` and re-sorts the inbox like a human one, and a deleted message stays behind as a tombstone. That is on purpose: "Your ride was cancelled" matters to the passenger, so the inbox lists a thread only your app has spoken in. Two questions, two scopes:
|
|
122
|
+
|
|
123
|
+
- **`with_messages`** asks *is there anything here?*: any message, system lines and tombstones included. Every list the gem renders reads through it, so a thread that was opened and never used is not listed.
|
|
124
|
+
- **`with_human_messages`** asks *did a messager write something here that still stands?*: at least one `text` message that isn't deleted. "Human" is the gem's word for a `text` message: written by a messager, person or not, as opposed to posted by the app. So a bot's or a headless desk's message counts, and so does a photo with no caption. It is for ops walls, moderation lists and "conversations started this week" charts, which must not count a thread holding only "Alice joined".
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# What a viewer sees: start from alice.chats, so blocks and config.inbox_scope apply
|
|
128
|
+
alice.chats.with_human_messages # threads where somebody actually wrote
|
|
129
|
+
alice.chats.with_messages.without_human_messages # listed, but only the app has spoken
|
|
130
|
+
conversation.last_human_message # the newest human message, or nil (a query, not the pointer)
|
|
131
|
+
Chats::Message.last_human_in(alice.chats.limit(20)) # the same for a page of threads, in ONE query
|
|
132
|
+
|
|
133
|
+
# Admin-wide: no viewer, so no blocks and no inbox_scope. Only behind your staff authorization.
|
|
134
|
+
Chats::Conversation.with_human_messages.count
|
|
135
|
+
Chats::Conversation.about(ride).without_human_messages
|
|
136
|
+
Chats::Message.last_human_in(page) # page: records, ids, or a relation (read as a subquery, see below)
|
|
137
|
+
.select(:id, :conversation_id, :body) # narrow the columns if you like
|
|
138
|
+
.index_by(&:conversation_id) # { id => message }; no key means nobody wrote
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Both conversation scopes are a single correlated `EXISTS` built from `Chats::Message.human` (kind `text`, not deleted), so they compose with anything (`about`, `unread_by`, `with_messages`) and never multiply rows. `last_human_in` reads an unloaded relation as a subquery without its `includes` (they only load records), so a relation that filters on an associated table needs that table joined explicitly, with `joins` or `left_joins`; `includes(:participants).references(:participants).where(chats_participants: …)` loses the join and raises. Or pass the loaded page, or its ids. Counting what messagers wrote per conversation is the plain `Chats::Message.human.where(conversation_id: ids).group(:conversation_id).count`. None of this changes the inbox, unread counts, `messages_count` or `last_message`.
|
|
142
|
+
|
|
117
143
|
## 🧩 The macros
|
|
118
144
|
|
|
119
145
|
### `acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil, verified: false)`
|
|
@@ -155,12 +181,34 @@ All three are inert by default (the label falls back to the record's `to_s`, not
|
|
|
155
181
|
|
|
156
182
|
- **The thread** subscribes to one conversation stream. New messages append surgically; edits/deletes replace bubbles in place; the sender's own bubble comes straight back in the form response (no cable round-trip), and Turbo's same-id dedup makes the echo broadcast a no-op.
|
|
157
183
|
- **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.
|
|
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.
|
|
184
|
+
- **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. Each refresh carries `method="morph" scroll="preserve"` itself, so it morphs whatever your layout's refresh meta says. A search keeps its query in the URL, so a refresh re-renders the same results, and the field you're typing in is never rewritten under you.
|
|
159
185
|
- **Unread badges** get their own stream (`chats_unread_badge` helper) so any page can host a live badge without inheriting inbox refreshes.
|
|
160
186
|
- **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.
|
|
161
187
|
- **Typing indicators** are a Turbo Stream *custom action* — ephemeral, nothing persisted, no Action Cable channel class, no connection identification requirements.
|
|
162
188
|
- **Cable down?** Everything still works request/response. Real-time is an enhancement, not a requirement.
|
|
163
189
|
|
|
190
|
+
### What happens when a broadcast is missed
|
|
191
|
+
|
|
192
|
+
Action Cable has no replay: a broadcast sent while a page's socket is down (a laptop asleep, a phone in a pocket, a backgrounded Hotwire Native WebView, an elevator) never reaches it. So every live surface catches up by itself, with no polling and nothing to configure. It fetches what it missed at the moments it may have missed something, and only then:
|
|
193
|
+
|
|
194
|
+
- **its stream reconnects.** Every confirmation of the subscription after the first counts, including a socket Action Cable reopened without ever reporting it closed (the half-open TCP of a phone that slept);
|
|
195
|
+
- **the page comes back after 10 seconds or more hidden** (an app foregrounded, a tab returned to). A hidden page never fetches; what it missed waits until it is seen;
|
|
196
|
+
- **the network comes back online.** Action Cable is asked to check its socket right away instead of waiting out its backoff (a dead one reconnects: the first trigger), and the surface catches up anyway, because after a short outage the socket can still look open while what was sent during it is gone;
|
|
197
|
+
- **the page is restored**, from the browser's back/forward cache or from Turbo's own snapshot cache (a back navigation, a Hotwire Native stack pop).
|
|
198
|
+
|
|
199
|
+
Triggers that arrive together make one catch-up, and catch-ups are at least five seconds apart, so a flapping connection can't turn into a fetch loop. While the socket is healthy and the page is on screen, nothing is fetched. What each surface fetches:
|
|
200
|
+
|
|
201
|
+
| surface | catches up with |
|
|
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. 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
|
+
| 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
|
+
| `chats_unread_badge` and the dock's badges | `GET /badge`, every unread badge on the page in one Turbo Stream |
|
|
206
|
+
| the dock's recent list | a reload of its frame, morphed in place so it keeps its scroll position and your search |
|
|
207
|
+
|
|
208
|
+
None of these requests marks anything read, and none can be prefetched (they are not links). On the server, the inbox refresh is enqueued straight into your job queue when the write commits (`Chats::InboxRefreshJob`), and not parked first in Turbo's in-process half-second debounce, where a deploy or a crash inside that window would drop it. A burst is one job per recipient, not one per write: a refresh carries no data (the inbox re-requests itself), so while one is queued and hasn't started it stands for every write that commits before it runs. A short-lived marker in `Rails.cache` says one is queued, and the job removes it as it starts, before it broadcasts, so a write that still finds the marker is covered and a write after it queues the next job. Because another write may already be counting on it, an enqueue that fails is tried once more, and if the queue refuses again the refresh is broadcast on the spot. Every refusal goes to `Rails.error`, whether the adapter raised, returned `false` or declined the job. Whatever happens to the marker costs an extra job, never a missed refresh: a cache that can't hold it (a null store, a per-process memory store, a Redis that refuses the write) only means no coalescing, and it expires after two seconds, so a job lost from the queue holds nothing back for longer than that. The refresh names no request id, because it may stand for several requests' writes. Refreshes that still arrive close together merge in the browser: Turbo runs one page refresh for those within 150 ms of each other.
|
|
209
|
+
|
|
210
|
+
**Hotwire Native** needs nothing. The catch-up is the same code path a broadcast takes (the inbox's is a Turbo page refresh, which Turbo hands to the native adapter as it does any refresh), and a back navigation is a Turbo restore, which catches up like any other. A WebView that lives for the whole app session is the case all of this is for: when the app comes back to the foreground, the WebView reports the page visible again, and in any case the socket that died in the background reconnects, and that reconnect is a trigger of its own. **If you wrapped the inbox in a reconciler of your own** (a stream-reconciler controller around `turbo_stream_from … :chats_inbox`), remove it: two reconcilers on one page both fire, and the gem's now covers everything yours did.
|
|
211
|
+
|
|
164
212
|
## 🛡️ Trust & Safety: snaps onto the [`moderate`](https://github.com/rameerez/moderate) gem
|
|
165
213
|
|
|
166
214
|
Messages are user-generated content — App Store Guideline 1.2, Google Play's UGC policy, and the EU DSA all expect **report**, **block**, and **filter** capabilities before you ship a chat. Instead of re-implementing any of that, `chats` exposes the exact seams the `moderate` gem expects, with **no hard dependency in either direction**: each gem runs standalone, and together they behave like one system. This section is the complete recipe.
|
|
@@ -536,6 +584,33 @@ Ejecting a whole screen to add one row or one button is too coarse. The bundled
|
|
|
536
584
|
|
|
537
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.
|
|
538
586
|
|
|
587
|
+
## 🪟 Two panes
|
|
588
|
+
|
|
589
|
+
`/messages` is LinkedIn's messaging page: the conversation list on the left, the open conversation on the right. `/messages/:id` is that same page with its conversation open and its row highlighted, so a link to a conversation, a reload, back and forward all land where they should.
|
|
590
|
+
|
|
591
|
+

|
|
592
|
+
|
|
593
|
+
Each row is a real link to `/messages/:id` (keeping the list's search or stack in its query, so the page's URL always names the list it shows). On a wide screen it opens the conversation in the right pane, a `turbo_frame_tag "chats_conversation"` whose `data-turbo-action="advance"` moves the address bar and history with it, and the server answers that frame with the pane alone. The pane's thread sits in a `data-turbo-permanent` element keyed by the conversation (and the viewer), so the inbox's live refreshes (Turbo 8 morphs of the whole page) re-render the list and leave the conversation alone: its draft, scroll position, read state and stream subscription. Opening one takes you into it, as on LinkedIn: the focus goes to its composer (on a touch screen, to its title, so the keyboard waits until you ask for it), inside a pane a screen reader names after the conversation.
|
|
594
|
+
|
|
595
|
+
**On a phone** (under 700px wide, where the dock turns into a full-screen sheet) it is one column, exactly the separate pages as before: `/messages` is the list, and a row opens the conversation's page, with its back link to the list. One media query decides, so the layout holds before JavaScript runs and without it; with JavaScript off a row always opens the page.
|
|
596
|
+
|
|
597
|
+
<img src="docs/screenshots/split-inbox-phone-list.png" alt="A phone: the messaging page is the conversation list" width="240"> <img src="docs/screenshots/split-inbox-phone-thread.png" alt="A phone: a conversation fills the screen, with its back link to the list" width="240">
|
|
598
|
+
|
|
599
|
+
**Reads** happen when you open a conversation: a row clicked, a link, a reload. The page's own refreshes never read (one may arrive in a background tab), and a conversation the page renders unread reads itself as soon as it's on screen.
|
|
600
|
+
|
|
601
|
+
The [messaging dock](#-messaging-dock) stays off this page, as on every engine page; its "See all messages" opens it, and a window's ⤢ opens its conversation here.
|
|
602
|
+
|
|
603
|
+
```ruby
|
|
604
|
+
config.inbox_layout = :split # the default: two panes from 700px wide
|
|
605
|
+
config.inbox_layout = :stacked # separate pages at every width, as before 0.6
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
The list pane is `--chats-list-width` wide (320px), and the page as wide as the list plus a thread (`--chats-max-width`). It is as tall as the screen, so each pane scrolls on its own and the composer stays at the bottom. If your layout puts a header above it, say how much is left, or the page will scroll by the header's height:
|
|
609
|
+
|
|
610
|
+
```css
|
|
611
|
+
:root { --chats-split-height: calc(100dvh - 4rem); } /* 4rem: your header */
|
|
612
|
+
``` Views ejected with `rails generate chats:views` before 0.6 keep their own separate pages; re-eject to get the panes.
|
|
613
|
+
|
|
539
614
|
## 🪟 Messaging dock
|
|
540
615
|
|
|
541
616
|
LinkedIn and Steam don't send you to another page to answer a message: chats live in small windows in the bottom-right corner while you keep doing what you were doing. One line in your layout, for signed-in viewers, gives your app the same:
|
|
@@ -552,7 +627,7 @@ LinkedIn and Steam don't send you to another page to answer a message: chats liv
|
|
|
552
627
|
- **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
628
|
- **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
629
|
- **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.
|
|
630
|
+
- **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. A message that arrived while you were offline doesn't pop a window late: the badge and the list [catch up](#what-happens-when-a-broadcast-is-missed), and so does every open window's thread.
|
|
556
631
|
- **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
632
|
- **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
633
|
- **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.
|
|
@@ -605,7 +680,7 @@ It needs `chats_dock` on the same page: without the dock, the button's response
|
|
|
605
680
|
}
|
|
606
681
|
```
|
|
607
682
|
|
|
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.*`.
|
|
683
|
+
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.*`. The window's unread count (`chats.dock.window_unread`) is pluralized in the browser with `Intl.PluralRules` in the page's `<html lang>`: the gem ships `one`/`other`, so add the forms your languages need (`zero`, `two`, `few`, `many`) in your own locale file, e.g. Polish `few: "%{count} nowe wiadomości"`, `many: "%{count} nowych wiadomości"`.
|
|
609
684
|
|
|
610
685
|
## 🔗 Profile links
|
|
611
686
|
|
|
@@ -630,6 +705,8 @@ The bundled UI is intentionally framework-free (semantic `chats-*` classes + one
|
|
|
630
705
|
--chats-accent: #facc15; /* own bubbles, send button, badges */
|
|
631
706
|
--chats-accent-contrast: #111827;
|
|
632
707
|
--chats-verified: #0284c7; /* the "official account" badge */
|
|
708
|
+
--chats-list-width: 320px; /* the list beside an open conversation */
|
|
709
|
+
--chats-split-height: calc(100dvh - 4rem); /* the two-pane page under a 4rem header (default 100dvh) */
|
|
633
710
|
}
|
|
634
711
|
```
|
|
635
712
|
|
|
@@ -639,7 +716,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
|
|
|
639
716
|
rails generate chats:views
|
|
640
717
|
```
|
|
641
718
|
|
|
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/
|
|
719
|
+
Override any bundled Stimulus controller by pinning the same importmap key — host pins win. The current keys are `controllers/chats/thread_controller`, `controllers/chats/composer_controller`, `controllers/chats/debounced_submit_controller`, `controllers/chats/refresh_inbox_controller`, `controllers/chats/unread_badge_controller`, `controllers/chats/dock_controller`, and `controllers/chats/split_controller`. What the inbox, thread, badge and dock controllers share to catch up on missed broadcasts is `chats/recovery` (`recoverMissedBroadcasts`, `renderStreamFrom`); a controller of your own can import it too.
|
|
643
720
|
|
|
644
721
|
## Configuration reference
|
|
645
722
|
|
|
@@ -681,6 +758,7 @@ Chats.configure do |config|
|
|
|
681
758
|
# Inbox shaping
|
|
682
759
|
config.inbox_limit = 200
|
|
683
760
|
config.inbox_scope = ->(relation, viewer) { relation }
|
|
761
|
+
config.inbox_layout = :split # :split (two panes from 700px wide) | :stacked
|
|
684
762
|
|
|
685
763
|
# Ecosystem seams (no-op defaults; chats runs standalone)
|
|
686
764
|
config.blocked_messager_ids = ->(messager) { [] }
|
|
@@ -706,6 +784,7 @@ alice.message!(bob, "hi", about: ride) # send (resolves the thread)
|
|
|
706
784
|
alice.message!(conversation, "hi", files: []) # send into a conversation
|
|
707
785
|
alice.chats # every conversation she's in (what she may open), newest first
|
|
708
786
|
alice.chats.with_messages # ...the ones with a message: what lists show
|
|
787
|
+
alice.chats.with_human_messages # ...the ones a messager wrote in (system lines and tombstones don't count)
|
|
709
788
|
alice.unread_chats_count # conversations with unread messages
|
|
710
789
|
alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
|
|
711
790
|
alice.message!(bob, "Let's play!", # with one button under it
|
|
@@ -755,6 +834,8 @@ Chats::Conversation.group!(alice, [bob, carol], title: "Trip")
|
|
|
755
834
|
Chats::Conversation.direct .groups .about(ride) .recent_first
|
|
756
835
|
Chats::Conversation.inbox_for(alice) .excluding_blocked_for(alice) .unread_by(alice)
|
|
757
836
|
Chats::Conversation.with_messages # has at least one message, system messages and tombstones included (`last_message_id` set); every list reads through this
|
|
837
|
+
Chats::Conversation.with_human_messages .without_human_messages # a messager wrote / only system lines, tombstones or nothing
|
|
838
|
+
conversation.last_human_message # newest text, non-deleted message, or nil (a query)
|
|
758
839
|
Chats::Conversation.unread_counts_for(alice, conversations) # { id => count } in one query
|
|
759
840
|
conversation.direct? / conversation.group?
|
|
760
841
|
conversation.participant_for(alice) # the seat, or nil
|
|
@@ -767,6 +848,8 @@ message.attachments?
|
|
|
767
848
|
message.sent_by?(alice) message.sender_key
|
|
768
849
|
Chats::Message.visible .oldest_first .recent_first .before_message(message) # keyset pagination
|
|
769
850
|
Chats::Message.created_since(time) .updated_since(time) # what the stale-thread catch-up reads
|
|
851
|
+
Chats::Message.human # kind text, not deleted: what messagers wrote
|
|
852
|
+
Chats::Message.last_human_in(conversations) # newest human message per conversation, one query
|
|
770
853
|
|
|
771
854
|
# Participants — reading
|
|
772
855
|
participant.active? / participant.left? / participant.muted? / participant.owner?
|
|
@@ -809,7 +892,7 @@ All available in the bundled views **and** in your own templates:
|
|
|
809
892
|
| helper | renders |
|
|
810
893
|
|---|---|
|
|
811
894
|
| `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 |
|
|
812
|
-
| `chats_unread_badge(messager = viewer)` | the live unread badge with its own stream |
|
|
895
|
+
| `chats_unread_badge(messager = viewer)` | the live unread badge with its own stream; the viewer's own also [catches up](#what-happens-when-a-broadcast-is-missed) on updates it missed |
|
|
813
896
|
| `chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)` | the [messaging dock](#-messaging-dock): bar, panel and floating windows |
|
|
814
897
|
| `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
898
|
| `chats_muted?(conversation, viewer)` | whether the viewer muted it (read from the preloaded seats) |
|
|
@@ -849,7 +932,7 @@ rails generate chats:upgrade # 0.2.0: message authorship columns · 0.4.0: the
|
|
|
849
932
|
rails db:migrate
|
|
850
933
|
```
|
|
851
934
|
|
|
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.
|
|
935
|
+
0.6.0 needs no migration: the messaging page becomes [two panes](#-two-panes) on wide screens by itself; `config.inbox_layout = :stacked` keeps the separate pages. Views you ejected before 0.6 keep rendering as they are (the separate pages); the [CHANGELOG](CHANGELOG.md) lists the templates that changed. 0.5.1 needs no migration and nothing to configure: every live surface now [catches up](#what-happens-when-a-broadcast-is-missed) on missed broadcasts by itself. If you wrapped the inbox in a reconciler of your own, remove it; if you ejected the inbox, the thread or the message partial, read the 0.5.1 [CHANGELOG](CHANGELOG.md) entry. 0.5.0 needs no migration: the messaging dock is opt-in (`chats_dock`). A few things change on their own: a conversation with no messages is no longer listed (it used to show as a "No messages yet" row), a thread the host's policy no longer allows shows the notice in the composer's place, a Turbo hover prefetch no longer marks a thread read, and the thread body moved into `chats/conversations/_thread.html.erb`. Read the 0.5.0 [CHANGELOG](CHANGELOG.md) entry if you ejected `show.html.erb` or the unread badge. 0.4.0 needs that migration (one nullable JSON column, `chats_messages.action`) and nothing else: actions and the `composer_actions` slot are opt-in. The generator is guarded: it doesn't write the action migration where the column already exists (a fresh 0.4.0 install), and re-running it is harmless. Rolling that migration back keeps the column, because a migration must never drop a column it may not have added. If you ejected the composer, read the 0.4.0 [CHANGELOG](CHANGELOG.md) entry: its DOM changed shape. 0.3.x needs no migration and no new configuration: `verified: true` (0.3.0) and `Chats::SendRateLimited` (0.3.2) are opt-in. Nothing in 0.2.0 changes behaviour until you set an option.
|
|
853
936
|
|
|
854
937
|
## Database support
|
|
855
938
|
|