chats 0.4.2 → 0.5.1
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 +478 -0
- data/README.md +150 -10
- data/app/assets/stylesheets/chats.css +599 -0
- data/app/controllers/chats/application_controller.rb +45 -4
- data/app/controllers/chats/conversations_controller.rb +273 -55
- data/app/helpers/chats/engine_helper.rb +102 -6
- data/app/javascript/chats/composer_controller.js +14 -1
- data/app/javascript/chats/dock_controller.js +1106 -0
- data/app/javascript/chats/recovery.js +539 -0
- data/app/javascript/chats/refresh_inbox_controller.js +55 -65
- data/app/javascript/chats/thread_controller.js +317 -60
- data/app/javascript/chats/unread_badge_controller.js +24 -0
- 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/_history.html.erb +54 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +8 -3
- data/app/views/chats/conversations/_thread.html.erb +133 -0
- data/app/views/chats/conversations/badge.turbo_stream.erb +6 -0
- data/app/views/chats/conversations/dock.html.erb +40 -0
- data/app/views/chats/conversations/index.html.erb +24 -10
- data/app/views/chats/conversations/refresh.turbo_stream.erb +24 -4
- 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 +138 -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/messages/_message.html.erb +8 -6
- data/app/views/chats/shared/_unread_badge.html.erb +4 -1
- data/config/importmap.rb +7 -0
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +17 -0
- data/config/routes.rb +8 -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 +178 -17
- data/lib/chats/inbox.rb +68 -30
- data/lib/chats/inbox_refresh_job.rb +14 -0
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +57 -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 +1 -0
- data/lib/generators/chats/views_generator.rb +3 -2
- metadata +23 -2
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,14 +84,22 @@ 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
|
|
|
91
99
|
> [!NOTE]
|
|
92
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.
|
|
93
103
|
|
|
94
104
|
## 🧱 The data model
|
|
95
105
|
|
|
@@ -106,6 +116,30 @@ Two deliberate design decisions worth knowing:
|
|
|
106
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.
|
|
107
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.
|
|
108
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
|
+
|
|
109
143
|
## 🧩 The macros
|
|
110
144
|
|
|
111
145
|
### `acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil, verified: false)`
|
|
@@ -147,11 +181,34 @@ All three are inert by default (the label falls back to the record's `to_s`, not
|
|
|
147
181
|
|
|
148
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.
|
|
149
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.
|
|
150
|
-
- **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.
|
|
151
185
|
- **Unread badges** get their own stream (`chats_unread_badge` helper) so any page can host a live badge without inheriting inbox refreshes.
|
|
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.
|
|
152
187
|
- **Typing indicators** are a Turbo Stream *custom action* — ephemeral, nothing persisted, no Action Cable channel class, no connection identification requirements.
|
|
153
188
|
- **Cable down?** Everything still works request/response. Real-time is an enhancement, not a requirement.
|
|
154
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 |
|
|
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
|
+
|
|
155
212
|
## 🛡️ Trust & Safety: snaps onto the [`moderate`](https://github.com/rameerez/moderate) gem
|
|
156
213
|
|
|
157
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.
|
|
@@ -518,6 +575,7 @@ Ejecting a whole screen to add one row or one button is too coarse. The bundled
|
|
|
518
575
|
| `composer_actions` | a row of your own buttons above the composer's input, outside its form (gets `conversation:`; gone when the thread locks) |
|
|
519
576
|
| `locked_composer` | the locked composer's body |
|
|
520
577
|
| `message_meta` | after each bubble's timestamp |
|
|
578
|
+
| `dock_panel_footer` | beside "See all messages" in the [messaging dock](#-messaging-dock)'s panel (gets `viewer:`) |
|
|
521
579
|
|
|
522
580
|
```erb
|
|
523
581
|
<%# app/views/chats/slots/_inbox_top.html.erb %>
|
|
@@ -526,6 +584,77 @@ Ejecting a whole screen to add one row or one button is too coarse. The bundled
|
|
|
526
584
|
|
|
527
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.
|
|
528
586
|
|
|
587
|
+
## 🪟 Messaging dock
|
|
588
|
+
|
|
589
|
+
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:
|
|
590
|
+
|
|
591
|
+
```erb
|
|
592
|
+
<%# app/views/layouts/application.html.erb, before </body> %>
|
|
593
|
+
<%= chats_dock %>
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+

|
|
597
|
+
|
|
598
|
+
*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.*
|
|
599
|
+
|
|
600
|
+
- **The bar** sits flush with the bottom-right corner: your avatar, "Messages" and the live unread badge (the same number as `chats_unread_badge`).
|
|
601
|
+
- **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.
|
|
602
|
+
- **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.
|
|
603
|
+
- **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.
|
|
604
|
+
- **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.
|
|
605
|
+
- **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.
|
|
606
|
+
- **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.
|
|
607
|
+
|
|
608
|
+
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.
|
|
609
|
+
|
|
610
|
+
<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">
|
|
611
|
+
|
|
612
|
+
**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):
|
|
613
|
+
|
|
614
|
+
```erb
|
|
615
|
+
<%= chat_button_to @player, dock: true %>
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
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:
|
|
619
|
+
|
|
620
|
+
```erb
|
|
621
|
+
<%= link_to "Chat", chats.conversation_path(conversation), data: chats_dock_opener(conversation) %>
|
|
622
|
+
<%= link_to "Messages", chats.conversations_path, data: chats_dock_toggle %>
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
**Options:**
|
|
626
|
+
|
|
627
|
+
```erb
|
|
628
|
+
<%= chats_dock windows: 2, recent: 8 %> <%# at most this many windows (3), conversations in the panel (10) %>
|
|
629
|
+
<%= chats_dock launcher: false %> <%# no round button on phones: your nav opens the sheet (chats_dock_toggle) %>
|
|
630
|
+
<%= chats_dock everywhere: true %> <%# also on the engine's own pages (see below) %>
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
**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.
|
|
634
|
+
|
|
635
|
+
**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:
|
|
636
|
+
|
|
637
|
+
```css
|
|
638
|
+
.chats-dock {
|
|
639
|
+
--chats-dock-z: 40; /* above your page, below your modals */
|
|
640
|
+
--chats-dock-offset: 0px; /* bottom clearance: a cookie banner, a tab bar */
|
|
641
|
+
--chats-dock-inset: 16px; /* from the right edge */
|
|
642
|
+
--chats-dock-gap: 8px;
|
|
643
|
+
--chats-dock-bar-height: 48px; /* the bar and every window header */
|
|
644
|
+
--chats-dock-panel-width: 288px;
|
|
645
|
+
--chats-dock-panel-height: 560px;
|
|
646
|
+
--chats-dock-window-width: 336px;
|
|
647
|
+
--chats-dock-window-height: 480px;
|
|
648
|
+
--chats-dock-radius: 12px;
|
|
649
|
+
--chats-dock-hover: #f9fafb; /* row hover: muted text stays at 4.63:1 */
|
|
650
|
+
--chats-dock-unread-bg: var(--chats-accent); /* a window waiting to be read */
|
|
651
|
+
--chats-dock-unread-text: var(--chats-accent-contrast);
|
|
652
|
+
--chats-dock-title-font: inherit;
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
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"`.
|
|
657
|
+
|
|
529
658
|
## 🔗 Profile links
|
|
530
659
|
|
|
531
660
|
`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 +687,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
|
|
|
558
687
|
rails generate chats:views
|
|
559
688
|
```
|
|
560
689
|
|
|
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/
|
|
690
|
+
Override any bundled Stimulus controller by pinning the same importmap key — host pins win. The current keys are `controllers/chats/thread_controller`, `controllers/chats/composer_controller`, `controllers/chats/debounced_submit_controller`, `controllers/chats/refresh_inbox_controller`, `controllers/chats/unread_badge_controller`, and `controllers/chats/dock_controller`. What the inbox, thread, badge and dock controllers share to catch up on missed broadcasts is `chats/recovery` (`recoverMissedBroadcasts`, `renderStreamFrom`); a controller of your own can import it too.
|
|
562
691
|
|
|
563
692
|
## Configuration reference
|
|
564
693
|
|
|
@@ -623,7 +752,9 @@ alice.chat_with(bob, about: ride) # the DM about that ride
|
|
|
623
752
|
alice.chat_with(bob, carol, title: "Trip") # a group (alice is owner)
|
|
624
753
|
alice.message!(bob, "hi", about: ride) # send (resolves the thread)
|
|
625
754
|
alice.message!(conversation, "hi", files: []) # send into a conversation
|
|
626
|
-
alice.chats #
|
|
755
|
+
alice.chats # every conversation she's in (what she may open), newest first
|
|
756
|
+
alice.chats.with_messages # ...the ones with a message: what lists show
|
|
757
|
+
alice.chats.with_human_messages # ...the ones a messager wrote in (system lines and tombstones don't count)
|
|
627
758
|
alice.unread_chats_count # conversations with unread messages
|
|
628
759
|
alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
|
|
629
760
|
alice.message!(bob, "Let's play!", # with one button under it
|
|
@@ -672,6 +803,9 @@ Chats::Conversation.direct_between!(alice, bob, about: ride) # find-or-create;
|
|
|
672
803
|
Chats::Conversation.group!(alice, [bob, carol], title: "Trip")
|
|
673
804
|
Chats::Conversation.direct .groups .about(ride) .recent_first
|
|
674
805
|
Chats::Conversation.inbox_for(alice) .excluding_blocked_for(alice) .unread_by(alice)
|
|
806
|
+
Chats::Conversation.with_messages # has at least one message, system messages and tombstones included (`last_message_id` set); every list reads through this
|
|
807
|
+
Chats::Conversation.with_human_messages .without_human_messages # a messager wrote / only system lines, tombstones or nothing
|
|
808
|
+
conversation.last_human_message # newest text, non-deleted message, or nil (a query)
|
|
675
809
|
Chats::Conversation.unread_counts_for(alice, conversations) # { id => count } in one query
|
|
676
810
|
conversation.direct? / conversation.group?
|
|
677
811
|
conversation.participant_for(alice) # the seat, or nil
|
|
@@ -684,6 +818,8 @@ message.attachments?
|
|
|
684
818
|
message.sent_by?(alice) message.sender_key
|
|
685
819
|
Chats::Message.visible .oldest_first .recent_first .before_message(message) # keyset pagination
|
|
686
820
|
Chats::Message.created_since(time) .updated_since(time) # what the stale-thread catch-up reads
|
|
821
|
+
Chats::Message.human # kind text, not deleted: what messagers wrote
|
|
822
|
+
Chats::Message.last_human_in(conversations) # newest human message per conversation, one query
|
|
687
823
|
|
|
688
824
|
# Participants — reading
|
|
689
825
|
participant.active? / participant.left? / participant.muted? / participant.owner?
|
|
@@ -693,6 +829,7 @@ Chats::Participant.active .muted
|
|
|
693
829
|
|
|
694
830
|
# The inbox
|
|
695
831
|
inbox = Chats::Inbox.for(alice, query: "trip", with: sgid) # search, or filtered to one counterpart's stack
|
|
832
|
+
Chats::Inbox.for(alice, limit: 10) # the first rows only (the dock's recent list)
|
|
696
833
|
inbox.rows inbox.each inbox.to_a inbox.size inbox.any? inbox.empty? inbox.filtered?
|
|
697
834
|
inbox.conversations # the flat, unstacked list
|
|
698
835
|
inbox.unread_counts inbox.unread_count_for(conversation) inbox.unread_count
|
|
@@ -724,8 +861,11 @@ All available in the bundled views **and** in your own templates:
|
|
|
724
861
|
|
|
725
862
|
| helper | renders |
|
|
726
863
|
|---|---|
|
|
727
|
-
| `chat_button_to(other, about:, label:, **html)` | a "Message" button, only when the viewer may message them |
|
|
728
|
-
| `chats_unread_badge(messager = viewer)` | the live unread badge with its own stream |
|
|
864
|
+
| `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 |
|
|
865
|
+
| `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 |
|
|
866
|
+
| `chats_dock(windows: 3, recent: 10, launcher: true, everywhere: false)` | the [messaging dock](#-messaging-dock): bar, panel and floating windows |
|
|
867
|
+
| `chats_dock_opener(conversation)` / `chats_dock_toggle` | data for your own links: open that conversation's dock window, or open the dock's panel |
|
|
868
|
+
| `chats_muted?(conversation, viewer)` | whether the viewer muted it (read from the preloaded seats) |
|
|
729
869
|
| `chats_messager_name(messager, css_class:)` | the name, linked through `messager_url`, badged when verified |
|
|
730
870
|
| `chats_messager_avatar(messager)` / `chats_conversation_avatar(conversation, viewer)` | avatar (URL, attachment, variant or initials) |
|
|
731
871
|
| `chats_verified_badge(messager)` | the official-account mark, or nil |
|
|
@@ -744,7 +884,7 @@ See the end of [the full Ruby API](#-the-full-ruby-api): `Chats::Error` → `Blo
|
|
|
744
884
|
|
|
745
885
|
## Locales
|
|
746
886
|
|
|
747
|
-
`en` and `es` ship with the gem under `chats.*` (`inbox`, `thread`, `conversation`, `verified`, `message`, `composer`, `buttons`, `flashes`). Your own locale
|
|
887
|
+
`en` and `es` ship with the gem under `chats.*` (`inbox`, `thread`, `conversation`, `verified`, `message`, `composer`, `dock`, `buttons`, `flashes`). Your own locale
|
|
748
888
|
files **outrank** the gem's — Rails loads every engine's locales first and the
|
|
749
889
|
app's last (a 0.2.0 fix; the gem used to re-append its files after yours and
|
|
750
890
|
silently win) — so override any key in your `es.yml` and the gem's copy loses.
|
|
@@ -762,7 +902,7 @@ rails generate chats:upgrade # 0.2.0: message authorship columns · 0.4.0: the
|
|
|
762
902
|
rails db:migrate
|
|
763
903
|
```
|
|
764
904
|
|
|
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.
|
|
905
|
+
0.5.1 needs no migration and nothing to configure: every live surface now [catches up](#what-happens-when-a-broadcast-is-missed) on missed broadcasts by itself. If you wrapped the inbox in a reconciler of your own, remove it; if you ejected the inbox, the thread or the message partial, read the 0.5.1 [CHANGELOG](CHANGELOG.md) entry. 0.5.0 needs no migration: the messaging dock is opt-in (`chats_dock`). A few things change on their own: a conversation with no messages is no longer listed (it used to show as a "No messages yet" row), a thread the host's policy no longer allows shows the notice in the composer's place, a Turbo hover prefetch no longer marks a thread read, and the thread body moved into `chats/conversations/_thread.html.erb`. Read the 0.5.0 [CHANGELOG](CHANGELOG.md) entry if you ejected `show.html.erb` or the unread badge. 0.4.0 needs that migration (one nullable JSON column, `chats_messages.action`) and nothing else: actions and the `composer_actions` slot are opt-in. The generator is guarded: it doesn't write the action migration where the column already exists (a fresh 0.4.0 install), and re-running it is harmless. Rolling that migration back keeps the column, because a migration must never drop a column it may not have added. If you ejected the composer, read the 0.4.0 [CHANGELOG](CHANGELOG.md) entry: its DOM changed shape. 0.3.x needs no migration and no new configuration: `verified: true` (0.3.0) and `Chats::SendRateLimited` (0.3.2) are opt-in. Nothing in 0.2.0 changes behaviour until you set an option.
|
|
766
906
|
|
|
767
907
|
## Database support
|
|
768
908
|
|
|
@@ -770,7 +910,7 @@ PostgreSQL, MySQL, and SQLite. The migration adapts automatically: it honors you
|
|
|
770
910
|
|
|
771
911
|
## Testing
|
|
772
912
|
|
|
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).
|
|
913
|
+
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
914
|
|
|
775
915
|
```bash
|
|
776
916
|
bundle exec rake test # full suite
|