chats 0.1.1 → 0.2.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 +166 -0
- data/README.md +163 -28
- data/app/assets/stylesheets/chats.css +44 -0
- data/app/controllers/chats/conversations_controller.rb +43 -43
- data/app/controllers/chats/messages_controller.rb +34 -0
- data/app/controllers/chats/reactions_controller.rb +14 -0
- data/app/helpers/chats/engine_helper.rb +94 -0
- data/app/javascript/chats/refresh_inbox_controller.js +86 -0
- data/app/views/chats/conversations/_group.html.erb +38 -0
- data/app/views/chats/conversations/_locked_composer.html.erb +15 -0
- data/app/views/chats/conversations/index.html.erb +39 -9
- data/app/views/chats/conversations/show.html.erb +44 -3
- data/app/views/chats/messages/_composer.html.erb +4 -0
- data/app/views/chats/messages/_message.html.erb +29 -8
- data/app/views/chats/messages/locked.turbo_stream.erb +6 -0
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +11 -0
- data/config/locales/es.yml +11 -0
- data/context7.json +4 -0
- data/docs/PRD.md +1 -1
- data/docs/campfire_review.md +1 -1
- data/gemfiles/rails_7.1.gemfile +1 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/chats/configuration.rb +63 -1
- data/lib/chats/engine.rb +29 -7
- data/lib/chats/errors.rb +16 -0
- data/lib/chats/inbox.rb +303 -0
- data/lib/chats/inbox_group.rb +75 -0
- data/lib/chats/macros.rb +24 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +81 -2
- data/lib/chats/models/conversation.rb +59 -5
- data/lib/chats/models/message.rb +67 -3
- data/lib/chats/models/participant.rb +59 -0
- data/lib/chats/models/reaction.rb +5 -0
- data/lib/chats/subscribers.rb +156 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +114 -15
- data/lib/generators/chats/templates/add_author_to_chats_messages.rb.erb +44 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +18 -2
- data/lib/generators/chats/templates/initializer.rb +95 -14
- data/lib/generators/chats/upgrade_generator.rb +48 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c51ebc5c0e2da96363bb5b2e85d9a9c2a91e41f7ddb9d351ff10001654a48325
|
|
4
|
+
data.tar.gz: a06cde14d3953972e7061b2950b7a52c696f4292e25a2a91c9c7bb6ce7d269cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d23c31395edb382ec05fc6b7478a4e219b1878dc29f67092451101ef720bf2df556ae92976384665c0e108ca4607557ed9d1badf0af9357143182f1fdb57e6d
|
|
7
|
+
data.tar.gz: 3f02985c90410059af142a03fc3a3298540f71fb05d9b46991f39ea22999bfab64738f86beb92db7355e98c68e7962c2b6e79925335cf409d67842c1b1a7a5eb
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,172 @@ 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.2.0] - 2026-09-16
|
|
8
|
+
|
|
9
|
+
The release that makes `chats` a foundation other products can be built on:
|
|
10
|
+
a messager that isn't a person, a conversation whose openness belongs to its
|
|
11
|
+
subject, a message someone wrote on someone else's behalf, and extension
|
|
12
|
+
points that don't require ejecting a screen. **Nothing here changes existing
|
|
13
|
+
behaviour until you set an option** — 0.1.1 installs upgrade by running
|
|
14
|
+
`rails generate chats:upgrade && rails db:migrate`.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Headless messagers.** `acts_as_messager notifications: false, blockable:
|
|
18
|
+
false, inbox: :grouped` — a support desk, a bot, an org mailbox. Class
|
|
19
|
+
predicates (`chat_notifications?`, `chat_blockable?`, `chat_inbox_mode`,
|
|
20
|
+
`chat_group_path`) are read duck-typed everywhere, `Participant#
|
|
21
|
+
notifiable_for?` honours them, and the bundled views hide block/report
|
|
22
|
+
affordances against a non-blockable counterpart. Hosts stop writing
|
|
23
|
+
`is_a?(User)` in every notifier and view.
|
|
24
|
+
- **Subject-owned locks.** `Chats::ChatSubject#chat_locked?` /
|
|
25
|
+
`#chat_locked_notice` (both inert by default) decide whether a conversation
|
|
26
|
+
still accepts messages; `Conversation#locked?` / `#locked_notice` read
|
|
27
|
+
them, and `Chats::Message` refuses non-system writes with an `:locked`
|
|
28
|
+
error, and every OTHER write refuses too — `Message#edit!`,
|
|
29
|
+
`#soft_delete!` and `Reaction.toggle!` raise `Chats::LockedError` (a
|
|
30
|
+
`NotAllowedError` subclass), and the edit/delete/react endpoints answer 422
|
|
31
|
+
with the notice. The bundled bubble stops offering what would only fail:
|
|
32
|
+
no Edit, no Delete, no reaction toggles, while existing reactions still
|
|
33
|
+
render as plain counts and Copy still works. Moderation is the one
|
|
34
|
+
exception — `remove_reported_field!` removes reported content from a locked
|
|
35
|
+
conversation, because a product lock must never shield it. The thread stays
|
|
36
|
+
readable: the composer is replaced by the notice
|
|
37
|
+
(`chats/conversations/_locked_composer`, overridable through the
|
|
38
|
+
`locked_composer` slot), and a send that lands on a freshly locked
|
|
39
|
+
conversation gets a **422 that swaps the composer** instead of an
|
|
40
|
+
exception. System messages are exempt, so your app can always explain the
|
|
41
|
+
lock in the thread it just closed.
|
|
42
|
+
- **Message authorship.** `chats_messages.author_type/author_id` (nullable,
|
|
43
|
+
polymorphic, indexed) plus `Message#author`, `#signed?`, `#authored_by?`
|
|
44
|
+
and `Messager#message!(…, author:)`. `sender` stays the seat; `author` is
|
|
45
|
+
who wrote it. Signed bubbles render a signature line ("— Lucía G."),
|
|
46
|
+
rewritable with `config.message_signature`. New generator: **`rails
|
|
47
|
+
generate chats:upgrade`** writes the migration (guarded, so it is a no-op
|
|
48
|
+
on a fresh 0.2.0 install, which already has the columns).
|
|
49
|
+
- **Grouped inbox rows.** `Chats::Inbox.for(viewer)` returns
|
|
50
|
+
`Chats::Conversation | Chats::InboxGroup` rows sorted by activity; every
|
|
51
|
+
direct thread with an `inbox: :grouped` counterpart folds into one stack
|
|
52
|
+
(`#messager`, `#conversations`, `#unread_count`, `#last_message`,
|
|
53
|
+
`#last_message_at`, `#open_count`). A stack of one links straight to its
|
|
54
|
+
thread, which gains a "see all" link back; a deeper stack opens
|
|
55
|
+
`GET /conversations?with=<signed gid>` (purpose `:chats_inbox_with`, minted
|
|
56
|
+
by `Chats.inbox_with_sgid`) or wherever `group_path:` points. Grouping
|
|
57
|
+
happens in ONE place. `config.inbox_limit` bounds ROWS, not
|
|
58
|
+
conversations: stacked threads are queried separately from ordinary ones,
|
|
59
|
+
so a desk with hundreds of open threads can never evict the rest of the
|
|
60
|
+
inbox, and a stack's `open_count`/`unread_count` are GLOBAL — two indexed
|
|
61
|
+
aggregates per stack, never per conversation and never by loading the
|
|
62
|
+
stack to count it. `Chats::Inbox#unread_count` is the stack-aware badge number;
|
|
63
|
+
`unread_chats_count` is unchanged.
|
|
64
|
+
- **`config.inbox_limit`** (200, replacing a literal in the controller) and
|
|
65
|
+
**`config.inbox_scope`** `->(relation, viewer) { relation }`, composed into
|
|
66
|
+
the inbox query before the limit.
|
|
67
|
+
- **View slots.** The bundled views render `chats/slots/_inbox_top`,
|
|
68
|
+
`_inbox_empty`, `_conversation_header_actions`, `_locked_composer` and
|
|
69
|
+
`_message_meta` when such a partial exists — one memoized lookup when it
|
|
70
|
+
doesn't. Hosts (and engines mounted on top of chats) add a row or a button
|
|
71
|
+
without ejecting a screen.
|
|
72
|
+
- **Subscribers.** `Chats.on(:message_created | :conversation_created |
|
|
73
|
+
:participant_left | :conversation_read)` replaces the single notifier
|
|
74
|
+
proc: many subscribers per event, each isolated through
|
|
75
|
+
`Rails.error.report(e, handled: true, context: { event: })` so a failing
|
|
76
|
+
one is *visible* and never stops the others or the write that emitted
|
|
77
|
+
them. Registration is reload-safe (`key:` replaces in place;
|
|
78
|
+
`Chats.reset_subscribers!` clears). Two NEW events:
|
|
79
|
+
`:conversation_created` (once per conversation, never on resume) and
|
|
80
|
+
`:participant_left`.
|
|
81
|
+
- **`config.messager_url`** `->(messager) { nil }` — the bundled views link
|
|
82
|
+
names and titles to it, and render plain text when it returns nil. The gem
|
|
83
|
+
no longer assumes a host has `user_path`.
|
|
84
|
+
- **`Participant#reseat!(new_messager)`** — hand a seat to another messager
|
|
85
|
+
inside a transaction, keeping the read horizon, the role and the history,
|
|
86
|
+
and re-indexing a direct thread's `direct_key` so `chat_with` keeps
|
|
87
|
+
resolving to it instead of stranding a duplicate. Refuses with
|
|
88
|
+
`Chats::NotAllowedError` when the resulting pair already has a direct
|
|
89
|
+
conversation, checked BEFORE the write so a unique-index violation can
|
|
90
|
+
never poison a host's transaction.
|
|
91
|
+
- **Inbox missed-broadcast recovery** (`chats--refresh-inbox` controller): the
|
|
92
|
+
inbox already receives Turbo 8 page *refreshes*, but Action Cable has no
|
|
93
|
+
replay — a refresh broadcast sent while the client's socket was down
|
|
94
|
+
(backgrounded tab/app, network blip) was lost and the inbox sat stale until
|
|
95
|
+
the user navigated. The new controller re-runs the same page refresh on
|
|
96
|
+
cable reconnect and on return-to-visible, extending the thread's
|
|
97
|
+
stale-catch-up doctrine (`docs/campfire_review.md`) to the inbox. It reuses
|
|
98
|
+
the thread's channel-free reconnect detection (observing the
|
|
99
|
+
`<turbo-cable-stream-source>` `connected` attribute), so no new Action Cable
|
|
100
|
+
channel is introduced. Auto-registered via the engine importmap pin; hosts
|
|
101
|
+
need zero changes.
|
|
102
|
+
|
|
103
|
+
### Changed
|
|
104
|
+
- `config.notifier` is **deprecated** (removed in 1.0). It still works and
|
|
105
|
+
receives `:message_created` and `:conversation_read` — the two events 0.1.1
|
|
106
|
+
had — and ONLY those: the events added in 0.2.0 are `Chats.on`-only, so a
|
|
107
|
+
0.1.x hook written `->(event, message:, **)` can never be handed an event
|
|
108
|
+
it has no keyword for. It registers as a subscriber under a reserved key,
|
|
109
|
+
so re-assigning it replaces rather than stacks, and warns through
|
|
110
|
+
`Chats.deprecator`, which the engine registers with
|
|
111
|
+
`Rails.application.deprecators`.
|
|
112
|
+
**If your test environment sets `config.active_support.deprecation =
|
|
113
|
+
:raise`** (a common default) and you still assign `config.notifier`, that
|
|
114
|
+
warning now raises at boot, because the engine registers the gem's
|
|
115
|
+
deprecator with the app. Either move the hook to `Chats.on` — the migration
|
|
116
|
+
is one line — or silence just this one:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# config/initializers/chats.rb
|
|
120
|
+
Chats.deprecator.silence do
|
|
121
|
+
Chats.configure { |config| config.notifier = ->(event, **payload) { … } }
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
- The install migration now creates the `author` columns, so a fresh install
|
|
125
|
+
needs no upgrade step.
|
|
126
|
+
- **If you ejected the inbox or the composer under 0.1.x**, nothing breaks:
|
|
127
|
+
`ConversationsController#index` still assigns `@conversations` (the flat,
|
|
128
|
+
unstacked list an ejected inbox loops over), and an ejected composer simply
|
|
129
|
+
misses the DOM id the locked-composer swap targets — the 422 is then a
|
|
130
|
+
no-op instead of a replace. Re-eject (or delete) those two files to pick up
|
|
131
|
+
stacked rows and locked composers.
|
|
132
|
+
|
|
133
|
+
### Fixed
|
|
134
|
+
- **A host's own locale file no longer loses to the gem's.** The engine
|
|
135
|
+
appended its `config/locales` onto the application's `i18n.load_path` on
|
|
136
|
+
top of Rails' own `:add_locales`. Railtie paths are unshifted ahead of
|
|
137
|
+
everything, so that second copy landed *after* the host's files and
|
|
138
|
+
silently overrode them — a host rewording `chats.flashes.blocked` in its
|
|
139
|
+
own `es.yml` kept reading ours, with no error to see. Gem first, host last,
|
|
140
|
+
pinned by a test that ships a host override in the dummy app.
|
|
141
|
+
- **Migrations name every adapter they actually run on.** `json_column_type`
|
|
142
|
+
matched `"postgresql"`, which activerecord-postgis-adapter never reports
|
|
143
|
+
(it answers `"PostGIS"`), so PostGIS hosts silently got `json` where the
|
|
144
|
+
gem meant `jsonb`. `json_column_default` matched `/mysql/`, which misses
|
|
145
|
+
Trilogy (Rails reports `"Trilogy"`), handing those hosts a default MySQL
|
|
146
|
+
rejects. Both now match by prefix and by both spellings.
|
|
147
|
+
- **The thread's missed-broadcast recovery never took effect for a deep
|
|
148
|
+
backlog.** `ConversationsController#refresh` answered with `render html:
|
|
149
|
+
… content_type: "text/vnd.turbo-stream.html"`, and `render html:` forces
|
|
150
|
+
`text/html` and ignores the content type — so the response said
|
|
151
|
+
`<turbo-stream action="refresh">` in a body nothing would treat as a
|
|
152
|
+
stream. It now renders `turbo_stream.refresh(request_id: nil)`; the nil
|
|
153
|
+
request id matters, because Turbo skips a refresh tagged with a request id
|
|
154
|
+
it recognizes as its own, and this response answers the client's own
|
|
155
|
+
catch-up fetch. The failure was invisible by construction: a recovery path
|
|
156
|
+
that does nothing looks exactly like the staleness it exists to fix.
|
|
157
|
+
- `:participant_added` was documented as a notifier event but never emitted.
|
|
158
|
+
The event catalogue is now exactly what the gem fires, and registering for
|
|
159
|
+
anything else raises at boot with the valid list.
|
|
160
|
+
- **A signed message is its author's to answer for.** `Message#reported_owner`
|
|
161
|
+
now returns `author || sender`. With authorship, an answer sent from a
|
|
162
|
+
headless seat (a support desk) carries a human author, and a host's
|
|
163
|
+
moderation `owner` is typed to its user class — a desk there raised an
|
|
164
|
+
association type mismatch from inside the agent's own reply the first time
|
|
165
|
+
a text filter tripped, and the moderation screens then asked the desk for
|
|
166
|
+
an avatar it does not have.
|
|
167
|
+
- **`jsonb` on PostGIS.** The install migration decided jsonb-or-json with
|
|
168
|
+
`adapter_name.downcase.include?("postgresql")`, and activerecord-postgis-
|
|
169
|
+
adapter answers `"PostGIS"`, so PostGIS hosts silently got plain `json`
|
|
170
|
+
columns. The template now matches the prefix (`/\Apostg/i`). Existing
|
|
171
|
+
installs are unaffected; a host that wants jsonb can `change_column` it.
|
|
172
|
+
|
|
7
173
|
## [0.1.1] - 2026-06-10
|
|
8
174
|
|
|
9
175
|
Reliability + UX patterns adopted after a deep review of Basecamp's
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
`chats` gives your Rails app **Instagram-class user-to-user messaging**: direct messages, group chats, image attachments, emoji reactions, read receipts, unread badges, and typing indicators — all real-time, all server-rendered.
|
|
9
9
|
|
|
10
|
-
It's **Hotwire-native**: messages stream live over Turbo Streams + Action Cable, the inbox refreshes itself with Turbo 8 morphing, and the only JavaScript is
|
|
10
|
+
It's **Hotwire-native**: messages stream live over Turbo Streams + Action Cable, the inbox refreshes itself with Turbo 8 morphing, and the only JavaScript is a few tiny Stimulus controllers the gem ships and registers for you. No SPA, no build step, no custom WebSocket code — and everything degrades gracefully to plain request/response when WebSockets are down. Both the thread and the inbox self-heal after a missed broadcast (cable reconnect / return-to-visible), so a client that slept through a WebSocket drop still catches up.
|
|
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
|
|
|
@@ -66,7 +66,7 @@ end
|
|
|
66
66
|
mount Chats::Engine => "/messages"
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
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
|
|
69
|
+
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.
|
|
70
70
|
|
|
71
71
|
Drop a "Message" button anywhere — it renders only when the viewer is allowed to message that person:
|
|
72
72
|
|
|
@@ -86,6 +86,9 @@ And a live unread badge in your nav:
|
|
|
86
86
|
|
|
87
87
|
**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
88
|
|
|
89
|
+
> [!NOTE]
|
|
90
|
+
> **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.
|
|
91
|
+
|
|
89
92
|
## 🧱 The data model
|
|
90
93
|
|
|
91
94
|
Five concepts, namespaced and polymorphic from day one (no hard `User` coupling anywhere):
|
|
@@ -94,7 +97,7 @@ Five concepts, namespaced and polymorphic from day one (no hard `User` coupling
|
|
|
94
97
|
- **`Chats::Participant`** — a messager's seat in a conversation. Holds role, read horizon, mute, soft-leave, and notification bookkeeping.
|
|
95
98
|
- **`Chats::Message`** — `text` (human) or `system` (posted by your app). Soft-deletes to a tombstone. Attachments via ActiveStorage.
|
|
96
99
|
- **`Chats::Reaction`** — one row per (message, reactor, emoji); tap-to-toggle, race-safe.
|
|
97
|
-
- **Any model with `acts_as_messager`** — users, organizations, support
|
|
100
|
+
- **Any model with `acts_as_messager`** — users, organizations, support desks, bots: participants and senders are polymorphic. A messager that is not a person declares it (`notifications: false, blockable: false, inbox: :grouped`) and the gem stops treating it like one. See [`support_desk`](https://github.com/rameerez/support_desk) for the worked example.
|
|
98
101
|
|
|
99
102
|
Two deliberate design decisions worth knowing:
|
|
100
103
|
|
|
@@ -201,30 +204,32 @@ Moderate::Flag.flag!(
|
|
|
201
204
|
- [ ] admin queue handles chat flags/reports (it does, automatically — verify with one test)
|
|
202
205
|
- [ ] a test that a block placed mid-conversation stops the next send
|
|
203
206
|
|
|
204
|
-
## 🔔
|
|
207
|
+
## 🔔 Events: subscribe to the domain moments
|
|
205
208
|
|
|
206
|
-
`chats` fires domain moments
|
|
209
|
+
`chats` fires domain moments at subscribers — it does **not** build its own notification bus:
|
|
207
210
|
|
|
208
211
|
```ruby
|
|
209
|
-
config.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
when :conversation_read
|
|
215
|
-
# payload: conversation:, participant: — fired when a read actually
|
|
216
|
-
# consumed unread content. Use it to keep EXTERNAL notification
|
|
217
|
-
# surfaces truthful: e.g. mark this chat's rows read in your
|
|
218
|
-
# notification center the moment the thread is read, so a bell badge
|
|
219
|
-
# doesn't keep advertising messages the user has already seen.
|
|
220
|
-
end
|
|
221
|
-
}
|
|
212
|
+
# config/initializers/chats.rb (or anywhere that runs at boot)
|
|
213
|
+
Chats.on(:message_created) { |message| NewMessageNotifier.with(record: message).deliver }
|
|
214
|
+
Chats.on(:conversation_created) { |conversation| Analytics.track("chat_started", conversation) }
|
|
215
|
+
Chats.on(:participant_left) { |participant| AuditLog.log("chat_left", participant) }
|
|
216
|
+
Chats.on(:conversation_read) { |conversation:, participant:| Bell.mark_read(participant.messager, conversation) }
|
|
222
217
|
```
|
|
223
218
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
219
|
+
Four properties, all of which matter the first time something goes wrong at 3am:
|
|
220
|
+
|
|
221
|
+
- **Many subscribers per event.** Your mailer, your analytics and your audit log don't have to share one `case` statement.
|
|
222
|
+
- **Each one is isolated.** A raising subscriber is reported through `Rails.error.report(e, handled: true, context: { event: })` — *visible*, not swallowed — and the next subscriber still runs. The message is already committed; notifications are best-effort fan-out.
|
|
223
|
+
- **Reload-safe.** Registering from reloadable code? Pass a key and a reload replaces the subscriber instead of stacking a second one:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
Rails.application.config.to_prepare do
|
|
227
|
+
Chats.on(:message_created, key: :unread_email) { |message| … }
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
- **Unknown events fail loudly**, at boot, naming the ones that exist.
|
|
231
|
+
|
|
232
|
+
> **Deprecated:** `config.notifier = ->(event, **payload) {}` still works and will be removed in 1.0. It receives `:message_created` and `:conversation_read` — the two events that existed in 0.1.1 — and *only* those, so an old `->(event, message:, **)` hook can never start raising on an event it was never written for. The events added in 0.2.0 are `Chats.on`-only. Move it to `Chats.on` — that's the whole migration.
|
|
228
233
|
|
|
229
234
|
The etiquette helpers every messaging product needs ship on the participant, so a debounced "email me only once until I come back" digest is a tiny host job:
|
|
230
235
|
|
|
@@ -232,7 +237,7 @@ The etiquette helpers every messaging product needs ship on the participant, so
|
|
|
232
237
|
class ChatsUnreadEmailJob < ApplicationJob
|
|
233
238
|
def perform(message)
|
|
234
239
|
message.conversation.participants.active.each do |participant|
|
|
235
|
-
next unless participant.notifiable_for?(message) # not the sender, not muted, not departed
|
|
240
|
+
next unless participant.notifiable_for?(message) # not the sender, not muted, not departed, not headless
|
|
236
241
|
next unless participant.should_notify? # unread + not already notified this burst
|
|
237
242
|
|
|
238
243
|
ChatsMailer.with(participant: participant).unread_messages.deliver_now
|
|
@@ -241,9 +246,7 @@ class ChatsUnreadEmailJob < ApplicationJob
|
|
|
241
246
|
end
|
|
242
247
|
end
|
|
243
248
|
|
|
244
|
-
|
|
245
|
-
ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) if event == :message_created
|
|
246
|
-
}
|
|
249
|
+
Chats.on(:message_created) { |message| ChatsUnreadEmailJob.set(wait: 10.minutes).perform_later(message) }
|
|
247
250
|
```
|
|
248
251
|
|
|
249
252
|
And it works in the other direction too — your app can post **into** conversations:
|
|
@@ -252,6 +255,111 @@ And it works in the other direction too — your app can post **into** conversat
|
|
|
252
255
|
ride.chat_conversations.find_each { |c| c.post_system_message!("Your ride was cancelled") }
|
|
253
256
|
```
|
|
254
257
|
|
|
258
|
+
## 🤖 Headless messagers: desks, bots, storefronts
|
|
259
|
+
|
|
260
|
+
Not every messager is a person. A support desk, an order bot or an organization mailbox converses like anyone else but must never be notified, can't meaningfully be blocked, and shouldn't fill the inbox with one row per thread. Say so once, on the model:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
class SupportDesk < ApplicationRecord
|
|
264
|
+
acts_as_messager notifications: false, # Participant#notifiable_for? says no, always
|
|
265
|
+
blockable: false, # the views hide block/report affordances
|
|
266
|
+
inbox: :grouped # every thread with it is ONE inbox row
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
That's the whole point of the option: **your notifiers and views stop asking `is_a?(User)`**. The predicates are on the class (`SupportDesk.chat_notifications?`, `.chat_blockable?`, `.chat_inbox_mode`) and duck-typed everywhere the gem reads them, so an ordinary `acts_as_messager` model behaves exactly as it always did.
|
|
271
|
+
|
|
272
|
+
## 🗂️ Grouped inbox rows
|
|
273
|
+
|
|
274
|
+
With `inbox: :grouped`, every direct conversation a viewer has with that messager folds into a single inbox row — a stack:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
inbox = Chats::Inbox.for(current_user) # [Chats::Conversation | Chats::InboxGroup], newest activity first
|
|
278
|
+
inbox.unread_count # the stack-aware badge number
|
|
279
|
+
|
|
280
|
+
group = inbox.rows.first
|
|
281
|
+
group.messager # the desk
|
|
282
|
+
group.conversations # the stacked threads, freshest first
|
|
283
|
+
group.unread_count # aggregated across the stack
|
|
284
|
+
group.open_count # how many are in it
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- `config.inbox_limit` bounds **rows**, not conversations: stacked threads are queried separately from ordinary ones, so a desk with 500 open tickets can never evict your friends from the inbox. A stack's `open_count` and `unread_count` are **global** — two indexed aggregates per stack, however deep it runs — so stacking neither goes N+1 nor loads a stack to count it.
|
|
288
|
+
- A stack of one links **straight to the thread**, which then carries a small "see all" link back to the stack.
|
|
289
|
+
- The stack list is chats' own filtered inbox — `GET /conversations?with=<signed gid>` — unless you point it somewhere else with `group_path: ->(viewer) { support_path }`.
|
|
290
|
+
- Two knobs shape the whole query: `config.inbox_limit` (200) and `config.inbox_scope = ->(relation, viewer) { relation }`.
|
|
291
|
+
|
|
292
|
+
`user.unread_chats_count` is unchanged (it counts conversations); `Chats::Inbox#unread_count` is the stack-aware number for badges.
|
|
293
|
+
|
|
294
|
+
## 🔒 Locked conversations
|
|
295
|
+
|
|
296
|
+
Whether a conversation still takes messages belongs to the thing it's **about** — a closed ticket, a delivered order, an archived listing. The subject already owns the conversation's meaning; it owns its openness too:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
class Ticket < ApplicationRecord
|
|
300
|
+
acts_as_chat_subject
|
|
301
|
+
|
|
302
|
+
def chat_locked? = closed?
|
|
303
|
+
def chat_locked_notice = "This ticket is closed. Reply to reopen it."
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
- `Chats::Message` refuses new messages with an `:locked` error; `Conversation#locked?` and `#locked_notice` read the subject.
|
|
308
|
+
- **System messages are exempt**: your app can always post "This ticket was closed" into the thread it just closed.
|
|
309
|
+
- 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.
|
|
310
|
+
- 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.
|
|
311
|
+
|
|
312
|
+
## ✍️ Signed messages
|
|
313
|
+
|
|
314
|
+
`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:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
desk.message!(alice, "On it!", author: lucia) # sender: the desk, author: Lucía
|
|
318
|
+
message.signed? # true — author present and not the sender
|
|
319
|
+
message.authored_by?(lucia)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The bundled bubble renders a signature line ("— Lucía G.") via `Chats.display_name_for`; `config.message_signature = ->(message) { … }` rewrites it. Ordinary messages have no author and render exactly as before.
|
|
323
|
+
|
|
324
|
+
Existing installs get the columns with one command:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
rails generate chats:upgrade && rails db:migrate
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## 🔌 View slots
|
|
331
|
+
|
|
332
|
+
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:
|
|
333
|
+
|
|
334
|
+
| slot | where it renders |
|
|
335
|
+
|---|---|
|
|
336
|
+
| `inbox_top` | above the first inbox row |
|
|
337
|
+
| `inbox_empty` | inside the empty state |
|
|
338
|
+
| `conversation_header_actions` | the thread's menu (gets `blockable:`) |
|
|
339
|
+
| `locked_composer` | the locked composer's body |
|
|
340
|
+
| `message_meta` | after each bubble's timestamp |
|
|
341
|
+
|
|
342
|
+
```erb
|
|
343
|
+
<%# app/views/chats/slots/_inbox_top.html.erb %>
|
|
344
|
+
<%= link_to "Need help? Write to us", support_path, class: "support-door" %>
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
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.
|
|
348
|
+
|
|
349
|
+
## 🔗 Profile links
|
|
350
|
+
|
|
351
|
+
`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:
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
config.messager_url = lambda do |messager|
|
|
355
|
+
routes = Rails.application.routes.url_helpers
|
|
356
|
+
|
|
357
|
+
case messager
|
|
358
|
+
when User then routes.user_path(messager) # a desk or a bot has no profile: nil
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
```
|
|
362
|
+
|
|
255
363
|
## 🎨 Make it yours
|
|
256
364
|
|
|
257
365
|
The bundled UI is intentionally framework-free (semantic `chats-*` classes + one self-contained stylesheet, themed with CSS variables):
|
|
@@ -269,7 +377,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
|
|
|
269
377
|
rails generate chats:views
|
|
270
378
|
```
|
|
271
379
|
|
|
272
|
-
Override
|
|
380
|
+
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/refresh_inbox_controller`.
|
|
273
381
|
|
|
274
382
|
## Configuration reference
|
|
275
383
|
|
|
@@ -308,13 +416,19 @@ Chats.configure do |config|
|
|
|
308
416
|
config.can_message = ->(sender, recipient) { true }
|
|
309
417
|
config.can_create_group = ->(creator) { true }
|
|
310
418
|
|
|
419
|
+
# Inbox shaping
|
|
420
|
+
config.inbox_limit = 200
|
|
421
|
+
config.inbox_scope = ->(relation, viewer) { relation }
|
|
422
|
+
|
|
311
423
|
# Ecosystem seams (no-op defaults; chats runs standalone)
|
|
312
424
|
config.blocked_messager_ids = ->(messager) { [] }
|
|
313
|
-
config.notifier = ->(event, **payload) {}
|
|
425
|
+
config.notifier = ->(event, **payload) {} # DEPRECATED — use Chats.on
|
|
314
426
|
|
|
315
427
|
# Display (used by the bundled views)
|
|
316
428
|
config.messager_display_name = ->(messager) { messager.display_name }
|
|
317
429
|
config.messager_avatar = ->(messager) { messager.avatar } # URL/attachment/variant or nil
|
|
430
|
+
config.messager_url = ->(messager) { nil } # nil ⇒ names render as plain text
|
|
431
|
+
config.message_signature = nil # ->(message) { } for signed bubbles
|
|
318
432
|
end
|
|
319
433
|
```
|
|
320
434
|
|
|
@@ -329,6 +443,8 @@ alice.message!(bob, "hi", about: ride) # send (resolves the thread)
|
|
|
329
443
|
alice.message!(conversation, "hi", files: []) # send into a conversation
|
|
330
444
|
alice.chats # inbox relation, newest first
|
|
331
445
|
alice.unread_chats_count # conversations with unread messages
|
|
446
|
+
alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
|
|
447
|
+
Chats::Inbox.for(alice) # [Conversation | InboxGroup] + #unread_count
|
|
332
448
|
|
|
333
449
|
# Conversations
|
|
334
450
|
conversation.participant?(user) # active membership
|
|
@@ -339,11 +455,14 @@ conversation.unread_count_for(user)
|
|
|
339
455
|
conversation.mark_read_by!(user)
|
|
340
456
|
conversation.post_system_message!("Ride cancelled")
|
|
341
457
|
conversation.add_participant!(user) # idempotent, race-safe
|
|
458
|
+
conversation.locked? # the SUBJECT decides (chat_locked?)
|
|
459
|
+
conversation.locked_notice # why, in words
|
|
342
460
|
|
|
343
461
|
# Messages
|
|
344
462
|
message.edit!("fixed") # stamps edited_at
|
|
345
463
|
message.soft_delete! # tombstone (or destroy, per config)
|
|
346
464
|
message.read_by?(user)
|
|
465
|
+
message.signed? / message.authored_by?(lucia) # authorship
|
|
347
466
|
Chats::Reaction.toggle!(message:, reactor:, emoji: "👍")
|
|
348
467
|
|
|
349
468
|
# Participants (the per-member state)
|
|
@@ -352,10 +471,26 @@ participant.mute! / participant.unmute!
|
|
|
352
471
|
participant.leave! # groups
|
|
353
472
|
participant.notifiable_for?(message) # notification etiquette
|
|
354
473
|
participant.should_notify? / participant.mark_notified!
|
|
474
|
+
participant.reseat!(new_messager) # hand the seat over, read horizon intact
|
|
475
|
+
|
|
476
|
+
# Events
|
|
477
|
+
Chats.on(:message_created) { |message| } # also :conversation_created,
|
|
478
|
+
# :participant_left, :conversation_read
|
|
355
479
|
```
|
|
356
480
|
|
|
357
481
|
Errors are namespaced and meaningful: `Chats::BlockedError`, `Chats::NotAllowedError`, `Chats::ConfigurationError` — all under `Chats::Error`.
|
|
358
482
|
|
|
483
|
+
## Upgrading
|
|
484
|
+
|
|
485
|
+
`chats` ships the migrations a version bump needs; your initializer and views stay yours:
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
rails generate chats:upgrade # 0.2.0: message authorship columns
|
|
489
|
+
rails db:migrate
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Nothing in 0.2.0 changes behaviour until you set an option — see the [CHANGELOG](CHANGELOG.md).
|
|
493
|
+
|
|
359
494
|
## Database support
|
|
360
495
|
|
|
361
496
|
PostgreSQL, MySQL, and SQLite. The migration adapts automatically: it honors your app's configured primary key type (**uuid or bigint** — same detection `rails g model` uses), picks `jsonb` on Postgres / `json` elsewhere, and handles MySQL's no-defaults-on-JSON rule. Works on Rails 7.1+ and shines on the Rails 8 omakase.
|
|
@@ -816,3 +816,47 @@ turbo-frame[id^="chats_page_"] {
|
|
|
816
816
|
border-radius: 0.5rem;
|
|
817
817
|
}
|
|
818
818
|
.chats-emoji:hover { background: var(--chats-surface); }
|
|
819
|
+
|
|
820
|
+
/* ---------------------------------------------------------------------------
|
|
821
|
+
0.2.0 — stacked inbox rows, locked composers, signed messages
|
|
822
|
+
--------------------------------------------------------------------------- */
|
|
823
|
+
|
|
824
|
+
/* A stack (Chats::InboxGroup) reads as one row; the count line sits where a
|
|
825
|
+
single conversation shows its subject. */
|
|
826
|
+
.chats-row--group .chats-row__subject { font-weight: 600; }
|
|
827
|
+
|
|
828
|
+
/* The inbox filtered to one counterpart (?with=). */
|
|
829
|
+
.chats-inbox__filter {
|
|
830
|
+
display: flex;
|
|
831
|
+
align-items: baseline;
|
|
832
|
+
gap: 0.5rem;
|
|
833
|
+
margin: 0 0 0.5rem;
|
|
834
|
+
font-size: 0.85rem;
|
|
835
|
+
color: var(--chats-text-muted);
|
|
836
|
+
}
|
|
837
|
+
.chats-inbox__filter-clear { color: inherit; text-decoration: underline; }
|
|
838
|
+
|
|
839
|
+
/* "See all" back to the stack, under the thread title. */
|
|
840
|
+
.chats-thread__see-all { margin: 0; font-size: 0.72rem; }
|
|
841
|
+
.chats-thread__see-all-link { color: var(--chats-text-muted); text-decoration: underline; }
|
|
842
|
+
|
|
843
|
+
/* Who wrote it, when that isn't the seat it came from. */
|
|
844
|
+
.chats-message__signature {
|
|
845
|
+
margin-top: 0.25rem;
|
|
846
|
+
font-size: 0.7rem;
|
|
847
|
+
font-style: italic;
|
|
848
|
+
opacity: 0.75;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/* The composer, replaced by the reason the conversation is closed. Same box,
|
|
852
|
+
so the thread doesn't jump when a subject locks under an open composer. */
|
|
853
|
+
.chats-composer--locked {
|
|
854
|
+
display: block;
|
|
855
|
+
padding: 0.85rem 1rem;
|
|
856
|
+
text-align: center;
|
|
857
|
+
}
|
|
858
|
+
.chats-composer__locked-notice {
|
|
859
|
+
margin: 0;
|
|
860
|
+
font-size: 0.85rem;
|
|
861
|
+
color: var(--chats-text-muted);
|
|
862
|
+
}
|
|
@@ -5,16 +5,22 @@ module Chats
|
|
|
5
5
|
# pages (create), and the per-member actions (read/typing/leave/mute).
|
|
6
6
|
class ConversationsController < ApplicationController
|
|
7
7
|
before_action :set_conversation, only: %i[show read typing leave mute unmute refresh]
|
|
8
|
+
helper_method :chats_counterpart
|
|
8
9
|
|
|
9
10
|
# The inbox. Everything is preloaded/batched so rendering N rows costs a
|
|
10
11
|
# constant number of queries (conversations + last messages + participants
|
|
11
12
|
# + one grouped unread-count query — see Conversation.unread_counts_for).
|
|
13
|
+
# Rows are Conversation | InboxGroup: stacking, search and the `?with=`
|
|
14
|
+
# filter all live in Chats::Inbox, so this action stays three lines.
|
|
12
15
|
def index
|
|
13
|
-
@
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@
|
|
16
|
+
@inbox = Chats::Inbox.for(chats_current_messager, query: params[:q], with: inbox_filter)
|
|
17
|
+
@rows = @inbox.rows
|
|
18
|
+
@unread_counts = @inbox.unread_counts
|
|
19
|
+
# Back-compat for inboxes ejected under 0.1.x, which loop over
|
|
20
|
+
# @conversations: they keep rendering the flat list (no stacking, i.e.
|
|
21
|
+
# exactly what they rendered before). Re-eject or delete your copy to
|
|
22
|
+
# get the stacked rows.
|
|
23
|
+
@conversations = @inbox.conversations
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
# The thread. Renders the LATEST page of messages; older pages stream in
|
|
@@ -76,11 +82,18 @@ module Chats
|
|
|
76
82
|
# A backlog deeper than one page would mean splicing an arbitrary
|
|
77
83
|
# amount of history through surgical appends; a Turbo 8 page refresh
|
|
78
84
|
# (morph + scroll preservation) re-renders the latest page + frame
|
|
79
|
-
# chain correctly instead.
|
|
80
|
-
#
|
|
85
|
+
# chain correctly instead.
|
|
86
|
+
#
|
|
87
|
+
# `render turbo_stream:`, NOT `render html: … content_type:` — the
|
|
88
|
+
# latter forces text/html and silently ignores the content type, so the
|
|
89
|
+
# body says <turbo-stream> while the response says it isn't one.
|
|
90
|
+
#
|
|
91
|
+
# `request_id: nil` on purpose: Turbo skips a refresh tagged with a
|
|
92
|
+
# request id it recognizes as its own, and this response is the answer
|
|
93
|
+
# to the client's OWN catch-up fetch — the one client that must not
|
|
94
|
+
# skip it.
|
|
81
95
|
if @new_messages.size > Chats.config.messages_per_page
|
|
82
|
-
render
|
|
83
|
-
content_type: "text/vnd.turbo-stream.html"
|
|
96
|
+
render turbo_stream: turbo_stream.refresh(request_id: nil)
|
|
84
97
|
return
|
|
85
98
|
end
|
|
86
99
|
|
|
@@ -156,43 +169,30 @@ module Chats
|
|
|
156
169
|
GlobalID::Locator.locate_signed(sgid, for: purpose) || raise(ActiveRecord::RecordNotFound)
|
|
157
170
|
end
|
|
158
171
|
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# full-text dependency is needed for this scale.
|
|
165
|
-
def apply_search(conversations)
|
|
166
|
-
return conversations unless Chats.config.search
|
|
167
|
-
|
|
168
|
-
query = params[:q].to_s.strip
|
|
169
|
-
return conversations if query.empty?
|
|
170
|
-
|
|
171
|
-
loaded = conversations.to_a
|
|
172
|
-
normalized_query = query.downcase
|
|
173
|
-
pattern = "%#{Chats::Conversation.sanitize_sql_like(query.downcase)}%"
|
|
174
|
-
message_match_ids =
|
|
175
|
-
if Chats.config.encrypt_messages
|
|
176
|
-
[]
|
|
177
|
-
else
|
|
178
|
-
Chats::Message.where(conversation_id: loaded.map(&:id), deleted_at: nil)
|
|
179
|
-
.where("LOWER(chats_messages.body) LIKE ?", pattern)
|
|
180
|
-
.distinct
|
|
181
|
-
.pluck(:conversation_id)
|
|
182
|
-
end
|
|
172
|
+
# `?with=<signed gid>` — the inbox filtered to one counterpart (a stack's
|
|
173
|
+
# contents). Signed and purpose-scoped like every other polymorphic
|
|
174
|
+
# param the engine accepts; a tampered one is a plain 404.
|
|
175
|
+
def inbox_filter
|
|
176
|
+
return nil if params[:with].blank?
|
|
183
177
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
searchable_metadata(conversation).downcase.include?(normalized_query)
|
|
187
|
-
end
|
|
188
|
-
end
|
|
178
|
+
messager = locate_signed!(params[:with], purpose: :chats_inbox_with)
|
|
179
|
+
raise ActiveRecord::RecordNotFound unless Chats.messager_class?(messager.class)
|
|
189
180
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
Chats.display_name_for(participant.messager) if participant.active?
|
|
193
|
-
end
|
|
181
|
+
messager
|
|
182
|
+
end
|
|
194
183
|
|
|
195
|
-
|
|
184
|
+
# The other party of a direct thread (nil for groups) — the thread header
|
|
185
|
+
# names them, links to their profile, and decides whether to offer the
|
|
186
|
+
# "see all" link back to their stack. LAZY and memoized: a group thread
|
|
187
|
+
# never pays for it, and a direct one pays once no matter how many of
|
|
188
|
+
# those three things the rendered view asks for.
|
|
189
|
+
def chats_counterpart
|
|
190
|
+
return @chats_counterpart if defined?(@chats_counterpart)
|
|
191
|
+
|
|
192
|
+
@chats_counterpart =
|
|
193
|
+
if @conversation&.direct?
|
|
194
|
+
@conversation.other_participants(chats_current_messager).includes(:messager).first&.messager
|
|
195
|
+
end
|
|
196
196
|
end
|
|
197
197
|
end
|
|
198
198
|
end
|