chats 0.3.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80fff570dabbff3e99a5a5f37c43b43acd334a94aae30c23b8c76bfbab5441e8
4
- data.tar.gz: 72efe83f785fe99876d6b5922a5d834897f1771f3880bead3b43ae6f342ba96d
3
+ metadata.gz: baea21fb7f7a5ce0c50ec662b84fbef24adaa4ceb0c12a876de29891f88a2419
4
+ data.tar.gz: c199fc0c645bd667b7672e1ca0ea7257eb0e9cf05179c576f606fcf784b9feb0
5
5
  SHA512:
6
- metadata.gz: 362eda363af938a99823b38d7fd4e0a5594d62d305f6a47f88a1dc9978c6556ab37561259007f1649567472a98deecff6cc96050afbbedc3d36637ee99207a84
7
- data.tar.gz: aa7b170ba97de94d58b08516dbd4674905f1ab78e2bcd4a2e9af997e0aab6d71a3fc35b1aea2f6f0ab97fd02d1d6fa46494a1826f784b35184105c0c1c3c89b6
6
+ metadata.gz: 7739f616cf9e555ea1ed2e6321937bc6698d9e08733ee6c496d95ed41e2331694b5340ab5be641ac2e73875a17c4a4859bfa09e01cb0b3984a5b4fa340dcceb1
7
+ data.tar.gz: 712ac6d49724af1505807c3aaadd4f10fd52c8fec9653efbb9d4a89843be6fa0b2ec680873e240cc4c2bc139048cb5796b1165fced263992dc13d1499d2da0fc
data/CHANGELOG.md CHANGED
@@ -4,6 +4,94 @@ 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.4.0] - 2026-10-17
8
+
9
+ Action buttons. A support desk writes "Your invoice is ready" and wants a
10
+ *Pay now* button under it; a game writes "Let's play!" and wants *Join my
11
+ room*. Until now a host pasted a URL into the body. **0.4.0 needs one
12
+ migration** (`rails generate chats:upgrade && rails db:migrate`, one
13
+ nullable JSON column) and otherwise changes nothing until you pass
14
+ `action:`.
15
+
16
+ ### Added
17
+ - **One action per message.** `alice.message!(bob, "Let's play!", action:
18
+ { label: "Join my room", url: "/play/abcd" })`, and the same `action:` on
19
+ `Conversation#post_system_message!`. `message.action` is a
20
+ `Chats::Action` (an immutable `Data` value: `#label`, `#url`, `#to_h`) or
21
+ nil, and `message.action?` says which. `Chats::Action.wrap` accepts a
22
+ Hash with string or symbol keys, an Action, or nil, and raises
23
+ `ArgumentError` for anything else, including a key an action doesn't have
24
+ (`method: :post` would otherwise have turned into a GET link silently).
25
+ Assigning goes through `wrap` immediately, so a mistake raises on the line
26
+ that made it.
27
+ - **Validated at the write.** The label is present and at most 80
28
+ characters. The url is at most 2048 characters and is either an absolute
29
+ http(s) URL with a host (and a port from 1 to 65535, if it names one) or
30
+ a path on this site starting with a single `/`. `javascript:`, `data:`,
31
+ other schemes and relative paths are refused, and so is everything a
32
+ browser would send off-site: `//host`, `///host` (which URI.parse reads
33
+ as a hostless path), `/\host` and `/<tab>/host`. Anything starting with
34
+ `//` or `/\` is refused before the url is even parsed. The error is
35
+ `:action, :invalid`, in `en` and `es`. It is checked only when the action
36
+ changes, so editing an old message never fails because of an action
37
+ nobody touched.
38
+ - **The bubble renders it** as `a.chats-message__action` under the text and
39
+ attachments, and inside the note for system messages
40
+ (`.chats-message__system--with-action`). The link has
41
+ `data-turbo-frame="_top"`: older pages of a thread load inside lazy
42
+ turbo-frames, and without it a link there would navigate only the frame.
43
+ It is a link and never a form because the bubble is rendered once for
44
+ every viewer and cloned into the long-press popup, so it cannot hold a
45
+ per-viewer CSRF token. An action is a destination; a host that needs a
46
+ POST points it at a page that asks first. The markup lives in its own
47
+ partial, `chats/messages/_action`. The button uses the accent pair: filled
48
+ with the accent on foreign bubbles and system notes, reversed on your own
49
+ accent bubble.
50
+ - **Tombstones drop it.** Soft deletion clears the action along with the
51
+ body, and so does moderation's `remove_reported_field!`, which goes
52
+ through the same path. `edit!` keeps it.
53
+ - **The composer can never set one.** `Chats::MessagesController` still
54
+ permits only `body` and `files`, the same as with `author:`, and a test
55
+ checks that a posted `message[action]` is ignored.
56
+ - **`chats_messages.action`** (jsonb on PostgreSQL, json elsewhere, nullable,
57
+ no default), created by the install migration and added to existing
58
+ installs by `rails generate chats:upgrade`. The generator doesn't write
59
+ that migration where the column already exists (it asks the database; a
60
+ fresh 0.4.0 install has it), its `up` is guarded, and rolling it back
61
+ KEEPS the column: nothing a migration can see records whether it or the
62
+ create migration added it, and it must never drop a column, with every
63
+ message's button in it, that it did not add.
64
+ - **The `composer_actions` slot.** `app/views/chats/slots/_composer_actions.
65
+ html.erb` (receives `conversation:`) renders a row of your own buttons
66
+ above the composer's input ("Create a game", "Send my location") without
67
+ ejecting it. The row is outside the composer's `<form>`, so `button_to`
68
+ works there. It goes away with the composer when the thread locks, whether
69
+ on page load or through the live 422 swap. Unstyled links and buttons get
70
+ a chip look applied with zero specificity, so any class you add wins.
71
+
72
+ ### Changed
73
+ - **The composer partial's DOM.** `chats/messages/_composer` is now a dock
74
+ wrapping the form:
75
+ `div#<dom_id(conversation, :composer)>.chats-composer` contains the
76
+ `.chats-composer__actions` row (only when the slot exists) and then
77
+ `form.chats-composer__form`, which keeps the `chats--composer` controller,
78
+ its data-actions and everything inside it. In 0.3.x the form itself was
79
+ `form#…composer.chats-composer`. The swap id moved to the dock so the live
80
+ lock replaces the host's buttons along with the form. **If you ejected the
81
+ composer** (CarHey did), your copy keeps working unchanged:
82
+ `.chats-composer` still styles whatever element carries it, and the
83
+ locked-composer swap still targets your form's id. You only need to
84
+ re-eject to use `composer_actions`. If your own CSS or JS selects
85
+ `form.chats-composer`, change it to `form.chats-composer__form` (or
86
+ `.chats-composer form`) once you're on the bundled partial.
87
+ - The Trust & Safety report-link recipe in the README referred to the
88
+ inline actions row that 0.1.1 removed, and to its `chats-message__action`
89
+ class, which now names the action button. It now puts the link in the
90
+ long-press menu as a `chats-popup__item` with `data-chats-other-only`. The
91
+ bundled CSS styles `.chats-message__action` only as a direct child of a
92
+ bubble, so links in views ejected from 0.1.0 that still use the class are
93
+ left alone.
94
+
7
95
  ## [0.3.2] - 2026-09-17
8
96
 
9
97
  ### Fixed
data/README.md CHANGED
@@ -11,6 +11,8 @@ 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)
15
+
14
16
  ## 👨‍💻 Example
15
17
 
16
18
  `chats` reads like plain English:
@@ -82,7 +84,7 @@ And a live unread badge in your nav:
82
84
 
83
85
  ## What `chats` does (and doesn't) do
84
86
 
85
- **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, per-sender rate limiting, inbox search, infinite scroll-up pagination, optional encryption at rest, and small adapter seams for moderation + notifications.
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.
86
88
 
87
89
  **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.
88
90
 
@@ -104,6 +106,43 @@ Two deliberate design decisions worth knowing:
104
106
  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.
105
107
  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.
106
108
 
109
+ ## 🧩 The macros
110
+
111
+ ### `acts_as_messager(notifications: true, blockable: true, inbox: :default, group_path: nil, verified: false)`
112
+
113
+ Anyone (or anything) that can hold a seat in a conversation. Every option is a
114
+ class-level fact the gem reads duck-typed, so an ordinary model behaves exactly
115
+ as before and nothing asks `is_a?(User)`:
116
+
117
+ | option | default | what it means |
118
+ |---|---|---|
119
+ | `notifications:` | `true` | `false` for a headless seat: `Participant#notifiable_for?` is never true for it, and `Chats.notifications_for?(messager)` says so |
120
+ | `blockable:` | `true` | `false` hides block/report affordances against it and makes `Chats.blockable?(messager)` false |
121
+ | `inbox:` | `:default` | `:grouped` folds every direct thread with this messager into ONE inbox row (`Chats::InboxGroup`) |
122
+ | `group_path:` | `nil` | `->(viewer) { path }` — where a deep stack opens; default is chats' own filtered inbox |
123
+ | `verified:` | `false` | the official-account badge wherever the name renders; refuses to coerce (`"false"` raises at boot) |
124
+
125
+ Class predicates: `Klass.chat_notifications?`, `.chat_blockable?`, `.chat_inbox_mode`,
126
+ `.chat_group_path`, `.chat_verified?`. Instance API below.
127
+
128
+ ### `acts_as_chat_subject`
129
+
130
+ A domain record conversations can be *about*. Adds `chat_conversations` (`has_many`)
131
+ and three overridable readers — the contract a product built on chats (a ticket, an
132
+ order) implements:
133
+
134
+ ```ruby
135
+ class Ticket < ApplicationRecord
136
+ acts_as_chat_subject
137
+
138
+ def chat_subject_label = "Ticket #{reference}" # the context line in inbox rows and the thread header
139
+ def chat_locked? = closed? # whether the conversation still takes messages
140
+ def chat_locked_notice = "This ticket is closed." # what replaces the composer when it doesn't
141
+ end
142
+ ```
143
+
144
+ All three are inert by default (the label falls back to the record's `to_s`, nothing is locked).
145
+
107
146
  ## ⚡ Real-time, the Hotwire way
108
147
 
109
148
  - **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.
@@ -166,16 +205,19 @@ Use **`:flag`, never `:block`** for chat: you don't gag someone mid-conversation
166
205
  Put a report link on every bubble **someone else** sent. One nuance matters: `chats` renders each bubble **once per broadcast, viewer-agnostically** (that's what makes real-time fan-out cheap), so anything depending on `current_user` at render time — like moderate's `report_link` helper, which checks `report_visible_to?(viewer)` — would silently vanish from live-appended bubbles. Use the **signed-target URL** instead (viewer-independent), and hide it on own bubbles with the same client-side mechanism the gem uses for edit/delete:
167
206
 
168
207
  ```erb
169
- <%# in your ejected chats/messages/_message.html.erb, inside the actions row %>
208
+ <%# in your ejected chats/messages/_message.html.erb, inside .chats-popup__menu
209
+ in the <template data-chats-message-menu> (the long-press menu) %>
170
210
  <% if message.sender %>
171
211
  <%= link_to "Report",
172
212
  main_app.new_abuse_report_path(
173
213
  target: message.to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE),
174
214
  field: "body"
175
215
  ),
176
- class: "chats-message__action in-own-hidden" %>
177
- <%# hide on .chats-message--own via your CSS; moderate's controller
178
- re-checks report_visible_to? server-side, so hiding is cosmetic %>
216
+ class: "chats-popup__item", role: "menuitem",
217
+ data: { chats_other_only: true } %>
218
+ <%# data-chats-other-only: the popup drops it from your own bubbles;
219
+ moderate's controller re-checks report_visible_to? server-side,
220
+ so hiding is cosmetic %>
179
221
  <% end %>
180
222
  ```
181
223
 
@@ -216,6 +258,14 @@ Chats.on(:participant_left) { |participant| AuditLog.log("chat_left", partic
216
258
  Chats.on(:conversation_read) { |conversation:, participant:| Bell.mark_read(participant.messager, conversation) }
217
259
  ```
218
260
 
261
+ | event | block arguments | when |
262
+ |---|---|---|
263
+ | `:message_created` | `message` | after a **text** message commits (system messages never notify) |
264
+ | `:conversation_created` | `conversation` | once per conversation, never when an existing one is resumed |
265
+ | `:participant_left` | `participant` | someone left a group |
266
+ | `:conversation_read` | `conversation:, participant:` | a read advanced the horizon past unread content |
267
+
268
+
219
269
  Four properties, all of which matter the first time something goes wrong at 3am:
220
270
 
221
271
  - **Many subscribers per event.** Your mailer, your analytics and your audit log don't have to share one `case` statement.
@@ -356,6 +406,13 @@ end
356
406
  - The thread **stays readable**. Only the composer changes: it's replaced by the notice (the `locked_composer` slot overrides the body). Gate the action, never hide the explanation.
357
407
  - A send that lands on a conversation locked since the page loaded gets a **422 that swaps the composer for the notice** — no raise, no lying screen.
358
408
 
409
+ Every other write refuses too: `Message#edit!`, `#soft_delete!` and
410
+ `Chats::Reaction.toggle!` raise `Chats::LockedError` (a `NotAllowedError`
411
+ subclass), the edit/delete/react endpoints answer 422 with the notice, and the
412
+ bundled bubble stops offering them. Moderation is the one exception —
413
+ `remove_reported_field!` removes reported content from a locked conversation,
414
+ because a product lock must never shield it.
415
+
359
416
  ## ✍️ Signed messages
360
417
 
361
418
  `sender` is the seat a message came from; `author` is who **wrote** it on that seat's behalf. That's how a shared desk answers as itself while the human stays visible:
@@ -378,6 +435,77 @@ Existing installs get the columns with one command:
378
435
  rails generate chats:upgrade && rails db:migrate
379
436
  ```
380
437
 
438
+
439
+ ## 🎯 Action buttons
440
+
441
+ A support desk writes "Your invoice is ready" and wants a **Pay now** button under it. A game writes "Let's play!" and wants **Join my room**. A marketplace posts "Your ride was cancelled" with **Book another**. Pasting a URL into the body is the wrong answer: one message can carry **one action**, a label and a url, and the bundled bubble renders it as a button.
442
+
443
+ ```ruby
444
+ alice.message!(bob, "Let's play!", action: { label: "Join my room", url: play_room_path(room) })
445
+ desk.message!(user, "Your invoice is ready", author: lucia, action: { label: "Pay now", url: invoice.payment_url })
446
+ conversation.post_system_message!("Your ride was cancelled", action: { label: "Book another", url: new_ride_path })
447
+ ```
448
+
449
+ <img src="docs/screenshots/thread-actions-phone.png" alt="A thread on a phone: a received message with a dark Join my room button, and your own dark bubble with a white Open my lobby button" width="320">
450
+
451
+ *One action per message: accent-filled under someone else's message, reversed in your own bubble.*
452
+
453
+ ```ruby
454
+ message.action # => #<data Chats::Action label="Join my room", url="/play/abcd">, or nil
455
+ message.action? # => true
456
+ message.action.label # => "Join my room"
457
+ message.action.to_h # => { label: "Join my room", url: "/play/abcd" }
458
+
459
+ Chats::Action.new(label: "Pay now", url: "/pay") # accepted anywhere a Hash is
460
+ ```
461
+
462
+ `Chats::Action` is an immutable value (`Data`), compared by value. Assign a Hash (string or symbol keys), an Action, or `nil`; anything else raises `ArgumentError` where you assigned it. A key an action doesn't have raises too: `{ label:, url:, method: :post }` would otherwise turn into a plain link without telling you.
463
+
464
+ **An action is a place to go, not something that happens.** It renders as a link (`data-turbo-frame="_top"`, so it opens the page even from an older page of the thread, which loads inside a frame). It is never a form, and that is on purpose: every bubble is rendered **once** and broadcast to everyone in the thread, and the long-press popup clones it, so nothing that belongs to one viewer can live inside it. A form would need that viewer's CSRF token. If the button should *do* something (accept an offer, pay), point it at a page that asks first and does the work there.
465
+
466
+ **It is validated when it is written**, so a bad one fails loudly in your code and never renders as a broken or dangerous link:
467
+
468
+ - `label`: present, at most 80 characters.
469
+ - `url`: at most 2048 characters, and EITHER an absolute `http://` / `https://` URL with a host (and, if it names one, a port from 1 to 65535), OR a path on your site that starts with a single `/`. Everything else is refused: `javascript:`, `data:`, `mailto:`, relative paths, and anything a browser would send to another site: `//elsewhere.example`, `///elsewhere.example`, `/\elsewhere.example`, `/<tab>/…`. URLs must be escaped, as Rails' url helpers already produce them.
470
+ - The error is `errors.of_kind?(:action, :invalid)`, with a readable message in `en` and `es`.
471
+
472
+ Body rules don't change: an action goes with a message, it is never a message by itself. Both text and system messages can carry one. `edit!` keeps the action. **A tombstone shows neither the body nor the button**: soft deletion clears the action, and so does a moderator's `remove_reported_field!`, because it goes through the same tombstone. The inbox preview still shows the body.
473
+
474
+ **Only your Ruby code sets an action.** The composer can't: `Chats::MessagesController` permits `body` and `files` only, the same as with `author:`. Restyle the button with `.chats-message__action` (eject `chats/messages/_action.html.erb` to change its markup). It uses your `--chats-accent` / `--chats-accent-contrast` colours: accent-filled under someone else's message or a system note, reversed inside your own accent-coloured bubble.
475
+
476
+ ### Buttons next to the composer: the `composer_actions` slot
477
+
478
+ Actions are the buttons your app puts under a message. The `composer_actions` [slot](#-view-slots) is for buttons *you* put next to the composer, such as "Create a game" or "Send my location", without ejecting it:
479
+
480
+ ```erb
481
+ <%# app/views/chats/slots/_composer_actions.html.erb %>
482
+ <%= button_to "🎮 Create a game", games_path, params: { conversation_id: conversation.id } %>
483
+ <%= link_to "📍 Send my location", new_location_path(conversation_id: conversation.id) %>
484
+ ```
485
+
486
+ The row appears above the input, **outside** the composer's `<form>`, so `button_to` forms are fine there: a form inside a form is invalid HTML. If the conversation locks, the row goes away together with the composer. Unstyled buttons and links get a small chip look by default. It is applied with zero specificity, so any class you add overrides it.
487
+
488
+ ## ⏱️ One send budget for every composer
489
+
490
+ `config.send_rate_limit` (60 per minute per sender by default) is enforced by
491
+ the gem's own message controller. A product with its **own** composer that
492
+ still ends in a chats message — a support form, an order note — shares the
493
+ same budget by including one concern, so nobody gets two allowances:
494
+
495
+ ```ruby
496
+ class Support::TicketsController < ApplicationController
497
+ include Chats::SendRateLimited
498
+ before_action :enforce_chat_send_rate_limit, only: :create
499
+
500
+ private
501
+
502
+ def chat_rate_limit_messager = current_user # whose budget; defaults to the configured current messager
503
+ end
504
+ ```
505
+
506
+ It counts in the host's cache store (atomic increment, so it works on Rails 7.1
507
+ too) and answers `429` with `chats.flashes.rate_limited` when the budget is spent.
508
+
381
509
  ## 🔌 View slots
382
510
 
383
511
  Ejecting a whole screen to add one row or one button is too coarse. The bundled views render a partial named `chats/slots/_<slot>` **when it exists** — no configuration, no registration, and an absent slot costs one memoized lookup:
@@ -387,6 +515,7 @@ Ejecting a whole screen to add one row or one button is too coarse. The bundled
387
515
  | `inbox_top` | above the first inbox row |
388
516
  | `inbox_empty` | inside the empty state |
389
517
  | `conversation_header_actions` | the thread's menu (gets `blockable:`) |
518
+ | `composer_actions` | a row of your own buttons above the composer's input, outside its form (gets `conversation:`; gone when the thread locks) |
390
519
  | `locked_composer` | the locked composer's body |
391
520
  | `message_meta` | after each bubble's timestamp |
392
521
 
@@ -497,6 +626,8 @@ alice.message!(conversation, "hi", files: []) # send into a conversation
497
626
  alice.chats # inbox relation, newest first
498
627
  alice.unread_chats_count # conversations with unread messages
499
628
  alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
629
+ alice.message!(bob, "Let's play!", # with one button under it
630
+ action: { label: "Join my room", url: "/play/abcd" })
500
631
  Chats::Inbox.for(alice) # [Conversation | InboxGroup] + #unread_count
501
632
  Chats.verified?(desk) # official account? (acts_as_messager verified: true)
502
633
 
@@ -509,6 +640,7 @@ conversation.subject_label # "Madrid → Barcelona"
509
640
  conversation.unread_count_for(user)
510
641
  conversation.mark_read_by!(user)
511
642
  conversation.post_system_message!("Ride cancelled")
643
+ conversation.post_system_message!("Ride cancelled", action: { label: "Book another", url: "/rides/new" })
512
644
  conversation.add_participant!(user) # idempotent, race-safe
513
645
  conversation.locked? # the SUBJECT decides (chat_locked?)
514
646
  conversation.locked_notice # why, in words
@@ -518,6 +650,8 @@ message.edit!("fixed") # stamps edited_at
518
650
  message.soft_delete! # tombstone (or destroy, per config)
519
651
  message.read_by?(user)
520
652
  message.signed? / message.authored_by?(lucia) # authorship
653
+ message.action / message.action? # the button: a Chats::Action, or nil
654
+ Chats::Action.new(label: "Pay now", url: "/pay") # .label .url .to_h; Chats::Action.wrap(hash_or_action_or_nil)
521
655
  Chats::Reaction.toggle!(message:, reactor:, emoji: "👍")
522
656
 
523
657
  # Participants (the per-member state)
@@ -531,20 +665,104 @@ participant.reseat!(new_messager) # hand the seat over, read horizon
531
665
  # Events
532
666
  Chats.on(:message_created) { |message| } # also :conversation_created,
533
667
  # :participant_left, :conversation_read
534
- ```
535
668
 
536
- Errors are namespaced and meaningful: `Chats::BlockedError`, `Chats::NotAllowedError`, `Chats::ConfigurationError` — all under `Chats::Error`.
669
+ # Conversations — finding and creating
670
+ Chats::Conversation.direct_between(alice, bob, about: ride) # nil when none exists
671
+ Chats::Conversation.direct_between!(alice, bob, about: ride) # find-or-create; checks blocks and can_message(alice, bob)
672
+ Chats::Conversation.group!(alice, [bob, carol], title: "Trip")
673
+ Chats::Conversation.direct .groups .about(ride) .recent_first
674
+ Chats::Conversation.inbox_for(alice) .excluding_blocked_for(alice) .unread_by(alice)
675
+ Chats::Conversation.unread_counts_for(alice, conversations) # { id => count } in one query
676
+ conversation.direct? / conversation.group?
677
+ conversation.participant_for(alice) # the seat, or nil
678
+ ride.chat_conversations # every conversation about a subject
679
+
680
+ # Messages — reading
681
+ message.system? / message.text? / message.deleted? / message.edited?
682
+ message.visible_body # body, or the tombstone text
683
+ message.attachments?
684
+ message.sent_by?(alice) message.sender_key
685
+ Chats::Message.visible .oldest_first .recent_first .before_message(message) # keyset pagination
686
+ Chats::Message.created_since(time) .updated_since(time) # what the stale-thread catch-up reads
687
+
688
+ # Participants — reading
689
+ participant.active? / participant.left? / participant.muted? / participant.owner?
690
+ participant.unread? / participant.unread_count / participant.unread_messages
691
+ participant.display_name
692
+ Chats::Participant.active .muted
693
+
694
+ # The inbox
695
+ inbox = Chats::Inbox.for(alice, query: "trip", with: sgid) # search, or filtered to one counterpart's stack
696
+ inbox.rows inbox.each inbox.to_a inbox.size inbox.any? inbox.empty? inbox.filtered?
697
+ inbox.conversations # the flat, unstacked list
698
+ inbox.unread_counts inbox.unread_count_for(conversation) inbox.unread_count
699
+ group = inbox.rows.first # a Chats::InboxGroup when the counterpart is inbox: :grouped
700
+ group.messager group.conversations group.conversation group.single?
701
+ group.unread_count group.unread? group.open_count group.last_message group.last_message_at
702
+ group.title_for(viewer) group.dom_id group.with_sgid # the signed token GET /conversations?with= takes
703
+
704
+ # Module-level
705
+ Chats.display_name_for(messager) Chats.avatar_for(messager) Chats.messager_url_for(messager)
706
+ Chats.message_signature_for(message) # "— Lucía G." or nil
707
+ Chats.verified?(m) Chats.notifications_for?(m) Chats.blockable?(m) Chats.grouped_inbox?(m)
708
+ Chats.blocked_ids_for(messager) Chats.blocked_between?(a, b) Chats.can_message?(sender, recipient)
709
+ Chats.inbox_with_sgid(messager) # the token behind a stack's "see all"
710
+ Chats.messager_key(messager) # "User:42" — what bubbles carry to align own-vs-other
711
+ Chats.messager_class?(klass) Chats.chat_subject_class?(klass)
712
+ Chats.on(event, key: nil) { … } Chats.reset_subscribers! Chats.reset! Chats.deprecator
713
+ ```
714
+
715
+ Errors are namespaced and meaningful, all under `Chats::Error`:
716
+ `Chats::BlockedError` (a blocked pair opening or sending), `Chats::NotAllowedError`
717
+ (`can_message` / `can_create_group` said no, groups disabled, a reseat that would
718
+ collide) and its subclass `Chats::LockedError` (any non-system write into a
719
+ locked conversation), `Chats::ConfigurationError` (boot).
720
+
721
+ ## 🧰 View helpers
722
+
723
+ All available in the bundled views **and** in your own templates:
724
+
725
+ | helper | renders |
726
+ |---|---|
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 |
729
+ | `chats_messager_name(messager, css_class:)` | the name, linked through `messager_url`, badged when verified |
730
+ | `chats_messager_avatar(messager)` / `chats_conversation_avatar(conversation, viewer)` | avatar (URL, attachment, variant or initials) |
731
+ | `chats_verified_badge(messager)` | the official-account mark, or nil |
732
+ | `chats_message_signature(message)` | the "— Lucía G." line for a signed message |
733
+ | `chats_preview_for(conversation, viewer)` | the inbox row's last-message preview with its speaker |
734
+ | `chats_timestamp(time)` | "12:04" · "Yesterday" · "12 Sep" |
735
+ | `chats_slot(name, **locals)` / `chats_slot?(name)` | render a host slot partial when it exists |
736
+ | `chats_blockable?(messager)` / `chats_messager_url(messager)` | the duck-typed reads, for your own screens |
737
+ | `chats_group_path(group)` / `chats_group_path_for(messager)` | where a stack opens |
738
+ | `chats_styles` | the bundled stylesheet tag for your `<head>` |
739
+ | `chats_viewer` | the current messager, as the engine resolved it |
740
+
741
+ ## Errors
742
+
743
+ See the end of [the full Ruby API](#-the-full-ruby-api): `Chats::Error` → `BlockedError`, `NotAllowedError` → `LockedError`, `ConfigurationError`.
744
+
745
+ ## Locales
746
+
747
+ `en` and `es` ship with the gem under `chats.*` (`inbox`, `thread`, `conversation`, `verified`, `message`, `composer`, `buttons`, `flashes`). Your own locale
748
+ files **outrank** the gem's — Rails loads every engine's locales first and the
749
+ app's last (a 0.2.0 fix; the gem used to re-append its files after yours and
750
+ silently win) — so override any key in your `es.yml` and the gem's copy loses.
751
+ Bubbles, flashes, the empty inbox, the locked composer and the verified label
752
+ (`chats.verified.label`) are all there, and so are the model errors
753
+ (`activerecord.errors.models.chats/message.…`, the action's included).
754
+
537
755
 
538
756
  ## Upgrading
539
757
 
540
758
  `chats` ships the migrations a version bump needs; your initializer and views stay yours:
541
759
 
542
760
  ```bash
543
- rails generate chats:upgrade # 0.2.0: message authorship columns
761
+ rails generate chats:upgrade # 0.2.0: message authorship columns · 0.4.0: the message action column
544
762
  rails db:migrate
545
763
  ```
546
764
 
547
- Nothing in 0.2.0 changes behaviour until you set an option — see the [CHANGELOG](CHANGELOG.md).
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.
548
766
 
549
767
  ## Database support
550
768
 
@@ -417,6 +417,64 @@ turbo-frame[id^="chats_page_"] {
417
417
  .chats-message--deleted .chats-message__bubble { background: transparent; border: 1px dashed var(--chats-border); }
418
418
  .chats-message__tombstone { color: var(--chats-text-muted); font-size: 0.85rem; }
419
419
 
420
+ /* --- Actions (Chats::Action) ------------------------------------------------------- */
421
+ /*
422
+ A message's one featured button, under its words: full width, so it reads
423
+ as something to tap rather than a link in the text. It is a sibling of
424
+ .chats-message__text, never inside it, so the text's pre-wrap stays off it.
425
+
426
+ Contrast comes from the pair a host already tunes together: the accent and
427
+ the text that sits on it. A foreign bubble (and a system note) gets an
428
+ accent button; an own bubble IS the accent, so its button swaps the pair.
429
+ Same two colours either way, so whatever passes for the send button and
430
+ own bubbles passes here — default theme or host theme alike.
431
+
432
+ Scoped to the bubble with `>`: 0.1.0's inline actions row used this class
433
+ name, and views ejected back then may still carry it on other elements.
434
+ The long-press popup clones .chats-message__bubble, so this applies there
435
+ too.
436
+ */
437
+ .chats-message__bubble > .chats-message__action,
438
+ .chats-message__system > .chats-message__action {
439
+ display: block;
440
+ margin-top: 0.5rem;
441
+ padding: 0.55rem 0.9rem;
442
+ border-radius: calc(var(--chats-radius) * 0.7);
443
+ background: var(--chats-accent);
444
+ color: var(--chats-accent-contrast);
445
+ font-size: 0.88rem;
446
+ font-weight: 700;
447
+ line-height: 1.25;
448
+ text-align: center;
449
+ text-decoration: none;
450
+ overflow-wrap: anywhere;
451
+ cursor: pointer;
452
+ }
453
+ .chats-message--own .chats-message__bubble > .chats-message__action {
454
+ background: var(--chats-accent-contrast);
455
+ color: var(--chats-accent);
456
+ }
457
+ .chats-message__bubble > .chats-message__action:hover,
458
+ .chats-message__system > .chats-message__action:hover { filter: brightness(0.92); }
459
+ /* The focus ring sits on the BUBBLE, just outside the button, so it takes
460
+ the colour of the bubble's own text — which is what contrasts there. */
461
+ .chats-message__bubble > .chats-message__action:focus-visible,
462
+ .chats-message__system > .chats-message__action:focus-visible {
463
+ outline: 2px solid var(--chats-bubble-text);
464
+ outline-offset: 2px;
465
+ }
466
+ .chats-message--own .chats-message__bubble > .chats-message__action:focus-visible {
467
+ outline-color: var(--chats-accent-contrast);
468
+ }
469
+ .chats-message__system > .chats-message__action:focus-visible { outline-color: var(--chats-text); }
470
+ /* A system note is a pill; one holding a button becomes a card, or the
471
+ button's corners would poke through the pill's rounded ends. */
472
+ .chats-message__system--with-action {
473
+ border-radius: var(--chats-radius);
474
+ padding: 0.6rem 0.9rem;
475
+ }
476
+ .chats-message__system > .chats-message__action { margin-top: 0.45rem; }
477
+
420
478
  /* --- Attachments -------------------------------------------------------------------- */
421
479
 
422
480
  .chats-message__attachments {
@@ -659,12 +717,43 @@ turbo-frame[id^="chats_page_"] {
659
717
 
660
718
  /* --- Composer ----------------------------------------------------------------------------- */
661
719
 
720
+ /* The dock (since 0.4.0 a div holding the host's composer_actions and the
721
+ form; a composer ejected before that is the form itself, and this box
722
+ styles it just the same). */
662
723
  .chats-composer {
663
724
  flex: 0 0 auto;
664
725
  background: var(--chats-bg);
665
726
  border-top: 1px solid var(--chats-border);
666
727
  padding: 0.6rem 0.9rem calc(0.6rem + env(safe-area-inset-bottom, 0px));
667
728
  }
729
+ .chats-composer__form { margin: 0; }
730
+
731
+ /* The composer_actions slot: a row of the host's own buttons above the
732
+ input. `display: contents` lets button_to's wrapping <form> step aside so
733
+ its button sits in the row like a link does. Every default here is
734
+ wrapped in :where() — zero specificity — so ANY class the host puts on
735
+ its buttons or forms wins, and an unstyled button still looks like it
736
+ belongs. */
737
+ .chats-composer__actions {
738
+ display: flex;
739
+ flex-wrap: wrap;
740
+ gap: 0.4rem;
741
+ margin: 0 0 0.55rem;
742
+ }
743
+ :where(.chats-composer__actions) :where(form) { display: contents; }
744
+ :where(.chats-composer__actions) :where(a, button) {
745
+ padding: 0.35rem 0.8rem;
746
+ border: 1px solid var(--chats-border);
747
+ border-radius: 999px;
748
+ background: var(--chats-surface);
749
+ color: var(--chats-text);
750
+ font: inherit;
751
+ font-size: 0.8rem;
752
+ font-weight: 600;
753
+ text-decoration: none;
754
+ cursor: pointer;
755
+ }
756
+ :where(.chats-composer__actions) :where(a, button):hover { filter: brightness(0.96); }
668
757
  .chats-composer__bar { display: flex; align-items: flex-end; gap: 0.55rem; }
669
758
  .chats-composer__input {
670
759
  flex: 1;
@@ -760,7 +849,12 @@ turbo-frame[id^="chats_page_"] {
760
849
  padding: 0.2rem 0.4rem;
761
850
  }
762
851
 
763
- .chats-composer__previews[hidden] { display: none; }
852
+ /* `hidden` loses to any author `display`, so every element here that sets
853
+ one and starts hidden says so explicitly — the edit cue and the file
854
+ count used to show in hosts with no CSS reset of their own. */
855
+ .chats-composer__previews[hidden],
856
+ .chats-composer__edit[hidden],
857
+ .chats-composer__file-count[hidden] { display: none; }
764
858
  .chats-composer__preview {
765
859
  position: relative;
766
860
  flex-shrink: 0;
@@ -12,6 +12,7 @@ module Chats
12
12
  inbox_top
13
13
  inbox_empty
14
14
  conversation_header_actions
15
+ composer_actions
15
16
  locked_composer
16
17
  message_meta
17
18
  ].freeze
@@ -147,7 +148,7 @@ module Chats
147
148
  # app/views/chats/slots/_inbox_top.html.erb
148
149
  #
149
150
  # The slots: inbox_top, inbox_empty, conversation_header_actions,
150
- # locked_composer, message_meta.
151
+ # composer_actions, locked_composer, message_meta.
151
152
  def chats_slot(name, **locals)
152
153
  return unless chats_slot?(name)
153
154
 
@@ -1,8 +1,8 @@
1
1
  <%# What sits where the composer would be when the conversation's SUBJECT
2
2
  has locked it (Chats::ChatSubject#chat_locked?). It carries the same DOM
3
- id as the composer form, so MessagesController#create can swap one for
3
+ id as the composer's dock, so MessagesController#create can swap one for
4
4
  the other with a single Turbo replace when a thread closes under an open
5
- composer.
5
+ composer — the host's composer_actions leave with it.
6
6
 
7
7
  The `locked_composer` slot replaces the BODY (a reopen button, a link to
8
8
  the closed ticket…) while keeping the id contract intact. %>
@@ -14,6 +14,22 @@
14
14
  and on return-to-visible. It needs to wrap the <turbo-cable-stream-source>
15
15
  that `turbo_stream_from` renders so it can observe its `connected`
16
16
  attribute as the heartbeat. %>
17
+
18
+ <%# The OTHER inbox-staleness case, distinct from a missed broadcast: a Turbo
19
+ RESTORATION visit (browser back / Hotwire Native stack pop) serves the
20
+ inbox's cached snapshot with no GET. Open a chat from a profile, send a
21
+ message, tap back — the snapshot cached before the chat existed comes back
22
+ and the new conversation is missing until you navigate away and return.
23
+ The reconciler above can't help (it only fires on reconnect / visibility,
24
+ not on a restore visit). `turbo-cache-control: no-cache` makes the inbox
25
+ uncacheable, so a restore visit re-fetches from the network and is always
26
+ fresh — on the web AND in Hotwire Native, whose back-pop issues a Turbo
27
+ `.restore` visit that honors this. Requires the host layout to
28
+ `yield :head` (the Rails convention); harmless no-op otherwise.
29
+ https://turbo.hotwired.dev/handbook/building#opting-out-of-caching %>
30
+ <% content_for :head do %>
31
+ <meta name="turbo-cache-control" content="no-cache">
32
+ <% end %>
17
33
  <%= chats_styles %>
18
34
  <div data-controller="chats--refresh-inbox">
19
35
  <%= turbo_stream_from chats_current_messager, :chats_inbox %>
@@ -0,0 +1,15 @@
1
+ <%# A message's featured button (Chats::Action): the one place it takes you,
2
+ under the words it came with. Rendered inside the bubble, so it is part
3
+ of the single viewer-agnostic broadcast render AND of the clone the
4
+ long-press popup lifts — which is why it is a plain link and never a
5
+ form: a form would need a per-viewer CSRF token baked into a render
6
+ every subscriber shares. An action is a destination, not a mutation.
7
+
8
+ data-turbo-frame="_top": older pages of a thread arrive inside lazy
9
+ `chats_page_…` turbo-frames, and a link inside a frame navigates the
10
+ FRAME. _top makes it navigate the page, wherever the bubble landed.
11
+
12
+ The url was validated at the write (Chats::Message#
13
+ action_must_be_well_formed): http(s) or a path on this site, never
14
+ `javascript:` or protocol-relative. %>
15
+ <%= link_to action.label, action.url, class: "chats-message__action", data: { turbo_frame: "_top" } %>