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,80 @@
|
|
|
1
|
+
module ChatGem
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
module MentionSupport
|
|
4
|
+
include ChatGem::ApplicationHelper::MentionSupport::TokenBuilder
|
|
5
|
+
include ChatGem::ApplicationHelper::MentionSupport::EntryBuilder
|
|
6
|
+
include ChatGem::ApplicationHelper::MentionSupport::PermissionSupport
|
|
7
|
+
|
|
8
|
+
def chat_mention_options(chat:, permission: nil)
|
|
9
|
+
mention_permission = permission || mention_permission_for(chat)
|
|
10
|
+
allow_member_mentions = mention_permission.nil? ? true : mention_permission_allows?(mention_permission, :can_mention_members?)
|
|
11
|
+
allow_all_mentions = mention_permission.nil? ? true : mention_permission_allows?(mention_permission, :can_mention_all?)
|
|
12
|
+
allow_role_mentions = mention_permission.nil? ? true : mention_permission_allows?(mention_permission, :can_mention_roles?)
|
|
13
|
+
allow_role_mentions &&= !chat_mention_filter_hide_roles?
|
|
14
|
+
exclude_self_mentions = chat_mention_filter_exclude_self?
|
|
15
|
+
viewer_participant = mention_viewer_participant(permission: mention_permission)
|
|
16
|
+
|
|
17
|
+
options = []
|
|
18
|
+
options << { token: "@all", label: "All members", kind: "group" } if allow_all_mentions
|
|
19
|
+
return options unless chat.respond_to?(:chat_memberships)
|
|
20
|
+
|
|
21
|
+
if allow_member_mentions
|
|
22
|
+
chat_member_mention_entries(chat).each do |entry|
|
|
23
|
+
next if exclude_self_mentions && same_chat_participant?(entry[:participant], viewer_participant)
|
|
24
|
+
|
|
25
|
+
options << {
|
|
26
|
+
token: entry[:token],
|
|
27
|
+
label: entry[:label],
|
|
28
|
+
kind: "member",
|
|
29
|
+
participant_type: entry[:participant_type],
|
|
30
|
+
participant_id: entry[:participant_id]
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if allow_role_mentions
|
|
36
|
+
options.concat(chat_role_mention_entries(chat))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
options
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def chat_self_mention_tokens(chat:, participant: nil)
|
|
43
|
+
viewer = participant || current_chat_participant_for_view
|
|
44
|
+
return [] if viewer.nil?
|
|
45
|
+
return [] unless chat.respond_to?(:chat_memberships)
|
|
46
|
+
|
|
47
|
+
chat_member_mention_entries(chat).each_with_object([]) do |entry, tokens|
|
|
48
|
+
next unless same_chat_participant?(entry[:participant], viewer)
|
|
49
|
+
|
|
50
|
+
tokens << entry[:token]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def chat_self_role_mention_token(chat:, participant: nil)
|
|
55
|
+
viewer = participant || current_chat_participant_for_view
|
|
56
|
+
return nil if viewer.nil?
|
|
57
|
+
return nil unless chat.respond_to?(:chat_memberships)
|
|
58
|
+
|
|
59
|
+
membership = chat.chat_memberships.active.find_by(participant: viewer)
|
|
60
|
+
return nil if membership.nil?
|
|
61
|
+
|
|
62
|
+
role_key = membership.effective_role_key.to_s.strip
|
|
63
|
+
return nil if role_key.blank?
|
|
64
|
+
|
|
65
|
+
"@#{role_key.upcase}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def chat_mentions_enabled_for?(chat:, permission: nil)
|
|
69
|
+
return false unless ChatGem.configuration.enable_mentions
|
|
70
|
+
|
|
71
|
+
mention_permission = permission || mention_permission_for(chat)
|
|
72
|
+
return true if mention_permission.nil?
|
|
73
|
+
|
|
74
|
+
mention_permission_allows?(mention_permission, :can_mention_members?) ||
|
|
75
|
+
mention_permission_allows?(mention_permission, :can_mention_all?) ||
|
|
76
|
+
mention_permission_allows?(mention_permission, :can_mention_roles?)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module ChatGem
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
module MessageRendering
|
|
4
|
+
def chat_message_css_classes(chat_message:, own_message:)
|
|
5
|
+
classes = ["chat-bubble"]
|
|
6
|
+
classes << "chat-bubble--own" if own_message
|
|
7
|
+
classes.concat(resolve_custom_message_css_classes(chat_message: chat_message, own_message: own_message))
|
|
8
|
+
classes.uniq.join(" ")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def chat_message_inline_style(chat_message:, own_message:)
|
|
12
|
+
hex_color = resolve_message_hex_color(chat_message: chat_message, own_message: own_message)
|
|
13
|
+
return nil if hex_color.blank?
|
|
14
|
+
|
|
15
|
+
"--chat-bubble-bg: #{hex_color}; --chat-bubble-border: #{hex_color};"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def chat_mentions_container_inline_style
|
|
19
|
+
hex_color = normalize_hex_color(chat_config_value(:mention_mark_hex_color))
|
|
20
|
+
hex_color ||= normalize_hex_color(chat_config_value(:mention_highlight_hex_color))
|
|
21
|
+
return nil if hex_color.blank?
|
|
22
|
+
|
|
23
|
+
mention_mark_background = hex_color_with_alpha(hex_color, alpha: 0.22)
|
|
24
|
+
"--chat-mention-highlight-color: #{hex_color}; --chat-mention-mark-background: #{mention_mark_background};"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render_chat_message_body(chat_message)
|
|
28
|
+
body = chat_message.body.to_s
|
|
29
|
+
return content_tag(:p, decorate_plain_message_text(body).html_safe, class: "chat-body") unless ChatGem.configuration.render_message_html
|
|
30
|
+
|
|
31
|
+
sanitized_html = sanitize(
|
|
32
|
+
body,
|
|
33
|
+
tags: Array(ChatGem.configuration.message_html_tags),
|
|
34
|
+
attributes: Array(ChatGem.configuration.message_html_attributes)
|
|
35
|
+
)
|
|
36
|
+
content_tag(:div, sanitized_html, class: "chat-body")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def chat_message_mention_tokens(chat_message)
|
|
40
|
+
return [] unless ChatGem.configuration.enable_mentions
|
|
41
|
+
return [] if chat_message.nil?
|
|
42
|
+
|
|
43
|
+
chat_message.body.to_s.scan(MENTION_PATTERN).uniq
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def resolve_custom_message_css_classes(chat_message:, own_message:)
|
|
49
|
+
resolver = ChatGem.configuration.message_css_class_resolver
|
|
50
|
+
return [] unless resolver.respond_to?(:call)
|
|
51
|
+
|
|
52
|
+
classes = case resolver.arity
|
|
53
|
+
when 0
|
|
54
|
+
resolver.call
|
|
55
|
+
when 1
|
|
56
|
+
resolver.call(chat_message)
|
|
57
|
+
when 2
|
|
58
|
+
resolver.call(chat_message, own_message)
|
|
59
|
+
else
|
|
60
|
+
resolver.call(chat_message, own_message, self)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Array(classes).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:blank?)
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resolve_message_hex_color(chat_message:, own_message:)
|
|
69
|
+
role_color = resolve_role_message_hex_color(chat_message: chat_message, own_message: own_message)
|
|
70
|
+
return role_color if role_color.present?
|
|
71
|
+
|
|
72
|
+
base_color = own_message ? ChatGem.configuration.own_message_hex_color : ChatGem.configuration.other_message_hex_color
|
|
73
|
+
normalize_hex_color(base_color)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_role_message_hex_color(chat_message:, own_message:)
|
|
77
|
+
role_colors = ChatGem.configuration.role_message_hex_colors
|
|
78
|
+
return nil unless role_colors.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
role_key = chat_message_role_key(chat_message)
|
|
81
|
+
return nil if role_key.blank?
|
|
82
|
+
|
|
83
|
+
role_config = role_colors[role_key] || role_colors[role_key.to_sym]
|
|
84
|
+
return normalize_hex_color(role_config) unless role_config.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
variant = if own_message
|
|
87
|
+
role_config[:own] || role_config["own"]
|
|
88
|
+
else
|
|
89
|
+
role_config[:other] || role_config["other"]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
variant ||= role_config[:default] || role_config["default"]
|
|
93
|
+
normalize_hex_color(variant)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def chat_message_role_key(chat_message)
|
|
97
|
+
return nil unless chat_message.respond_to?(:participant_membership_role)
|
|
98
|
+
|
|
99
|
+
chat_message.participant_membership_role.to_s.strip.presence
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def normalize_hex_color(value)
|
|
103
|
+
candidate = value.to_s.strip
|
|
104
|
+
return nil if candidate.blank?
|
|
105
|
+
|
|
106
|
+
candidate = "##{candidate}" unless candidate.start_with?("#")
|
|
107
|
+
return nil unless HEX_COLOR_PATTERN.match?(candidate)
|
|
108
|
+
|
|
109
|
+
candidate.downcase
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def hex_color_with_alpha(hex_color, alpha:)
|
|
113
|
+
normalized_hex = normalize_hex_color(hex_color)
|
|
114
|
+
return nil if normalized_hex.blank?
|
|
115
|
+
|
|
116
|
+
red, green, blue = case normalized_hex.length
|
|
117
|
+
when 4
|
|
118
|
+
[
|
|
119
|
+
normalized_hex[1] * 2,
|
|
120
|
+
normalized_hex[2] * 2,
|
|
121
|
+
normalized_hex[3] * 2
|
|
122
|
+
]
|
|
123
|
+
when 7, 9
|
|
124
|
+
[
|
|
125
|
+
normalized_hex[1, 2],
|
|
126
|
+
normalized_hex[3, 2],
|
|
127
|
+
normalized_hex[5, 2]
|
|
128
|
+
]
|
|
129
|
+
else
|
|
130
|
+
return nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
alpha_value = alpha.to_f
|
|
134
|
+
alpha_value = 0.0 if alpha_value.negative?
|
|
135
|
+
alpha_value = 1.0 if alpha_value > 1.0
|
|
136
|
+
|
|
137
|
+
alpha_hex = (alpha_value * 255).round.to_s(16).rjust(2, "0")
|
|
138
|
+
"##{red}#{green}#{blue}#{alpha_hex}".downcase
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def decorate_plain_message_text(body)
|
|
142
|
+
formatted = ERB::Util.html_escape(body.to_s)
|
|
143
|
+
formatted = apply_emoji_aliases(formatted) if ChatGem.configuration.enable_emoji_aliases
|
|
144
|
+
formatted = apply_mention_highlights(formatted) if ChatGem.configuration.enable_mentions
|
|
145
|
+
formatted.gsub(/\r\n?|\n/, "<br>")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def apply_emoji_aliases(text)
|
|
149
|
+
emoji_aliases = ChatGem.configuration.effective_emoji_aliases
|
|
150
|
+
return text if emoji_aliases.empty?
|
|
151
|
+
|
|
152
|
+
text.gsub(EMOJI_ALIAS_PATTERN) do |match|
|
|
153
|
+
alias_key = Regexp.last_match(1).to_s.downcase
|
|
154
|
+
emoji_aliases.fetch(alias_key, match)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def apply_mention_highlights(text)
|
|
159
|
+
text.gsub(MENTION_PATTERN) do |mention|
|
|
160
|
+
%(<span class="chat-mention">#{mention}</span>)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module ChatGem
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
module ParticipantSupport
|
|
4
|
+
def chat_participant_name(participant)
|
|
5
|
+
return "Unknown" if participant.nil?
|
|
6
|
+
return participant.username if participant.respond_to?(:username) && participant.username.present?
|
|
7
|
+
return participant.name if participant.respond_to?(:name) && participant.name.present?
|
|
8
|
+
return participant.email if participant.respond_to?(:email) && participant.email.present?
|
|
9
|
+
|
|
10
|
+
participant.to_s
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def own_chat_message?(chat_message, participant: nil)
|
|
14
|
+
return false if chat_message.nil?
|
|
15
|
+
|
|
16
|
+
participant ||= current_chat_participant_for_view
|
|
17
|
+
return false if participant.nil?
|
|
18
|
+
|
|
19
|
+
participant_type = participant.class.base_class.name
|
|
20
|
+
participant_id = participant.id
|
|
21
|
+
return false if participant_type.blank? || participant_id.blank?
|
|
22
|
+
|
|
23
|
+
chat_message.participant_type.to_s == participant_type &&
|
|
24
|
+
chat_message.participant_id.to_s == participant_id.to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def can_edit_chat_message?(chat_message, participant: nil)
|
|
28
|
+
return false if chat_message.nil?
|
|
29
|
+
|
|
30
|
+
participant ||= current_chat_participant_for_view
|
|
31
|
+
if participant.nil?
|
|
32
|
+
return true unless respond_to?(:current_chat_participant, true)
|
|
33
|
+
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
adapter = ChatGem.configuration.permission_adapter
|
|
38
|
+
return false unless adapter.respond_to?(:new)
|
|
39
|
+
|
|
40
|
+
permission = adapter.new(participant, chat_message.chat)
|
|
41
|
+
if permission.respond_to?(:can_edit_message?)
|
|
42
|
+
return permission.can_edit_message?(chat_message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
return false if permission.respond_to?(:can_post_message?) && !permission.can_post_message?
|
|
46
|
+
|
|
47
|
+
own_chat_message?(chat_message, participant: participant)
|
|
48
|
+
rescue StandardError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def current_chat_participant_for_view
|
|
55
|
+
return nil unless respond_to?(:current_chat_participant, true)
|
|
56
|
+
|
|
57
|
+
current_chat_participant
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def mention_viewer_participant(permission:)
|
|
63
|
+
return permission.participant if permission.respond_to?(:participant) && permission.participant.present?
|
|
64
|
+
|
|
65
|
+
current_chat_participant_for_view
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def same_chat_participant?(first, second)
|
|
69
|
+
return false if first.nil? || second.nil?
|
|
70
|
+
return false unless first.respond_to?(:id) && second.respond_to?(:id)
|
|
71
|
+
|
|
72
|
+
first_type = first.class.base_class.name
|
|
73
|
+
second_type = second.class.base_class.name
|
|
74
|
+
return false if first_type.blank? || second_type.blank?
|
|
75
|
+
return false if first.id.blank? || second.id.blank?
|
|
76
|
+
|
|
77
|
+
first_type == second_type && first.id.to_s == second.id.to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module ChatGem
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
HEX_COLOR_PATTERN = /\A#(?:\h{3}|\h{6}|\h{8})\z/.freeze
|
|
4
|
+
EMOJI_ALIAS_PATTERN = /:([a-z0-9_+\-]{2,32}):/i.freeze
|
|
5
|
+
MENTION_PATTERN = /(?<![[:alnum:]_])@[[:alpha:]][[:alnum:]_]{0,31}/.freeze
|
|
6
|
+
|
|
7
|
+
include ChatGem::ApplicationHelper::ConfigSupport
|
|
8
|
+
include ChatGem::ApplicationHelper::ParticipantSupport
|
|
9
|
+
include ChatGem::ApplicationHelper::MessageRendering
|
|
10
|
+
include ChatGem::ApplicationHelper::MentionSupport
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module ChatGem
|
|
2
|
+
class Chat < ApplicationRecord
|
|
3
|
+
has_many :chat_memberships,
|
|
4
|
+
class_name: "ChatGem::ChatMembership",
|
|
5
|
+
dependent: :destroy,
|
|
6
|
+
inverse_of: :chat
|
|
7
|
+
|
|
8
|
+
has_many :chat_messages,
|
|
9
|
+
class_name: "ChatGem::ChatMessage",
|
|
10
|
+
dependent: :destroy,
|
|
11
|
+
inverse_of: :chat
|
|
12
|
+
|
|
13
|
+
validates :title, presence: true
|
|
14
|
+
|
|
15
|
+
scope :for_participant, lambda { |participant|
|
|
16
|
+
return none if participant.nil?
|
|
17
|
+
|
|
18
|
+
joins(:chat_memberships)
|
|
19
|
+
.merge(ChatGem::ChatMembership.active.where(participant: participant))
|
|
20
|
+
.distinct
|
|
21
|
+
}
|
|
22
|
+
scope :opened, -> { where(closed_at: nil) }
|
|
23
|
+
scope :closed, -> { where.not(closed_at: nil) }
|
|
24
|
+
|
|
25
|
+
scope :active, lambda { |window: nil|
|
|
26
|
+
cutoff = Time.current - activity_window_seconds(window)
|
|
27
|
+
joins(:chat_messages)
|
|
28
|
+
.merge(ChatGem::ChatMessage.message.where("#{ChatGem::ChatMessage.table_name}.created_at >= ?", cutoff))
|
|
29
|
+
.distinct
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
scope :inactive, lambda { |window: nil|
|
|
33
|
+
where.not(id: active(window: window).select(:id))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def active_signals(window: 12.seconds)
|
|
37
|
+
cutoff = Time.current - window
|
|
38
|
+
latest_message_at = chat_messages
|
|
39
|
+
.message
|
|
40
|
+
.where("created_at >= ?", cutoff)
|
|
41
|
+
.group(:participant_type, :participant_id)
|
|
42
|
+
.maximum(:created_at)
|
|
43
|
+
|
|
44
|
+
recent = chat_messages
|
|
45
|
+
.signal
|
|
46
|
+
.where("created_at >= ?", cutoff)
|
|
47
|
+
.ordered
|
|
48
|
+
.reverse
|
|
49
|
+
|
|
50
|
+
seen = {}
|
|
51
|
+
recent.each_with_object([]) do |message, output|
|
|
52
|
+
participant_key = [message.participant_type, message.participant_id]
|
|
53
|
+
last_message_time = latest_message_at[participant_key]
|
|
54
|
+
next if last_message_time && last_message_time >= message.created_at
|
|
55
|
+
|
|
56
|
+
key = "#{message.participant_type}-#{message.participant_id}"
|
|
57
|
+
next if seen[key]
|
|
58
|
+
|
|
59
|
+
output << message
|
|
60
|
+
seen[key] = true
|
|
61
|
+
end.reverse
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def visible_messages(limit: ChatGem.configuration.message_history_limit)
|
|
65
|
+
relation = chat_messages.messages_only
|
|
66
|
+
normalized_limit = normalize_message_limit(limit)
|
|
67
|
+
limited_relation = if normalized_limit.nil?
|
|
68
|
+
relation
|
|
69
|
+
else
|
|
70
|
+
recent_ids = relation.reorder(created_at: :desc, id: :desc).limit(normalized_limit).select(:id)
|
|
71
|
+
relation.where(id: recent_ids)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
limited_relation.ordered.preload(:participant)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def last_message_at
|
|
78
|
+
chat_messages.message.maximum(:created_at)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def active?(window: nil, at: Time.current)
|
|
82
|
+
message_time = last_message_at
|
|
83
|
+
return false if message_time.nil?
|
|
84
|
+
|
|
85
|
+
message_time >= at - self.class.activity_window_seconds(window)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inactive?(window: nil, at: Time.current)
|
|
89
|
+
!active?(window: window, at: at)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def closed?
|
|
93
|
+
closed_at.present?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def opened?
|
|
97
|
+
!closed?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def close!(at: Time.current)
|
|
101
|
+
update!(closed_at: at)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def reopen!
|
|
105
|
+
update!(closed_at: nil)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.activity_window_seconds(window = nil)
|
|
109
|
+
value = window.nil? ? ChatGem.configuration.active_chat_window : window
|
|
110
|
+
seconds = value.to_i
|
|
111
|
+
return seconds if seconds.positive?
|
|
112
|
+
|
|
113
|
+
raise ArgumentError, "active chat window must be a positive duration"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def normalize_message_limit(limit)
|
|
119
|
+
return nil if limit.nil?
|
|
120
|
+
|
|
121
|
+
normalized = limit.to_i
|
|
122
|
+
return nil if normalized <= 0
|
|
123
|
+
|
|
124
|
+
normalized
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
module ChatGem
|
|
2
|
+
class ChatMembership < ApplicationRecord
|
|
3
|
+
belongs_to :chat, class_name: "ChatGem::Chat", inverse_of: :chat_memberships
|
|
4
|
+
belongs_to :participant, polymorphic: true
|
|
5
|
+
|
|
6
|
+
enum :role, { member: 0, moderator: 1, admin: 2 }, default: :member
|
|
7
|
+
|
|
8
|
+
scope :active, lambda {
|
|
9
|
+
base_scope = where(removed_at: nil)
|
|
10
|
+
invitation_tracking_supported? ? base_scope.where(invitation_accepted: true) : base_scope
|
|
11
|
+
}
|
|
12
|
+
scope :pending, lambda {
|
|
13
|
+
return none unless invitation_tracking_supported?
|
|
14
|
+
|
|
15
|
+
where(removed_at: nil, invitation_accepted: false)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
validates :participant_type, :participant_id, presence: true
|
|
19
|
+
validates :invitation_accepted, inclusion: { in: [true, false] }, if: :invitation_tracking_supported_for_record?
|
|
20
|
+
validate :enforce_chat_participant_limit, if: :active?
|
|
21
|
+
validate :custom_role_must_exist, if: -> { custom_role_key.present? }
|
|
22
|
+
|
|
23
|
+
before_validation :normalize_custom_role_key
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def invitation_tracking_supported?
|
|
27
|
+
column_names.include?("invitation_accepted")
|
|
28
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def active?
|
|
34
|
+
return removed_at.nil? unless self.class.invitation_tracking_supported?
|
|
35
|
+
|
|
36
|
+
removed_at.nil? && invitation_accepted?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pending?
|
|
40
|
+
return false unless removed_at.nil?
|
|
41
|
+
return false unless self.class.invitation_tracking_supported?
|
|
42
|
+
|
|
43
|
+
!invitation_accepted?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def accept_invitation!
|
|
47
|
+
update_attributes = { muted: false, timed_out_until: nil }
|
|
48
|
+
update_attributes[:invitation_accepted] = true if self.class.invitation_tracking_supported?
|
|
49
|
+
update!(update_attributes)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def role_key
|
|
53
|
+
custom_role_key.presence || role
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def role_key=(value)
|
|
57
|
+
normalized = normalize_role_key_value(value)
|
|
58
|
+
if normalized.blank?
|
|
59
|
+
self.custom_role_key = nil
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if self.class.roles.key?(normalized)
|
|
64
|
+
self.role = normalized
|
|
65
|
+
self.custom_role_key = nil
|
|
66
|
+
else
|
|
67
|
+
self.role = :member if role.blank?
|
|
68
|
+
self.custom_role_key = normalized
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def effective_role_key
|
|
73
|
+
role_key.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def effective_role_definition
|
|
77
|
+
ChatGem.configuration.role_definition(effective_role_key)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def effective_role_name
|
|
81
|
+
definition = effective_role_definition
|
|
82
|
+
return effective_role_key.humanize if definition.nil?
|
|
83
|
+
|
|
84
|
+
definition[:name].presence || effective_role_key.humanize
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def effective_role_rank
|
|
88
|
+
definition = effective_role_definition
|
|
89
|
+
return -1 if definition.nil?
|
|
90
|
+
|
|
91
|
+
definition[:rank].to_i
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def effective_role_permissions
|
|
95
|
+
definition = effective_role_definition
|
|
96
|
+
return [] if definition.nil?
|
|
97
|
+
|
|
98
|
+
Array(definition[:permissions]).map(&:to_sym)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def invitation_tracking_supported_for_record?
|
|
104
|
+
self.class.invitation_tracking_supported?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def normalize_custom_role_key
|
|
108
|
+
self.custom_role_key = normalize_role_key_value(custom_role_key)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def custom_role_must_exist
|
|
112
|
+
return if ChatGem.configuration.role_definition(custom_role_key).present?
|
|
113
|
+
|
|
114
|
+
errors.add(:custom_role_key, "is not configured")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def normalize_role_key_value(value)
|
|
118
|
+
value.to_s.strip.presence
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def enforce_chat_participant_limit
|
|
122
|
+
return if chat.nil?
|
|
123
|
+
|
|
124
|
+
limit = ChatGem.configuration.max_chat_participants
|
|
125
|
+
return if limit.nil?
|
|
126
|
+
|
|
127
|
+
limit = limit.to_i
|
|
128
|
+
return if limit <= 0
|
|
129
|
+
|
|
130
|
+
active_count_without_self = chat.chat_memberships.active.where.not(id: id).count
|
|
131
|
+
return if active_count_without_self < limit
|
|
132
|
+
|
|
133
|
+
errors.add(:chat, "has reached the participant limit (#{limit})")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|