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 +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +227 -9
- data/app/assets/stylesheets/chats.css +95 -1
- data/app/helpers/chats/engine_helper.rb +2 -1
- data/app/views/chats/conversations/_locked_composer.html.erb +2 -2
- data/app/views/chats/conversations/index.html.erb +16 -0
- data/app/views/chats/messages/_action.html.erb +15 -0
- data/app/views/chats/messages/_composer.html.erb +74 -60
- data/app/views/chats/messages/_message.html.erb +6 -1
- data/config/locales/en.yml +5 -0
- data/config/locales/es.yml +5 -0
- data/docs/screenshots/composer-actions.png +0 -0
- data/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- data/docs/screenshots/thread-actions-phone.png +0 -0
- data/lib/chats/action.rb +70 -0
- data/lib/chats/models/concerns/messager.rb +10 -2
- data/lib/chats/models/conversation.rb +7 -2
- data/lib/chats/models/message.rb +74 -1
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/add_action_to_chats_messages.rb.erb +37 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +4 -0
- data/lib/generators/chats/templates/initializer.rb +1 -0
- data/lib/generators/chats/upgrade_generator.rb +37 -6
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baea21fb7f7a5ce0c50ec662b84fbef24adaa4ceb0c12a876de29891f88a2419
|
|
4
|
+
data.tar.gz: c199fc0c645bd667b7672e1ca0ea7257eb0e9cf05179c576f606fcf784b9feb0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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" } %>
|