turbo_chat 0.1.2 → 0.1.3

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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +192 -607
  3. data/app/assets/config/turbo_chat_manifest.js +6 -0
  4. data/app/assets/javascripts/turbo_chat/application.js +4 -0
  5. data/app/assets/javascripts/{chat_gem → turbo_chat}/lifecycle_events.js +1 -1
  6. data/app/assets/javascripts/{chat_gem → turbo_chat}/messages.js +4 -4
  7. data/app/assets/javascripts/{chat_gem → turbo_chat}/realtime.js +3 -3
  8. data/app/controllers/turbo_chat/application_controller.rb +66 -0
  9. data/app/controllers/{chat_gem → turbo_chat}/chat_memberships_controller.rb +4 -4
  10. data/app/controllers/{chat_gem → turbo_chat}/chat_messages_controller.rb +7 -7
  11. data/app/controllers/{chat_gem → turbo_chat}/chats_controller/event_payload_support.rb +5 -5
  12. data/app/controllers/{chat_gem → turbo_chat}/chats_controller/invitation_support.rb +4 -4
  13. data/app/controllers/{chat_gem → turbo_chat}/chats_controller.rb +10 -10
  14. data/app/helpers/{chat_gem → turbo_chat}/application_helper/config_support.rb +2 -2
  15. data/app/helpers/{chat_gem → turbo_chat}/application_helper/mention_support/entry_builder.rb +1 -1
  16. data/app/helpers/{chat_gem → turbo_chat}/application_helper/mention_support/permission_support.rb +2 -2
  17. data/app/helpers/{chat_gem → turbo_chat}/application_helper/mention_support/token_builder.rb +1 -1
  18. data/app/helpers/{chat_gem → turbo_chat}/application_helper/mention_support.rb +5 -5
  19. data/app/helpers/{chat_gem → turbo_chat}/application_helper/message_rendering.rb +11 -11
  20. data/app/helpers/{chat_gem → turbo_chat}/application_helper/participant_support.rb +2 -2
  21. data/app/helpers/turbo_chat/application_helper.rb +12 -0
  22. data/app/models/{chat_gem → turbo_chat}/application_record.rb +1 -1
  23. data/app/models/{chat_gem → turbo_chat}/chat.rb +7 -7
  24. data/app/models/{chat_gem → turbo_chat}/chat_membership.rb +5 -5
  25. data/app/models/{chat_gem → turbo_chat}/chat_message/blocked_words_moderation.rb +7 -7
  26. data/app/models/{chat_gem → turbo_chat}/chat_message/body_length_validation.rb +2 -2
  27. data/app/models/{chat_gem → turbo_chat}/chat_message/broadcasting.rb +1 -1
  28. data/app/models/{chat_gem → turbo_chat}/chat_message/formatting.rb +3 -3
  29. data/app/models/{chat_gem → turbo_chat}/chat_message/mention_validation.rb +3 -3
  30. data/app/models/{chat_gem → turbo_chat}/chat_message/signals.rb +2 -2
  31. data/app/models/{chat_gem → turbo_chat}/chat_message.rb +11 -11
  32. data/app/views/layouts/turbo_chat/application.html.erb +20 -0
  33. data/app/views/turbo_chat/chat_messages/_chat_message.html.erb +1 -0
  34. data/app/views/{chat_gem → turbo_chat}/chat_messages/_form.html.erb +2 -2
  35. data/app/views/{chat_gem → turbo_chat}/chat_messages/_message.html.erb +2 -2
  36. data/app/views/{chat_gem → turbo_chat}/chat_messages/_signals.html.erb +1 -1
  37. data/app/views/{chat_gem → turbo_chat}/chats/show.html.erb +3 -3
  38. data/config/routes.rb +1 -1
  39. data/db/migrate/20260215000000_create_turbo_chat_chats.rb +8 -0
  40. data/db/migrate/{20260215000001_create_chat_gem_chat_memberships.rb → 20260215000001_create_turbo_chat_chat_memberships.rb} +5 -5
  41. data/db/migrate/{20260215000002_create_chat_gem_chat_messages.rb → 20260215000002_create_turbo_chat_chat_messages.rb} +4 -4
  42. data/db/migrate/20260218000011_add_closed_at_to_turbo_chat_chats.rb +6 -0
  43. data/db/migrate/20260218000012_add_custom_role_key_to_chat_memberships.rb +2 -2
  44. data/db/migrate/20260218000013_add_invitation_accepted_to_turbo_chat_chat_memberships.rb +5 -0
  45. data/lib/generators/turbo_chat/install/install_generator.rb +1 -1
  46. data/lib/generators/turbo_chat/install/templates/turbo_chat.rb +2 -0
  47. data/lib/tasks/turbo_chat_tasks.rake +6 -2
  48. data/lib/{chat_gem → turbo_chat}/configuration.rb +4 -2
  49. data/lib/turbo_chat/engine.rb +29 -0
  50. data/lib/{chat_gem → turbo_chat}/model_extensions/chat_participant.rb +6 -6
  51. data/lib/{chat_gem → turbo_chat}/moderation.rb +11 -11
  52. data/lib/{chat_gem → turbo_chat}/permission.rb +1 -1
  53. data/lib/{chat_gem → turbo_chat}/signals.rb +5 -5
  54. data/lib/turbo_chat/version.rb +1 -3
  55. data/lib/turbo_chat.rb +11 -15
  56. metadata +53 -58
  57. data/app/assets/config/chat_gem_manifest.js +0 -6
  58. data/app/assets/javascripts/chat_gem/application.js +0 -4
  59. data/app/controllers/chat_gem/application_controller.rb +0 -41
  60. data/app/helpers/chat_gem/application_helper.rb +0 -12
  61. data/app/views/chat_gem/chat_messages/_chat_message.html.erb +0 -1
  62. data/app/views/layouts/chat_gem/application.html.erb +0 -20
  63. data/db/migrate/20260215000000_create_chat_gem_chats.rb +0 -8
  64. data/db/migrate/20260218000011_add_closed_at_to_chat_gem_chats.rb +0 -6
  65. data/db/migrate/20260218000013_add_invitation_accepted_to_chat_gem_chat_memberships.rb +0 -5
  66. data/lib/chat_gem/engine.rb +0 -29
  67. data/lib/chat_gem/version.rb +0 -3
  68. data/lib/chat_gem.rb +0 -24
  69. data/lib/generators/chat_gem/install/install_generator.rb +0 -18
  70. data/lib/generators/chat_gem/install/templates/chat_gem.rb +0 -36
  71. data/lib/tasks/chat_gem_tasks.rake +0 -1
  72. /data/app/assets/javascripts/{chat_gem → turbo_chat}/shared.js +0 -0
  73. /data/app/assets/stylesheets/{chat_gem → turbo_chat}/application.css +0 -0
  74. /data/app/views/{chat_gem → turbo_chat}/chat_messages/_signal.html.erb +0 -0
  75. /data/app/views/{chat_gem → turbo_chat}/chat_messages/index.html.erb +0 -0
  76. /data/app/views/{chat_gem → turbo_chat}/chats/index.html.erb +0 -0
  77. /data/app/views/{chat_gem → turbo_chat}/chats/new.html.erb +0 -0
data/README.md CHANGED
@@ -1,64 +1,65 @@
1
1
  # TurboChat
2
2
 
3
- Mountable Rails engine gem for lightweight, realtime chats using Turbo Streams.
4
-
5
- ## Table of Contents
6
-
7
- - [Quick Start](#quick-start)
8
- - [Host App Contract](#host-app-contract)
9
- - [Simple Example](#simple-example)
10
- - [Feature Overview](#feature-overview)
11
- - [Configuration](#configuration)
12
- - [Core Concepts](#core-concepts)
13
- - [Chat Lifecycle](#chat-lifecycle)
14
- - [Mentions and Emoji](#mentions-and-emoji)
15
- - [UI Customization](#ui-customization)
16
- - [Styling and Custom Markup](#styling-and-custom-markup)
17
- - [Rich HTML Message Rendering](#rich-html-message-rendering)
18
- - [Browser Events](#browser-events)
19
- - [Participants, Roles, and Moderation](#participants-roles-and-moderation)
20
- - [Programmatic Signals](#programmatic-signals)
21
- - [Dependencies](#dependencies)
22
- - [Maintainer](#maintainer)
23
-
24
- ## Quick Start
25
-
26
- 1. Add the gem to your host app:
3
+ TurboChat is a mountable Rails chat engine for server-rendered apps.
4
+
5
+ What you get:
6
+ - Turbo Stream chat UI.
7
+ - Role-based permissions.
8
+ - Mentions, typing signals, invites, moderation.
9
+ - Practical customization hooks without rewriting everything.
10
+
11
+ What this is not:
12
+ - A hosted chat service.
13
+ - A React-first component library.
14
+ - A "just add JS" widget.
15
+
16
+ ## Use This If
17
+
18
+ Use TurboChat if your app is Rails-first and you want chat now, not after building a custom permission/event/moderation system.
19
+
20
+ Skip it if you want fully custom frontend architecture from day one.
21
+
22
+ ## Install
27
23
 
28
24
  ```ruby
29
25
  # Gemfile
30
26
  gem "turbo_chat"
31
27
  ```
32
28
 
33
- 2. Install and copy setup files:
34
-
35
29
  ```bash
36
30
  bundle install
37
31
  bin/rails generate turbo_chat:install
38
32
  bin/rails db:migrate
39
33
  ```
40
34
 
41
- When upgrading `turbo_chat`, also install engine migrations in the host app before migrating:
42
-
43
- ```bash
44
- bin/rails turbo_chat:install:migrations
45
- bin/rails db:migrate
46
- ```
47
-
48
- 3. Mount the engine:
35
+ Mount with an explicit helper prefix:
49
36
 
50
37
  ```ruby
51
38
  # config/routes.rb
52
- mount TurboChat::Engine => "/"
39
+ mount TurboChat::Engine => "/chat", as: "turbo_chat"
53
40
  ```
54
41
 
55
- Use the `TurboChat` namespace in host app code.
42
+ Opinionated recommendation: mount under `/chat` (or another scoped path), not root.
43
+
44
+ ## Participant Resolution (Most Important Section)
45
+
46
+ This is where installs usually fail. TurboChat now resolves the current participant in this order:
47
+
48
+ 1. `current_chat_participant` on your host `ApplicationController` (preferred explicit hook).
49
+ 2. `config.current_participant_resolver` (custom resolver lambda).
50
+ 3. `current_user` (if your app exposes it).
51
+
52
+ If none are available, TurboChat raises `NotImplementedError` with guidance.
53
+
54
+ Return value must be:
55
+ - `nil` (unauthenticated), or
56
+ - a model that uses `acts_as_chat_participant`.
56
57
 
57
- ## Host App Contract
58
+ If you use Devise and your `User` model is a chat participant, this works out of the box.
58
59
 
59
- ### Participant models
60
+ ## Host Contract
60
61
 
61
- Any model that should join chats must call `acts_as_chat_participant`:
62
+ ### Participant model opt-in
62
63
 
63
64
  ```ruby
64
65
  class User < ApplicationRecord
@@ -66,122 +67,75 @@ class User < ApplicationRecord
66
67
  end
67
68
  ```
68
69
 
69
- ### Current participant
70
-
71
- Expose the current participant from your host `ApplicationController`:
70
+ ### Optional explicit hook (recommended for clarity)
72
71
 
73
72
  ```ruby
74
73
  class ApplicationController < ActionController::Base
75
- helper_method :chat_current_participant
76
-
77
- def chat_current_participant
78
- Current.user
74
+ def current_chat_participant
75
+ current_user
79
76
  end
80
77
  end
81
78
  ```
82
79
 
83
- `chat_current_participant` must return a model using `acts_as_chat_participant`, or `nil` for unauthenticated sessions.
80
+ ### Optional custom resolver (for non-`current_user` auth)
84
81
 
85
- ## Simple Example
82
+ ```ruby
83
+ TurboChat.configure do |config|
84
+ config.current_participant_resolver = ->(controller) { controller.send(:current_member) }
85
+ end
86
+ ```
86
87
 
87
- Create a chat, add the current participant, and link to the chat view:
88
+ ## First Working Usage
88
89
 
89
90
  ```ruby
90
91
  chat = TurboChat::Chat.create!(title: "Support")
91
- TurboChat::ChatMembership.create!(chat: chat, participant: Current.user, role: :member)
92
+ TurboChat::ChatMembership.create!(chat: chat, participant: current_user, role: :admin)
92
93
  ```
93
94
 
94
95
  ```erb
95
- <%= link_to "Open chat", chat_gem.chat_path(chat) %>
96
+ <%= link_to "Open chat", turbo_chat.chat_path(chat) %>
96
97
  ```
97
98
 
98
- `chat_gem` is the default route helper prefix when mounted as `mount TurboChat::Engine => "/"`.
99
-
100
- ## Feature Overview
101
-
102
- - Mountable chat UI with Turbo Stream updates.
103
- - Message rows + signal rows (`typing`, `thinking`, `planning`).
104
- - Role-aware permissions, mentions, moderation, and chat close/reopen.
105
- - Configurable styling and optional sanitized HTML rendering.
106
- - Optional browser events for typing and message submit lifecycle.
107
- - Programmatic signal helpers for user-facing typing/thinking/planning states.
108
-
109
- ## Configuration
110
-
111
- ### Key options by concern
112
-
113
- #### Access and limits
114
-
115
- - `config.permission_adapter` (`TurboChat::Permission` by default).
116
- - `config.max_chat_participants` (`10` by default).
117
- - `config.max_message_length` (`1000` by default).
118
- - `config.message_history_limit` (`200` by default; set `nil` or `0` to disable).
119
-
120
- #### Behavior and lifecycle
121
-
122
- - `config.active_chat_window` (`5.minutes` by default).
123
- - `config.show_self_signals` (`false` by default).
124
- - `config.replace_signals_on_message_submit` (`false` by default; clears a participant's existing signals when they submit a regular message).
125
-
126
- #### Mentions and emoji
127
-
128
- - `config.enable_mentions` (`true` by default).
129
- - `config.mention_filter_exclude_self` (`true` by default; hides current participant from mention autocomplete options).
130
- - `config.mention_filter_hide_roles` (`true` by default; hides role mention options like `@ADMIN` from autocomplete).
131
- - `config.enable_emoji_aliases` (`true` by default).
132
- - `config.emoji_aliases` (`TurboChat::Configuration::DEFAULT_EMOJI_ALIASES.dup` by default).
133
- - `config.blocked_words` (`[]` by default).
134
- - `config.blocked_words_action` (`:reject` by default; supports `:reject` or `:scramble`).
135
-
136
- #### Rendering and styling
99
+ ## Routes You Actually Need
137
100
 
138
- - `config.show_timestamp` (`true` by default).
139
- - `config.show_role` (`false` by default).
140
- - `config.mention_mark_hex_color` (`nil` by default; sets viewer-targeted mention mark background color).
141
- - `config.mention_highlight_hex_color` (`nil` by default; backward-compatible alias for mention mark color).
142
- - `config.own_message_hex_color`, `config.other_message_hex_color` (`nil` by default).
143
- - `config.role_message_hex_colors` (`{}` by default).
144
- - `config.message_css_class_resolver` (`nil` by default).
145
- - `config.render_message_html` (`false` by default).
146
- - `config.message_html_tags` (`%w[a b br code em i li ol p pre strong ul]` by default).
147
- - `config.message_html_attributes` (`%w[href target rel class]` by default).
148
- - `config.timestamp_formatter` (`->(timestamp, _chat_message) { I18n.l(timestamp.in_time_zone, format: :long) }` by default).
149
- - `config.role_formatter` (`->(role, _chat_message) { role.to_s.humanize }` by default).
101
+ From the mounted engine:
102
+ - `GET /chats`
103
+ - `GET /chats/:id`
104
+ - `POST /chats`
105
+ - `PATCH /chats/:id/accept`
106
+ - `PATCH /chats/:id/decline`
107
+ - `PATCH /chats/:id/leave`
108
+ - `PATCH /chats/:id/close`
109
+ - `PATCH /chats/:id/reopen`
110
+ - `POST /chats/:chat_id/chat_memberships`
111
+ - `POST /chats/:chat_id/chat_messages`
112
+ - `PATCH /chats/:chat_id/chat_messages/:id`
150
113
 
151
- #### Browser events
114
+ ## Recommended Baseline Config
152
115
 
153
- - `config.emit_typing_events` (`false` by default).
154
- - `config.emit_message_events` (`false` by default).
155
- - `config.emit_mention_events` (`false` by default).
156
- - `config.emit_invitation_events` (`false` by default).
157
- - `config.emit_chat_lifecycle_events` (`false` by default).
158
- - `config.emit_moderation_events` (`false` by default; emits `ActiveSupport::Notifications` moderation events).
159
- - `config.emit_blocked_words_events` (`false` by default; emits `ActiveSupport::Notifications` blocked-word moderation events).
160
-
161
- <details>
162
- <summary>Full default initializer</summary>
116
+ Keep this simple initially:
163
117
 
164
118
  ```ruby
165
119
  TurboChat.configure do |config|
166
120
  config.permission_adapter = TurboChat::Permission
121
+
167
122
  config.max_chat_participants = 10
168
123
  config.max_message_length = 1000
169
124
  config.message_history_limit = 200
125
+ config.active_chat_window = 5.minutes
126
+
170
127
  config.enable_mentions = true
171
128
  config.mention_filter_exclude_self = true
172
129
  config.mention_filter_hide_roles = true
173
130
  config.enable_emoji_aliases = true
174
- config.emoji_aliases = TurboChat::Configuration::DEFAULT_EMOJI_ALIASES.dup
131
+
175
132
  config.blocked_words = []
176
- config.blocked_words_action = :reject
177
- config.mention_mark_hex_color = nil
178
- config.mention_highlight_hex_color = nil
179
- config.own_message_hex_color = nil
180
- config.other_message_hex_color = nil
181
- config.role_message_hex_colors = {}
133
+ config.blocked_words_action = :reject # or :scramble
134
+
135
+ config.render_message_html = false
182
136
  config.show_timestamp = true
183
137
  config.show_role = false
184
- config.active_chat_window = 5.minutes
138
+
185
139
  config.emit_typing_events = false
186
140
  config.emit_message_events = false
187
141
  config.emit_mention_events = false
@@ -189,553 +143,184 @@ TurboChat.configure do |config|
189
143
  config.emit_chat_lifecycle_events = false
190
144
  config.emit_moderation_events = false
191
145
  config.emit_blocked_words_events = false
192
- config.show_self_signals = false
193
- config.replace_signals_on_message_submit = false
194
- config.message_css_class_resolver = nil
195
- config.render_message_html = false
196
- config.message_html_tags = %w[a b br code em i li ol p pre strong ul]
197
- config.message_html_attributes = %w[href target rel class]
198
- config.timestamp_formatter = ->(timestamp, _chat_message) { I18n.l(timestamp.in_time_zone, format: :long) }
199
- config.role_formatter = ->(role, _chat_message) { role.to_s.humanize }
200
- end
201
- ```
202
-
203
- </details>
204
-
205
- ## Core Concepts
206
-
207
- ### Chat Lifecycle
208
-
209
- A chat is considered active when it has a regular message within the configured window (`config.active_chat_window`).
210
- Signal rows do not count as activity. Closed chats (`closed_at` set) remain viewable but cannot receive new messages.
211
-
212
- ```ruby
213
- TurboChat.configure do |config|
214
- config.active_chat_window = 5.minutes
215
146
  end
216
-
217
- chat.active? # => true/false
218
- chat.inactive? # => true/false
219
-
220
- TurboChat::Chat.active
221
- TurboChat::Chat.inactive
222
- TurboChat::Chat.active(window: 10.minutes)
223
147
  ```
224
148
 
225
- ### Mentions and Emoji
149
+ Opinionated defaults:
150
+ - Leave HTML rendering off unless required.
151
+ - Leave event emission off unless consumed.
152
+ - Keep permissions adapter default until you have a concrete policy gap.
226
153
 
227
- Mention suggestions are built from active chat memberships and can include:
154
+ ## Roles and Permissions
228
155
 
229
- - member handles such as `@username`
230
- - `@all`
231
- - role targets such as `@ADMIN` and `@MODERATOR` (hidden by default in autocomplete; enable via `config.mention_filter_hide_roles = false`)
156
+ Built-in roles:
157
+ - `member`
158
+ - `moderator`
159
+ - `admin`
232
160
 
233
- By default, autocomplete also excludes the current participant (`config.mention_filter_exclude_self = true`).
161
+ Behavior summary:
162
+ - `member`: view/post and member mentions.
163
+ - `moderator`: invite, `@all`, role mentions, mute/timeout/ban, delete lower-rank messages.
164
+ - `admin`: moderator powers plus close/reopen chat.
234
165
 
235
- Mentions are permission-filtered and server-validated:
166
+ Hard rules:
167
+ - Moderation is rank-based.
168
+ - No self-moderation.
169
+ - Closed chat blocks posting.
170
+ - Muted/timed-out members cannot post.
236
171
 
237
- - `:mention_member` controls member handles.
238
- - `:mention_all` controls `@all`.
239
- - `:mention_role` controls role mentions.
240
-
241
- Emoji aliases are enabled by default for plain-text message rendering.
242
-
243
- #### Add aliases incrementally
172
+ Custom role example:
244
173
 
245
174
  ```ruby
246
175
  TurboChat.configure do |config|
247
- config.add_emoji_alias(:shipit, "🚢")
248
- config.add_emoji_alias("party_parrot", "🦜")
249
- end
250
- ```
251
-
252
- #### Override alias map
253
-
254
- ```ruby
255
- TurboChat.configure do |config|
256
- config.emoji_aliases = TurboChat::Configuration::DEFAULT_EMOJI_ALIASES.merge(
257
- "shipit" => "🚢",
258
- "party_parrot" => "🦜"
176
+ config.add_role(
177
+ :support_agent,
178
+ name: "Support Agent",
179
+ rank: 1,
180
+ permissions: %i[view_chat post_message delete_message]
259
181
  )
260
182
  end
261
183
  ```
262
184
 
263
- #### Blocked words moderation
264
-
265
- Configure blocked words and choose whether to reject messages or scramble blocked words.
266
-
267
- `scramble` now shuffles the blocked word's own characters (for example, `badword` -> `darbwod`).
268
-
269
- ```ruby
270
- TurboChat.configure do |config|
271
- config.blocked_words = %w[foo bar]
272
- config.blocked_words_action = :reject
273
- end
274
- ```
275
-
276
- ```ruby
277
- TurboChat.configure do |config|
278
- config.blocked_words = %w[foo bar]
279
- config.blocked_words_action = :scramble
280
- end
281
- ```
282
-
283
- ## UI Customization
284
-
285
- ### Styling and Custom Markup
286
-
287
- #### Bubble colors
288
-
289
- ```ruby
290
- TurboChat.configure do |config|
291
- config.own_message_hex_color = "#c9f2ff"
292
- config.other_message_hex_color = "#f6f8fb"
293
- config.role_message_hex_colors = {
294
- admin: "#ffe6e6",
295
- moderator: { own: "#fff0c2", other: "#fff7de" },
296
- support_agent: { default: "#e9f8ff" }
297
- }
298
- end
299
- ```
300
-
301
- Role-specific colors override own/other defaults. Invalid hex values are ignored.
302
-
303
- Viewer-targeted mentions can be color-customized:
304
-
305
- ```ruby
306
- TurboChat.configure do |config|
307
- config.mention_mark_hex_color = "#cf1322"
308
- end
309
- ```
310
-
311
- #### CSS class resolver (basic)
312
-
313
- ```ruby
314
- TurboChat.configure do |config|
315
- config.message_css_class_resolver = ->(_chat_message, own_message) {
316
- own_message ? "msg-card msg-card--own" : "msg-card msg-card--other"
317
- }
318
- end
319
- ```
320
-
321
- ```html
322
- <article id="chat_gem_chat_message_42" class="chat-bubble chat-bubble--own msg-card msg-card--own">
323
- <header class="chat-meta">
324
- <span class="chat-meta__author">you@example.com</span>
325
- <time datetime="2026-02-18T16:40:00Z">February 18, 2026 4:40 PM</time>
326
- </header>
327
- <p class="chat-body">Hello world</p>
328
- </article>
329
- ```
330
-
331
- #### CSS class resolver (role-aware)
332
-
333
- ```ruby
334
- TurboChat.configure do |config|
335
- config.message_css_class_resolver = lambda { |chat_message, own_message|
336
- classes = ["msg-card"]
337
- classes << (own_message ? "msg-card--own" : "msg-card--other")
338
- classes << "msg-card--role-#{chat_message.participant_membership_role}" if chat_message.participant_membership_role.present?
339
- classes << "msg-card--long" if chat_message.body.to_s.length > 280
340
- classes
341
- }
342
- end
343
- ```
344
-
345
- #### Full markup override
346
-
347
- `message_css_class_resolver` controls classes only. To change structure, override the message partial in your host app.
348
-
349
- 1. Create `app/views/chat_gem/chat_messages/_message.html.erb`.
350
- 2. Copy the engine partial and customize it.
351
- 3. Keep `id="<%= dom_id(chat_message) %>"` on the wrapper so Turbo updates/removals keep working.
352
-
353
- ```erb
354
- <% own_message = own_chat_message?(chat_message) %>
355
- <% show_timestamp = TurboChat.configuration.show_timestamp %>
356
- <article id="<%= dom_id(chat_message) %>" class="<%= chat_message_css_classes(chat_message: chat_message, own_message: own_message) %>">
357
- <div class="msg-card__header">
358
- <span class="chat-meta__author"><%= chat_message.participant_display_name %></span>
359
- <% if show_timestamp %>
360
- <time datetime="<%= chat_message.created_at.iso8601 %>"><%= chat_message.formatted_timestamp %></time>
361
- <% end %>
362
- </div>
363
-
364
- <div class="msg-card__body">
365
- <%= render_chat_message_body(chat_message) %>
366
- </div>
367
-
368
- <div class="msg-card__footer">
369
- <!-- custom badges/actions -->
370
- </div>
371
- </article>
372
- ```
373
-
374
- ### Rich HTML Message Rendering
375
-
376
- #### Enable sanitized rendering
377
-
378
- Enable sanitized HTML rendering for message bodies:
379
-
380
- ```ruby
381
- TurboChat.configure do |config|
382
- config.render_message_html = true
383
- config.message_html_tags = %w[a b br code em i li ol p pre strong ul]
384
- config.message_html_attributes = %w[href target rel class]
385
- end
386
- ```
387
-
388
- #### Extend the allowlist
389
-
390
- Extend the allowlist as needed:
391
-
392
- ```ruby
393
- TurboChat.configure do |config|
394
- config.render_message_html = true
395
- config.message_html_tags = TurboChat::Configuration::DEFAULT_MESSAGE_HTML_TAGS + %w[blockquote h4 mark]
396
- config.message_html_attributes = TurboChat::Configuration::DEFAULT_MESSAGE_HTML_ATTRIBUTES + %w[title]
397
- end
398
- ```
399
-
400
- #### Simple Example
401
-
402
- Given:
403
-
404
- ```html
405
- <h4 title="notice">Update</h4><blockquote><mark>Done</mark></blockquote><u>underline</u>
406
- ```
407
-
408
- Rendered/sanitized output:
185
+ ## Invitations
409
186
 
410
- ```html
411
- <h4 title="notice">Update</h4><blockquote><mark>Done</mark></blockquote>underline
412
- ```
187
+ - Invite creation: `POST /chats/:chat_id/chat_memberships`
188
+ - Accept: `PATCH /chats/:id/accept`
189
+ - Decline: `PATCH /chats/:id/decline`
413
190
 
414
- ### Browser Events
191
+ Important constraints:
192
+ - Invite type must match inviter participant base class.
193
+ - Participant limits apply to active memberships.
194
+ - Re-invite reactivates existing memberships.
415
195
 
416
- #### Typing lifecycle events
196
+ ## Messages, Mentions, Signals
417
197
 
418
- ```ruby
419
- TurboChat.configure do |config|
420
- config.emit_typing_events = true
421
- end
422
- ```
198
+ ### Messages
199
+ - Realtime append/update/remove via Turbo Streams.
200
+ - Inline edit for your own messages (permission-gated).
201
+ - History capped by `message_history_limit`.
423
202
 
424
- ```js
425
- document.addEventListener("chat-gem:typing-started", function (event) {
426
- // event.detail.chatId
427
- });
203
+ ### Mentions
204
+ - `@username`, `@all`, `@ROLE`.
205
+ - Server-side mention permission validation.
206
+ - Autocomplete defaults: excludes self, hides roles.
428
207
 
429
- document.addEventListener("chat-gem:typing-ended", function (event) {
430
- // event.detail.chatId
431
- });
432
- ```
208
+ ### Signals
209
+ Automatic typing loop is built in.
433
210
 
434
- #### Message sent event
211
+ Manual APIs:
435
212
 
436
213
  ```ruby
437
- TurboChat.configure do |config|
438
- config.emit_message_events = true
439
- end
214
+ TurboChat::Signals.start!(chat: chat, participant: current_user, signal_type: :thinking)
215
+ TurboChat::Signals.replace!(chat: chat, participant: current_user, signal_type: :planning)
216
+ TurboChat::Signals.clear!(chat: chat, participant: current_user)
440
217
  ```
441
218
 
442
- ```js
443
- document.addEventListener("chat-gem:message-sent", function (event) {
444
- // event.detail.chatId
445
- });
446
- ```
447
-
448
- #### Mention event
449
-
450
219
  ```ruby
451
- TurboChat.configure do |config|
452
- config.emit_mention_events = true
220
+ TurboChat::Signals.with(chat: chat, participant: current_user, signal_type: :thinking) do
221
+ # work
453
222
  end
454
223
  ```
455
224
 
456
- ```js
457
- document.addEventListener("chat-gem:mention", function (event) {
458
- // event.detail.chatId
459
- // event.detail.messageId
460
- // event.detail.mentions
461
- // event.detail.targetsCurrentParticipant
462
- // event.detail.targetedMentions
463
- });
464
- ```
465
-
466
- #### Invitation accepted event
225
+ ## Moderation API
467
226
 
468
227
  ```ruby
469
- TurboChat.configure do |config|
470
- config.emit_invitation_events = true
471
- end
472
- ```
473
-
474
- When an invited participant accepts from the chats index (`PATCH /chats/:id/accept`),
475
- the chats index emits `chat-gem:invitation-accepted` on page load after redirect.
476
-
477
- ```js
478
- document.addEventListener("chat-gem:invitation-accepted", function (event) {
479
- // event.detail.chatId
480
- // event.detail.chatTitle
481
- // event.detail.chatMembershipId
482
- });
228
+ TurboChat::Moderation.mute_member!(actor: moderator, membership: membership)
229
+ TurboChat::Moderation.unmute_member!(actor: moderator, membership: membership)
230
+ TurboChat::Moderation.timeout_member!(actor: moderator, membership: membership, until_time: 30.minutes.from_now)
231
+ TurboChat::Moderation.clear_timeout!(actor: moderator, membership: membership)
232
+ TurboChat::Moderation.ban_member!(actor: moderator, membership: membership)
233
+ TurboChat::Moderation.delete_message!(actor: moderator, message: message)
234
+ TurboChat::Moderation.close_chat!(actor: admin, chat: chat)
235
+ TurboChat::Moderation.reopen_chat!(actor: admin, chat: chat)
483
236
  ```
484
237
 
485
- #### Chat lifecycle events
238
+ Raises:
239
+ - `TurboChat::Moderation::AuthorizationError`
240
+ - `TurboChat::Moderation::InvalidActionError`
486
241
 
487
- ```ruby
488
- TurboChat.configure do |config|
489
- config.emit_chat_lifecycle_events = true
490
- end
491
- ```
242
+ ## Browser Events
492
243
 
493
- Emits lifecycle events on page load after redirect:
244
+ Enable with config flags.
494
245
 
495
- - `chat-gem:chat-invited` when the current participant invites someone to a chat
496
- - `chat-gem:chat-joined` when the current participant joins a chat (chat creation or invitation acceptance)
497
- - `chat-gem:chat-declined` when the current participant declines a pending invitation
498
- - `chat-gem:chat-left` when the current participant leaves a chat
499
- - `chat-gem:chat-closed` when the current participant closes a chat
500
- - `chat-gem:chat-reopened` when the current participant reopens a chat
246
+ Event names:
247
+ - `turbo-chat:typing-started`
248
+ - `turbo-chat:typing-ended`
249
+ - `turbo-chat:message-sent`
250
+ - `turbo-chat:mention`
251
+ - `turbo-chat:invitation-accepted`
252
+ - `turbo-chat:chat-invited`
253
+ - `turbo-chat:chat-joined`
254
+ - `turbo-chat:chat-declined`
255
+ - `turbo-chat:chat-left`
256
+ - `turbo-chat:chat-closed`
257
+ - `turbo-chat:chat-reopened`
501
258
 
502
259
  ```js
503
- document.addEventListener("chat-gem:chat-invited", function (event) {
504
- // event.detail.action => "invited"
505
- // event.detail.chatId
506
- // event.detail.chatTitle
507
- // event.detail.chatMembershipId
260
+ document.addEventListener("turbo-chat:message-sent", function (event) {
261
+ console.log(event.detail.chatId);
508
262
  });
509
-
510
- document.addEventListener("chat-gem:chat-joined", function (event) {
511
- // event.detail.action => "joined"
512
- // event.detail.chatId
513
- // event.detail.chatTitle
514
- // event.detail.chatMembershipId
515
- });
516
-
517
- document.addEventListener("chat-gem:chat-declined", function (event) {
518
- // event.detail.action => "declined"
519
- // event.detail.chatId
520
- // event.detail.chatTitle
521
- // event.detail.chatMembershipId
522
- });
523
-
524
- document.addEventListener("chat-gem:chat-left", function (event) {
525
- // event.detail.action => "left"
526
- // event.detail.chatId
527
- // event.detail.chatTitle
528
- // event.detail.chatMembershipId
529
- });
530
-
531
- document.addEventListener("chat-gem:chat-closed", function (event) {
532
- // event.detail.action => "closed"
533
- // event.detail.chatId
534
- // event.detail.chatTitle
535
- });
536
-
537
- document.addEventListener("chat-gem:chat-reopened", function (event) {
538
- // event.detail.action => "reopened"
539
- // event.detail.chatId
540
- // event.detail.chatTitle
541
- });
542
- ```
543
-
544
- #### Moderation notifications (server-side)
545
-
546
- ```ruby
547
- TurboChat.configure do |config|
548
- config.emit_moderation_events = true
549
- end
550
- ```
551
-
552
- When enabled, TurboChat instruments `ActiveSupport::Notifications` events:
553
-
554
- - `chat_gem.moderation.member_muted`
555
- - `chat_gem.moderation.member_unmuted`
556
- - `chat_gem.moderation.member_timed_out`
557
- - `chat_gem.moderation.member_timeout_cleared`
558
- - `chat_gem.moderation.member_banned`
559
- - `chat_gem.moderation.message_deleted`
560
- - `chat_gem.moderation.chat_closed`
561
- - `chat_gem.moderation.chat_reopened`
562
-
563
- ```ruby
564
- ActiveSupport::Notifications.subscribe("chat_gem.moderation.member_banned") do |_name, _start, _finish, _id, payload|
565
- # payload includes chat_id, membership_id, participant_type, participant_id, actor_type, actor_id
566
- end
567
263
  ```
568
264
 
569
- #### Blocked words notifications (server-side)
265
+ Client namespace: `window.TurboChatUI`
570
266
 
571
- ```ruby
572
- TurboChat.configure do |config|
573
- config.emit_blocked_words_events = true
574
- end
575
- ```
267
+ ## ActiveSupport::Notifications
576
268
 
577
- When enabled, blocked-word moderation instruments:
269
+ Moderation (`emit_moderation_events = true`):
270
+ - `turbo_chat.moderation.member_muted`
271
+ - `turbo_chat.moderation.member_unmuted`
272
+ - `turbo_chat.moderation.member_timed_out`
273
+ - `turbo_chat.moderation.member_timeout_cleared`
274
+ - `turbo_chat.moderation.member_banned`
275
+ - `turbo_chat.moderation.message_deleted`
276
+ - `turbo_chat.moderation.chat_closed`
277
+ - `turbo_chat.moderation.chat_reopened`
578
278
 
579
- - `chat_gem.blocked_words.detected`
580
- - `chat_gem.blocked_words.rejected`
581
- - `chat_gem.blocked_words.scrambled`
582
-
583
- ```ruby
584
- ActiveSupport::Notifications.subscribe("chat_gem.blocked_words.detected") do |_name, _start, _finish, _id, payload|
585
- # payload includes chat_id, message_id, participant_type, participant_id, blocked_words, action
586
- end
587
- ```
588
-
589
- ## Participants, Roles, and Moderation
590
-
591
- Use `TurboChat::ChatMembership` to add participants to a chat.
592
- Any model using `acts_as_chat_participant` works (users, bots, service accounts).
593
-
594
- ```ruby
595
- chat = TurboChat::Chat.find(chat_id)
596
- participant = User.find(user_id)
279
+ Blocked words (`emit_blocked_words_events = true`):
280
+ - `turbo_chat.blocked_words.detected`
281
+ - `turbo_chat.blocked_words.rejected`
282
+ - `turbo_chat.blocked_words.scrambled`
597
283
 
598
- TurboChat::ChatMembership.find_or_create_by!(chat: chat, participant: participant) do |membership|
599
- membership.role = :member
600
- end
601
- ```
602
-
603
- Invitations are pending until accepted by the invited participant.
604
- `POST /chats/:id/chat_memberships` creates or reopens a pending invite (`invitation_accepted: false`).
605
- Pending invites are listed on the chats index for the invited participant, where they can accept (`PATCH /chats/:id/accept`) or decline (`PATCH /chats/:id/decline`).
606
-
607
- Configure participant limits:
608
-
609
- ```ruby
610
- TurboChat.configure do |config|
611
- config.max_chat_participants = 10
612
- # Set to nil or 0 to disable the limit.
613
- end
614
- ```
615
-
616
- Built-in roles: `:member`, `:moderator`, `:admin`.
617
-
618
- - `member`: view chat, post messages, mention members.
619
- - `moderator`: member abilities plus invites, `@all`, `@ROLE`, mute/timeout/ban members, and delete member messages.
620
- - `admin`: moderator abilities plus moderating moderators and closing/reopening chats.
621
-
622
- Custom role registration:
623
-
624
- ```ruby
625
- TurboChat.configure do |config|
626
- config.add_role(
627
- :support_agent,
628
- name: "Support Agent",
629
- rank: 1,
630
- permissions: %i[view_chat post_message delete_message]
631
- )
632
- end
633
- ```
634
-
635
- Assign a custom role:
636
-
637
- ```ruby
638
- membership = TurboChat::ChatMembership.find_or_create_by!(chat: chat, participant: participant)
639
- membership.role_key = :support_agent
640
- membership.save!
641
- ```
642
-
643
- Available permissions:
644
- `:view_chat`, `:post_message`, `:mention_member`, `:mention_all`, `:mention_role`, `:invite_member`, `:mute_member`, `:timeout_member`, `:ban_member`, `:delete_message`, `:close_chat`, `:reopen_chat`
645
-
646
- Higher `rank` can moderate lower `rank` (never self).
647
-
648
- If a participant was removed (`removed_at` set), reactivate that membership:
649
-
650
- ```ruby
651
- membership = TurboChat::ChatMembership.find_by!(chat: chat, participant: participant)
652
- membership.update!(removed_at: nil, muted: false, timed_out_until: nil)
653
- ```
654
-
655
- Use moderation service APIs for role-checked actions:
656
-
657
- ```ruby
658
- chat = TurboChat::Chat.find(chat_id)
659
- moderator = User.find(moderator_id)
660
- member_membership = chat.chat_memberships.find_by!(participant_id: member_id, participant_type: "User")
661
-
662
- TurboChat::Moderation.mute_member!(actor: moderator, membership: member_membership)
663
- TurboChat::Moderation.timeout_member!(actor: moderator, membership: member_membership, until_time: 30.minutes.from_now)
664
- TurboChat::Moderation.ban_member!(actor: moderator, membership: member_membership)
665
- TurboChat::Moderation.delete_message!(actor: moderator, message: chat.chat_messages.find(message_id))
666
-
667
- admin = User.find(admin_id)
668
- TurboChat::Moderation.close_chat!(actor: admin, chat: chat)
669
- TurboChat::Moderation.reopen_chat!(actor: admin, chat: chat)
670
- ```
671
-
672
- ## Programmatic Signals
673
-
674
- Use signal helpers to show temporary participant states in normal chat flows (`typing`, `thinking`, `planning`).
675
-
676
- ### Start and clear a signal
677
-
678
- ```ruby
679
- chat = TurboChat::Chat.find(chat_id)
680
- participant = Current.user
284
+ ## UI Customization
681
285
 
682
- TurboChat::Signals.start!(chat: chat, participant: participant, signal_type: :thinking)
683
- # ...perform work (drafting, validation, lookup, etc.)...
684
- TurboChat::Signals.clear!(chat: chat, participant: participant)
685
- ```
286
+ Do this in order:
287
+ 1. Theme with config (`own_message_hex_color`, `role_message_hex_colors`, `mention_mark_hex_color`, formatters).
288
+ 2. Add class-level customization via `message_css_class_resolver`.
289
+ 3. Only then override markup partials.
686
290
 
687
- ### Replace signal state
291
+ Primary partial override point:
292
+ - `app/views/turbo_chat/chat_messages/_message.html.erb`
688
293
 
689
- ```ruby
690
- TurboChat::Signals.start!(chat: chat, participant: participant, signal_type: :thinking)
691
- TurboChat::Signals.replace!(chat: chat, participant: participant, signal_type: :planning)
692
- ```
294
+ Keep `id="<%= dom_id(chat_message) %>"` on the message wrapper or Turbo replacements break.
693
295
 
694
- ### Auto-clear signals with a block
296
+ ## Internal Names
695
297
 
696
- ```ruby
697
- final_text = TurboChat::Signals.with(chat: chat, participant: participant, signal_type: :thinking) do
698
- params[:body].to_s.strip
699
- end
298
+ Use `TurboChat` in app code.
700
299
 
701
- TurboChat::ChatMessage.create!(
702
- chat: chat,
703
- participant: participant,
704
- kind: :message,
705
- body: final_text
706
- )
707
- ```
300
+ These are implementation identifiers used by the engine:
301
+ - Engine namespace in source: `TurboChat`
302
+ - Database table prefix: `turbo_chat_`
303
+ - Browser event prefix: `turbo-chat:*`
304
+ - `ActiveSupport::Notifications` prefix: `turbo_chat.*`
708
305
 
709
- ### Submit-time replacement on message send
306
+ Mount with `as: "turbo_chat"` and use `turbo_chat.*` route helpers.
710
307
 
711
- When the composer submits a regular message, it stops the typing loop and requests signal clear.
712
- For an additional server-side safeguard, enable submit-time cleanup:
308
+ ## Upgrade
713
309
 
714
- ```ruby
715
- TurboChat.configure do |config|
716
- config.replace_signals_on_message_submit = true
717
- end
310
+ ```bash
311
+ bin/rails turbo_chat:install:migrations
312
+ bin/rails db:migrate
718
313
  ```
719
314
 
720
- With this enabled, existing signals for that participant are cleared before the message record is created.
721
-
722
315
  ## Dependencies
723
316
 
724
- Runtime dependencies:
725
-
726
317
  - Ruby `>= 3.1`
727
318
  - Rails `>= 7.0`, `< 8.0`
728
319
  - `turbo-rails` `>= 1.4`, `< 3.0`
320
+ - PostgreSQL or SQLite
729
321
 
730
- Database adapter requirement:
731
-
732
- - PostgreSQL or SQLite (required for the partial unique index used by chat memberships).
733
-
734
- Development dependencies in this repository:
322
+ ## Development
735
323
 
736
- - `sqlite3` `~> 1.4`
737
- - `minitest` `~> 5.27`
738
-
739
- ## Maintainer
740
-
741
- [haumer](https://github.com/haumer)
324
+ ```bash
325
+ bundle exec rake test
326
+ ```