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
|
@@ -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,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>
|