FEATURE: Allow users to DM groups in chat (#25189)

Allows users to create DMs by selecting groups as a target. It also allows adding user groups to an existing chat

- When creating the channel, it expands the user group and adds all its members with chat enabled to the channel.
- After creation, there's no difference between adding a group or adding its members individually.
- Users can add multiple groups and users simultaneously.
- There are UI validations; the member count preview updates according to the member count of added groups, and it does not allow users to add more members than SiteSetting.chat_max_direct_message_users."
This commit is contained in:
Jan Cernik
2024-01-19 11:09:47 -03:00
committed by GitHub
parent bd2ca8d617
commit f4e51e0789
29 changed files with 573 additions and 91 deletions

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
module Chat
class UsersFromUsernamesAndGroupsQuery
def self.call(usernames:, groups:, excluded_user_ids: [])
User
.joins(:user_option)
.left_outer_joins(:groups)
.where(user_options: { chat_enabled: true })
.where(
"username IN (?) OR (groups.name IN (?) AND group_users.user_id IS NOT NULL)",
usernames,
groups,
)
.where.not(id: excluded_user_ids)
.distinct
end
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
module Chat
class ChatableGroupSerializer < BasicGroupSerializer
attributes :chat_enabled, :chat_enabled_user_count, :can_chat
def chat_enabled
SiteSetting.chat_enabled
end
def chat_enabled_user_count
object.users.count { |user| user.user_option&.chat_enabled }
end
def can_chat
# + 1 for current user
chat_enabled && chat_enabled_user_count + 1 <= SiteSetting.chat_max_direct_message_users
end
end
end

View File

@@ -3,6 +3,7 @@
module Chat
class ChatablesSerializer < ::ApplicationSerializer
attributes :users
attributes :groups
attributes :direct_message_channels
attributes :category_channels
@@ -18,6 +19,18 @@ module Chat
.as_json
end
def groups
(object.groups || [])
.map do |group|
{
identifier: "g-#{group.id}",
model: ::Chat::ChatableGroupSerializer.new(group, scope: scope, root: false),
type: "group",
}
end
.as_json
end
def direct_message_channels
(object.direct_message_channels || [])
.map do |channel|

View File

@@ -20,6 +20,7 @@ module Chat
# @param [Integer] id of the channel
# @param [Hash] params_to_create
# @option params_to_create [Array<String>] usernames
# @option params_to_create [Array<String>] groups
# @return [Service::Base::Context]
contract
model :channel
@@ -27,6 +28,7 @@ module Chat
model :users, optional: true
transaction do
step :validate_user_count
step :upsert_memberships
step :recompute_users_count
step :notice_channel
@@ -35,20 +37,15 @@ module Chat
# @!visibility private
class Contract
attribute :usernames, :array
validates :usernames, presence: true
attribute :groups, :array
attribute :channel_id, :integer
validates :channel_id, presence: true
validate :usernames_length
validate :target_presence
def usernames_length
if usernames && usernames.length > SiteSetting.chat_max_direct_message_users + 1 # 1 for current user
errors.add(
:usernames,
"should have less than #{SiteSetting.chat_max_direct_message_users} elements",
)
end
def target_presence
usernames.present? || groups.present?
end
end
@@ -60,17 +57,23 @@ module Chat
end
def fetch_users(contract:, channel:, **)
::User.where(
"username IN (?) AND id NOT IN (?)",
[*contract.usernames],
channel.chatable.direct_message_users.select(:user_id),
).to_a
::Chat::UsersFromUsernamesAndGroupsQuery.call(
usernames: contract.usernames,
groups: contract.groups,
excluded_user_ids: channel.chatable.direct_message_users.pluck(:user_id),
)
end
def fetch_channel(contract:, **)
::Chat::Channel.includes(:chatable).find_by(id: contract.channel_id)
end
def validate_user_count(channel:, users:, **)
if channel.user_count + users.length > SiteSetting.chat_max_direct_message_users
fail!("should have less than #{SiteSetting.chat_max_direct_message_users} elements")
end
end
def upsert_memberships(channel:, users:, **)
always_level = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]

View File

@@ -19,6 +19,7 @@ module Chat
# @param [Guardian] guardian
# @param [Hash] params_to_create
# @option params_to_create [Array<String>] target_usernames
# @option params_to_create [Array<String>] target_groups
# @return [Service::Base::Context]
policy :can_create_direct_message
@@ -32,6 +33,7 @@ module Chat
class_name: Chat::DirectMessageChannel::CanCommunicateAllPartiesPolicy
model :direct_message, :fetch_or_create_direct_message
model :channel, :fetch_or_create_channel
step :validate_user_count
step :set_optional_name
step :update_memberships
step :recompute_users_count
@@ -40,7 +42,13 @@ module Chat
class Contract
attribute :name, :string
attribute :target_usernames, :array
validates :target_usernames, presence: true
attribute :target_groups, :array
validate :target_presence
def target_presence
target_usernames.present? || target_groups.present?
end
end
private
@@ -50,13 +58,22 @@ module Chat
end
def fetch_target_users(guardian:, contract:, **)
User.where(username: [guardian.user.username, *contract.target_usernames]).to_a
::Chat::UsersFromUsernamesAndGroupsQuery.call(
usernames: [*contract.target_usernames, guardian.user.username],
groups: contract.target_groups,
)
end
def fetch_user_comm_screener(target_users:, guardian:, **)
UserCommScreener.new(acting_user: guardian.user, target_user_ids: target_users.map(&:id))
end
def validate_user_count(target_users:, **)
if target_users.length > SiteSetting.chat_max_direct_message_users
fail!("should have less than #{SiteSetting.chat_max_direct_message_users} elements")
end
end
def actor_allows_dms(user_comm_screener:, **)
!user_comm_screener.actor_disallowing_all_pms?
end

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Chat
# Returns a list of chatables (users, category channels, direct message channels) that can be chatted with.
# Returns a list of chatables (users, groups ,category channels, direct message channels) that can be chatted with.
#
# @example
# Chat::SearchChatable.call(term: "@bob", guardian: guardian)
@@ -18,6 +18,7 @@ module Chat
step :clean_term
model :memberships, optional: true
model :users, optional: true
model :groups, optional: true
model :category_channels, optional: true
model :direct_message_channels, optional: true
@@ -25,6 +26,7 @@ module Chat
class Contract
attribute :term, :string, default: ""
attribute :include_users, :boolean, default: true
attribute :include_groups, :boolean, default: true
attribute :include_category_channels, :boolean, default: true
attribute :include_direct_message_channels, :boolean, default: true
attribute :excluded_memberships_channel_id, :integer
@@ -46,6 +48,12 @@ module Chat
search_users(context, guardian, contract)
end
def fetch_groups(guardian:, contract:, **)
return unless contract.include_groups
return unless guardian.can_create_direct_message?
search_groups(context, guardian, contract)
end
def fetch_category_channels(guardian:, contract:, **)
return unless contract.include_category_channels
return if !SiteSetting.enable_public_channels
@@ -109,5 +117,15 @@ module Chat
user_search
end
def search_groups(context, guardian, contract)
Group
.visible_groups(guardian.user)
.includes(users: :user_option)
.where(
"groups.name ILIKE :term_like OR groups.full_name ILIKE :term_like",
term_like: "%#{context.term}%",
)
end
end
end