chats 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +233 -0
- data/README.md +214 -28
- data/app/assets/stylesheets/chats.css +86 -0
- data/app/controllers/chats/conversations_controller.rb +39 -42
- 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 +113 -2
- data/app/javascript/chats/refresh_inbox_controller.js +86 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +7 -1
- data/app/views/chats/conversations/_group.html.erb +41 -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 +48 -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/app/views/chats/shared/_verified_badge.html.erb +32 -0
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +13 -0
- data/config/locales/es.yml +13 -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 +79 -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 +33 -1
- data/lib/chats/models/concerns/chat_subject.rb +23 -0
- data/lib/chats/models/concerns/messager.rb +99 -2
- data/lib/chats/models/conversation.rb +78 -7
- 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 +122 -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 +106 -14
- data/lib/generators/chats/upgrade_generator.rb +48 -0
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0e116038dc60b864f51b65edb4f5b879305da0cabef4c8aa2db73e23464c674
|
|
4
|
+
data.tar.gz: '0179846f4e07b60cb1d07a9d50909a836456794636da584a6178843f0d39f8ff'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c3dc69a2bdba881bc7fc6543bc768885797e121f8d23b619e72a578cb3e7c6555c9b19c6dde52bec3085efe52d98e40dc277af932015bb08f65a7aaf67ca5c1
|
|
7
|
+
data.tar.gz: 4ab1a0ae08133bbe02240e12b1efa8efccb6dfe5bfbf53a1662acce5542a74ec4bb3adfc1eeaf10e6db94a961b17dc81523e8b2ac5044fa02ff22bba6904e303
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,239 @@ 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.3.1] - 2026-09-16
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **The verified badge was a star, not a check.** 0.3.0 shipped a
|
|
11
|
+
five-pointed star as the "official account" mark, copied from a host icon
|
|
12
|
+
that was named `verified_badge` but drawn as a star. A star reads as
|
|
13
|
+
"favourite" or "rated", not "this account is really us", and at 14px a
|
|
14
|
+
solid star and a solid rosette are the same blob. It is now heroicons
|
|
15
|
+
`check-badge`: a scalloped rosette with a tick knocked out of it, which
|
|
16
|
+
is the mark people already read as verified. `fill-rule="evenodd"` is
|
|
17
|
+
what knocks the tick out — without it the rosette fills solid.
|
|
18
|
+
|
|
19
|
+
## [0.3.0] - 2026-09-16
|
|
20
|
+
|
|
21
|
+
Official accounts. Some counterparts are not people you met — they are a
|
|
22
|
+
support desk, an organization, a shop, a brand — and a person should be able
|
|
23
|
+
to tell at a glance. **Nothing changes until a model says so**: 0.2.x
|
|
24
|
+
installs upgrade by bumping the gem, with no migration and no new
|
|
25
|
+
configuration.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **`acts_as_messager verified: true`.** A messager declares itself an
|
|
29
|
+
OFFICIAL account, and every bundled view that shows its name marks it: the
|
|
30
|
+
inbox row, the stacked inbox row, and the thread header. `Klass.
|
|
31
|
+
chat_verified?` is the class predicate and `Chats.verified?(messager)`
|
|
32
|
+
reads it duck-typed — false for a plain model, a nil, a non-messager — so
|
|
33
|
+
hosts can badge their own screens without a class check. The option is
|
|
34
|
+
independent of the headless ones: a desk is usually headless *and*
|
|
35
|
+
official, a shop is usually official and nothing else. Unlike its boolean
|
|
36
|
+
neighbours it refuses to coerce — `verified: "false"` raises
|
|
37
|
+
`Chats::ConfigurationError` at boot rather than quietly verifying an
|
|
38
|
+
account, because a badge is a trust claim and not a display preference.
|
|
39
|
+
- **The badge itself**, `chats/shared/_verified_badge` — an inline rosette
|
|
40
|
+
that sizes itself from the text it sits beside. It is an image with a
|
|
41
|
+
name, not decoration: `role="img"` plus a localized label
|
|
42
|
+
(`chats.verified.label`, "Official account" / "Cuenta oficial"), and the
|
|
43
|
+
glyph is `aria-hidden` so a screen reader never announces it twice.
|
|
44
|
+
- **`config.verified_badge`** `->(messager) { markup }` — swap the glyph for
|
|
45
|
+
your design system's own, vary it per messager, or return nil for no badge.
|
|
46
|
+
The default (nil) renders the gem's rosette.
|
|
47
|
+
- **`--chats-verified`** (`#0284c7`) — the badge colour, a CSS custom
|
|
48
|
+
property like the rest of the gem's theming, inherited through
|
|
49
|
+
`currentColor`. Not the familiar `#1d9bf0`: the badge is a meaningful
|
|
50
|
+
graphic, so WCAG 1.4.11 asks 3:1 of it, and `#1d9bf0` is 3.00:1 on
|
|
51
|
+
`--chats-bg` but 2.73:1 on `--chats-surface` — the inbox row's HOVER
|
|
52
|
+
background, so it failed exactly while somebody was pointing at it.
|
|
53
|
+
`#0284c7` clears the bar on both grounds and on a dark one, so a host
|
|
54
|
+
inverting the palette inherits a badge that still passes.
|
|
55
|
+
`test/verified_badge_contrast_test.rb` computes it rather than trusting a
|
|
56
|
+
swatch.
|
|
57
|
+
- **`chats_verified_badge(messager)`** — the view helper behind all three
|
|
58
|
+
surfaces, available in host views too. Nil for everyone who hasn't
|
|
59
|
+
declared `verified: true`, so it is safe to drop next to any name
|
|
60
|
+
unconditionally.
|
|
61
|
+
- **`Chats::Conversation#counterpart_for(viewer)`** — the other messager in a
|
|
62
|
+
direct thread (nil for a group, and for a thread whose other seat left).
|
|
63
|
+
The title, the avatar and the badge on an inbox row now resolve the
|
|
64
|
+
counterpart through this one method, memoized per viewer, so a row that
|
|
65
|
+
cost one query in 0.2.0 still costs one.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
- The bundled inbox row wraps its title in `.chats-row__name`, and the thread
|
|
69
|
+
header wraps its name in `.chats-thread__name`, so a long name still
|
|
70
|
+
ellipsizes while the badge beside it stays visible. If you ejected these
|
|
71
|
+
views with `rails generate chats:views`, your copies are untouched and keep
|
|
72
|
+
working; re-run the generator only if you want the badge.
|
|
73
|
+
|
|
74
|
+
## [0.2.0] - 2026-09-16
|
|
75
|
+
|
|
76
|
+
The release that makes `chats` a foundation other products can be built on:
|
|
77
|
+
a messager that isn't a person, a conversation whose openness belongs to its
|
|
78
|
+
subject, a message someone wrote on someone else's behalf, and extension
|
|
79
|
+
points that don't require ejecting a screen. **Nothing here changes existing
|
|
80
|
+
behaviour until you set an option** — 0.1.1 installs upgrade by running
|
|
81
|
+
`rails generate chats:upgrade && rails db:migrate`.
|
|
82
|
+
|
|
83
|
+
### Added
|
|
84
|
+
- **Headless messagers.** `acts_as_messager notifications: false, blockable:
|
|
85
|
+
false, inbox: :grouped` — a support desk, a bot, an org mailbox. Class
|
|
86
|
+
predicates (`chat_notifications?`, `chat_blockable?`, `chat_inbox_mode`,
|
|
87
|
+
`chat_group_path`) are read duck-typed everywhere, `Participant#
|
|
88
|
+
notifiable_for?` honours them, and the bundled views hide block/report
|
|
89
|
+
affordances against a non-blockable counterpart. Hosts stop writing
|
|
90
|
+
`is_a?(User)` in every notifier and view.
|
|
91
|
+
- **Subject-owned locks.** `Chats::ChatSubject#chat_locked?` /
|
|
92
|
+
`#chat_locked_notice` (both inert by default) decide whether a conversation
|
|
93
|
+
still accepts messages; `Conversation#locked?` / `#locked_notice` read
|
|
94
|
+
them, and `Chats::Message` refuses non-system writes with an `:locked`
|
|
95
|
+
error, and every OTHER write refuses too — `Message#edit!`,
|
|
96
|
+
`#soft_delete!` and `Reaction.toggle!` raise `Chats::LockedError` (a
|
|
97
|
+
`NotAllowedError` subclass), and the edit/delete/react endpoints answer 422
|
|
98
|
+
with the notice. The bundled bubble stops offering what would only fail:
|
|
99
|
+
no Edit, no Delete, no reaction toggles, while existing reactions still
|
|
100
|
+
render as plain counts and Copy still works. Moderation is the one
|
|
101
|
+
exception — `remove_reported_field!` removes reported content from a locked
|
|
102
|
+
conversation, because a product lock must never shield it. The thread stays
|
|
103
|
+
readable: the composer is replaced by the notice
|
|
104
|
+
(`chats/conversations/_locked_composer`, overridable through the
|
|
105
|
+
`locked_composer` slot), and a send that lands on a freshly locked
|
|
106
|
+
conversation gets a **422 that swaps the composer** instead of an
|
|
107
|
+
exception. System messages are exempt, so your app can always explain the
|
|
108
|
+
lock in the thread it just closed.
|
|
109
|
+
- **Message authorship.** `chats_messages.author_type/author_id` (nullable,
|
|
110
|
+
polymorphic, indexed) plus `Message#author`, `#signed?`, `#authored_by?`
|
|
111
|
+
and `Messager#message!(…, author:)`. `sender` stays the seat; `author` is
|
|
112
|
+
who wrote it. Signed bubbles render a signature line ("— Lucía G."),
|
|
113
|
+
rewritable with `config.message_signature`. New generator: **`rails
|
|
114
|
+
generate chats:upgrade`** writes the migration (guarded, so it is a no-op
|
|
115
|
+
on a fresh 0.2.0 install, which already has the columns).
|
|
116
|
+
- **Grouped inbox rows.** `Chats::Inbox.for(viewer)` returns
|
|
117
|
+
`Chats::Conversation | Chats::InboxGroup` rows sorted by activity; every
|
|
118
|
+
direct thread with an `inbox: :grouped` counterpart folds into one stack
|
|
119
|
+
(`#messager`, `#conversations`, `#unread_count`, `#last_message`,
|
|
120
|
+
`#last_message_at`, `#open_count`). A stack of one links straight to its
|
|
121
|
+
thread, which gains a "see all" link back; a deeper stack opens
|
|
122
|
+
`GET /conversations?with=<signed gid>` (purpose `:chats_inbox_with`, minted
|
|
123
|
+
by `Chats.inbox_with_sgid`) or wherever `group_path:` points. Grouping
|
|
124
|
+
happens in ONE place. `config.inbox_limit` bounds ROWS, not
|
|
125
|
+
conversations: stacked threads are queried separately from ordinary ones,
|
|
126
|
+
so a desk with hundreds of open threads can never evict the rest of the
|
|
127
|
+
inbox, and a stack's `open_count`/`unread_count` are GLOBAL — two indexed
|
|
128
|
+
aggregates per stack, never per conversation and never by loading the
|
|
129
|
+
stack to count it. `Chats::Inbox#unread_count` is the stack-aware badge number;
|
|
130
|
+
`unread_chats_count` is unchanged.
|
|
131
|
+
- **`config.inbox_limit`** (200, replacing a literal in the controller) and
|
|
132
|
+
**`config.inbox_scope`** `->(relation, viewer) { relation }`, composed into
|
|
133
|
+
the inbox query before the limit.
|
|
134
|
+
- **View slots.** The bundled views render `chats/slots/_inbox_top`,
|
|
135
|
+
`_inbox_empty`, `_conversation_header_actions`, `_locked_composer` and
|
|
136
|
+
`_message_meta` when such a partial exists — one memoized lookup when it
|
|
137
|
+
doesn't. Hosts (and engines mounted on top of chats) add a row or a button
|
|
138
|
+
without ejecting a screen.
|
|
139
|
+
- **Subscribers.** `Chats.on(:message_created | :conversation_created |
|
|
140
|
+
:participant_left | :conversation_read)` replaces the single notifier
|
|
141
|
+
proc: many subscribers per event, each isolated through
|
|
142
|
+
`Rails.error.report(e, handled: true, context: { event: })` so a failing
|
|
143
|
+
one is *visible* and never stops the others or the write that emitted
|
|
144
|
+
them. Registration is reload-safe (`key:` replaces in place;
|
|
145
|
+
`Chats.reset_subscribers!` clears). Two NEW events:
|
|
146
|
+
`:conversation_created` (once per conversation, never on resume) and
|
|
147
|
+
`:participant_left`.
|
|
148
|
+
- **`config.messager_url`** `->(messager) { nil }` — the bundled views link
|
|
149
|
+
names and titles to it, and render plain text when it returns nil. The gem
|
|
150
|
+
no longer assumes a host has `user_path`.
|
|
151
|
+
- **`Participant#reseat!(new_messager)`** — hand a seat to another messager
|
|
152
|
+
inside a transaction, keeping the read horizon, the role and the history,
|
|
153
|
+
and re-indexing a direct thread's `direct_key` so `chat_with` keeps
|
|
154
|
+
resolving to it instead of stranding a duplicate. Refuses with
|
|
155
|
+
`Chats::NotAllowedError` when the resulting pair already has a direct
|
|
156
|
+
conversation, checked BEFORE the write so a unique-index violation can
|
|
157
|
+
never poison a host's transaction.
|
|
158
|
+
- **Inbox missed-broadcast recovery** (`chats--refresh-inbox` controller): the
|
|
159
|
+
inbox already receives Turbo 8 page *refreshes*, but Action Cable has no
|
|
160
|
+
replay — a refresh broadcast sent while the client's socket was down
|
|
161
|
+
(backgrounded tab/app, network blip) was lost and the inbox sat stale until
|
|
162
|
+
the user navigated. The new controller re-runs the same page refresh on
|
|
163
|
+
cable reconnect and on return-to-visible, extending the thread's
|
|
164
|
+
stale-catch-up doctrine (`docs/campfire_review.md`) to the inbox. It reuses
|
|
165
|
+
the thread's channel-free reconnect detection (observing the
|
|
166
|
+
`<turbo-cable-stream-source>` `connected` attribute), so no new Action Cable
|
|
167
|
+
channel is introduced. Auto-registered via the engine importmap pin; hosts
|
|
168
|
+
need zero changes.
|
|
169
|
+
|
|
170
|
+
### Changed
|
|
171
|
+
- `config.notifier` is **deprecated** (removed in 1.0). It still works and
|
|
172
|
+
receives `:message_created` and `:conversation_read` — the two events 0.1.1
|
|
173
|
+
had — and ONLY those: the events added in 0.2.0 are `Chats.on`-only, so a
|
|
174
|
+
0.1.x hook written `->(event, message:, **)` can never be handed an event
|
|
175
|
+
it has no keyword for. It registers as a subscriber under a reserved key,
|
|
176
|
+
so re-assigning it replaces rather than stacks, and warns through
|
|
177
|
+
`Chats.deprecator`, which the engine registers with
|
|
178
|
+
`Rails.application.deprecators`.
|
|
179
|
+
**If your test environment sets `config.active_support.deprecation =
|
|
180
|
+
:raise`** (a common default) and you still assign `config.notifier`, that
|
|
181
|
+
warning now raises at boot, because the engine registers the gem's
|
|
182
|
+
deprecator with the app. Either move the hook to `Chats.on` — the migration
|
|
183
|
+
is one line — or silence just this one:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
# config/initializers/chats.rb
|
|
187
|
+
Chats.deprecator.silence do
|
|
188
|
+
Chats.configure { |config| config.notifier = ->(event, **payload) { … } }
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
- The install migration now creates the `author` columns, so a fresh install
|
|
192
|
+
needs no upgrade step.
|
|
193
|
+
- **If you ejected the inbox or the composer under 0.1.x**, nothing breaks:
|
|
194
|
+
`ConversationsController#index` still assigns `@conversations` (the flat,
|
|
195
|
+
unstacked list an ejected inbox loops over), and an ejected composer simply
|
|
196
|
+
misses the DOM id the locked-composer swap targets — the 422 is then a
|
|
197
|
+
no-op instead of a replace. Re-eject (or delete) those two files to pick up
|
|
198
|
+
stacked rows and locked composers.
|
|
199
|
+
|
|
200
|
+
### Fixed
|
|
201
|
+
- **A host's own locale file no longer loses to the gem's.** The engine
|
|
202
|
+
appended its `config/locales` onto the application's `i18n.load_path` on
|
|
203
|
+
top of Rails' own `:add_locales`. Railtie paths are unshifted ahead of
|
|
204
|
+
everything, so that second copy landed *after* the host's files and
|
|
205
|
+
silently overrode them — a host rewording `chats.flashes.blocked` in its
|
|
206
|
+
own `es.yml` kept reading ours, with no error to see. Gem first, host last,
|
|
207
|
+
pinned by a test that ships a host override in the dummy app.
|
|
208
|
+
- **Migrations name every adapter they actually run on.** `json_column_type`
|
|
209
|
+
matched `"postgresql"`, which activerecord-postgis-adapter never reports
|
|
210
|
+
(it answers `"PostGIS"`), so PostGIS hosts silently got `json` where the
|
|
211
|
+
gem meant `jsonb`. `json_column_default` matched `/mysql/`, which misses
|
|
212
|
+
Trilogy (Rails reports `"Trilogy"`), handing those hosts a default MySQL
|
|
213
|
+
rejects. Both now match by prefix and by both spellings.
|
|
214
|
+
- **The thread's missed-broadcast recovery never took effect for a deep
|
|
215
|
+
backlog.** `ConversationsController#refresh` answered with `render html:
|
|
216
|
+
… content_type: "text/vnd.turbo-stream.html"`, and `render html:` forces
|
|
217
|
+
`text/html` and ignores the content type — so the response said
|
|
218
|
+
`<turbo-stream action="refresh">` in a body nothing would treat as a
|
|
219
|
+
stream. It now renders `turbo_stream.refresh(request_id: nil)`; the nil
|
|
220
|
+
request id matters, because Turbo skips a refresh tagged with a request id
|
|
221
|
+
it recognizes as its own, and this response answers the client's own
|
|
222
|
+
catch-up fetch. The failure was invisible by construction: a recovery path
|
|
223
|
+
that does nothing looks exactly like the staleness it exists to fix.
|
|
224
|
+
- `:participant_added` was documented as a notifier event but never emitted.
|
|
225
|
+
The event catalogue is now exactly what the gem fires, and registering for
|
|
226
|
+
anything else raises at boot with the valid list.
|
|
227
|
+
- **A signed message is its author's to answer for.** `Message#reported_owner`
|
|
228
|
+
now returns `author || sender`. With authorship, an answer sent from a
|
|
229
|
+
headless seat (a support desk) carries a human author, and a host's
|
|
230
|
+
moderation `owner` is typed to its user class — a desk there raised an
|
|
231
|
+
association type mismatch from inside the agent's own reply the first time
|
|
232
|
+
a text filter tripped, and the moderation screens then asked the desk for
|
|
233
|
+
an avatar it does not have.
|
|
234
|
+
- **`jsonb` on PostGIS.** The install migration decided jsonb-or-json with
|
|
235
|
+
`adapter_name.downcase.include?("postgresql")`, and activerecord-postgis-
|
|
236
|
+
adapter answers `"PostGIS"`, so PostGIS hosts silently got plain `json`
|
|
237
|
+
columns. The template now matches the prefix (`/\Apostg/i`). Existing
|
|
238
|
+
installs are unaffected; a host that wants jsonb can `change_column` it.
|
|
239
|
+
|
|
7
240
|
## [0.1.1] - 2026-06-10
|
|
8
241
|
|
|
9
242
|
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; an official one (`verified: true`) gets the badge everywhere its name appears. 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,158 @@ 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
|
+
## ✅ Official accounts: the verified badge
|
|
273
|
+
|
|
274
|
+
A support desk, an organization, a shop or a brand is an **official** counterpart, and the person talking to it should see that at a glance — the blue tick everyone already reads. Say it once, on the model, next to the other `acts_as_messager` options:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
class SupportDesk < ApplicationRecord
|
|
278
|
+
acts_as_messager verified: true
|
|
279
|
+
end
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Every bundled view that shows a messager's name now marks it: the inbox row, the stacked inbox row, and the thread header. The mark is an image with a name, not decoration — `role="img"` plus a localized label (`chats.verified.label`: "Official account" / "Cuenta oficial"), with the glyph itself `aria-hidden` so nothing is announced twice.
|
|
283
|
+
|
|
284
|
+
`verified:` is **independent of everything else**. A desk is usually headless *and* official; a shop is usually official and completely ordinary otherwise. Combine what you need:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
acts_as_messager verified: true # official, notifiable, blockable
|
|
288
|
+
acts_as_messager notifications: false, inbox: :grouped, verified: true # an official desk
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
It is the one boolean option that **refuses to coerce**, and that is deliberate — please don't "fix" it into a `!!` to match its neighbours. `notifications:` and `blockable:` coerce, so `notifications: "false"` quietly means `true`; on those two the damage is a stray notification. Here the same slip would hand an account the mark that tells people it is really us, and the strings that reach a model declaration come from exactly the places that produce `"false"`: an ENV var, a YAML round-trip, a settings row. So `verified: "false"` raises `Chats::ConfigurationError` at boot, where somebody is looking, rather than shipping a verified impostor nobody notices.
|
|
292
|
+
|
|
293
|
+
Read it anywhere you render your own screens — duck-typed, never a class check:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
SupportDesk.chat_verified? # the class predicate
|
|
297
|
+
Chats.verified?(messager) # false for a plain model, a nil, a non-messager
|
|
298
|
+
chats_verified_badge(messager) # the view helper: markup, or nil for everyone else
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
**Change the colour** with one CSS variable (the badge inherits it through `currentColor`):
|
|
302
|
+
|
|
303
|
+
```css
|
|
304
|
+
:root { --chats-verified: #0284c7; }
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The default is `#0284c7` rather than the more familiar `#1d9bf0`. The badge is a meaningful graphic, so it owes 3:1 against what it sits on (WCAG 1.4.11), and `#1d9bf0` is 3.00:1 on the page but **2.73:1 on `--chats-surface`** — the inbox row's hover background, so it failed exactly while someone was pointing at it. `#0284c7` clears the bar on both (4.10 and 3.72) and on a dark ground too (4.33 on `#111827`), so inverting the palette doesn't leave you with a badge you have to remember to fix. If you override it, `test/verified_badge_contrast_test.rb` shows the arithmetic worth repeating.
|
|
308
|
+
|
|
309
|
+
**Change the glyph** — to your design system's icon, a per-messager mark, or nothing — with a callable that gets the messager and returns html_safe markup (or `nil` for no badge):
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
config.verified_badge = lambda do |messager|
|
|
313
|
+
ApplicationController.helpers.image_tag("official.svg", class: "badge", alt: "Official account")
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Or eject `app/views/chats/shared/_verified_badge.html.erb` with `rails generate chats:views` and rewrite it.
|
|
318
|
+
|
|
319
|
+
## 🗂️ Grouped inbox rows
|
|
320
|
+
|
|
321
|
+
With `inbox: :grouped`, every direct conversation a viewer has with that messager folds into a single inbox row — a stack:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
inbox = Chats::Inbox.for(current_user) # [Chats::Conversation | Chats::InboxGroup], newest activity first
|
|
325
|
+
inbox.unread_count # the stack-aware badge number
|
|
326
|
+
|
|
327
|
+
group = inbox.rows.first
|
|
328
|
+
group.messager # the desk
|
|
329
|
+
group.conversations # the stacked threads, freshest first
|
|
330
|
+
group.unread_count # aggregated across the stack
|
|
331
|
+
group.open_count # how many are in it
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- `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.
|
|
335
|
+
- A stack of one links **straight to the thread**, which then carries a small "see all" link back to the stack.
|
|
336
|
+
- 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 }`.
|
|
337
|
+
- Two knobs shape the whole query: `config.inbox_limit` (200) and `config.inbox_scope = ->(relation, viewer) { relation }`.
|
|
338
|
+
|
|
339
|
+
`user.unread_chats_count` is unchanged (it counts conversations); `Chats::Inbox#unread_count` is the stack-aware number for badges.
|
|
340
|
+
|
|
341
|
+
## 🔒 Locked conversations
|
|
342
|
+
|
|
343
|
+
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:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
class Ticket < ApplicationRecord
|
|
347
|
+
acts_as_chat_subject
|
|
348
|
+
|
|
349
|
+
def chat_locked? = closed?
|
|
350
|
+
def chat_locked_notice = "This ticket is closed. Reply to reopen it."
|
|
351
|
+
end
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
- `Chats::Message` refuses new messages with an `:locked` error; `Conversation#locked?` and `#locked_notice` read the subject.
|
|
355
|
+
- **System messages are exempt**: your app can always post "This ticket was closed" into the thread it just closed.
|
|
356
|
+
- 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
|
+
- 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
|
+
|
|
359
|
+
## ✍️ Signed messages
|
|
360
|
+
|
|
361
|
+
`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:
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
desk.message!(alice, "On it!", author: lucia) # sender: the desk, author: Lucía
|
|
365
|
+
message.signed? # true — author present and not the sender
|
|
366
|
+
message.authored_by?(lucia)
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
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.
|
|
370
|
+
|
|
371
|
+
Existing installs get the columns with one command:
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
rails generate chats:upgrade && rails db:migrate
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## 🔌 View slots
|
|
378
|
+
|
|
379
|
+
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:
|
|
380
|
+
|
|
381
|
+
| slot | where it renders |
|
|
382
|
+
|---|---|
|
|
383
|
+
| `inbox_top` | above the first inbox row |
|
|
384
|
+
| `inbox_empty` | inside the empty state |
|
|
385
|
+
| `conversation_header_actions` | the thread's menu (gets `blockable:`) |
|
|
386
|
+
| `locked_composer` | the locked composer's body |
|
|
387
|
+
| `message_meta` | after each bubble's timestamp |
|
|
388
|
+
|
|
389
|
+
```erb
|
|
390
|
+
<%# app/views/chats/slots/_inbox_top.html.erb %>
|
|
391
|
+
<%= link_to "Need help? Write to us", support_path, class: "support-door" %>
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
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.
|
|
395
|
+
|
|
396
|
+
## 🔗 Profile links
|
|
397
|
+
|
|
398
|
+
`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:
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
config.messager_url = lambda do |messager|
|
|
402
|
+
routes = Rails.application.routes.url_helpers
|
|
403
|
+
|
|
404
|
+
case messager
|
|
405
|
+
when User then routes.user_path(messager) # a desk or a bot has no profile: nil
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
```
|
|
409
|
+
|
|
255
410
|
## 🎨 Make it yours
|
|
256
411
|
|
|
257
412
|
The bundled UI is intentionally framework-free (semantic `chats-*` classes + one self-contained stylesheet, themed with CSS variables):
|
|
@@ -260,6 +415,7 @@ The bundled UI is intentionally framework-free (semantic `chats-*` classes + one
|
|
|
260
415
|
:root {
|
|
261
416
|
--chats-accent: #facc15; /* own bubbles, send button, badges */
|
|
262
417
|
--chats-accent-contrast: #111827;
|
|
418
|
+
--chats-verified: #0284c7; /* the "official account" badge */
|
|
263
419
|
}
|
|
264
420
|
```
|
|
265
421
|
|
|
@@ -269,7 +425,7 @@ Want full control? Eject the views Devise-style and restyle with your own stack
|
|
|
269
425
|
rails generate chats:views
|
|
270
426
|
```
|
|
271
427
|
|
|
272
|
-
Override
|
|
428
|
+
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
429
|
|
|
274
430
|
## Configuration reference
|
|
275
431
|
|
|
@@ -308,13 +464,20 @@ Chats.configure do |config|
|
|
|
308
464
|
config.can_message = ->(sender, recipient) { true }
|
|
309
465
|
config.can_create_group = ->(creator) { true }
|
|
310
466
|
|
|
467
|
+
# Inbox shaping
|
|
468
|
+
config.inbox_limit = 200
|
|
469
|
+
config.inbox_scope = ->(relation, viewer) { relation }
|
|
470
|
+
|
|
311
471
|
# Ecosystem seams (no-op defaults; chats runs standalone)
|
|
312
472
|
config.blocked_messager_ids = ->(messager) { [] }
|
|
313
|
-
config.notifier = ->(event, **payload) {}
|
|
473
|
+
config.notifier = ->(event, **payload) {} # DEPRECATED — use Chats.on
|
|
314
474
|
|
|
315
475
|
# Display (used by the bundled views)
|
|
316
476
|
config.messager_display_name = ->(messager) { messager.display_name }
|
|
317
477
|
config.messager_avatar = ->(messager) { messager.avatar } # URL/attachment/variant or nil
|
|
478
|
+
config.messager_url = ->(messager) { nil } # nil ⇒ names render as plain text
|
|
479
|
+
config.message_signature = nil # ->(message) { } for signed bubbles
|
|
480
|
+
config.verified_badge = nil # ->(messager) { markup } for verified: true
|
|
318
481
|
end
|
|
319
482
|
```
|
|
320
483
|
|
|
@@ -329,21 +492,28 @@ alice.message!(bob, "hi", about: ride) # send (resolves the thread)
|
|
|
329
492
|
alice.message!(conversation, "hi", files: []) # send into a conversation
|
|
330
493
|
alice.chats # inbox relation, newest first
|
|
331
494
|
alice.unread_chats_count # conversations with unread messages
|
|
495
|
+
alice.message!(bob, "hi", author: lucia) # written by lucia, sent from alice's seat
|
|
496
|
+
Chats::Inbox.for(alice) # [Conversation | InboxGroup] + #unread_count
|
|
497
|
+
Chats.verified?(desk) # official account? (acts_as_messager verified: true)
|
|
332
498
|
|
|
333
499
|
# Conversations
|
|
334
500
|
conversation.participant?(user) # active membership
|
|
335
501
|
conversation.other_participants(user)
|
|
502
|
+
conversation.counterpart_for(viewer) # the other messager (nil for groups)
|
|
336
503
|
conversation.title_for(viewer) # counterpart name / group title
|
|
337
504
|
conversation.subject_label # "Madrid → Barcelona"
|
|
338
505
|
conversation.unread_count_for(user)
|
|
339
506
|
conversation.mark_read_by!(user)
|
|
340
507
|
conversation.post_system_message!("Ride cancelled")
|
|
341
508
|
conversation.add_participant!(user) # idempotent, race-safe
|
|
509
|
+
conversation.locked? # the SUBJECT decides (chat_locked?)
|
|
510
|
+
conversation.locked_notice # why, in words
|
|
342
511
|
|
|
343
512
|
# Messages
|
|
344
513
|
message.edit!("fixed") # stamps edited_at
|
|
345
514
|
message.soft_delete! # tombstone (or destroy, per config)
|
|
346
515
|
message.read_by?(user)
|
|
516
|
+
message.signed? / message.authored_by?(lucia) # authorship
|
|
347
517
|
Chats::Reaction.toggle!(message:, reactor:, emoji: "👍")
|
|
348
518
|
|
|
349
519
|
# Participants (the per-member state)
|
|
@@ -352,10 +522,26 @@ participant.mute! / participant.unmute!
|
|
|
352
522
|
participant.leave! # groups
|
|
353
523
|
participant.notifiable_for?(message) # notification etiquette
|
|
354
524
|
participant.should_notify? / participant.mark_notified!
|
|
525
|
+
participant.reseat!(new_messager) # hand the seat over, read horizon intact
|
|
526
|
+
|
|
527
|
+
# Events
|
|
528
|
+
Chats.on(:message_created) { |message| } # also :conversation_created,
|
|
529
|
+
# :participant_left, :conversation_read
|
|
355
530
|
```
|
|
356
531
|
|
|
357
532
|
Errors are namespaced and meaningful: `Chats::BlockedError`, `Chats::NotAllowedError`, `Chats::ConfigurationError` — all under `Chats::Error`.
|
|
358
533
|
|
|
534
|
+
## Upgrading
|
|
535
|
+
|
|
536
|
+
`chats` ships the migrations a version bump needs; your initializer and views stay yours:
|
|
537
|
+
|
|
538
|
+
```bash
|
|
539
|
+
rails generate chats:upgrade # 0.2.0: message authorship columns
|
|
540
|
+
rails db:migrate
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Nothing in 0.2.0 changes behaviour until you set an option — see the [CHANGELOG](CHANGELOG.md).
|
|
544
|
+
|
|
359
545
|
## Database support
|
|
360
546
|
|
|
361
547
|
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.
|