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,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,5 @@
1
+ module ChatGem
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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