turbo_chat 0.1.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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +741 -0
  4. data/app/assets/config/chat_gem_manifest.js +6 -0
  5. data/app/assets/javascripts/chat_gem/application.js +4 -0
  6. data/app/assets/javascripts/chat_gem/lifecycle_events.js +93 -0
  7. data/app/assets/javascripts/chat_gem/messages.js +442 -0
  8. data/app/assets/javascripts/chat_gem/realtime.js +398 -0
  9. data/app/assets/javascripts/chat_gem/shared.js +488 -0
  10. data/app/assets/stylesheets/chat_gem/application.css +741 -0
  11. data/app/controllers/chat_gem/application_controller.rb +41 -0
  12. data/app/controllers/chat_gem/chat_memberships_controller.rb +81 -0
  13. data/app/controllers/chat_gem/chat_messages_controller.rb +144 -0
  14. data/app/controllers/chat_gem/chats_controller/event_payload_support.rb +58 -0
  15. data/app/controllers/chat_gem/chats_controller/invitation_support.rb +31 -0
  16. data/app/controllers/chat_gem/chats_controller.rb +125 -0
  17. data/app/helpers/chat_gem/application_helper/config_support.rb +41 -0
  18. data/app/helpers/chat_gem/application_helper/mention_support/entry_builder.rb +55 -0
  19. data/app/helpers/chat_gem/application_helper/mention_support/permission_support.rb +28 -0
  20. data/app/helpers/chat_gem/application_helper/mention_support/token_builder.rb +49 -0
  21. data/app/helpers/chat_gem/application_helper/mention_support.rb +80 -0
  22. data/app/helpers/chat_gem/application_helper/message_rendering.rb +165 -0
  23. data/app/helpers/chat_gem/application_helper/participant_support.rb +81 -0
  24. data/app/helpers/chat_gem/application_helper.rb +12 -0
  25. data/app/models/chat_gem/application_record.rb +5 -0
  26. data/app/models/chat_gem/chat.rb +127 -0
  27. data/app/models/chat_gem/chat_membership.rb +136 -0
  28. data/app/models/chat_gem/chat_message/blocked_words_moderation.rb +120 -0
  29. data/app/models/chat_gem/chat_message/body_length_validation.rb +20 -0
  30. data/app/models/chat_gem/chat_message/broadcasting.rb +61 -0
  31. data/app/models/chat_gem/chat_message/formatting.rb +81 -0
  32. data/app/models/chat_gem/chat_message/mention_validation.rb +85 -0
  33. data/app/models/chat_gem/chat_message/signals.rb +61 -0
  34. data/app/models/chat_gem/chat_message.rb +40 -0
  35. data/app/views/chat_gem/chat_messages/_chat_message.html.erb +1 -0
  36. data/app/views/chat_gem/chat_messages/_form.html.erb +22 -0
  37. data/app/views/chat_gem/chat_messages/_message.html.erb +83 -0
  38. data/app/views/chat_gem/chat_messages/_signal.html.erb +3 -0
  39. data/app/views/chat_gem/chat_messages/_signals.html.erb +24 -0
  40. data/app/views/chat_gem/chat_messages/index.html.erb +1 -0
  41. data/app/views/chat_gem/chats/index.html.erb +51 -0
  42. data/app/views/chat_gem/chats/new.html.erb +13 -0
  43. data/app/views/chat_gem/chats/show.html.erb +95 -0
  44. data/app/views/layouts/chat_gem/application.html.erb +20 -0
  45. data/config/routes.rb +16 -0
  46. data/db/migrate/20260215000000_create_chat_gem_chats.rb +8 -0
  47. data/db/migrate/20260215000001_create_chat_gem_chat_memberships.rb +19 -0
  48. data/db/migrate/20260215000002_create_chat_gem_chat_messages.rb +14 -0
  49. data/db/migrate/20260218000011_add_closed_at_to_chat_gem_chats.rb +6 -0
  50. data/db/migrate/20260218000012_add_custom_role_key_to_chat_memberships.rb +6 -0
  51. data/db/migrate/20260218000013_add_invitation_accepted_to_chat_gem_chat_memberships.rb +5 -0
  52. data/lib/chat_gem/configuration.rb +242 -0
  53. data/lib/chat_gem/engine.rb +29 -0
  54. data/lib/chat_gem/model_extensions/chat_participant.rb +45 -0
  55. data/lib/chat_gem/moderation.rb +194 -0
  56. data/lib/chat_gem/permission.rb +193 -0
  57. data/lib/chat_gem/signals.rb +26 -0
  58. data/lib/chat_gem/version.rb +3 -0
  59. data/lib/chat_gem.rb +24 -0
  60. data/lib/generators/chat_gem/install/install_generator.rb +18 -0
  61. data/lib/generators/chat_gem/install/templates/chat_gem.rb +36 -0
  62. data/lib/generators/turbo_chat/install/install_generator.rb +18 -0
  63. data/lib/generators/turbo_chat/install/templates/turbo_chat.rb +36 -0
  64. data/lib/tasks/chat_gem_tasks.rake +1 -0
  65. data/lib/tasks/turbo_chat_tasks.rake +10 -0
  66. data/lib/turbo_chat/version.rb +5 -0
  67. data/lib/turbo_chat.rb +24 -0
  68. data/turbo_chat.gemspec +31 -0
  69. metadata +155 -0
data/README.md ADDED
@@ -0,0 +1,741 @@
1
+ # TurboChat
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:
27
+
28
+ ```ruby
29
+ # Gemfile
30
+ gem "turbo_chat"
31
+ ```
32
+
33
+ 2. Install and copy setup files:
34
+
35
+ ```bash
36
+ bundle install
37
+ bin/rails generate turbo_chat:install
38
+ bin/rails db:migrate
39
+ ```
40
+
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:
49
+
50
+ ```ruby
51
+ # config/routes.rb
52
+ mount TurboChat::Engine => "/"
53
+ ```
54
+
55
+ `turbo_chat` exposes `TurboChat` and keeps `ChatGem` as a backwards-compatible alias.
56
+
57
+ ## Host App Contract
58
+
59
+ ### Participant models
60
+
61
+ Any model that should join chats must call `acts_as_chat_participant`:
62
+
63
+ ```ruby
64
+ class User < ApplicationRecord
65
+ acts_as_chat_participant
66
+ end
67
+ ```
68
+
69
+ ### Current participant
70
+
71
+ Expose the current participant from your host `ApplicationController`:
72
+
73
+ ```ruby
74
+ class ApplicationController < ActionController::Base
75
+ helper_method :chat_current_participant
76
+
77
+ def chat_current_participant
78
+ Current.user
79
+ end
80
+ end
81
+ ```
82
+
83
+ `chat_current_participant` must return a model using `acts_as_chat_participant`, or `nil` for unauthenticated sessions.
84
+
85
+ ## Simple Example
86
+
87
+ Create a chat, add the current participant, and link to the chat view:
88
+
89
+ ```ruby
90
+ chat = TurboChat::Chat.create!(title: "Support")
91
+ TurboChat::ChatMembership.create!(chat: chat, participant: Current.user, role: :member)
92
+ ```
93
+
94
+ ```erb
95
+ <%= link_to "Open chat", chat_gem.chat_path(chat) %>
96
+ ```
97
+
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
137
+
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).
150
+
151
+ #### Browser events
152
+
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>
163
+
164
+ ```ruby
165
+ TurboChat.configure do |config|
166
+ config.permission_adapter = TurboChat::Permission
167
+ config.max_chat_participants = 10
168
+ config.max_message_length = 1000
169
+ config.message_history_limit = 200
170
+ config.enable_mentions = true
171
+ config.mention_filter_exclude_self = true
172
+ config.mention_filter_hide_roles = true
173
+ config.enable_emoji_aliases = true
174
+ config.emoji_aliases = TurboChat::Configuration::DEFAULT_EMOJI_ALIASES.dup
175
+ 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 = {}
182
+ config.show_timestamp = true
183
+ config.show_role = false
184
+ config.active_chat_window = 5.minutes
185
+ config.emit_typing_events = false
186
+ config.emit_message_events = false
187
+ config.emit_mention_events = false
188
+ config.emit_invitation_events = false
189
+ config.emit_chat_lifecycle_events = false
190
+ config.emit_moderation_events = false
191
+ 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
+ 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
+ ```
224
+
225
+ ### Mentions and Emoji
226
+
227
+ Mention suggestions are built from active chat memberships and can include:
228
+
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`)
232
+
233
+ By default, autocomplete also excludes the current participant (`config.mention_filter_exclude_self = true`).
234
+
235
+ Mentions are permission-filtered and server-validated:
236
+
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
244
+
245
+ ```ruby
246
+ 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" => "🦜"
259
+ )
260
+ end
261
+ ```
262
+
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:
409
+
410
+ ```html
411
+ <h4 title="notice">Update</h4><blockquote><mark>Done</mark></blockquote>underline
412
+ ```
413
+
414
+ ### Browser Events
415
+
416
+ #### Typing lifecycle events
417
+
418
+ ```ruby
419
+ TurboChat.configure do |config|
420
+ config.emit_typing_events = true
421
+ end
422
+ ```
423
+
424
+ ```js
425
+ document.addEventListener("chat-gem:typing-started", function (event) {
426
+ // event.detail.chatId
427
+ });
428
+
429
+ document.addEventListener("chat-gem:typing-ended", function (event) {
430
+ // event.detail.chatId
431
+ });
432
+ ```
433
+
434
+ #### Message sent event
435
+
436
+ ```ruby
437
+ TurboChat.configure do |config|
438
+ config.emit_message_events = true
439
+ end
440
+ ```
441
+
442
+ ```js
443
+ document.addEventListener("chat-gem:message-sent", function (event) {
444
+ // event.detail.chatId
445
+ });
446
+ ```
447
+
448
+ #### Mention event
449
+
450
+ ```ruby
451
+ TurboChat.configure do |config|
452
+ config.emit_mention_events = true
453
+ end
454
+ ```
455
+
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
467
+
468
+ ```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
+ });
483
+ ```
484
+
485
+ #### Chat lifecycle events
486
+
487
+ ```ruby
488
+ TurboChat.configure do |config|
489
+ config.emit_chat_lifecycle_events = true
490
+ end
491
+ ```
492
+
493
+ Emits lifecycle events on page load after redirect:
494
+
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
501
+
502
+ ```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
508
+ });
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
+ ```
568
+
569
+ #### Blocked words notifications (server-side)
570
+
571
+ ```ruby
572
+ TurboChat.configure do |config|
573
+ config.emit_blocked_words_events = true
574
+ end
575
+ ```
576
+
577
+ When enabled, blocked-word moderation instruments:
578
+
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)
597
+
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
681
+
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
+ ```
686
+
687
+ ### Replace signal state
688
+
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
+ ```
693
+
694
+ ### Auto-clear signals with a block
695
+
696
+ ```ruby
697
+ final_text = TurboChat::Signals.with(chat: chat, participant: participant, signal_type: :thinking) do
698
+ params[:body].to_s.strip
699
+ end
700
+
701
+ TurboChat::ChatMessage.create!(
702
+ chat: chat,
703
+ participant: participant,
704
+ kind: :message,
705
+ body: final_text
706
+ )
707
+ ```
708
+
709
+ ### Submit-time replacement on message send
710
+
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:
713
+
714
+ ```ruby
715
+ TurboChat.configure do |config|
716
+ config.replace_signals_on_message_submit = true
717
+ end
718
+ ```
719
+
720
+ With this enabled, existing signals for that participant are cleared before the message record is created.
721
+
722
+ ## Dependencies
723
+
724
+ Runtime dependencies:
725
+
726
+ - Ruby `>= 3.1`
727
+ - Rails `>= 7.0`, `< 8.0`
728
+ - `turbo-rails` `>= 1.4`, `< 3.0`
729
+
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:
735
+
736
+ - `sqlite3` `~> 1.4`
737
+ - `minitest` `~> 5.27`
738
+
739
+ ## Maintainer
740
+
741
+ [haumer](https://github.com/haumer)