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,41 @@
1
+ module ChatGem
2
+ class ApplicationController < ::ApplicationController
3
+ layout "chat_gem/application"
4
+
5
+ helper_method :current_chat_participant
6
+
7
+ private
8
+
9
+ def current_chat_participant
10
+ method_name = :chat_current_participant
11
+ raise NotImplementedError, "Define ##{method_name} in your host application controller" unless respond_to?(method_name, true)
12
+
13
+ participant = send(method_name)
14
+ return participant if participant.nil? || participant.respond_to?(:active_chat_memberships)
15
+
16
+ raise ArgumentError, "##{method_name} must return a model that uses `acts_as_chat_participant`"
17
+ end
18
+
19
+ def permission_for(chat = nil)
20
+ ChatGem.configuration.permission_adapter.new(current_chat_participant, chat)
21
+ end
22
+
23
+ def authorize_create_chat!
24
+ return if permission_for.can_create_chat?
25
+
26
+ head :forbidden
27
+ end
28
+
29
+ def authorize_view_chat!(chat)
30
+ return if permission_for(chat).can_view_chat?
31
+
32
+ head :forbidden
33
+ end
34
+
35
+ def authorize_post_message!(chat)
36
+ return if permission_for(chat).can_post_message?
37
+
38
+ head :forbidden
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,81 @@
1
+ module ChatGem
2
+ class ChatMembershipsController < ApplicationController
3
+ include ChatGem::ChatsController::EventPayloadSupport
4
+
5
+ before_action :set_chat
6
+ before_action -> { authorize_view_chat!(@chat) }
7
+ before_action :authorize_invite_member!
8
+
9
+ def create
10
+ participant = invite_participant
11
+ membership = @chat.chat_memberships.find_or_initialize_by(participant: participant)
12
+ pending_invitation_attributes = invitation_pending_attributes
13
+
14
+ if membership.persisted?
15
+ membership.assign_attributes(
16
+ removed_at: nil,
17
+ muted: false,
18
+ timed_out_until: nil
19
+ )
20
+ membership.assign_attributes(pending_invitation_attributes)
21
+ else
22
+ membership.assign_attributes(role: :member)
23
+ membership.assign_attributes(pending_invitation_attributes)
24
+ end
25
+
26
+ membership.save!
27
+ set_chat_lifecycle_event(action: :invited, chat: @chat, membership: membership)
28
+ redirect_to chat_path(@chat), notice: "Participant invited"
29
+ rescue ActiveRecord::RecordNotFound
30
+ redirect_to chat_path(@chat), alert: "Participant not found"
31
+ rescue NameError, ArgumentError
32
+ redirect_to chat_path(@chat), alert: "Invalid participant type"
33
+ rescue ActiveRecord::RecordInvalid => error
34
+ redirect_to chat_path(@chat), alert: error.record.errors.full_messages.to_sentence
35
+ end
36
+
37
+ private
38
+
39
+ def set_chat
40
+ @chat = ChatGem::Chat.find(params[:chat_id])
41
+ end
42
+
43
+ def authorize_invite_member!
44
+ permission = permission_for(@chat)
45
+ return if permission.respond_to?(:can_invite_member?) && permission.can_invite_member?
46
+
47
+ head :forbidden
48
+ end
49
+
50
+ def invite_params
51
+ params.require(:chat_membership).permit(:participant_type, :participant_id)
52
+ end
53
+
54
+ def invite_participant
55
+ participant_type = invite_params.fetch(:participant_type).to_s
56
+ participant_id = invite_params.fetch(:participant_id).to_s
57
+ raise ArgumentError if participant_type.blank? || participant_id.blank?
58
+
59
+ participant_class = participant_type.safe_constantize
60
+ raise NameError if participant_class.nil?
61
+ raise ArgumentError unless participant_class < ActiveRecord::Base
62
+ raise ArgumentError unless participant_class.method_defined?(:active_chat_memberships)
63
+ raise ArgumentError unless invite_type_allowed?(participant_class)
64
+
65
+ participant_class.find(participant_id)
66
+ end
67
+
68
+ def invite_type_allowed?(participant_class)
69
+ inviter = current_chat_participant
70
+ return false if inviter.nil?
71
+
72
+ participant_class.base_class.name == inviter.class.base_class.name
73
+ end
74
+
75
+ def invitation_pending_attributes
76
+ return {} unless ChatGem::ChatMembership.invitation_tracking_supported?
77
+
78
+ { invitation_accepted: false }
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,144 @@
1
+ module ChatGem
2
+ class ChatMessagesController < ApplicationController
3
+ before_action :set_chat
4
+ before_action -> { authorize_view_chat!(@chat) }, only: :index
5
+ before_action -> { authorize_post_message!(@chat) }, only: :create
6
+ before_action :set_chat_message, only: :update
7
+ before_action :authorize_edit_chat_message!, only: :update
8
+
9
+ def index
10
+ @chat_messages = @chat.visible_messages
11
+ end
12
+
13
+ def create
14
+ return respond_to_clear_signal_request if clear_signal_request?
15
+
16
+ build_chat_message
17
+
18
+ if @chat_message.save
19
+ respond_to_chat_message_create_success
20
+ else
21
+ respond_to_chat_message_create_failure
22
+ end
23
+ end
24
+
25
+ def update
26
+ if @chat_message.update(edit_chat_message_params)
27
+ return render_chat_message_update if turbo_stream_request?
28
+
29
+ redirect_to chat_path(@chat), notice: "Message updated"
30
+ else
31
+ return render_chat_message_update(force_edit_open: true, status: :unprocessable_entity) if turbo_stream_request?
32
+
33
+ redirect_to chat_path(@chat), alert: @chat_message.errors.full_messages.to_sentence
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def set_chat
40
+ @chat = ChatGem::Chat.find(params[:chat_id])
41
+ end
42
+
43
+ def chat_message_params
44
+ params.require(:chat_message).permit(:body, :kind, :signal_type)
45
+ end
46
+
47
+ def edit_chat_message_params
48
+ params.require(:chat_message).permit(:body)
49
+ end
50
+
51
+ def signal_request?
52
+ params.dig(:chat_message, :kind).to_s == "signal"
53
+ end
54
+
55
+ def clear_signal_request?
56
+ return false unless signal_request?
57
+
58
+ ActiveModel::Type::Boolean.new.cast(params.dig(:chat_message, :clear))
59
+ end
60
+
61
+ def build_chat_message
62
+ @chat_message = @chat.chat_messages.build(chat_message_params)
63
+ @chat_message.participant = current_chat_participant
64
+ end
65
+
66
+ def respond_to_clear_signal_request
67
+ ChatGem::ChatMessage.clear_signals!(chat: @chat, participant: current_chat_participant)
68
+ respond_to do |format|
69
+ format.turbo_stream { render_signals_update }
70
+ format.html { redirect_to chat_path(@chat) }
71
+ end
72
+ end
73
+
74
+ def respond_to_chat_message_create_success
75
+ respond_to do |format|
76
+ format.turbo_stream do
77
+ signal_request? ? render_signals_update : head(:ok)
78
+ end
79
+ format.html { redirect_to chat_path(@chat) }
80
+ end
81
+ end
82
+
83
+ def respond_to_chat_message_create_failure
84
+ @chat_messages = @chat.visible_messages
85
+ @chat_permission = permission_for(@chat)
86
+ @can_post_message = @chat_permission.can_post_message?
87
+ respond_to do |format|
88
+ format.turbo_stream { render "chat_gem/chats/show", status: :unprocessable_entity }
89
+ format.html { render "chat_gem/chats/show", status: :unprocessable_entity }
90
+ end
91
+ end
92
+
93
+ def render_signals_update
94
+ render turbo_stream: turbo_stream.update(
95
+ view_context.dom_id(@chat, :signals),
96
+ partial: "chat_gem/chat_messages/signals",
97
+ locals: { chat: @chat }
98
+ )
99
+ end
100
+
101
+ def set_chat_message
102
+ @chat_message = @chat.chat_messages.messages_only.find(params[:id])
103
+ end
104
+
105
+ def authorize_edit_chat_message!
106
+ chat_permission = permission_for(@chat)
107
+ can_edit = if chat_permission.respond_to?(:can_edit_message?)
108
+ chat_permission.can_edit_message?(@chat_message)
109
+ else
110
+ can_post = if chat_permission.respond_to?(:can_post_message?)
111
+ chat_permission.can_post_message?
112
+ elsif chat_permission.respond_to?(:can_view_chat?)
113
+ chat_permission.can_view_chat?
114
+ else
115
+ false
116
+ end
117
+
118
+ can_post &&
119
+ @chat_message.participant_type.to_s == current_chat_participant.class.base_class.name &&
120
+ @chat_message.participant_id.to_s == current_chat_participant.id.to_s
121
+ end
122
+ return if can_edit
123
+
124
+ respond_to do |format|
125
+ format.html { redirect_to chat_path(@chat), alert: "Not allowed to edit this message" }
126
+ format.any { head :forbidden }
127
+ end
128
+ end
129
+
130
+ def render_chat_message_update(force_edit_open: false, status: :ok)
131
+ locals = { chat_message: @chat_message }
132
+ locals[:force_edit_open] = true if force_edit_open
133
+ render turbo_stream: turbo_stream.replace(
134
+ view_context.dom_id(@chat_message),
135
+ partial: "chat_gem/chat_messages/message",
136
+ locals: locals
137
+ ), status: status
138
+ end
139
+
140
+ def turbo_stream_request?
141
+ request.headers["Accept"].to_s.include?("turbo-stream")
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,58 @@
1
+ module ChatGem
2
+ class ChatsController < ApplicationController
3
+ module EventPayloadSupport
4
+ private
5
+
6
+ def invitation_accepted_payload
7
+ payload = flash[:chat_gem_invitation_accepted]
8
+ return nil unless payload.respond_to?(:to_h)
9
+
10
+ symbolized_payload = payload.to_h.symbolize_keys
11
+ chat_id = symbolized_payload[:chatId].presence || symbolized_payload[:chat_id].presence
12
+ return nil if chat_id.blank?
13
+
14
+ {
15
+ chatId: chat_id.to_s,
16
+ chatTitle: symbolized_payload[:chatTitle].presence || symbolized_payload[:chat_title].presence,
17
+ chatMembershipId: symbolized_payload[:chatMembershipId].presence || symbolized_payload[:chat_membership_id].presence
18
+ }.compact
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ def chat_lifecycle_event_payload
24
+ payload = flash[:chat_gem_chat_lifecycle_event]
25
+ return nil unless payload.respond_to?(:to_h)
26
+
27
+ symbolized_payload = payload.to_h.symbolize_keys
28
+ event_name = symbolized_payload[:eventName].presence || symbolized_payload[:event_name].presence
29
+ return nil if event_name.blank?
30
+
31
+ chat_id = symbolized_payload[:chatId].presence || symbolized_payload[:chat_id].presence
32
+
33
+ {
34
+ eventName: event_name.to_s,
35
+ action: symbolized_payload[:action].presence,
36
+ chatId: chat_id.to_s.presence,
37
+ chatTitle: symbolized_payload[:chatTitle].presence || symbolized_payload[:chat_title].presence,
38
+ chatMembershipId: symbolized_payload[:chatMembershipId].presence || symbolized_payload[:chat_membership_id].presence
39
+ }.compact
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ def set_chat_lifecycle_event(action:, chat:, membership: nil)
45
+ return if action.blank? || chat.nil?
46
+
47
+ action_key = action.to_s
48
+ flash[:chat_gem_chat_lifecycle_event] = {
49
+ eventName: "chat-gem:chat-#{action_key}",
50
+ action: action_key,
51
+ chatId: chat.id.to_s,
52
+ chatTitle: chat.title,
53
+ chatMembershipId: membership&.id&.to_s
54
+ }.compact
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,31 @@
1
+ module ChatGem
2
+ class ChatsController < ApplicationController
3
+ module InvitationSupport
4
+ private
5
+
6
+ def build_invitable_participants
7
+ participant_type = current_chat_participant.class.base_class.name
8
+ participant_class = participant_type.safe_constantize
9
+ return [] if participant_class.nil?
10
+ return [] unless participant_class < ActiveRecord::Base
11
+ return [] unless participant_class.method_defined?(:active_chat_memberships)
12
+
13
+ current_member_ids = @chat.chat_memberships.where(removed_at: nil, participant_type: participant_type).pluck(:participant_id)
14
+ participant_class.where.not(id: current_member_ids).order(id: :asc).limit(100).to_a
15
+ rescue StandardError
16
+ []
17
+ end
18
+
19
+ def pending_invitations_for(participant)
20
+ return ChatGem::ChatMembership.none if participant.nil?
21
+ return ChatGem::ChatMembership.none unless ChatGem::ChatMembership.invitation_tracking_supported?
22
+
23
+ ChatGem::ChatMembership
24
+ .pending
25
+ .where(participant: participant)
26
+ .includes(:chat)
27
+ .order(created_at: :desc, id: :desc)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,125 @@
1
+ module ChatGem
2
+ class ChatsController < ApplicationController
3
+ include ChatGem::ChatsController::InvitationSupport
4
+ include ChatGem::ChatsController::EventPayloadSupport
5
+
6
+ before_action :authorize_create_chat!, only: %i[new create]
7
+ before_action :set_chat, only: %i[show accept decline leave close reopen]
8
+
9
+ def index
10
+ participant = current_chat_participant
11
+ @chats = ChatGem::Chat.for_participant(participant).order(created_at: :desc, id: :desc)
12
+ @pending_invitations = pending_invitations_for(participant)
13
+ @invitation_accepted_event = invitation_accepted_payload
14
+ @chat_lifecycle_event = chat_lifecycle_event_payload
15
+ end
16
+
17
+ def accept
18
+ participant = current_chat_participant
19
+ return head :forbidden if participant.nil?
20
+ return redirect_to(chats_path, alert: "Run latest chat_gem migrations to accept invitations", status: :see_other) unless ChatGem::ChatMembership.invitation_tracking_supported?
21
+
22
+ membership = @chat.chat_memberships.pending.find_by(participant: participant)
23
+ return redirect_to(chats_path, alert: "Invitation not found", status: :see_other) if membership.nil?
24
+
25
+ membership.accept_invitation!
26
+ flash[:chat_gem_invitation_accepted] = {
27
+ chatId: @chat.id,
28
+ chatTitle: @chat.title,
29
+ chatMembershipId: membership.id
30
+ }
31
+ set_chat_lifecycle_event(action: :joined, chat: @chat, membership: membership)
32
+ redirect_to chats_path, notice: "Invitation accepted", status: :see_other
33
+ end
34
+
35
+ def decline
36
+ participant = current_chat_participant
37
+ return head :forbidden if participant.nil?
38
+ return redirect_to(chats_path, alert: "Run latest chat_gem migrations to decline invitations", status: :see_other) unless ChatGem::ChatMembership.invitation_tracking_supported?
39
+
40
+ membership = @chat.chat_memberships.pending.find_by(participant: participant)
41
+ return redirect_to(chats_path, alert: "Invitation not found", status: :see_other) if membership.nil?
42
+
43
+ membership.update!(removed_at: Time.current, muted: false, timed_out_until: nil, invitation_accepted: false)
44
+ set_chat_lifecycle_event(action: :declined, chat: @chat, membership: membership)
45
+ redirect_to chats_path, notice: "Invitation declined", status: :see_other
46
+ end
47
+
48
+ def new
49
+ @chat = ChatGem::Chat.new
50
+ end
51
+
52
+ def create
53
+ @chat = ChatGem::Chat.new(chat_params)
54
+
55
+ if @chat.save
56
+ membership = @chat.chat_memberships.create!(participant: current_chat_participant, role: :admin)
57
+ set_chat_lifecycle_event(action: :joined, chat: @chat, membership: membership)
58
+ redirect_to chat_path(@chat), notice: "Chat created"
59
+ else
60
+ render :new, status: :unprocessable_entity
61
+ end
62
+ end
63
+
64
+ def show
65
+ authorize_view_chat!(@chat)
66
+ return if performed?
67
+
68
+ @chat_lifecycle_event = chat_lifecycle_event_payload
69
+ @chat_permission = permission_for(@chat)
70
+ @chat_messages = @chat.visible_messages
71
+ @can_post_message = @chat_permission.can_post_message?
72
+ @can_invite_member = @chat_permission.respond_to?(:can_invite_member?) && @chat_permission.can_invite_member?
73
+ @can_close_chat = @chat_permission.can_close_chat?
74
+ @can_reopen_chat = @chat_permission.can_reopen_chat?
75
+ @can_edit_own_messages = if @chat_permission.respond_to?(:can_edit_message?)
76
+ @chat_permission.can_edit_message?
77
+ else
78
+ @chat_permission.can_post_message?
79
+ end
80
+ @chat_message = @chat.chat_messages.build if @can_post_message
81
+ @invitable_participants = build_invitable_participants if @can_invite_member
82
+ @invite_participant_type = current_chat_participant.class.base_class.name if @can_invite_member
83
+ end
84
+
85
+ def leave
86
+ authorize_view_chat!(@chat)
87
+ return if performed?
88
+
89
+ membership = @chat.chat_memberships.active.find_by(participant: current_chat_participant)
90
+ return redirect_to(chats_path, alert: "You are no longer a participant in this chat", status: :see_other) if membership.nil?
91
+
92
+ membership.update!(removed_at: Time.current, muted: false, timed_out_until: nil)
93
+ set_chat_lifecycle_event(action: :left, chat: @chat, membership: membership)
94
+ redirect_to chats_path, notice: "You left the chat", status: :see_other
95
+ end
96
+
97
+ def close
98
+ chat_permission = permission_for(@chat)
99
+ return head :forbidden unless chat_permission.can_close_chat?
100
+
101
+ @chat.close!
102
+ set_chat_lifecycle_event(action: :closed, chat: @chat)
103
+ redirect_to chat_path(@chat), notice: "Chat closed", status: :see_other
104
+ end
105
+
106
+ def reopen
107
+ chat_permission = permission_for(@chat)
108
+ return head :forbidden unless chat_permission.can_reopen_chat?
109
+
110
+ @chat.reopen!
111
+ set_chat_lifecycle_event(action: :reopened, chat: @chat)
112
+ redirect_to chat_path(@chat), notice: "Chat reopened", status: :see_other
113
+ end
114
+
115
+ private
116
+
117
+ def set_chat
118
+ @chat = ChatGem::Chat.find(params[:id])
119
+ end
120
+
121
+ def chat_params
122
+ params.require(:chat).permit(:title)
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,41 @@
1
+ module ChatGem
2
+ module ApplicationHelper
3
+ module ConfigSupport
4
+ def chat_mention_filter_exclude_self?
5
+ chat_config_boolean(:mention_filter_exclude_self, default: true)
6
+ end
7
+
8
+ def chat_mention_filter_hide_roles?
9
+ chat_config_boolean(:mention_filter_hide_roles, default: true)
10
+ end
11
+
12
+ def chat_emit_mention_events?
13
+ chat_config_boolean(:emit_mention_events, default: false)
14
+ end
15
+
16
+ def chat_emit_invitation_events?
17
+ chat_config_boolean(:emit_invitation_events, default: false)
18
+ end
19
+
20
+ def chat_emit_chat_lifecycle_events?
21
+ chat_config_boolean(:emit_chat_lifecycle_events, default: false)
22
+ end
23
+
24
+ private
25
+
26
+ def chat_config_value(method_name, default: nil)
27
+ configuration = ChatGem.configuration
28
+ return default unless configuration.respond_to?(method_name)
29
+
30
+ configuration.public_send(method_name)
31
+ rescue StandardError
32
+ default
33
+ end
34
+
35
+ def chat_config_boolean(method_name, default:)
36
+ value = chat_config_value(method_name, default: default)
37
+ ActiveModel::Type::Boolean.new.cast(value)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,55 @@
1
+ module ChatGem
2
+ module ApplicationHelper
3
+ module MentionSupport
4
+ module EntryBuilder
5
+ private
6
+
7
+ def chat_member_mention_entries(chat)
8
+ taken_tokens = {}
9
+
10
+ active_chat_memberships(chat).each_with_object([]) do |membership, entries|
11
+ participant = membership.participant
12
+ next if participant.nil?
13
+
14
+ identifier = normalized_mention_identifier(participant_mention_base_identifier(participant))
15
+ identifier = fallback_mention_identifier(participant) if identifier.blank?
16
+ token = unique_mention_token(identifier, taken_tokens)
17
+
18
+ entries << {
19
+ token: token,
20
+ label: chat_participant_name(participant),
21
+ participant: participant,
22
+ participant_type: participant.class.base_class.name,
23
+ participant_id: participant.id
24
+ }
25
+ end
26
+ end
27
+
28
+ def chat_role_mention_entries(chat)
29
+ role_tokens = {}
30
+
31
+ active_chat_memberships(chat).each_with_object([]) do |membership, entries|
32
+ role_key = membership.effective_role_key.to_s.strip
33
+ next if role_key.blank?
34
+
35
+ role_token = "@#{role_key.upcase}"
36
+ next if role_tokens[role_token]
37
+
38
+ role_tokens[role_token] = true
39
+ entries << {
40
+ token: role_token,
41
+ label: "#{membership.effective_role_name} role",
42
+ kind: "role"
43
+ }
44
+ end
45
+ end
46
+
47
+ def active_chat_memberships(chat)
48
+ return [] unless chat.respond_to?(:chat_memberships)
49
+
50
+ chat.chat_memberships.active.includes(:participant).order(:id)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,28 @@
1
+ module ChatGem
2
+ module ApplicationHelper
3
+ module MentionSupport
4
+ module PermissionSupport
5
+ private
6
+
7
+ def mention_permission_for(chat)
8
+ return nil unless respond_to?(:current_chat_participant, true)
9
+
10
+ participant = current_chat_participant
11
+ return nil if participant.nil?
12
+
13
+ ChatGem.configuration.permission_adapter.new(participant, chat)
14
+ rescue StandardError
15
+ nil
16
+ end
17
+
18
+ def mention_permission_allows?(permission, method_name)
19
+ return true unless permission.respond_to?(method_name)
20
+
21
+ permission.public_send(method_name)
22
+ rescue StandardError
23
+ false
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,49 @@
1
+ module ChatGem
2
+ module ApplicationHelper
3
+ module MentionSupport
4
+ module TokenBuilder
5
+ private
6
+
7
+ def participant_mention_base_identifier(participant)
8
+ return participant.username if participant.respond_to?(:username) && participant.username.present?
9
+ if participant.respond_to?(:email) && participant.email.present?
10
+ return participant.email.to_s.split("@").first
11
+ end
12
+ return participant.name if participant.respond_to?(:name) && participant.name.present?
13
+
14
+ participant.to_s
15
+ end
16
+
17
+ def fallback_mention_identifier(participant)
18
+ participant_id = participant.respond_to?(:id) ? participant.id : nil
19
+ return "member_#{participant_id}" if participant_id.present?
20
+
21
+ "member"
22
+ end
23
+
24
+ def normalized_mention_identifier(value)
25
+ slug = I18n.transliterate(value.to_s)
26
+ slug = slug.downcase.gsub(/[^a-z0-9_]+/, "_").gsub(/\A_+|_+\z/, "").squeeze("_")
27
+ slug = "member_#{slug}" if slug.match?(/\A\d/)
28
+ slug.presence
29
+ end
30
+
31
+ def unique_mention_token(identifier, taken_tokens)
32
+ base = normalized_mention_identifier(identifier) || "member"
33
+ token = "@#{base}"
34
+ return taken_tokens[token] = token unless taken_tokens.key?(token)
35
+
36
+ suffix = 2
37
+ loop do
38
+ candidate = "@#{base}_#{suffix}"
39
+ unless taken_tokens.key?(candidate)
40
+ taken_tokens[candidate] = candidate
41
+ return candidate
42
+ end
43
+ suffix += 1
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end