turbo_chat 0.1.12 → 0.1.14

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc8d322788826376f34b0944e94e293a5721494eae3e1e2d7767722f33073a8d
4
- data.tar.gz: 3394be20cb1728f8bc71e76fa652b8ed03dccbc189d527c08c7eb55d7616dcd5
3
+ metadata.gz: b1c6f0dd5991186ff33af55ffef201d7ab37af996f24884e4e25fb2fe8bf6b0c
4
+ data.tar.gz: e0d4b6d10fff71303104ea2d0f797d62222d694d4a27aa7201699730abad6c7a
5
5
  SHA512:
6
- metadata.gz: d796b827e0efca5f8653bcfe7a0bfa6d642b61733920e5526fc6180c73d3a059b2ef6abbc7c5f051761cf75de1bc70b50cf309e5f382ea3c20e5197df6bb0be9
7
- data.tar.gz: 5669ff78684930d750766fccf1e1b18f25c8e90d3d52ac9d5f4aa5cd5f9b6eea3ffc4c3b357de8a1c6068e1ef244417649e2ae1dad2fd5e5b51badefc923e94d
6
+ metadata.gz: 4968592c0c8462d24a43db894c60229fbcb6d172d0036612934d22e4713e66e454712d2b28c6ce53b9b5e34f8560e6b5f1e7cd5f624e6cfbe696fa1ddfd3747f
7
+ data.tar.gz: eafbf1fdfbc738c0922ca8885c53537eac0377c25ce494dfc04a70038a25e3e63fad432960c39b6b31e25bfa2ce5782cc052e44a797a771d14c53d3a8e7cf0f3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to `turbo_chat` will be documented in this file.
4
4
 
5
+ ## [0.1.14] - 2026-02-28
6
+
7
+ ### Added
8
+ - Scoped configuration namespaces for clearer organization: `config.chat`, `config.chat_message`, `config.style`, `config.moderation`, `config.events`, and `config.signals`.
9
+ - Scoped installer initializer output with inline guidance grouped by scope.
10
+
11
+ ### Changed
12
+ - Updated README examples to use scoped configuration.
13
+ - Flat configuration aliases (for example `config.max_message_length`) remain supported for backward compatibility.
14
+
15
+ ## [0.1.13] - 2026-02-26
16
+
17
+ ### Added
18
+ - Configurable timeline insert behavior via `config.message_insert_position` (`append_end` or `append_start`).
19
+ - Configurable composer disable toggle via `config.disable_input` to hide input and reject message posts.
20
+
21
+ ### Changed
22
+ - Signal indicators now render status text for standard signal types (`typing`, `thinking`, `planning`) so shimmer styling can pass over the status text only (not participant names).
23
+ - `config.signal_text_sheen` is now consistently cast as a boolean during signal rendering.
24
+
5
25
  ## [0.1.12] - 2026-02-26
6
26
 
7
27
  ### Added
data/README.md CHANGED
@@ -77,7 +77,7 @@ end
77
77
  Resolution order:
78
78
 
79
79
  1. Host `ApplicationController#current_chat_participant` (if defined)
80
- 2. `config.current_participant_resolver` (if configured)
80
+ 2. `config.chat.current_participant_resolver` (if configured)
81
81
  3. `current_user` (if available)
82
82
  4. Raise `NotImplementedError`
83
83
 
@@ -85,7 +85,7 @@ Optional resolver for non-`current_user` auth:
85
85
 
86
86
  ```ruby
87
87
  TurboChat.configure do |config|
88
- config.current_participant_resolver = ->(controller) { controller.send(:current_member) }
88
+ config.chat.current_participant_resolver = ->(controller) { controller.send(:current_member) }
89
89
  end
90
90
  ```
91
91
 
@@ -110,32 +110,36 @@ Start with a minimal initializer and only expand when needed:
110
110
 
111
111
  ```ruby
112
112
  TurboChat.configure do |config|
113
- config.permission_adapter = TurboChat::Permission
114
-
115
- config.max_chat_participants = 10
116
- config.max_message_length = 1000
117
- config.message_history_limit = 200
118
-
119
- config.enable_mentions = true
120
- config.enable_emoji_aliases = true
121
-
122
- config.blocked_words = []
123
- config.blocked_words_action = :reject # or :scramble
124
-
125
- config.render_message_html = false
126
- config.show_timestamp = true
127
- config.show_role = false
128
- config.message_source_labels = TurboChat::Configuration::DEFAULT_MESSAGE_SOURCE_LABELS.dup
129
- config.chat_style = "chat_style_bounded"
130
- config.signal_ttl_seconds = 60
131
- config.signal_text_sheen = true
132
-
133
- config.emit_moderation_events = false
134
- config.emit_blocked_words_events = false
135
- config.emit_mention_events = false
113
+ config.chat.permission_adapter = TurboChat::Permission
114
+
115
+ config.chat.max_chat_participants = 10
116
+ config.chat_message.max_message_length = 1000
117
+ config.chat_message.message_history_limit = 200
118
+
119
+ config.chat_message.enable_mentions = true
120
+ config.chat_message.enable_emoji_aliases = true
121
+
122
+ config.chat_message.blocked_words = []
123
+ config.chat_message.blocked_words_action = :reject # or :scramble
124
+
125
+ config.chat_message.render_message_html = false
126
+ config.style.show_timestamp = true
127
+ config.style.show_role = false
128
+ config.chat_message.message_source_labels = TurboChat::Configuration::DEFAULT_MESSAGE_SOURCE_LABELS.dup
129
+ config.style.chat_style = "chat_style_bounded"
130
+ config.chat_message.message_insert_position = "append_end" # or "append_start"
131
+ config.chat.disable_input = false
132
+ config.signals.signal_ttl_seconds = 60
133
+ config.style.signal_text_sheen = true
134
+
135
+ config.moderation.emit_moderation_events = false
136
+ config.moderation.emit_blocked_words_events = false
137
+ config.events.emit_mention_events = false
136
138
  end
137
139
  ```
138
140
 
141
+ Flat aliases (for example `config.max_message_length`) still work for backward compatibility.
142
+
139
143
  ## Message Ingest API
140
144
 
141
145
  Post messages as a specific participant, including external sources like WhatsApp:
@@ -168,7 +172,7 @@ Source labels shown in message badges are configurable:
168
172
 
169
173
  ```ruby
170
174
  TurboChat.configure do |config|
171
- config.message_source_labels = {
175
+ config.chat_message.message_source_labels = {
172
176
  "app" => "In App",
173
177
  "whatsapp" => "WhatsApp",
174
178
  "sms_gateway" => "SMS"
@@ -180,7 +184,23 @@ Chat UI layout style is configurable:
180
184
 
181
185
  ```ruby
182
186
  TurboChat.configure do |config|
183
- config.chat_style = "chat_style_unbounded" # or "chat_style_bounded"
187
+ config.style.chat_style = "chat_style_unbounded" # or "chat_style_bounded"
188
+ end
189
+ ```
190
+
191
+ Timeline insert position is configurable:
192
+
193
+ ```ruby
194
+ TurboChat.configure do |config|
195
+ config.chat_message.message_insert_position = "append_end" # or "append_start"
196
+ end
197
+ ```
198
+
199
+ Composer input can be disabled globally:
200
+
201
+ ```ruby
202
+ TurboChat.configure do |config|
203
+ config.chat.disable_input = true
184
204
  end
185
205
  ```
186
206
 
@@ -244,13 +264,13 @@ Enable only what you consume:
244
264
 
245
265
  ```ruby
246
266
  TurboChat.configure do |config|
247
- config.emit_typing_events = true
248
- config.emit_message_events = true
249
- config.emit_mention_events = true
250
- config.emit_invitation_events = true
251
- config.emit_chat_lifecycle_events = true
252
- config.emit_moderation_events = true
253
- config.emit_blocked_words_events = true
267
+ config.events.emit_typing_events = true
268
+ config.events.emit_message_events = true
269
+ config.events.emit_mention_events = true
270
+ config.events.emit_invitation_events = true
271
+ config.events.emit_chat_lifecycle_events = true
272
+ config.moderation.emit_moderation_events = true
273
+ config.moderation.emit_blocked_words_events = true
254
274
  end
255
275
  ```
256
276
 
@@ -419,6 +419,17 @@
419
419
  return;
420
420
  }
421
421
 
422
+ var insertPosition = String(container.dataset.chatMessageInsertPosition || "").trim().toLowerCase();
423
+ if (insertPosition === "append_start") {
424
+ var firstMessage = container.firstElementChild;
425
+ if (!firstMessage) {
426
+ return;
427
+ }
428
+
429
+ container.scrollTop = Math.max(0, firstMessage.offsetTop - 2);
430
+ return;
431
+ }
432
+
422
433
  var lastMessage = container.lastElementChild;
423
434
  if (!lastMessage) {
424
435
  return;
@@ -27,7 +27,7 @@ module TurboChat
27
27
  return [send(:current_user), "#current_user"]
28
28
  end
29
29
 
30
- raise NotImplementedError, "Define #current_chat_participant, configure TurboChat.configuration.current_participant_resolver, or expose #current_user"
30
+ raise NotImplementedError, "Define #current_chat_participant, configure TurboChat.configuration.chat.current_participant_resolver, or expose #current_user"
31
31
  end
32
32
 
33
33
  def invoke_current_participant_resolver(resolver)
@@ -58,9 +58,18 @@ module TurboChat
58
58
  end
59
59
 
60
60
  def authorize_post_message!(chat)
61
+ return head(:forbidden) if chat_input_disabled?
61
62
  return if permission_for(chat).can_post_message?
62
63
 
63
64
  head :forbidden
64
65
  end
66
+
67
+ def chat_input_disabled?
68
+ configuration = TurboChat.configuration
69
+ value = configuration.respond_to?(:disable_input) ? configuration.disable_input : false
70
+ ActiveModel::Type::Boolean.new.cast(value)
71
+ rescue StandardError
72
+ false
73
+ end
65
74
  end
66
75
  end
@@ -82,7 +82,7 @@ module TurboChat
82
82
  def respond_to_chat_message_create_failure
83
83
  @chat_messages = @chat.visible_messages
84
84
  @chat_permission = permission_for(@chat)
85
- @can_post_message = @chat_permission.can_post_message?
85
+ @can_post_message = @chat_permission.can_post_message? && !chat_input_disabled?
86
86
  @show_members = chat_config_boolean(:show_members, default: true)
87
87
  respond_to do |format|
88
88
  format.turbo_stream { render "turbo_chat/chats/show", status: :unprocessable_entity }
@@ -61,7 +61,7 @@ module TurboChat
61
61
  @chat_lifecycle_event = chat_lifecycle_event_payload
62
62
  @chat_permission = permission_for(@chat)
63
63
  @chat_messages = @chat.visible_messages
64
- @can_post_message = @chat_permission.can_post_message?
64
+ @can_post_message = @chat_permission.can_post_message? && !chat_input_disabled?
65
65
  @show_members = show_members_enabled?
66
66
  @can_invite_member = permission_allows?(@chat_permission, :can_invite_member?)
67
67
  @can_manage_member_permissions = permission_allows?(@chat_permission, :can_grant_member_permissions?)
@@ -8,6 +8,8 @@ module TurboChat
8
8
  emit_invitation_events: false,
9
9
  emit_chat_lifecycle_events: false,
10
10
  show_members: true,
11
+ show_self_signals: false,
12
+ disable_input: false,
11
13
  composer_add_files_display: false,
12
14
  composer_add_files_active: false,
13
15
  composer_microphone_display: false,
@@ -79,6 +81,28 @@ module TurboChat
79
81
  chat_unbounded_style? ? "chat-shell--style-unbounded" : "chat-shell--style-bounded"
80
82
  end
81
83
 
84
+ def chat_message_insert_position
85
+ raw_position = chat_config_value(:message_insert_position, default: "append_end")
86
+ normalized = raw_position.to_s.strip.downcase
87
+
88
+ case normalized
89
+ when "append_start", "start", "prepend"
90
+ "append_start"
91
+ else
92
+ "append_end"
93
+ end
94
+ end
95
+
96
+ def chat_message_append_start?
97
+ chat_message_insert_position == "append_start"
98
+ end
99
+
100
+ def chat_signal_ttl_seconds
101
+ value = chat_config_value(:signal_ttl_seconds, default: 60)
102
+ ttl = value.to_i
103
+ ttl.positive? ? ttl : 60
104
+ end
105
+
82
106
  private
83
107
 
84
108
  def chat_config_value(method_name, default: nil)
@@ -105,7 +105,12 @@ module TurboChat
105
105
  end
106
106
 
107
107
  def self.signal_window_seconds(window = nil)
108
- value = window.nil? ? TurboChat.configuration.signal_ttl_seconds : window
108
+ value = if window.nil?
109
+ configuration = TurboChat.configuration
110
+ configuration.respond_to?(:signal_ttl_seconds) ? configuration.signal_ttl_seconds : 60
111
+ else
112
+ window
113
+ end
109
114
  seconds = value.to_i
110
115
  return seconds if seconds.positive?
111
116
 
@@ -10,13 +10,8 @@ module TurboChat
10
10
 
11
11
  stream = stream_name
12
12
 
13
- if appendable_timeline_message? && respond_to?(:broadcast_append_to)
14
- broadcast_append_to(
15
- stream,
16
- target: ActionView::RecordIdentifier.dom_id(chat, :messages),
17
- partial: CHAT_MESSAGE_PARTIAL,
18
- locals: { chat_message: self }
19
- )
13
+ if appendable_timeline_message?
14
+ broadcast_timeline_create(stream)
20
15
  end
21
16
 
22
17
  broadcast_update_to(
@@ -60,6 +55,34 @@ module TurboChat
60
55
  def appendable_timeline_message?
61
56
  message? || system?
62
57
  end
58
+
59
+ def broadcast_timeline_create(stream)
60
+ options = {
61
+ target: ActionView::RecordIdentifier.dom_id(chat, :messages),
62
+ partial: CHAT_MESSAGE_PARTIAL,
63
+ locals: { chat_message: self }
64
+ }
65
+
66
+ if append_start_position?
67
+ return unless respond_to?(:broadcast_prepend_to)
68
+
69
+ broadcast_prepend_to(stream, **options)
70
+ return
71
+ end
72
+
73
+ return unless respond_to?(:broadcast_append_to)
74
+
75
+ broadcast_append_to(stream, **options)
76
+ end
77
+
78
+ def append_start_position?
79
+ configuration = TurboChat.configuration
80
+ value = configuration.respond_to?(:message_insert_position) ? configuration.message_insert_position : "append_end"
81
+ normalized = value.to_s.strip.downcase
82
+ %w[append_start start prepend].include?(normalized)
83
+ rescue StandardError
84
+ false
85
+ end
63
86
  end
64
87
  end
65
88
  end
@@ -1,5 +1,11 @@
1
- <% show_self_signals = TurboChat.configuration.show_self_signals %>
2
- <% signal_text_sheen_enabled = TurboChat.configuration.signal_text_sheen %>
1
+ <% show_self_signals = if respond_to?(:chat_show_self_signals?, true)
2
+ chat_show_self_signals?
3
+ else
4
+ configuration = TurboChat.configuration
5
+ value = configuration.respond_to?(:show_self_signals) ? configuration.show_self_signals : false
6
+ ActiveModel::Type::Boolean.new.cast(value)
7
+ end %>
8
+ <% signal_text_sheen_enabled = ActiveModel::Type::Boolean.new.cast(TurboChat.configuration.signal_text_sheen) %>
3
9
  <% current_participant = respond_to?(:current_chat_participant, true) ? current_chat_participant : nil %>
4
10
  <% current_participant_type = current_participant&.class&.base_class&.name %>
5
11
  <% current_participant_id = current_participant&.id %>
@@ -17,19 +23,18 @@
17
23
  data-chat-signal-at="<%= signal_message.created_at.to_i %>"
18
24
  data-chat-signal-participant-type="<%= signal_message.participant_type %>"
19
25
  data-chat-signal-participant-id="<%= signal_message.participant_id %>">
26
+ <% signal_text = if signal_message.signal_type_custom? && signal_message.signal_text.present?
27
+ signal_message.signal_text
28
+ else
29
+ signal_message.signal_type.to_s
30
+ end %>
20
31
  <strong><%= signal_message.participant_display_name %></strong>
21
- <% if signal_message.signal_type_custom? && signal_message.signal_text.present? %>
22
- <span class="chat-signal-text<%= " chat-signal-text--sheen" if signal_text_sheen_enabled %>">
23
- <% if signal_text_sheen_enabled %>
24
- <span class="chat-signal-text-sheen"><%= signal_message.signal_text %></span>
25
- <% else %>
26
- <%= signal_message.signal_text %>
27
- <% end %>
28
- </span>
29
- <% else %>
30
- <span class="chat-dots">
31
- <i></i><i></i><i></i>
32
- </span>
33
- <% end %>
32
+ <span class="chat-signal-text<%= " chat-signal-text--sheen" if signal_text_sheen_enabled %>">
33
+ <% if signal_text_sheen_enabled %>
34
+ <span class="chat-signal-text-sheen"><%= signal_text %></span>
35
+ <% else %>
36
+ <%= signal_text %>
37
+ <% end %>
38
+ </span>
34
39
  </div>
35
40
  <% end %>
@@ -10,6 +10,10 @@
10
10
  ("chat-shell--closed" if @chat.closed?),
11
11
  ("chat-shell--can-manage-member-permissions" if @can_manage_member_permissions)
12
12
  ].compact.join(" ") %>
13
+ <% message_insert_position = chat_message_insert_position %>
14
+ <% chat_messages = @chat_messages.to_a %>
15
+ <% chat_messages.reverse! if message_insert_position == "append_start" %>
16
+ <% can_render_composer = @can_post_message && !chat_disable_input? %>
13
17
  <section class="<%= chat_shell_classes %>"
14
18
  data-chat-id="<%= @chat.id %>"
15
19
  data-chat-style="<%= chat_style_key %>"
@@ -113,6 +117,7 @@
113
117
  <div id="<%= dom_id(@chat, :messages) %>"
114
118
  class="chat-messages"
115
119
  data-chat-id="<%= @chat.id %>"
120
+ data-chat-message-insert-position="<%= message_insert_position %>"
116
121
  data-chat-self-participant-type="<%= current_participant_type %>"
117
122
  data-chat-self-participant-id="<%= current_participant_id %>"
118
123
  data-chat-self-mention-tokens="<%= json_escape(self_mention_tokens.to_json) %>"
@@ -122,20 +127,20 @@
122
127
  data-chat-emit-mention-events="<%= chat_emit_mention_events? %>"
123
128
  data-chat-can-edit-own-messages="<%= @can_edit_own_messages %>"
124
129
  <%= %(style="#{mention_container_style}") if mention_container_style.present? %>>
125
- <%= render @chat_messages %>
130
+ <%= render chat_messages %>
126
131
  </div>
127
132
 
128
133
  <div id="<%= dom_id(@chat, :signals) %>"
129
134
  class="chat-signals"
130
- data-chat-show-self-signals="<%= TurboChat.configuration.show_self_signals %>"
131
- data-chat-signal-ttl-seconds="<%= TurboChat.configuration.signal_ttl_seconds %>"
135
+ data-chat-show-self-signals="<%= chat_show_self_signals? %>"
136
+ data-chat-signal-ttl-seconds="<%= chat_signal_ttl_seconds %>"
132
137
  data-chat-self-participant-type="<%= current_participant_type %>"
133
138
  data-chat-self-participant-id="<%= current_participant_id %>">
134
139
  <%= render "turbo_chat/chat_messages/signals", chat: @chat %>
135
140
  </div>
136
141
  </section>
137
142
 
138
- <% if @can_post_message %>
143
+ <% if can_render_composer %>
139
144
  <%= render "turbo_chat/chat_messages/form",
140
145
  chat: @chat,
141
146
  chat_message: @chat_message,
@@ -1,118 +1,120 @@
1
1
  TurboChat.configure do |config|
2
- # Permissions
3
- config.permission_adapter = TurboChat::Permission
2
+ # Chat scope
3
+ config.chat.permission_adapter = TurboChat::Permission
4
4
  # Class that answers permission checks for participants/chats.
5
5
 
6
6
  # Authentication
7
- # config.current_participant_resolver = ->(controller) { controller.send(:current_member) }
7
+ # config.chat.current_participant_resolver = ->(controller) { controller.send(:current_member) }
8
8
  # Fallback resolver when you do not define `current_chat_participant`.
9
9
 
10
- # Limits
11
- config.max_chat_participants = 10
10
+ # Chat limits
11
+ config.chat.max_chat_participants = 10
12
12
  # Maximum active members allowed in a chat.
13
- config.max_message_length = 1000
13
+ config.chat_message.max_message_length = 1000
14
14
  # Maximum characters allowed in a message body.
15
- config.message_history_limit = 200
15
+ config.chat_message.message_history_limit = 200
16
16
  # Number of recent messages loaded in chat history.
17
- config.active_chat_window = 5.minutes
17
+ config.chat.active_chat_window = 5.minutes
18
18
  # Duration used to classify chats as active.
19
+ config.chat.show_members = true
20
+ # Shows member list panel in chat UI.
21
+ config.chat.system_messages = true
22
+ # Shows system timeline messages (joins, invites, moderation).
23
+ # config.chat.disable_input = true
24
+ # Disables composer rendering and blocks message submissions.
19
25
 
20
- # Mentions and emoji
21
- config.enable_mentions = true
26
+ # Chat message behavior
27
+ config.chat_message.enable_mentions = true
22
28
  # Enables mention parsing/highlighting and mention UI.
23
- config.mention_filter_exclude_self = true
29
+ config.chat_message.mention_filter_exclude_self = true
24
30
  # Hides self from mention suggestions.
25
- config.mention_filter_hide_roles = true
31
+ config.chat_message.mention_filter_hide_roles = true
26
32
  # Hides @ROLE suggestions in mention picker.
27
- config.enable_emoji_aliases = true
33
+ config.chat_message.enable_emoji_aliases = true
28
34
  # Expands :alias: tokens using configured emoji aliases.
29
- config.emoji_aliases = TurboChat::Configuration::DEFAULT_EMOJI_ALIASES.dup
35
+ config.chat_message.emoji_aliases = TurboChat::Configuration::DEFAULT_EMOJI_ALIASES.dup
30
36
  # Hash of emoji alias mappings, e.g. "smile" => "😄".
31
37
 
32
38
  # Blocked words
33
- config.blocked_words = []
39
+ config.chat_message.blocked_words = []
34
40
  # Case-insensitive word list checked before message save.
35
- config.blocked_words_action = :reject
41
+ config.chat_message.blocked_words_action = :reject
36
42
  # `:reject` adds a validation error, `:scramble` rewrites matched words.
37
- config.blocked_words_scramble_chars = TurboChat::Configuration::DEFAULT_BLOCKED_WORDS_SCRAMBLE_CHARS.dup
43
+ config.chat_message.blocked_words_scramble_chars = TurboChat::Configuration::DEFAULT_BLOCKED_WORDS_SCRAMBLE_CHARS.dup
38
44
  # Character pool used when scrambling blocked words.
45
+ config.chat_message.message_source_labels = TurboChat::Configuration::DEFAULT_MESSAGE_SOURCE_LABELS.dup
46
+ # Maps message source keys to labels shown on non-default source badges.
47
+ # config.chat_message.message_insert_position = "append_end"
48
+ # Timeline insert behavior: `append_end` (near composer) or `append_start` (top of list).
49
+ # config.chat_message.render_message_html = true
50
+ # Renders/sanitizes HTML in message bodies.
51
+ config.chat_message.message_html_tags = %w[a b br code em i li ol p pre strong ul]
52
+ # Allowed HTML tags when `render_message_html` is true.
53
+ config.chat_message.message_html_attributes = %w[href target rel class]
54
+ # Allowed HTML attributes when `render_message_html` is true.
55
+ config.chat_message.timestamp_formatter = ->(timestamp, _chat_message) { I18n.l(timestamp.in_time_zone, format: :long) }
56
+ # Formatter lambda for displayed timestamps.
57
+ config.chat_message.role_formatter = ->(role, _chat_message) { role.to_s.humanize }
58
+ # Formatter lambda for displayed role labels.
39
59
 
40
- # Visuals
41
- # config.mention_mark_hex_color = "b42318"
60
+ # Style scope
61
+ # config.style.mention_mark_hex_color = "b42318"
42
62
  # Mention highlight color (without #).
43
- # config.mention_highlight_hex_color = "b42318"
63
+ # config.style.mention_highlight_hex_color = "b42318"
44
64
  # Legacy mention highlight color fallback.
45
- # config.own_message_hex_color = "eef6ff"
65
+ # config.style.own_message_hex_color = "eef6ff"
46
66
  # Bubble background color for current participant messages.
47
- # config.other_message_hex_color = "ffffff"
67
+ # config.style.other_message_hex_color = "ffffff"
48
68
  # Bubble background color for other participant messages.
49
- config.role_message_hex_colors = {}
69
+ config.style.role_message_hex_colors = {}
50
70
  # Map role key to message bubble color.
51
- config.show_timestamp = true
71
+ config.style.show_timestamp = true
52
72
  # Shows formatted timestamp on each message.
53
- # config.show_role = true
73
+ # config.style.show_role = true
54
74
  # Shows participant role label near messages.
55
- config.show_members = true
56
- # Shows member list panel in chat UI.
57
- config.system_messages = true
58
- # Shows system timeline messages (joins, invites, moderation).
59
- config.message_source_labels = TurboChat::Configuration::DEFAULT_MESSAGE_SOURCE_LABELS.dup
60
- # Maps message source keys to labels shown on non-default source badges.
61
- config.chat_style = "chat_style_bounded"
75
+ config.style.chat_style = "chat_style_bounded"
62
76
  # Chat layout style: `chat_style_bounded` or `chat_style_unbounded`.
63
- config.composer_placeholder_text = "start chatting"
77
+ config.style.composer_placeholder_text = "start chatting"
64
78
  # Placeholder text for the message composer input.
79
+ config.style.signal_text_sheen = true
80
+ # Adds bracketed sheen styling to custom signal text.
81
+ # config.style.message_css_class_resolver = ->(_chat_message) { "chat-message" }
82
+ # Returns extra CSS class names for a message wrapper.
65
83
 
66
84
  # Optional composer controls (disabled by default)
67
- # config.composer_add_files_display = true
85
+ # config.style.composer_add_files_display = true
68
86
  # Shows the add-files control in the composer.
69
- # config.composer_add_files_active = true
87
+ # config.style.composer_add_files_active = true
70
88
  # Enables add-files control interaction.
71
- # config.composer_microphone_display = true
89
+ # config.style.composer_microphone_display = true
72
90
  # Shows microphone control in composer.
73
- # config.composer_microphone_active = true
91
+ # config.style.composer_microphone_active = true
74
92
  # Enables microphone control interaction.
75
93
 
76
- # Optional browser events (disabled by default)
77
- # config.emit_typing_events = true
94
+ # Events scope (disabled by default)
95
+ # config.events.emit_typing_events = true
78
96
  # Emits `turbo-chat:typing-started` and `turbo-chat:typing-ended`.
79
- # config.emit_message_events = true
97
+ # config.events.emit_message_events = true
80
98
  # Emits `turbo-chat:message-sent`.
81
- # config.emit_mention_events = true
99
+ # config.events.emit_mention_events = true
82
100
  # Emits `turbo-chat:mention`.
83
- # config.emit_invitation_events = true
101
+ # config.events.emit_invitation_events = true
84
102
  # Emits `turbo-chat:invitation-accepted`.
85
- # config.emit_chat_lifecycle_events = true
103
+ # config.events.emit_chat_lifecycle_events = true
86
104
  # Emits `turbo-chat:chat-*` lifecycle events.
87
105
 
88
- # Optional ActiveSupport::Notifications events (disabled by default)
89
- # config.emit_moderation_events = true
106
+ # Moderation scope (disabled by default)
107
+ # config.moderation.emit_moderation_events = true
90
108
  # Emits `turbo_chat.moderation.*` notifications.
91
- # config.emit_blocked_words_events = true
109
+ # config.moderation.emit_blocked_words_events = true
92
110
  # Emits `turbo_chat.blocked_words.*` notifications.
93
111
 
94
- # Signal behavior
95
- config.signal_ttl_seconds = 60
112
+ # Signals scope
113
+ config.signals.signal_ttl_seconds = 60
96
114
  # Maximum age (in seconds) for active typing/signal indicators.
97
- config.signal_text_sheen = true
98
- # Adds bracketed sheen styling to custom signal text.
99
115
  # Optional toggles (disabled by default)
100
- # config.show_self_signals = true
116
+ # config.signals.show_self_signals = true
101
117
  # Shows your own active typing/signal indicators.
102
- # config.replace_signals_on_message_submit = true
118
+ # config.signals.replace_signals_on_message_submit = true
103
119
  # Replaces existing signals when sending a message.
104
-
105
- # Optional message rendering hooks
106
- # config.message_css_class_resolver = ->(_chat_message) { "chat-message" }
107
- # Returns extra CSS class names for a message wrapper.
108
- # config.render_message_html = true
109
- # Renders/sanitizes HTML in message bodies.
110
- config.message_html_tags = %w[a b br code em i li ol p pre strong ul]
111
- # Allowed HTML tags when `render_message_html` is true.
112
- config.message_html_attributes = %w[href target rel class]
113
- # Allowed HTML attributes when `render_message_html` is true.
114
- config.timestamp_formatter = ->(timestamp, _chat_message) { I18n.l(timestamp.in_time_zone, format: :long) }
115
- # Formatter lambda for displayed timestamps.
116
- config.role_formatter = ->(role, _chat_message) { role.to_s.humanize }
117
- # Formatter lambda for displayed role labels.
118
120
  end
@@ -1,4 +1,18 @@
1
1
  class TurboChat::Configuration
2
+ def self.build_attribute_scopes(scoped_defaults)
3
+ scoped_defaults.each_with_object({}) do |(scope_name, defaults), mapping|
4
+ defaults.each_key do |attribute|
5
+ if mapping.key?(attribute)
6
+ existing_scope = mapping.fetch(attribute)
7
+ raise ArgumentError, "Duplicate configuration attribute `#{attribute}` across scopes: #{existing_scope}, #{scope_name}"
8
+ end
9
+
10
+ mapping[attribute] = scope_name
11
+ end
12
+ end.freeze
13
+ end
14
+ private_class_method :build_attribute_scopes
15
+
2
16
  DEFAULT_ROLE_DEFINITIONS = {
3
17
  "member" => {
4
18
  name: "Member",
@@ -51,10 +65,17 @@ class TurboChat::Configuration
51
65
  "whatsapp" => "WhatsApp"
52
66
  }.freeze
53
67
 
54
- DEFAULTS = {
68
+ CHAT_DEFAULTS = {
55
69
  permission_adapter: -> { TurboChat::Permission },
56
70
  current_participant_resolver: nil,
57
71
  max_chat_participants: 10,
72
+ active_chat_window: -> { 5.minutes },
73
+ show_members: true,
74
+ system_messages: true,
75
+ disable_input: false
76
+ }.freeze
77
+
78
+ CHAT_MESSAGE_DEFAULTS = {
58
79
  max_message_length: 1000,
59
80
  message_history_limit: 200,
60
81
  enable_mentions: true,
@@ -65,6 +86,16 @@ class TurboChat::Configuration
65
86
  blocked_words: -> { [] },
66
87
  blocked_words_action: DEFAULT_BLOCKED_WORDS_ACTION,
67
88
  blocked_words_scramble_chars: -> { DEFAULT_BLOCKED_WORDS_SCRAMBLE_CHARS.dup },
89
+ message_insert_position: "append_end",
90
+ message_source_labels: -> { DEFAULT_MESSAGE_SOURCE_LABELS.dup },
91
+ render_message_html: false,
92
+ message_html_tags: -> { DEFAULT_MESSAGE_HTML_TAGS.dup },
93
+ message_html_attributes: -> { DEFAULT_MESSAGE_HTML_ATTRIBUTES.dup },
94
+ timestamp_formatter: -> { ->(timestamp, _chat_message = nil) { I18n.l(timestamp.in_time_zone, format: :long) } },
95
+ role_formatter: -> { ->(role, _chat_message = nil) { role.to_s.humanize } }
96
+ }.freeze
97
+
98
+ STYLE_DEFAULTS = {
68
99
  mention_mark_hex_color: nil,
69
100
  mention_highlight_hex_color: nil,
70
101
  own_message_hex_color: nil,
@@ -72,32 +103,50 @@ class TurboChat::Configuration
72
103
  role_message_hex_colors: -> { {} },
73
104
  show_timestamp: true,
74
105
  show_role: false,
75
- show_members: true,
76
- system_messages: true,
77
106
  composer_placeholder_text: "start chatting",
78
107
  composer_add_files_display: false,
79
108
  composer_add_files_active: false,
80
109
  composer_microphone_display: false,
81
110
  composer_microphone_active: false,
82
- active_chat_window: -> { 5.minutes },
111
+ chat_style: "chat_style_bounded",
112
+ message_css_class_resolver: nil,
113
+ signal_text_sheen: true
114
+ }.freeze
115
+
116
+ MODERATION_DEFAULTS = {
117
+ emit_moderation_events: false,
118
+ emit_blocked_words_events: false
119
+ }.freeze
120
+
121
+ EVENTS_DEFAULTS = {
83
122
  emit_typing_events: false,
84
123
  emit_message_events: false,
85
124
  emit_mention_events: false,
86
125
  emit_invitation_events: false,
87
- emit_chat_lifecycle_events: false,
88
- emit_moderation_events: false,
89
- emit_blocked_words_events: false,
126
+ emit_chat_lifecycle_events: false
127
+ }.freeze
128
+
129
+ SIGNALS_DEFAULTS = {
90
130
  signal_ttl_seconds: 60,
91
- signal_text_sheen: true,
92
131
  show_self_signals: false,
93
- replace_signals_on_message_submit: false,
94
- message_css_class_resolver: nil,
95
- message_source_labels: -> { DEFAULT_MESSAGE_SOURCE_LABELS.dup },
96
- chat_style: "chat_style_bounded",
97
- render_message_html: false,
98
- message_html_tags: -> { DEFAULT_MESSAGE_HTML_TAGS.dup },
99
- message_html_attributes: -> { DEFAULT_MESSAGE_HTML_ATTRIBUTES.dup },
100
- timestamp_formatter: -> { ->(timestamp, _chat_message = nil) { I18n.l(timestamp.in_time_zone, format: :long) } },
101
- role_formatter: -> { ->(role, _chat_message = nil) { role.to_s.humanize } }
132
+ replace_signals_on_message_submit: false
102
133
  }.freeze
134
+
135
+ SCOPED_DEFAULTS = {
136
+ chat: CHAT_DEFAULTS,
137
+ chat_message: CHAT_MESSAGE_DEFAULTS,
138
+ style: STYLE_DEFAULTS,
139
+ moderation: MODERATION_DEFAULTS,
140
+ events: EVENTS_DEFAULTS,
141
+ signals: SIGNALS_DEFAULTS
142
+ }.freeze
143
+
144
+ ATTRIBUTE_SCOPES = build_attribute_scopes(SCOPED_DEFAULTS)
145
+
146
+ DEFAULTS = ATTRIBUTE_SCOPES.each_with_object({}) do |(attribute, scope_name), defaults|
147
+ defaults[attribute] = SCOPED_DEFAULTS.fetch(scope_name).fetch(attribute)
148
+ end.freeze
149
+
150
+ SCOPE_NAMES = SCOPED_DEFAULTS.keys.freeze
151
+ ATTRIBUTES = ATTRIBUTE_SCOPES.keys.freeze
103
152
  end
@@ -7,19 +7,19 @@ class TurboChat::Configuration
7
7
  normalized_value = value.to_s.strip
8
8
  raise ArgumentError, "Emoji alias value cannot be blank" if normalized_value.blank?
9
9
 
10
- @emoji_aliases = effective_emoji_aliases.merge(key => normalized_value)
10
+ self.emoji_aliases = effective_emoji_aliases.merge(key => normalized_value)
11
11
  end
12
12
 
13
13
  def remove_emoji_alias(name)
14
14
  key = normalize_emoji_alias_key(name)
15
15
  return if key.blank?
16
16
 
17
- @emoji_aliases = effective_emoji_aliases.except(key)
17
+ self.emoji_aliases = effective_emoji_aliases.except(key)
18
18
  end
19
19
 
20
- def clear_emoji_aliases! = @emoji_aliases = {}
20
+ def clear_emoji_aliases! = self.emoji_aliases = {}
21
21
 
22
- def reset_emoji_aliases! = @emoji_aliases = DEFAULT_EMOJI_ALIASES.dup
22
+ def reset_emoji_aliases! = self.emoji_aliases = DEFAULT_EMOJI_ALIASES.dup
23
23
 
24
24
  def effective_emoji_aliases
25
25
  source = emoji_aliases.is_a?(Hash) ? emoji_aliases : {}
@@ -8,13 +8,78 @@ class TurboChat::Configuration
8
8
  include EmojiSupport
9
9
  include BlockedWordsSupport
10
10
 
11
- attr_accessor(*DEFAULTS.keys)
11
+ class Scope
12
+ class << self
13
+ attr_reader :scope_defaults
14
+
15
+ def configure_defaults(defaults)
16
+ @scope_defaults = defaults
17
+ attr_accessor(*defaults.keys)
18
+ end
19
+ end
20
+
21
+ def initialize
22
+ self.class.scope_defaults.each do |attribute, default_value|
23
+ instance_variable_set("@#{attribute}", TurboChat::Configuration.resolve_default_value(default_value))
24
+ end
25
+ end
26
+ end
27
+
28
+ class Chat < Scope
29
+ configure_defaults(SCOPED_DEFAULTS.fetch(:chat))
30
+ end
31
+
32
+ class ChatMessage < Scope
33
+ configure_defaults(SCOPED_DEFAULTS.fetch(:chat_message))
34
+ end
35
+
36
+ class Style < Scope
37
+ configure_defaults(SCOPED_DEFAULTS.fetch(:style))
38
+ end
39
+
40
+ class Moderation < Scope
41
+ configure_defaults(SCOPED_DEFAULTS.fetch(:moderation))
42
+ end
43
+
44
+ class Events < Scope
45
+ configure_defaults(SCOPED_DEFAULTS.fetch(:events))
46
+ end
47
+
48
+ class Signals < Scope
49
+ configure_defaults(SCOPED_DEFAULTS.fetch(:signals))
50
+ end
51
+
52
+ SCOPE_CLASSES = {
53
+ chat: Chat,
54
+ chat_message: ChatMessage,
55
+ style: Style,
56
+ moderation: Moderation,
57
+ events: Events,
58
+ signals: Signals
59
+ }.freeze
60
+
61
+ attr_reader(*SCOPE_NAMES)
62
+
63
+ ATTRIBUTE_SCOPES.each do |attribute, scope_name|
64
+ define_method(attribute) do
65
+ public_send(scope_name).public_send(attribute)
66
+ end
67
+
68
+ define_method("#{attribute}=") do |value|
69
+ public_send(scope_name).public_send("#{attribute}=", value)
70
+ end
71
+ end
12
72
 
13
73
  def initialize
14
- DEFAULTS.each do |attribute, default_value|
15
- value = default_value.respond_to?(:call) ? default_value.call : (default_value.dup rescue default_value)
16
- instance_variable_set("@#{attribute}", value)
74
+ SCOPE_CLASSES.each do |scope_name, klass|
75
+ instance_variable_set("@#{scope_name}", klass.new)
17
76
  end
18
77
  @additional_roles = {}
19
78
  end
79
+
80
+ class << self
81
+ def resolve_default_value(default_value)
82
+ default_value.respond_to?(:call) ? default_value.call : (default_value.dup rescue default_value)
83
+ end
84
+ end
20
85
  end
@@ -26,6 +26,7 @@ class TurboChat::Permission
26
26
 
27
27
  def can_post_message?
28
28
  can_view_chat? &&
29
+ !chat_input_disabled? &&
29
30
  role_permission?(:post_message) &&
30
31
  !chat_closed? &&
31
32
  !actor_membership_muted? &&
@@ -65,4 +66,14 @@ class TurboChat::Permission
65
66
  def can_close_chat? = can_view_chat? && role_permission?(:close_chat)
66
67
 
67
68
  def can_reopen_chat? = can_view_chat? && role_permission?(:reopen_chat)
69
+
70
+ private
71
+
72
+ def chat_input_disabled?
73
+ configuration = TurboChat.configuration
74
+ value = configuration.respond_to?(:disable_input) ? configuration.disable_input : false
75
+ ActiveModel::Type::Boolean.new.cast(value)
76
+ rescue StandardError
77
+ false
78
+ end
68
79
  end
@@ -1,3 +1,3 @@
1
1
  module TurboChat
2
- VERSION = "0.1.12"
2
+ VERSION = "0.1.14"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turbo_chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.12
4
+ version: 0.1.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Haumer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-26 00:00:00.000000000 Z
11
+ date: 2026-02-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails