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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +741 -0
- data/app/assets/config/chat_gem_manifest.js +6 -0
- data/app/assets/javascripts/chat_gem/application.js +4 -0
- data/app/assets/javascripts/chat_gem/lifecycle_events.js +93 -0
- data/app/assets/javascripts/chat_gem/messages.js +442 -0
- data/app/assets/javascripts/chat_gem/realtime.js +398 -0
- data/app/assets/javascripts/chat_gem/shared.js +488 -0
- data/app/assets/stylesheets/chat_gem/application.css +741 -0
- data/app/controllers/chat_gem/application_controller.rb +41 -0
- data/app/controllers/chat_gem/chat_memberships_controller.rb +81 -0
- data/app/controllers/chat_gem/chat_messages_controller.rb +144 -0
- data/app/controllers/chat_gem/chats_controller/event_payload_support.rb +58 -0
- data/app/controllers/chat_gem/chats_controller/invitation_support.rb +31 -0
- data/app/controllers/chat_gem/chats_controller.rb +125 -0
- data/app/helpers/chat_gem/application_helper/config_support.rb +41 -0
- data/app/helpers/chat_gem/application_helper/mention_support/entry_builder.rb +55 -0
- data/app/helpers/chat_gem/application_helper/mention_support/permission_support.rb +28 -0
- data/app/helpers/chat_gem/application_helper/mention_support/token_builder.rb +49 -0
- data/app/helpers/chat_gem/application_helper/mention_support.rb +80 -0
- data/app/helpers/chat_gem/application_helper/message_rendering.rb +165 -0
- data/app/helpers/chat_gem/application_helper/participant_support.rb +81 -0
- data/app/helpers/chat_gem/application_helper.rb +12 -0
- data/app/models/chat_gem/application_record.rb +5 -0
- data/app/models/chat_gem/chat.rb +127 -0
- data/app/models/chat_gem/chat_membership.rb +136 -0
- data/app/models/chat_gem/chat_message/blocked_words_moderation.rb +120 -0
- data/app/models/chat_gem/chat_message/body_length_validation.rb +20 -0
- data/app/models/chat_gem/chat_message/broadcasting.rb +61 -0
- data/app/models/chat_gem/chat_message/formatting.rb +81 -0
- data/app/models/chat_gem/chat_message/mention_validation.rb +85 -0
- data/app/models/chat_gem/chat_message/signals.rb +61 -0
- data/app/models/chat_gem/chat_message.rb +40 -0
- data/app/views/chat_gem/chat_messages/_chat_message.html.erb +1 -0
- data/app/views/chat_gem/chat_messages/_form.html.erb +22 -0
- data/app/views/chat_gem/chat_messages/_message.html.erb +83 -0
- data/app/views/chat_gem/chat_messages/_signal.html.erb +3 -0
- data/app/views/chat_gem/chat_messages/_signals.html.erb +24 -0
- data/app/views/chat_gem/chat_messages/index.html.erb +1 -0
- data/app/views/chat_gem/chats/index.html.erb +51 -0
- data/app/views/chat_gem/chats/new.html.erb +13 -0
- data/app/views/chat_gem/chats/show.html.erb +95 -0
- data/app/views/layouts/chat_gem/application.html.erb +20 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20260215000000_create_chat_gem_chats.rb +8 -0
- data/db/migrate/20260215000001_create_chat_gem_chat_memberships.rb +19 -0
- data/db/migrate/20260215000002_create_chat_gem_chat_messages.rb +14 -0
- data/db/migrate/20260218000011_add_closed_at_to_chat_gem_chats.rb +6 -0
- data/db/migrate/20260218000012_add_custom_role_key_to_chat_memberships.rb +6 -0
- data/db/migrate/20260218000013_add_invitation_accepted_to_chat_gem_chat_memberships.rb +5 -0
- data/lib/chat_gem/configuration.rb +242 -0
- data/lib/chat_gem/engine.rb +29 -0
- data/lib/chat_gem/model_extensions/chat_participant.rb +45 -0
- data/lib/chat_gem/moderation.rb +194 -0
- data/lib/chat_gem/permission.rb +193 -0
- data/lib/chat_gem/signals.rb +26 -0
- data/lib/chat_gem/version.rb +3 -0
- data/lib/chat_gem.rb +24 -0
- data/lib/generators/chat_gem/install/install_generator.rb +18 -0
- data/lib/generators/chat_gem/install/templates/chat_gem.rb +36 -0
- data/lib/generators/turbo_chat/install/install_generator.rb +18 -0
- data/lib/generators/turbo_chat/install/templates/turbo_chat.rb +36 -0
- data/lib/tasks/chat_gem_tasks.rake +1 -0
- data/lib/tasks/turbo_chat_tasks.rake +10 -0
- data/lib/turbo_chat/version.rb +5 -0
- data/lib/turbo_chat.rb +24 -0
- data/turbo_chat.gemspec +31 -0
- 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)
|