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
@@ -0,0 +1,120 @@
1
+ module ChatGem
2
+ class ChatMessage
3
+ module BlockedWordsModeration
4
+ extend ActiveSupport::Concern
5
+
6
+ private
7
+
8
+ def apply_blocked_words_moderation
9
+ blocked_words = blocked_words_from_configuration
10
+ return if blocked_words.empty?
11
+
12
+ matches = blocked_words_in_body(blocked_words)
13
+ return if matches.empty?
14
+
15
+ action = blocked_words_action_from_configuration
16
+ emit_blocked_words_event(
17
+ "chat_gem.blocked_words.detected",
18
+ blocked_words: matches,
19
+ action: action
20
+ )
21
+ if action == "scramble"
22
+ original_body = body.to_s.dup
23
+ scramble_blocked_words!(blocked_words)
24
+ emit_blocked_words_event(
25
+ "chat_gem.blocked_words.scrambled",
26
+ blocked_words: matches,
27
+ action: action,
28
+ original_body: original_body,
29
+ moderated_body: body.to_s
30
+ )
31
+ return
32
+ end
33
+
34
+ errors.add(:body, "contains blocked language")
35
+ emit_blocked_words_event(
36
+ "chat_gem.blocked_words.rejected",
37
+ blocked_words: matches,
38
+ action: action
39
+ )
40
+ end
41
+
42
+ def blocked_words_in_body(blocked_words)
43
+ blocked_words.select { |word| blocked_word_pattern(word).match?(body.to_s) }
44
+ end
45
+
46
+ def scramble_blocked_words!(blocked_words)
47
+ moderated_body = body.to_s.dup
48
+
49
+ blocked_words.each do |word|
50
+ moderated_body.gsub!(blocked_word_pattern(word)) do |match|
51
+ scramble_word(match)
52
+ end
53
+ end
54
+
55
+ self.body = moderated_body
56
+ end
57
+
58
+ def scramble_word(word)
59
+ source = word.to_s
60
+ characters = source.chars
61
+ return source if characters.length < 2
62
+
63
+ scrambled = characters.shuffle
64
+ if scrambled == characters && characters.uniq.length > 1
65
+ scrambled = characters.rotate(1)
66
+ end
67
+
68
+ scrambled.join
69
+ end
70
+
71
+ def blocked_word_pattern(word)
72
+ /(?<![[:alnum:]_])#{Regexp.escape(word)}(?![[:alnum:]_])/i
73
+ end
74
+
75
+ def blocked_words_from_configuration
76
+ configuration = ChatGem.configuration
77
+ return [] unless configuration.respond_to?(:effective_blocked_words)
78
+
79
+ Array(configuration.effective_blocked_words)
80
+ rescue StandardError
81
+ []
82
+ end
83
+
84
+ def blocked_words_action_from_configuration
85
+ configuration = ChatGem.configuration
86
+ return "reject" unless configuration.respond_to?(:effective_blocked_words_action)
87
+
88
+ configuration.effective_blocked_words_action.to_s
89
+ rescue StandardError
90
+ "reject"
91
+ end
92
+
93
+ def emit_blocked_words_event(name, blocked_words:, action:, original_body: nil, moderated_body: nil)
94
+ return unless blocked_words_events_enabled?
95
+ return unless defined?(ActiveSupport::Notifications)
96
+
97
+ payload = {
98
+ chat_id: chat_id,
99
+ message_id: id,
100
+ participant_type: participant_type,
101
+ participant_id: participant_id,
102
+ blocked_words: Array(blocked_words).map(&:to_s),
103
+ action: action.to_s
104
+ }
105
+ payload[:original_body] = original_body if original_body.present?
106
+ payload[:moderated_body] = moderated_body if moderated_body.present?
107
+ ActiveSupport::Notifications.instrument(name, payload)
108
+ end
109
+
110
+ def blocked_words_events_enabled?
111
+ configuration = ChatGem.configuration
112
+ return false unless configuration.respond_to?(:emit_blocked_words_events)
113
+
114
+ ActiveModel::Type::Boolean.new.cast(configuration.emit_blocked_words_events)
115
+ rescue StandardError
116
+ false
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,20 @@
1
+ module ChatGem
2
+ class ChatMessage
3
+ module BodyLengthValidation
4
+ extend ActiveSupport::Concern
5
+
6
+ private
7
+
8
+ def body_within_max_length
9
+ configured_limit = ChatGem.configuration.max_message_length
10
+ return if configured_limit.nil?
11
+
12
+ limit = configured_limit.to_i
13
+ return if limit <= 0
14
+ return if body.to_s.length <= limit
15
+
16
+ errors.add(:body, "is too long (maximum is #{limit} characters)")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,61 @@
1
+ module ChatGem
2
+ class ChatMessage
3
+ module Broadcasting
4
+ extend ActiveSupport::Concern
5
+
6
+ private
7
+
8
+ def broadcast_create
9
+ return unless respond_to?(:broadcast_update_to)
10
+
11
+ stream = stream_name
12
+
13
+ if 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
+ )
20
+ end
21
+
22
+ broadcast_update_to(
23
+ stream,
24
+ target: ActionView::RecordIdentifier.dom_id(chat, :signals),
25
+ partial: SIGNALS_PARTIAL,
26
+ locals: { chat: chat }
27
+ )
28
+ end
29
+
30
+ def broadcast_update
31
+ return unless message?
32
+ return unless saved_change_to_body?
33
+ return unless respond_to?(:broadcast_replace_to)
34
+
35
+ broadcast_replace_to(
36
+ stream_name,
37
+ target: ActionView::RecordIdentifier.dom_id(self),
38
+ partial: MESSAGE_PARTIAL,
39
+ locals: { chat_message: self }
40
+ )
41
+ end
42
+
43
+ def broadcast_destroy
44
+ stream = stream_name
45
+
46
+ if message? && respond_to?(:broadcast_remove_to)
47
+ broadcast_remove_to(
48
+ stream,
49
+ target: ActionView::RecordIdentifier.dom_id(self)
50
+ )
51
+ end
52
+
53
+ self.class.broadcast_signal_refresh(chat)
54
+ end
55
+
56
+ def stream_name
57
+ [chat, STREAM_NAME]
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,81 @@
1
+ module ChatGem
2
+ class ChatMessage
3
+ module Formatting
4
+ extend ActiveSupport::Concern
5
+
6
+ def participant_display_name
7
+ return "Unknown" if participant.nil?
8
+ return participant.username if participant.respond_to?(:username) && participant.username.present?
9
+ return participant.name if participant.respond_to?(:name) && participant.name.present?
10
+ return participant.email if participant.respond_to?(:email) && participant.email.present?
11
+
12
+ participant.to_s
13
+ end
14
+
15
+ def formatted_timestamp
16
+ formatted_time_for(created_at)
17
+ end
18
+
19
+ def formatted_updated_timestamp
20
+ formatted_time_for(updated_at)
21
+ end
22
+
23
+ def edited?
24
+ return false if created_at.blank? || updated_at.blank?
25
+
26
+ updated_at > created_at
27
+ end
28
+
29
+ def participant_membership_role
30
+ membership = participant_membership
31
+ return nil if membership.nil?
32
+
33
+ membership.effective_role_key
34
+ end
35
+
36
+ def formatted_participant_role
37
+ membership = participant_membership
38
+ return nil if membership.nil?
39
+
40
+ role = membership.effective_role_key
41
+
42
+ formatter = ChatGem.configuration.role_formatter
43
+ formatted = apply_formatter(formatter, role, self)
44
+ return formatted if formatted.present?
45
+
46
+ membership.effective_role_name
47
+ end
48
+
49
+ private
50
+
51
+ def participant_membership
52
+ return @participant_membership if instance_variable_defined?(:@participant_membership)
53
+
54
+ @participant_membership = chat.chat_memberships.active.find_by(participant: participant)
55
+ end
56
+
57
+ def formatted_time_for(timestamp)
58
+ formatter = ChatGem.configuration.timestamp_formatter
59
+ formatted = apply_formatter(formatter, timestamp, self)
60
+ return formatted if formatted.present?
61
+
62
+ I18n.l(timestamp.in_time_zone, format: :long)
63
+ end
64
+
65
+ def apply_formatter(formatter, *args)
66
+ return nil unless formatter.respond_to?(:call)
67
+
68
+ case formatter.arity
69
+ when 0
70
+ formatter.call
71
+ when 1
72
+ formatter.call(args.first)
73
+ else
74
+ formatter.call(*args)
75
+ end
76
+ rescue ArgumentError
77
+ nil
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,85 @@
1
+ module ChatGem
2
+ class ChatMessage
3
+ module MentionValidation
4
+ extend ActiveSupport::Concern
5
+
6
+ private
7
+
8
+ def mentions_allowed_for_participant
9
+ return unless ChatGem.configuration.enable_mentions
10
+
11
+ mentions = mention_tokens
12
+ return if mentions.empty?
13
+
14
+ permission = mention_permission
15
+ if permission.nil?
16
+ errors.add(:body, "mentions cannot be validated at this time")
17
+ return
18
+ end
19
+
20
+ invalid_mention = first_invalid_mention(permission, mentions)
21
+ return if invalid_mention.nil?
22
+
23
+ errors.add(:body, mention_permission_error(invalid_mention))
24
+ end
25
+
26
+ def mention_tokens
27
+ body.to_s.scan(MENTION_PATTERN).uniq
28
+ end
29
+
30
+ def first_invalid_mention(permission, mentions)
31
+ mentions.find { |mention| !mention_allowed?(permission, mention) }
32
+ end
33
+
34
+ def mention_permission
35
+ adapter = ChatGem.configuration.permission_adapter
36
+ return nil unless adapter.respond_to?(:new)
37
+
38
+ adapter.new(participant, chat)
39
+ rescue StandardError
40
+ nil
41
+ end
42
+
43
+ def mention_allowed?(permission, mention)
44
+ if permission.respond_to?(:can_mention_token?)
45
+ return permission.can_mention_token?(mention)
46
+ end
47
+
48
+ case mention_kind(mention)
49
+ when :all
50
+ permission_gate_allowed?(permission, :can_mention_all?)
51
+ when :role
52
+ permission_gate_allowed?(permission, :can_mention_roles?)
53
+ else
54
+ permission_gate_allowed?(permission, :can_mention_members?)
55
+ end
56
+ rescue StandardError
57
+ false
58
+ end
59
+
60
+ def permission_gate_allowed?(permission, method_name)
61
+ return true unless permission.respond_to?(method_name)
62
+
63
+ permission.public_send(method_name)
64
+ end
65
+
66
+ def mention_kind(mention)
67
+ return :all if mention.casecmp("@all").zero?
68
+ return :role if ROLE_MENTION_PATTERN.match?(mention)
69
+
70
+ :member
71
+ end
72
+
73
+ def mention_permission_error(mention)
74
+ case mention_kind(mention)
75
+ when :all
76
+ "cannot mention @all"
77
+ when :role
78
+ "cannot mention roles"
79
+ else
80
+ "cannot mention other members"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,61 @@
1
+ module ChatGem
2
+ class ChatMessage
3
+ module Signals
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ def start_signal!(chat:, participant:, signal_type: :typing)
8
+ create!(chat: chat, participant: participant, kind: :signal, signal_type: signal_type)
9
+ end
10
+
11
+ def replace_signal!(chat:, participant:, signal_type: :typing)
12
+ clear_signals!(chat: chat, participant: participant)
13
+ start_signal!(chat: chat, participant: participant, signal_type: signal_type)
14
+ end
15
+
16
+ def clear_signals!(chat:, participant:)
17
+ where(chat: chat, participant: participant, kind: kinds[:signal]).delete_all
18
+ broadcast_signal_refresh(chat)
19
+ true
20
+ end
21
+
22
+ def with_signal(chat:, participant:, signal_type: :typing)
23
+ replace_signal!(chat: chat, participant: participant, signal_type: signal_type)
24
+ yield
25
+ ensure
26
+ clear_signals!(chat: chat, participant: participant)
27
+ end
28
+
29
+ def broadcast_signal_refresh(chat)
30
+ return unless defined?(Turbo::StreamsChannel)
31
+
32
+ Turbo::StreamsChannel.broadcast_update_to(
33
+ [chat, STREAM_NAME],
34
+ target: ActionView::RecordIdentifier.dom_id(chat, :signals),
35
+ partial: SIGNALS_PARTIAL,
36
+ locals: { chat: chat }
37
+ )
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def normalize_signal_fields
44
+ self.signal_type = nil if message?
45
+ self.body = "" if signal?
46
+ end
47
+
48
+ def replace_participant_signals_on_submit
49
+ return unless ChatGem.configuration.replace_signals_on_message_submit
50
+ return if chat_id.blank? || participant_type.blank? || participant_id.blank?
51
+
52
+ self.class.where(
53
+ chat_id: chat_id,
54
+ participant_type: participant_type,
55
+ participant_id: participant_id,
56
+ kind: self.class.kinds[:signal]
57
+ ).delete_all
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,40 @@
1
+ module ChatGem
2
+ class ChatMessage < ApplicationRecord
3
+ MENTION_PATTERN = /(?<![[:alnum:]_])@[[:alpha:]][[:alnum:]_]{0,31}/.freeze
4
+ ROLE_MENTION_PATTERN = /\A@[A-Z][A-Z0-9_]{0,31}\z/.freeze
5
+ STREAM_NAME = :messages
6
+ MESSAGE_PARTIAL = "chat_gem/chat_messages/message"
7
+ CHAT_MESSAGE_PARTIAL = "chat_gem/chat_messages/chat_message"
8
+ SIGNALS_PARTIAL = "chat_gem/chat_messages/signals"
9
+
10
+ include ChatGem::ChatMessage::BodyLengthValidation
11
+ include ChatGem::ChatMessage::Formatting
12
+ include ChatGem::ChatMessage::MentionValidation
13
+ include ChatGem::ChatMessage::BlockedWordsModeration
14
+ include ChatGem::ChatMessage::Signals
15
+ include ChatGem::ChatMessage::Broadcasting
16
+
17
+ belongs_to :chat, class_name: "ChatGem::Chat", inverse_of: :chat_messages
18
+ belongs_to :participant, polymorphic: true
19
+
20
+ enum :kind, { message: 0, signal: 1 }, default: :message
21
+ enum :signal_type, { typing: 0, thinking: 1, planning: 2 }, prefix: true
22
+
23
+ scope :ordered, -> { order(created_at: :asc, id: :asc) }
24
+ scope :messages_only, -> { where(kind: kinds[:message]) }
25
+
26
+ validates :participant_type, :participant_id, presence: true
27
+ validates :body, presence: true, if: :message?
28
+ validates :signal_type, presence: true, if: :signal?
29
+ validate :body_within_max_length, if: :message?
30
+ validate :mentions_allowed_for_participant, if: :message?
31
+ validate :apply_blocked_words_moderation, if: :message?
32
+
33
+ before_validation :normalize_signal_fields
34
+ before_create :replace_participant_signals_on_submit, if: :message?
35
+
36
+ after_create_commit :broadcast_create
37
+ after_update_commit :broadcast_update
38
+ after_destroy_commit :broadcast_destroy
39
+ end
40
+ end
@@ -0,0 +1 @@
1
+ <%= render "chat_gem/chat_messages/#{chat_message.kind}", chat_message: chat_message %>
@@ -0,0 +1,22 @@
1
+ <% mention_permission = local_assigns[:chat_permission] %>
2
+ <% mention_options = chat_mention_options(chat: chat, permission: mention_permission) %>
3
+ <% mentions_enabled = chat_mentions_enabled_for?(chat: chat, permission: mention_permission) %>
4
+ <div class="chat-composer"
5
+ data-chat-composer
6
+ data-chat-id="<%= chat.id %>"
7
+ data-chat-emit-typing-events="<%= ChatGem.configuration.emit_typing_events %>"
8
+ data-chat-emit-message-events="<%= ChatGem.configuration.emit_message_events %>"
9
+ data-chat-enable-mentions="<%= mentions_enabled %>"
10
+ data-chat-mention-options="<%= json_escape(mention_options.to_json) %>">
11
+ <%= form_with model: [chat, chat_message], data: { chat_message_form: true }, class: "chat-form" do |f| %>
12
+ <%= f.hidden_field :kind, value: :message %>
13
+ <%= f.text_area :body, rows: 3, required: true, placeholder: "Write a message...", data: { chat_message_input: true } %>
14
+ <%= f.submit "Send", class: "chat-btn chat-btn--send" %>
15
+ <% end %>
16
+
17
+ <%= form_with url: chat_chat_messages_path(chat), method: :post, scope: :chat_message, data: { chat_signal_form: true }, class: "chat-form chat-form--hidden" do |f| %>
18
+ <%= f.hidden_field :kind, value: :signal %>
19
+ <%= f.hidden_field :signal_type, value: :typing %>
20
+ <%= f.hidden_field :body, value: "" %>
21
+ <% end %>
22
+ </div>
@@ -0,0 +1,83 @@
1
+ <% own_message = own_chat_message?(chat_message) %>
2
+ <% show_timestamp = ChatGem.configuration.show_timestamp %>
3
+ <% show_role = ChatGem.configuration.show_role %>
4
+ <% role_label = show_role ? chat_message.formatted_participant_role : nil %>
5
+ <% edited_message = chat_message.respond_to?(:edited?) && chat_message.edited? %>
6
+ <% bubble_style = chat_message_inline_style(chat_message: chat_message, own_message: own_message) %>
7
+ <% mention_tokens = if respond_to?(:chat_message_mention_tokens)
8
+ chat_message_mention_tokens(chat_message)
9
+ else
10
+ chat_message.body.to_s.scan(/(?<![[:alnum:]_])@[[:alpha:]][[:alnum:]_]{0,31}/).uniq
11
+ end %>
12
+ <% edit_message_path = if respond_to?(:chat_chat_message_path)
13
+ chat_chat_message_path(chat_message.chat, chat_message)
14
+ end %>
15
+ <% can_edit_message = chat_message.persisted? && edit_message_path.present? && can_edit_chat_message?(chat_message) %>
16
+ <% force_edit_open = local_assigns.fetch(:force_edit_open, false) && can_edit_message %>
17
+ <article id="<%= dom_id(chat_message) %>"
18
+ class="<%= chat_message_css_classes(chat_message: chat_message, own_message: own_message) %>"
19
+ data-chat-message-id="<%= chat_message.id %>"
20
+ data-chat-message-participant-type="<%= chat_message.participant_type %>"
21
+ data-chat-message-participant-id="<%= chat_message.participant_id %>"
22
+ data-chat-message-mentions="<%= json_escape(mention_tokens.to_json) %>"
23
+ <%= %(style="#{bubble_style}") if bubble_style.present? %>>
24
+ <header class="chat-meta">
25
+ <span class="chat-meta__author"><%= chat_message.participant_display_name %></span>
26
+ <% if role_label.present? %>
27
+ <span class="chat-meta__role"><%= role_label %></span>
28
+ <% end %>
29
+ <% if show_timestamp %>
30
+ <time class="chat-meta__timestamp" datetime="<%= chat_message.created_at.iso8601 %>"><%= chat_message.formatted_timestamp %></time>
31
+ <% end %>
32
+ <% if edited_message %>
33
+ <span class="chat-meta__edited" title="Edited <%= chat_message.formatted_updated_timestamp %>">
34
+ edited
35
+ </span>
36
+ <% end %>
37
+ </header>
38
+
39
+ <div class="chat-message-view" data-chat-message-view <%= "hidden" if force_edit_open %>>
40
+ <%= render_chat_message_body(chat_message) %>
41
+ </div>
42
+
43
+ <% if can_edit_message %>
44
+ <footer class="chat-message-actions">
45
+ <button type="button"
46
+ class="chat-message-action"
47
+ data-chat-message-edit-control
48
+ data-chat-edit-start
49
+ <%= "hidden" if force_edit_open %>>
50
+ Edit
51
+ </button>
52
+ </footer>
53
+
54
+ <div class="chat-message-edit" data-chat-message-edit <%= "hidden" unless force_edit_open %>>
55
+ <% if chat_message.errors.any? %>
56
+ <p class="chat-inline-edit-error"><%= chat_message.errors.full_messages.to_sentence %></p>
57
+ <% end %>
58
+
59
+ <% edit_mention_options = chat_mention_options(chat: chat_message.chat) %>
60
+ <% edit_mentions_enabled = chat_mentions_enabled_for?(chat: chat_message.chat) %>
61
+ <%= form_with model: chat_message,
62
+ url: edit_message_path,
63
+ class: "chat-inline-edit-form",
64
+ data: {
65
+ chat_inline_edit_form: true,
66
+ chat_enable_mentions: edit_mentions_enabled,
67
+ chat_mention_options: json_escape(edit_mention_options.to_json)
68
+ } do |f| %>
69
+ <div class="chat-inline-edit-field">
70
+ <%= f.text_area :body, rows: 3, required: true, data: { chat_inline_edit_input: true } %>
71
+ </div>
72
+ <div class="chat-inline-edit-actions">
73
+ <%= f.submit "Save", class: "chat-btn chat-btn--small", data: { chat_edit_save: true } %>
74
+ <button type="button"
75
+ class="chat-btn chat-btn--ghost chat-btn--small"
76
+ data-chat-edit-cancel>
77
+ Cancel
78
+ </button>
79
+ </div>
80
+ <% end %>
81
+ </div>
82
+ <% end %>
83
+ </article>
@@ -0,0 +1,3 @@
1
+ <article id="<%= dom_id(chat_message) %>" class="chat-signal-entry">
2
+ <span><%= chat_message.participant_display_name %> is <%= chat_message.signal_type %>...</span>
3
+ </article>
@@ -0,0 +1,24 @@
1
+ <% show_self_signals = ChatGem.configuration.show_self_signals %>
2
+ <% current_participant = respond_to?(:current_chat_participant, true) ? current_chat_participant : nil %>
3
+ <% current_participant_type = current_participant&.class&.base_class&.name %>
4
+ <% current_participant_id = current_participant&.id %>
5
+
6
+ <% chat.active_signals.each do |signal_message| %>
7
+ <% if !show_self_signals &&
8
+ current_participant_type.present? &&
9
+ signal_message.participant_type == current_participant_type &&
10
+ signal_message.participant_id == current_participant_id %>
11
+ <% next %>
12
+ <% end %>
13
+
14
+ <div class="chat-typing-indicator"
15
+ id="typing-<%= signal_message.participant_type.underscore %>-<%= signal_message.participant_id %>"
16
+ data-chat-signal-at="<%= signal_message.created_at.to_i %>"
17
+ data-chat-signal-participant-type="<%= signal_message.participant_type %>"
18
+ data-chat-signal-participant-id="<%= signal_message.participant_id %>">
19
+ <strong><%= signal_message.participant_display_name %></strong>
20
+ <span class="chat-dots">
21
+ <i></i><i></i><i></i>
22
+ </span>
23
+ </div>
24
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= render @chat_messages %>
@@ -0,0 +1,51 @@
1
+ <section class="chat-shell"
2
+ data-chat-index
3
+ data-chat-emit-invitation-events="<%= chat_emit_invitation_events? %>"
4
+ data-chat-emit-chat-lifecycle-events="<%= chat_emit_chat_lifecycle_events? %>"
5
+ data-chat-invitation-accepted="<%= json_escape(@invitation_accepted_event.to_json) if @invitation_accepted_event.present? %>"
6
+ data-chat-lifecycle-event="<%= json_escape(@chat_lifecycle_event.to_json) if @chat_lifecycle_event.present? %>">
7
+ <header class="chat-header">
8
+ <h1>Chats</h1>
9
+ <%= link_to "New Chat", new_chat_path, class: "chat-btn" %>
10
+ </header>
11
+
12
+ <% if @pending_invitations.present? %>
13
+ <section class="chat-invitations">
14
+ <h2>Pending Invitations</h2>
15
+ <ul class="chat-list">
16
+ <% @pending_invitations.each do |membership| %>
17
+ <% invitation_chat = membership.chat %>
18
+ <li class="chat-list-item chat-list-item--invitation">
19
+ <span class="chat-list-title"><%= invitation_chat.title %></span>
20
+ <div class="chat-list-actions">
21
+ <%= button_to "Accept",
22
+ accept_chat_path(invitation_chat),
23
+ method: :patch,
24
+ form_class: "chat-inline-form",
25
+ class: "chat-btn chat-btn--success chat-btn--small" %>
26
+ <%= button_to "Decline",
27
+ decline_chat_path(invitation_chat),
28
+ method: :patch,
29
+ form_class: "chat-inline-form",
30
+ class: "chat-btn chat-btn--danger chat-btn--small" %>
31
+ </div>
32
+ </li>
33
+ <% end %>
34
+ </ul>
35
+ </section>
36
+ <% end %>
37
+
38
+ <section class="chat-joined">
39
+ <h2>Your Chats</h2>
40
+ <ul class="chat-list">
41
+ <% @chats.each do |chat| %>
42
+ <li>
43
+ <%= link_to chat.title, chat_path(chat), class: "chat-list-link" %>
44
+ </li>
45
+ <% end %>
46
+ </ul>
47
+ <% if @chats.empty? %>
48
+ <p class="chat-hint">No accepted chats yet.</p>
49
+ <% end %>
50
+ </section>
51
+ </section>
@@ -0,0 +1,13 @@
1
+ <section class="chat-shell">
2
+ <header class="chat-header">
3
+ <h1>New Chat</h1>
4
+ </header>
5
+
6
+ <%= form_with model: @chat, url: chats_path do |f| %>
7
+ <div class="chat-field">
8
+ <%= f.label :title %>
9
+ <%= f.text_field :title, required: true, autofocus: true %>
10
+ </div>
11
+ <%= f.submit "Create", class: "chat-btn" %>
12
+ <% end %>
13
+ </section>