FEATURE: introduces group channels

This commit is contained in:
Joffrey JAFFEUX 2023-11-08 17:10:23 +01:00
parent 184f038cbf
commit 7a4671c6d1
110 changed files with 2606 additions and 2248 deletions

View File

@ -28,4 +28,12 @@ class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
}, },
) )
end end
def create
with_service(Chat::AddUsersToChannel) do
on_failed_policy(:can_add_users_to_channel) do
render_json_error("Users can't be added to this channel")
end
end
end
end end

View File

@ -27,21 +27,5 @@ module Chat
self.slug = Slug.for(self.title.strip, "") self.slug = Slug.for(self.title.strip, "")
self.slug = "" if duplicate_slug? self.slug = "" if duplicate_slug?
end end
def ensure_slug_ok
if self.slug.present?
# if we don't unescape it first we strip the % from the encoded version
slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug
self.slug = Slug.for(slug, "", method: :encoded)
if self.slug.blank?
errors.add(:slug, :invalid)
elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only?
errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars"))
elsif duplicate_slug?
errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use"))
end
end
end
end end
end end

View File

@ -27,6 +27,7 @@ module Chat
class_name: "Chat::Message", class_name: "Chat::Message",
foreign_key: :last_message_id, foreign_key: :last_message_id,
optional: true optional: true
def last_message def last_message
super || NullMessage.new super || NullMessage.new
end end
@ -109,9 +110,29 @@ module Chat
%i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } } %i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } }
def ensure_slug_ok
if self.slug.present?
# if we don't unescape it first we strip the % from the encoded version
slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug
self.slug = Slug.for(slug, "", method: :encoded)
if self.slug.blank?
errors.add(:slug, :invalid)
elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only?
errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars"))
elsif duplicate_slug?
errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use"))
end
end
end
def membership_for(user) def membership_for(user)
if user_chat_channel_memberships.loaded?
user_chat_channel_memberships.detect { |m| m.user_id == user.id }
else
user_chat_channel_memberships.find_by(user: user) user_chat_channel_memberships.find_by(user: user)
end end
end
def add(user) def add(user)
Chat::ChannelMembershipManager.new(self).follow(user) Chat::ChannelMembershipManager.new(self).follow(user)
@ -177,6 +198,7 @@ module Chat
AND (users.suspended_till IS NULL OR users.suspended_till <= CURRENT_TIMESTAMP) AND (users.suspended_till IS NULL OR users.suspended_till <= CURRENT_TIMESTAMP)
AND NOT users.staged AND NOT users.staged
AND user_chat_channel_memberships.following AND user_chat_channel_memberships.following
and users.id > 0
GROUP BY user_chat_channel_memberships.chat_channel_id GROUP BY user_chat_channel_memberships.chat_channel_id
) subquery ) subquery
WHERE channels.id = subquery.chat_channel_id WHERE channels.id = subquery.chat_channel_id

View File

@ -66,6 +66,7 @@ end
# Table name: direct_message_channels # Table name: direct_message_channels
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# group :boolean
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# #

View File

@ -20,12 +20,8 @@ module Chat
direct_message.chat_channel_title_for_user(self, user) direct_message.chat_channel_title_for_user(self, user)
end end
def ensure_slug_ok
true
end
def generate_auto_slug def generate_auto_slug
self.slug = nil return if !self.slug.present?
end end
end end
end end

View File

@ -114,7 +114,7 @@ module Chat
return uploads.first.original_filename if cooked.blank? && uploads.present? return uploads.first.original_filename if cooked.blank? && uploads.present?
# this may return blank for some complex things like quotes, that is acceptable # this may return blank for some complex things like quotes, that is acceptable
PrettyText.excerpt(cooked, max_length, strip_links: true) PrettyText.excerpt(cooked, max_length, strip_links: true, keep_mentions: true)
end end
def censored_excerpt(max_length: 50) def censored_excerpt(max_length: 50)

View File

@ -7,7 +7,7 @@ module Chat
Chat::UserChatChannelMembership Chat::UserChatChannelMembership
.joins(:user) .joins(:user)
.includes(:user) .includes(:user)
.where(user: User.activated.not_suspended.not_staged) .where(user: User.human_users.activated.not_suspended.not_staged)
.where(chat_channel: channel, following: true) .where(chat_channel: channel, following: true)
return query.count if count_only return query.count if count_only

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Chat
class BasicUserSerializer < BasicUserSerializer
attributes :can_chat, :has_chat_enabled
def can_chat
SiteSetting.chat_enabled && scope&.can_chat?
end
def has_chat_enabled
can_chat && object.user_option&.chat_enabled
end
end
end

View File

@ -2,9 +2,9 @@
module Chat module Chat
class DirectMessageSerializer < ApplicationSerializer class DirectMessageSerializer < ApplicationSerializer
attributes :id attribute :group
has_many :users, serializer: Chat::ChatableUserSerializer, embed: :objects has_many :users, serializer: ::Chat::ChatableUserSerializer, embed: :objects
def users def users
users = object.direct_message_users.map(&:user).map { |u| u || Chat::NullUser.new } users = object.direct_message_users.map(&:user).map { |u| u || Chat::NullUser.new }

View File

@ -37,7 +37,7 @@ module Chat
def mentioned_users def mentioned_users
object object
.chat_mentions .chat_mentions
.includes(:user) .includes(user: :user_status)
.map(&:user) .map(&:user)
.compact .compact
.sort_by(&:id) .sort_by(&:id)

View File

@ -7,8 +7,8 @@ module Chat
:last_reply_id, :last_reply_id,
:participant_count, :participant_count,
:reply_count :reply_count
has_many :participant_users, serializer: BasicUserSerializer, embed: :objects has_many :participant_users, serializer: ::BasicUserSerializer, embed: :objects
has_one :last_reply_user, serializer: BasicUserSerializer, embed: :objects has_one :last_reply_user, serializer: ::BasicUserSerializer, embed: :objects
def initialize(object, opts) def initialize(object, opts)
super(object, opts) super(object, opts)

View File

@ -2,10 +2,10 @@
module Chat module Chat
class UserChannelMembershipSerializer < BaseChannelMembershipSerializer class UserChannelMembershipSerializer < BaseChannelMembershipSerializer
has_one :user, serializer: BasicUserSerializer, embed: :objects has_one :user, serializer: ::Chat::BasicUserSerializer, embed: :objects
def user def user
object.user object.user || Chat::NullUser.new
end end
end end
end end

View File

@ -0,0 +1,139 @@
# frozen_string_literal: true
module Chat
# Service responsible to add users to a channel.
# The guardian passed in is the "acting user" when adding users.
# The service is essentially creating memberships for the users.
#
# @example
# ::Chat::AddUsersToChannel.call(
# guardian: guardian,
# channel_id: 1,
# usernames: ["bob", "alice"]
# )
#
class AddUsersToChannel
include Service::Base
# @!method call(guardian:, **params_to_create)
# @param [Guardian] guardian
# @param [Integer] id of the channel
# @param [Hash] params_to_create
# @option params_to_create [Array<String>] usernames
# @return [Service::Base::Context]
contract
model :channel
policy :can_add_users_to_channel
model :users
transaction do
step :upsert_memberships
step :recompute_users_count
step :notice_channel
end
# @!visibility private
class Contract
attribute :usernames, :array
validates :usernames, presence: true
attribute :channel_id, :integer
validates :channel_id, presence: true
validate :usernames_length
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
end
end
private
def can_add_users_to_channel(guardian:, channel:, **)
(guardian.user.admin? || channel.joined_by?(guardian.user)) &&
channel.direct_message_channel? && channel.chatable.group
end
def fetch_users(contract:, channel:, **)
::User.where(
"username IN (?) AND id NOT IN (?)",
[*contract.usernames],
channel.allowed_user_ids,
).to_a
end
def fetch_channel(contract:, **)
::Chat::Channel.includes(:chatable).find_by(id: contract.channel_id)
end
def upsert_memberships(channel:, users:, **)
always_level = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
memberships =
users.map do |user|
{
user_id: user.id,
chat_channel_id: channel.id,
muted: false,
following: true,
desktop_notification_level: always_level,
mobile_notification_level: always_level,
created_at: Time.zone.now,
updated_at: Time.zone.now,
}
end
context.added_user_ids =
::Chat::UserChatChannelMembership
.upsert_all(
memberships,
unique_by: %i[user_id chat_channel_id],
returning: Arel.sql("user_id, (xmax = '0') as inserted"),
)
.select { |row| row["inserted"] }
.map { |row| row["user_id"] }
::Chat::DirectMessageUser.upsert_all(
context.added_user_ids.map do |id|
{
user_id: id,
direct_message_channel_id: channel.chatable.id,
created_at: Time.zone.now,
updated_at: Time.zone.now,
}
end,
unique_by: %i[direct_message_channel_id user_id],
)
end
def recompute_users_count(channel:, **)
channel.update!(
user_count: ::Chat::ChannelMembershipsQuery.count(channel),
user_count_stale: false,
)
end
def notice_channel(guardian:, channel:, users:, **)
added_users = users.select { |u| context.added_user_ids.include?(u.id) }
return if added_users.blank?
::Chat::CreateMessage.call(
guardian: Discourse.system_user.guardian,
chat_channel_id: channel.id,
message:
I18n.t(
"chat.channel.users_invited_to_channel",
invited_users: added_users.map { |u| "@#{u.username}" }.join(", "),
inviting_user: "@#{guardian.user.username}",
count: added_users.count,
),
)
end
end
end

View File

@ -7,7 +7,7 @@ module Chat
# are passed in. # are passed in.
# #
# @example # @example
# Service::Chat::CreateDirectMessageChannel.call( # ::Chat::CreateDirectMessageChannel.call(
# guardian: guardian, # guardian: guardian,
# target_usernames: ["bob", "alice"] # target_usernames: ["bob", "alice"]
# ) # )
@ -32,10 +32,13 @@ module Chat
class_name: Chat::DirectMessageChannel::CanCommunicateAllPartiesPolicy class_name: Chat::DirectMessageChannel::CanCommunicateAllPartiesPolicy
model :direct_message, :fetch_or_create_direct_message model :direct_message, :fetch_or_create_direct_message
model :channel, :fetch_or_create_channel model :channel, :fetch_or_create_channel
step :set_optional_name
step :update_memberships step :update_memberships
step :recompute_users_count
# @!visibility private # @!visibility private
class Contract class Contract
attribute :name, :string
attribute :target_usernames, :array attribute :target_usernames, :array
validates :target_usernames, presence: true validates :target_usernames, presence: true
end end
@ -58,17 +61,26 @@ module Chat
!user_comm_screener.actor_disallowing_all_pms? !user_comm_screener.actor_disallowing_all_pms?
end end
def fetch_or_create_direct_message(target_users:, **) def fetch_or_create_direct_message(target_users:, contract:, **)
Chat::DirectMessage.for_user_ids(target_users.map(&:id)) || ids = target_users.map(&:id)
Chat::DirectMessage.create(user_ids: target_users.map(&:id))
if ids.size > 2 || contract.name.present?
::Chat::DirectMessage.create(user_ids: ids, group: true)
else
::Chat::DirectMessage.for_user_ids(ids) || ::Chat::DirectMessage.create(user_ids: ids)
end
end end
def fetch_or_create_channel(direct_message:, **) def fetch_or_create_channel(direct_message:, **)
Chat::DirectMessageChannel.find_or_create_by(chatable: direct_message) ::Chat::DirectMessageChannel.find_or_create_by(chatable: direct_message)
end
def set_optional_name(channel:, contract:, **)
channel.update!(name: contract.name) if contract.name&.length&.positive?
end end
def update_memberships(channel:, target_users:, **) def update_memberships(channel:, target_users:, **)
always_level = Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always] always_level = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
memberships = memberships =
target_users.map do |user| target_users.map do |user|
@ -84,10 +96,17 @@ module Chat
} }
end end
Chat::UserChatChannelMembership.upsert_all( ::Chat::UserChatChannelMembership.upsert_all(
memberships, memberships,
unique_by: %i[user_id chat_channel_id], unique_by: %i[user_id chat_channel_id],
) )
end end
def recompute_users_count(channel:, **)
channel.update!(
user_count: ::Chat::ChannelMembershipsQuery.count(channel),
user_count_stale: false,
)
end
end end
end end

View File

@ -22,9 +22,9 @@ module Chat
policy :no_silenced_user policy :no_silenced_user
contract contract
model :channel model :channel
step :enforce_system_membership
policy :allowed_to_join_channel policy :allowed_to_join_channel
policy :allowed_to_create_message_in_channel, class_name: Chat::Channel::MessageCreationPolicy policy :allowed_to_create_message_in_channel, class_name: Chat::Channel::MessageCreationPolicy
step :enforce_system_membership
model :channel_membership model :channel_membership
model :reply, optional: true model :reply, optional: true
policy :ensure_reply_consistency policy :ensure_reply_consistency
@ -76,7 +76,7 @@ module Chat
end end
def enforce_system_membership(guardian:, channel:, **) def enforce_system_membership(guardian:, channel:, **)
channel.add(guardian.user) if guardian.user.is_system_user? channel.add(guardian.user) if guardian.user&.is_system_user?
end end
def fetch_channel_membership(guardian:, channel:, **) def fetch_channel_membership(guardian:, channel:, **)

View File

@ -59,7 +59,7 @@ module Chat
private private
def fetch_channel(contract:, **) def fetch_channel(contract:, **)
::Chat::Channel.strict_loading.includes(:chatable).find_by(id: contract.channel_id) ::Chat::Channel.includes(:chatable).find_by(id: contract.channel_id)
end end
def fetch_optional_membership(channel:, guardian:, **) def fetch_optional_membership(channel:, guardian:, **)

View File

@ -15,50 +15,41 @@ module Chat
# @return [Service::Base::Context] # @return [Service::Base::Context]
contract contract
step :set_mode
step :clean_term step :clean_term
step :fetch_memberships model :memberships
step :fetch_users model :users, optional: true
step :fetch_category_channels model :category_channels, optional: true
step :fetch_direct_message_channels model :direct_message_channels, optional: true
# @!visibility private # @!visibility private
class Contract class Contract
attribute :term, default: "" attribute :term, :string, default: ""
attribute :include_users, :boolean, default: true
attribute :include_category_channels, :boolean, default: true
attribute :include_direct_message_channels, :boolean, default: true
attribute :excluded_memberships_channel_id, :integer
end end
private private
def set_mode
context.mode =
if context.contract.term&.start_with?("#")
:channel
elsif context.contract.term&.start_with?("@")
:user
else
:all
end
end
def clean_term(contract:, **) def clean_term(contract:, **)
context.term = contract.term.downcase&.gsub(/^#+/, "")&.gsub(/^@+/, "")&.strip context.term = contract.term.downcase&.gsub(/^#+/, "")&.gsub(/^@+/, "")&.strip
end end
def fetch_memberships(guardian:, **) def fetch_memberships(guardian:, **)
context.memberships = ::Chat::ChannelMembershipManager.all_for_user(guardian.user) ::Chat::ChannelMembershipManager.all_for_user(guardian.user)
end end
def fetch_users(guardian:, **) def fetch_users(guardian:, contract:, **)
return unless contract.include_users
return unless guardian.can_create_direct_message? return unless guardian.can_create_direct_message?
return if context.mode == :channel search_users(context, guardian, contract)
context.users = search_users(context.term, guardian)
end end
def fetch_category_channels(guardian:, **) def fetch_category_channels(guardian:, contract:, **)
return if context.mode == :user return unless contract.include_category_channels
return if !SiteSetting.enable_public_channels return if !SiteSetting.enable_public_channels
context.category_channels =
::Chat::ChannelFetcher.secured_public_channel_search( ::Chat::ChannelFetcher.secured_public_channel_search(
guardian, guardian,
filter_on_category_name: false, filter_on_category_name: false,
@ -69,24 +60,20 @@ module Chat
) )
end end
def fetch_direct_message_channels(guardian:, **args) def fetch_direct_message_channels(guardian:, users:, contract:, **args)
return if context.mode == :user return unless contract.include_direct_message_channels
user_ids = nil
if context.term.length > 0
user_ids =
(context.users.nil? ? search_users(context.term, guardian) : context.users).map(&:id)
end
channels = channels =
::Chat::ChannelFetcher.secured_direct_message_channels_search( ::Chat::ChannelFetcher.secured_direct_message_channels_search(
guardian.user.id, guardian.user.id,
guardian, guardian,
limit: 10, limit: 10,
user_ids: user_ids, match_filter_on_starts_with: false,
filter: context.term,
) || [] ) || []
if user_ids.present? && context.mode == :all if users && contract.include_users
user_ids = users.map(&:id)
channels = channels =
channels.reject do |channel| channels.reject do |channel|
channel_user_ids = channel.allowed_user_ids - [guardian.user.id] channel_user_ids = channel.allowed_user_ids - [guardian.user.id]
@ -96,17 +83,31 @@ module Chat
end end
end end
context.direct_message_channels = channels channels
end end
def search_users(term, guardian) def search_users(context, guardian, contract)
user_search = ::UserSearch.new(term, limit: 10) user_search = ::UserSearch.new(context.term, limit: 10)
if term.blank? if context.term.blank?
user_search.scoped_users.includes(:user_option) user_search = user_search.scoped_users.real.includes(:user_option)
else else
user_search.search.includes(:user_option) user_search = user_search.search.real.includes(:user_option)
end end
if context.excluded_memberships_channel_id
user_search =
user_search.where(
"NOT EXISTS (
SELECT 1
FROM user_chat_channel_memberships
WHERE user_chat_channel_memberships.user_id = users.id AND user_chat_channel_memberships.chat_channel_id = ?
)",
context.excluded_memberships_channel_id,
)
end
user_search
end end
end end
end end

View File

@ -7,7 +7,7 @@ module Chat
# and threading_enabled are also editable. # and threading_enabled are also editable.
# #
# @example # @example
# Service::Chat::UpdateChannel.call( # ::Chat::UpdateChannel.call(
# channel_id: 2, # channel_id: 2,
# guardian: guardian, # guardian: guardian,
# name: "SuperChannel", # name: "SuperChannel",
@ -26,13 +26,13 @@ module Chat
# @option params_to_edit [String,nil] name # @option params_to_edit [String,nil] name
# @option params_to_edit [String,nil] description # @option params_to_edit [String,nil] description
# @option params_to_edit [String,nil] slug # @option params_to_edit [String,nil] slug
# @option params_to_edit [Boolean] threading_enabled
# @option params_to_edit [Boolean] auto_join_users Only valid for {CategoryChannel}. Whether active users # @option params_to_edit [Boolean] auto_join_users Only valid for {CategoryChannel}. Whether active users
# with permission to see the category should automatically join the channel. # with permission to see the category should automatically join the channel.
# @option params_to_edit [Boolean] allow_channel_wide_mentions Allow the use of @here and @all in the channel. # @option params_to_edit [Boolean] allow_channel_wide_mentions Allow the use of @here and @all in the channel.
# @return [Service::Base::Context] # @return [Service::Base::Context]
model :channel, :fetch_channel model :channel, :fetch_channel
policy :no_direct_message_channel
policy :check_channel_permission policy :check_channel_permission
contract default_values_from: :channel contract default_values_from: :channel
step :update_channel step :update_channel
@ -62,10 +62,6 @@ module Chat
Chat::Channel.find_by(id: channel_id) Chat::Channel.find_by(id: channel_id)
end end
def no_direct_message_channel(channel:, **)
!channel.direct_message_channel?
end
def check_channel_permission(guardian:, channel:, **) def check_channel_permission(guardian:, channel:, **)
guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel? guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel?
end end

View File

@ -1,14 +1,19 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing"; import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import icon from "discourse-common/helpers/d-icon"; import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import ChatModalEditChannelName from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-name";
import ChatChannelStatus from "discourse/plugins/chat/discourse/components/chat-channel-status"; import ChatChannelStatus from "discourse/plugins/chat/discourse/components/chat-channel-status";
import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title"; import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
export default class ChatChannelMessageEmojiPicker extends Component { export default class ChatChannelMessageEmojiPicker extends Component {
@service chatChannelInfoRouteOriginManager; @service chatChannelInfoRouteOriginManager;
@service site; @service site;
@service modal;
@service chatGuardian;
membersLabel = I18n.t("chat.channel_info.tabs.members"); membersLabel = I18n.t("chat.channel_info.tabs.members");
settingsLabel = I18n.t("chat.channel_info.tabs.settings"); settingsLabel = I18n.t("chat.channel_info.tabs.settings");
@ -16,13 +21,25 @@ export default class ChatChannelMessageEmojiPicker extends Component {
backToAllChannelsLabel = I18n.t("chat.channel_info.back_to_channel"); backToAllChannelsLabel = I18n.t("chat.channel_info.back_to_channel");
get showTabs() { get showTabs() {
return this.site.desktopView && this.args.channel.isOpen;
}
get canEditChannel() {
return ( return (
this.site.desktopView && this.chatGuardian.canEditChatChannel() &&
this.args.channel.membershipsCount > 1 && (this.args.channel.isCategoryChannel ||
this.args.channel.isOpen (this.args.channel.isDirectMessageChannel &&
this.args.channel.chatable.group))
); );
} }
@action
editChannelTitle() {
return this.modal.show(ChatModalEditChannelName, {
model: this.args.channel,
});
}
<template> <template>
<div class="chat-full-page-header"> <div class="chat-full-page-header">
<div class="chat-channel-header-details"> <div class="chat-channel-header-details">
@ -48,6 +65,14 @@ export default class ChatChannelMessageEmojiPicker extends Component {
</div> </div>
<ChatChannelTitle @channel={{@channel}} /> <ChatChannelTitle @channel={{@channel}} />
{{#if this.canEditChannel}}
<DButton
@icon="pencil-alt"
class="btn-flat"
@action={{this.editChannelTitle}}
/>
{{/if}}
</div> </div>
</div> </div>

View File

@ -1,24 +1,31 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking"; import { cached, tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper"; import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { modifier } from "ember-modifier"; import { modifier } from "ember-modifier";
import isElementInViewport from "discourse/lib/is-element-in-viewport"; import isElementInViewport from "discourse/lib/is-element-in-viewport";
import DiscourseURL, { userPath } from "discourse/lib/url";
import { INPUT_DELAY } from "discourse-common/config/environment"; import { INPUT_DELAY } from "discourse-common/config/environment";
import icon from "discourse-common/helpers/d-icon";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import gt from "truth-helpers/helpers/gt"; import eq from "truth-helpers/helpers/eq";
import MessageCreator from "discourse/plugins/chat/discourse/components/chat/message-creator";
import ChatUserInfo from "discourse/plugins/chat/discourse/components/chat-user-info"; import ChatUserInfo from "discourse/plugins/chat/discourse/components/chat-user-info";
import DcFilterInput from "discourse/plugins/chat/discourse/components/dc-filter-input"; import DcFilterInput from "discourse/plugins/chat/discourse/components/dc-filter-input";
import { MODES } from "./chat/message-creator/constants";
export default class ChatChannelMembers extends Component { export default class ChatChannelMembers extends Component {
@service appEvents;
@service chatApi; @service chatApi;
@service modal; @service modal;
@service loadingSlider; @service loadingSlider;
@tracked filter = ""; @tracked filter = "";
@tracked showAddMembers = false;
filterPlaceholder = I18n.t("chat.members_view.filter_placeholder"); filterPlaceholder = I18n.t("chat.members_view.filter_placeholder");
noMembershipsFoundLabel = I18n.t("chat.channel.no_memberships_found"); noMembershipsFoundLabel = I18n.t("chat.channel.no_memberships_found");
@ -30,6 +37,22 @@ export default class ChatChannelMembers extends Component {
}); });
}); });
onEnter = modifier((element, [callback]) => {
const handler = (event) => {
if (event.key !== "Enter") {
return;
}
callback(event);
};
element.addEventListener("keydown", handler);
return () => {
element.removeEventListener("keydown", handler);
};
});
fill = modifier((element) => { fill = modifier((element) => {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
if (isElementInViewport(element)) { if (isElementInViewport(element)) {
@ -78,13 +101,41 @@ export default class ChatChannelMembers extends Component {
this.load(); this.load();
} }
@action
addMember() {
this.showAddMembers = true;
}
@action
hideAddMember() {
this.showAddMembers = false;
}
@action
openMemberCard(user, event) {
event.preventDefault();
DiscourseURL.routeTo(userPath(user.username_lower));
}
async debouncedLoad() { async debouncedLoad() {
this.loadingSlider.transitionStarted(); this.loadingSlider.transitionStarted();
await this.members.load({ limit: 20 }); await this.members.load({ limit: 20 });
this.loadingSlider.transitionEnded(); this.loadingSlider.transitionEnded();
} }
get addMembersMode() {
return MODES.add_members;
}
<template> <template>
{{#if this.showAddMembers}}
<MessageCreator
@mode={{this.addMembersMode}}
@channel={{@channel}}
@onClose={{this.hideAddMember}}
@onCancel={{this.hideAddMember}}
/>
{{else}}
<div class="chat-channel-members"> <div class="chat-channel-members">
<DcFilterInput <DcFilterInput
@class="chat-channel-members__filter" @class="chat-channel-members__filter"
@ -94,12 +145,34 @@ export default class ChatChannelMembers extends Component {
{{this.focusInput}} {{this.focusInput}}
/> />
{{#if (gt @channel.membershipsCount 0)}}
<ul class="chat-channel-members__list" {{this.fill}}> <ul class="chat-channel-members__list" {{this.fill}}>
{{#each this.members as |membership|}} {{#if @channel.chatable.group}}
<li class="chat-channel-members__list-item"> <li
<ChatUserInfo @user={{membership.user}} @avatarSize="tiny" /> class="chat-channel-members__list-item -add-member"
role="button"
{{on "click" this.addMember}}
{{this.onEnter this.addMember}}
tabindex="0"
>
{{icon "plus"}}
<span>Add Member</span>
</li> </li>
{{/if}}
{{#each this.members as |membership|}}
{{#unless (eq membership.user.id -1)}}
<li
class="chat-channel-members__list-item -member"
{{on "click" (fn this.openMemberCard membership.user)}}
{{this.onEnter (fn this.openMemberCard membership.user)}}
tabindex="0"
>
<ChatUserInfo
@user={{membership.user}}
@avatarSize="tiny"
@interactive={{false}}
/>
</li>
{{/unless}}
{{else}} {{else}}
{{#if this.noResults}} {{#if this.noResults}}
<li <li
@ -114,11 +187,7 @@ export default class ChatChannelMembers extends Component {
<div {{this.loadMore}}> <div {{this.loadMore}}>
<br /> <br />
</div> </div>
{{else}}
<p class="alert alert-info">
{{this.noMembershipsLabel}}
</p>
{{/if}}
</div> </div>
{{/if}}
</template> </template>
} }

View File

@ -69,10 +69,6 @@ export default class ChatAboutScreen extends Component {
return this.chatGuardian.canEditChatChannel(); return this.chatGuardian.canEditChatChannel();
} }
get shouldRenderTitleSection() {
return this.args.channel.isCategoryChannel;
}
get shouldRenderDescriptionSection() { get shouldRenderDescriptionSection() {
return this.args.channel.isCategoryChannel; return this.args.channel.isCategoryChannel;
} }
@ -293,7 +289,7 @@ export default class ChatAboutScreen extends Component {
} }
@action @action
onEditChannelName() { onEditChannelTitle() {
return this.modal.show(ChatModalEditChannelName, { return this.modal.show(ChatModalEditChannelName, {
model: this.args.channel, model: this.args.channel,
}); });
@ -309,7 +305,6 @@ export default class ChatAboutScreen extends Component {
<template> <template>
<div class="chat-channel-settings"> <div class="chat-channel-settings">
<ChatForm as |form|> <ChatForm as |form|>
{{#if this.shouldRenderTitleSection}}
<form.section @title={{this.titleSectionTitle}} as |section|> <form.section @title={{this.titleSectionTitle}} as |section|>
<section.row> <section.row>
<:default> <:default>
@ -333,7 +328,7 @@ export default class ChatAboutScreen extends Component {
{{#if this.canEditChannel}} {{#if this.canEditChannel}}
<DButton <DButton
@label="chat.channel_settings.edit" @label="chat.channel_settings.edit"
@action={{this.onEditChannelName}} @action={{this.onEditChannelTitle}}
class="edit-name-slug-btn btn-flat" class="edit-name-slug-btn btn-flat"
/> />
{{/if}} {{/if}}
@ -341,7 +336,6 @@ export default class ChatAboutScreen extends Component {
</section.row> </section.row>
</form.section> </form.section>
{{/if}}
{{#if this.shouldRenderDescriptionSection}} {{#if this.shouldRenderDescriptionSection}}
<form.section @title={{this.descriptionSectionTitle}} as |section|> <form.section @title={{this.descriptionSectionTitle}} as |section|>
@ -567,7 +561,7 @@ export default class ChatAboutScreen extends Component {
@channel={{@channel}} @channel={{@channel}}
@options={{hash @options={{hash
joinClass="btn-primary" joinClass="btn-primary"
leaveClass="btn-flat" leaveClass="btn-danger"
joinIcon="sign-in-alt" joinIcon="sign-in-alt"
leaveIcon="sign-out-alt" leaveIcon="sign-out-alt"
}} }}

View File

@ -0,0 +1,112 @@
import Component from "@glimmer/component";
import { get, hash } from "@ember/helper";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import PluginOutlet from "discourse/components/plugin-outlet";
import UserStatusMessage from "discourse/components/user-status-message";
import replaceEmoji from "discourse/helpers/replace-emoji";
import icon from "discourse-common/helpers/d-icon";
import ChatUserAvatar from "discourse/plugins/chat/discourse/components/chat-user-avatar";
import getFirstUser from "discourse/plugins/chat/discourse/lib/get-first-user";
export default class ChatChannelTitle extends Component {
@service currentUser;
get firstUser() {
return getFirstUser(this.args.channel.chatable.users, this.currentUser);
}
get users() {
return this.args.channel.chatable.users;
}
get groupDirectMessage() {
return (
this.args.channel.isDirectMessageChannel &&
this.args.channel.chatable.group
);
}
get groupsDirectMessageTitle() {
return this.args.channel.title || this.usernames;
}
get usernames() {
return this.users.mapBy("username").join(", ");
}
get channelColorStyle() {
return htmlSafe(`color: #${this.args.channel.chatable.color}`);
}
get showUserStatus() {
return !!(this.users.length === 1 && this.users[0].status);
}
<template>
{{#if @channel.isDirectMessageChannel}}
<div class="chat-channel-title is-dm">
{{#if this.groupDirectMessage}}
<span class="chat-channel-title__users-count">
{{@channel.membershipsCount}}
</span>
{{else}}
<div class="chat-channel-title__avatar">
<ChatUserAvatar @user={{this.firstUser}} @interactive={{false}} />
</div>
{{/if}}
<div class="chat-channel-title__user-info">
<div class="chat-channel-title__usernames">
{{#if this.groupDirectMessage}}
<span class="chat-channel-title__name">
{{this.groupsDirectMessageTitle}}
</span>
{{else}}
<span class="chat-channel-title__name">
{{this.firstUser.username}}
</span>
{{#if this.showUserStatus}}
<UserStatusMessage
@class="chat-channel-title__user-status-message"
@status={{get this.users "0.status"}}
@showDescription={{if this.site.mobileView "true"}}
/>
{{/if}}
<PluginOutlet
@name="after-chat-channel-username"
@outletArgs={{hash user=@user}}
@tagName=""
@connectorTagName=""
/>
{{/if}}
</div>
</div>
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
{{else if @channel.isCategoryChannel}}
<div class="chat-channel-title is-category">
<span
class="chat-channel-title__category-badge"
style={{this.channelColorStyle}}
>
{{icon "d-chat"}}
{{#if @channel.chatable.read_restricted}}
{{icon "lock" class="chat-channel-title__restricted-category-icon"}}
{{/if}}
</span>
<span class="chat-channel-title__name">
{{replaceEmoji @channel.title}}
</span>
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
{{/if}}
</template>
}

View File

@ -1,67 +0,0 @@
{{#if @channel.isDirectMessageChannel}}
<div class="chat-channel-title is-dm">
{{#if @channel.chatable.users.length}}
<div class="chat-channel-title__avatar">
{{#if this.multiDm}}
<span class="chat-channel-title__users-count">
{{@channel.chatable.users.length}}
</span>
{{else}}
<ChatUserAvatar @user={{get @channel.chatable.users "0"}} />
{{/if}}
</div>
{{/if}}
<div class="chat-channel-title__user-info">
<div class="chat-channel-title__usernames">
{{#if @channel.chatable.users.length}}
{{#if this.multiDm}}
<span class="chat-channel-title__name">{{this.usernames}}</span>
{{else}}
{{#let (get @channel.chatable.users "0") as |user|}}
<span class="chat-channel-title__name">{{user.username}}</span>
{{#if this.showUserStatus}}
<UserStatusMessage
@class="chat-channel-title__user-status-message"
@status={{get @channel.chatable.users "0.status"}}
@showDescription={{if this.site.mobileView "true"}}
/>
{{/if}}
<PluginOutlet
@name="after-chat-channel-username"
@outletArgs={{hash user=user}}
@tagName=""
@connectorTagName=""
/>
{{/let}}
{{/if}}
{{else}}
<span class="chat-channel-title__name">Add users</span>
{{/if}}
</div>
</div>
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
{{else if @channel.isCategoryChannel}}
<div class="chat-channel-title is-category">
<span
class="chat-channel-title__category-badge"
style={{this.channelColorStyle}}
>
{{d-icon "d-chat"}}
{{#if @channel.chatable.read_restricted}}
{{d-icon "lock" class="chat-channel-title__restricted-category-icon"}}
{{/if}}
</span>
<span class="chat-channel-title__name">
{{replace-emoji @channel.title}}
</span>
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
{{/if}}

View File

@ -1,24 +0,0 @@
import Component from "@glimmer/component";
import { htmlSafe } from "@ember/template";
export default class ChatChannelTitle extends Component {
get users() {
return this.args.channel.chatable.users;
}
get multiDm() {
return this.users.length > 1;
}
get usernames() {
return this.users.mapBy("username").join(", ");
}
get channelColorStyle() {
return htmlSafe(`color: #${this.args.channel.chatable.color}`);
}
get showUserStatus() {
return !!(this.users.length === 1 && this.users[0].status);
}
}

View File

@ -0,0 +1,109 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import and from "truth-helpers/helpers/and";
import or from "truth-helpers/helpers/or";
import ChatModalEditChannelName from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-name";
import ThreadsListButton from "discourse/plugins/chat/discourse/components/chat/thread/threads-list-button";
import ChatChannelStatus from "discourse/plugins/chat/discourse/components/chat-channel-status";
import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
export default class ChatFullPageHeader extends Component {
@service chatGuardian;
@service chatStateManager;
@service modal;
@service router;
@service site;
get displayed() {
return this.args.displayed ?? true;
}
get showThreadsListButton() {
return (
this.args.channel.threadingEnabled &&
this.router.currentRoute.name !== "chat.channel.threads" &&
this.router.currentRoute.name !== "chat.channel.thread.index" &&
this.router.currentRoute.name !== "chat.channel.thread"
);
}
get canEditChannel() {
return (
this.chatGuardian.canEditChatChannel() &&
(this.args.channel.isCategoryChannel ||
(this.args.channel.isDirectMessageChannel &&
this.args.channel.chatable.group))
);
}
@action
editChannelTitle() {
return this.modal.show(ChatModalEditChannelName, {
model: this.args.channel,
});
}
<template>
{{#if (and this.chatStateManager.isFullPageActive this.displayed)}}
<div
class={{concatClass
"chat-full-page-header"
(unless @channel.isFollowing "-not-following")
}}
>
<div class="chat-channel-header-details">
{{#if this.site.mobileView}}
<div class="chat-full-page-header__left-actions">
<LinkTo
@route="chat"
class="chat-full-page-header__back-btn no-text btn-flat"
>
{{icon "chevron-left"}}
</LinkTo>
</div>
{{/if}}
<LinkTo
@route="chat.channel.info"
@models={{@channel.routeModels}}
class="chat-channel-title-wrapper"
>
<ChatChannelTitle @channel={{@channel}} />
</LinkTo>
{{#if this.canEditChannel}}
<DButton
@icon="pencil-alt"
class="btn-flat"
@action={{this.editChannelTitle}}
/>
{{/if}}
{{#if (or @channel.threadingEnabled this.site.desktopView)}}
<div class="chat-full-page-header__right-actions">
{{#if this.site.desktopView}}
<DButton
@icon="discourse-compress"
@title="chat.close_full_page"
class="open-drawer-btn btn-flat"
@action={{@onCloseFullScreen}}
/>
{{/if}}
{{#if this.showThreadsListButton}}
<ThreadsListButton @channel={{@channel}} />
{{/if}}
</div>
{{/if}}
</div>
</div>
<ChatChannelStatus @channel={{@channel}} />
{{/if}}
</template>
}

View File

@ -1,48 +0,0 @@
{{#if (and this.chatStateManager.isFullPageActive this.displayed)}}
<div
class={{concat-class
"chat-full-page-header"
(unless @channel.isFollowing "-not-following")
}}
>
<div class="chat-channel-header-details">
{{#if this.site.mobileView}}
<div class="chat-full-page-header__left-actions">
<LinkTo
@route="chat"
class="chat-full-page-header__back-btn no-text btn-flat"
>
{{d-icon "chevron-left"}}
</LinkTo>
</div>
{{/if}}
<LinkTo
@route="chat.channel.info"
@models={{@channel.routeModels}}
class="chat-channel-title-wrapper"
>
<ChatChannelTitle @channel={{@channel}} />
</LinkTo>
{{#if (or @channel.threadingEnabled this.site.desktopView)}}
<div class="chat-full-page-header__right-actions">
{{#if this.site.desktopView}}
<DButton
@icon="discourse-compress"
@title="chat.close_full_page"
class="open-drawer-btn btn-flat"
@action={{@onCloseFullScreen}}
/>
{{/if}}
{{#if this.showThreadsListButton}}
<Chat::Thread::ThreadsListButton @channel={{@channel}} />
{{/if}}
</div>
{{/if}}
</div>
</div>
<ChatChannelStatus @channel={{@channel}} />
{{/if}}

View File

@ -1,21 +0,0 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class ChatFullPageHeader extends Component {
@service site;
@service chatStateManager;
@service router;
get displayed() {
return this.args.displayed ?? true;
}
get showThreadsListButton() {
return (
this.args.channel.threadingEnabled &&
this.router.currentRoute.name !== "chat.channel.threads" &&
this.router.currentRoute.name !== "chat.channel.thread.index" &&
this.router.currentRoute.name !== "chat.channel.thread"
);
}
}

View File

@ -3,6 +3,7 @@ import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class"; import concatClass from "discourse/helpers/concat-class";
import { renderAvatar } from "discourse/helpers/user-avatar"; import { renderAvatar } from "discourse/helpers/user-avatar";
import { userPath } from "discourse/lib/url";
export default class ChatUserAvatar extends Component { export default class ChatUserAvatar extends Component {
@service chat; @service chat;
@ -37,19 +38,23 @@ export default class ChatUserAvatar extends Component {
); );
} }
get userPath() {
return userPath(this.args.user.username);
}
<template> <template>
<div <div
class={{concatClass "chat-user-avatar" (if this.isOnline "is-online")}} class={{concatClass "chat-user-avatar" (if this.isOnline "is-online")}}
data-username={{@user.username}} data-username={{@user.username}}
> >
{{#if this.interactive}} {{#if this.interactive}}
<div <a
role="button" class="chat-user-avatar__container"
class="chat-user-avatar__container clickable" href={{this.userPath}}
data-user-card={{@user.username}} data-user-card={{@user.username}}
> >
{{this.avatar}} {{this.avatar}}
</div> </a>
{{else}} {{else}}
{{this.avatar}} {{this.avatar}}
{{/if}} {{/if}}

View File

@ -12,14 +12,25 @@ export default class ChatUserInfo extends Component {
return userPath(this.args.user.username); return userPath(this.args.user.username);
} }
get interactive() {
return this.args.interactive ?? false;
}
<template> <template>
{{#if @user}} {{#if @user}}
<a href={{this.userPath}} data-user-card={{@user.username}}> <ChatUserAvatar
<ChatUserAvatar @user={{@user}} @avatarSize={{this.avatarSize}} /> @user={{@user}}
</a> @avatarSize={{this.avatarSize}}
@interactive={{this.interactive}}
/>
{{#if this.interactive}}
<a href={{this.userPath}} data-user-card={{@user.username}}> <a href={{this.userPath}} data-user-card={{@user.username}}>
<ChatUserDisplayName @user={{@user}} /> <ChatUserDisplayName @user={{@user}} />
</a> </a>
{{else}}
<ChatUserDisplayName @user={{@user}} />
{{/if}}
{{/if}} {{/if}}
</template> </template>
} }

View File

@ -93,6 +93,11 @@ export default class ChatComposerChannel extends ChatComposer {
#messageRecipients(channel) { #messageRecipients(channel) {
if (channel.isDirectMessageChannel) { if (channel.isDirectMessageChannel) {
if (channel.chatable.group && channel.title) {
return I18n.t("chat.placeholder_channel", {
channelName: `#${channel.title}`,
});
} else {
const directMessageRecipients = channel.chatable.users; const directMessageRecipients = channel.chatable.users;
if ( if (
directMessageRecipients.length === 1 && directMessageRecipients.length === 1 &&
@ -106,6 +111,7 @@ export default class ChatComposerChannel extends ChatComposer {
.map((u) => u.name || `@${u.username}`) .map((u) => u.name || `@${u.username}`)
.join(I18n.t("word_connector.comma")), .join(I18n.t("word_connector.comma")),
}); });
}
} else { } else {
return I18n.t("chat.placeholder_channel", { return I18n.t("chat.placeholder_channel", {
channelName: `#${channel.title}`, channelName: `#${channel.title}`,

View File

@ -1,141 +0,0 @@
<div class="chat-message-creator__container">
<div class="chat-message-creator">
<div
class="chat-message-creator__selection-container"
{{did-insert this.focusInput}}
...attributes
>
<div class="chat-message-creator__selection">
<div class="chat-message-creator__search-icon-container">
{{d-icon "search" class="chat-message-creator__search-icon"}}
</div>
{{#each this.selection as |selection|}}
<div
class={{concat-class
"chat-message-creator__selection-item"
(concat "-" selection.type)
(if
(includes this.activeSelectionIdentifiers selection.identifier)
"-active"
)
}}
tabindex="-1"
data-id={{selection.identifier}}
{{on "click" (fn this.removeSelection selection.identifier)}}
>
{{component
(concat "chat/message-creator/" selection.type "-selection")
selection=selection
}}
<i
class="chat-message-creator__selection__remove-btn"
aria-hidden="true"
>
{{d-icon "times"}}
</i>
</div>
{{/each}}
<Input
class="chat-message-creator__input"
{{did-insert this.setQueryElement}}
{{on "input" this.handleInput}}
{{on "keydown" this.handleKeydown}}
placeholder={{this.placeholder}}
@value={{readonly this.query}}
@type="text"
/>
</div>
<DButton
class="chat-message-creator__close-btn btn-flat"
@icon="times"
@action={{@onClose}}
/>
</div>
{{#if this.showResults}}
<div class="chat-message-creator__content-container" role="presentation">
<div
class="chat-message-creator__content"
role="listbox"
aria-multiselectable="true"
tabindex="-1"
>
{{#if this.searchRequest.loading}}
<div class="chat-message-creator__loader-container">
<div class="chat-message-creator__loader spinner small"></div>
</div>
{{else}}
{{#each this.searchRequest.value as |result|}}
<div
class={{concat-class
"chat-message-creator__row"
(concat "-" result.type)
(unless result.enabled "-disabled")
(if
(eq this.activeResultIdentifier result.identifier) "-active"
)
(if
(includes this.selectionIdentifiers result.identifier)
"-selected"
)
}}
data-id={{result.identifier}}
tabindex="-1"
role="option"
{{on "click" (fn this.handleRowClick result.identifier)}}
{{on "mousemove" (fn (mut this.activeResult) result)}}
{{on "keydown" this.handleKeydown}}
aria-selected={{if
(includes this.selectionIdentifiers result.identifier)
"true"
"false"
}}
>
{{component
(concat "chat/message-creator/" result.type "-row")
content=result
selected=(includes
this.selectionIdentifiers result.identifier
)
active=(eq this.activeResultIdentifier result.identifier)
hasSelectedUsers=this.hasSelectedUsers
}}
</div>
{{else}}
{{#if this.query.length}}
<div class="chat-message-creator__no-items-container">
<span class="chat-message-creator__no-items">
{{i18n "chat.new_message_modal.no_items"}}
</span>
</div>
{{/if}}
{{/each}}
{{/if}}
</div>
</div>
{{/if}}
{{#if this.showFooter}}
<div class="chat-message-creator__footer-container">
<div class="chat-message-creator__footer">
{{#if this.showShortcut}}
<div class="chat-message-creator__shortcut">
{{this.shortcutLabel}}
</div>
{{/if}}
{{#if this.hasSelectedUsers}}
<DButton
class="chat-message-creator__open-dm-btn btn-primary"
@action={{fn this.openChannel this.selection}}
@translatedLabel={{this.openChannelLabel}}
/>
{{/if}}
</div>
</div>
{{/if}}
</div>
</div>

View File

@ -1,546 +0,0 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { getOwner, setOwner } from "@ember/application";
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { escapeExpression } from "discourse/lib/utilities";
import { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
import I18n from "discourse-i18n";
import ChatChatable from "discourse/plugins/chat/discourse/models/chat-chatable";
const MAX_RESULTS = 10;
const USER_PREFIX = "@";
const CHANNEL_PREFIX = "#";
const CHANNEL_TYPE = "channel";
const USER_TYPE = "user";
class Search {
@service("chat-api") api;
@service chat;
@service chatChannelsManager;
@tracked loading = false;
@tracked value = [];
@tracked query = "";
constructor(owner, options = {}) {
setOwner(this, owner);
options.preload ??= false;
options.onlyUsers ??= false;
if (!options.term && !options.preload) {
return;
}
if (!options.term && options.preload) {
this.value = this.#loadExistingChannels();
return;
}
this.loading = true;
this.api
.chatables({ term: options.term })
.then((results) => {
let chatables = [
...results.users,
...results.direct_message_channels,
...results.category_channels,
];
if (options.excludeUserId) {
chatables = chatables.filter(
(item) => item.identifier !== `u-${options.excludeUserId}`
);
}
this.value = chatables
.map((item) => {
const chatable = ChatChatable.create(item);
chatable.tracking = this.#injectTracking(chatable);
return chatable;
})
.slice(0, MAX_RESULTS);
})
.catch(() => (this.value = []))
.finally(() => (this.loading = false));
}
#loadExistingChannels() {
return this.chatChannelsManager.allChannels
.map((channel) => {
let chatable;
if (channel.chatable?.users?.length === 1) {
chatable = ChatChatable.createUser(channel.chatable.users[0]);
chatable.tracking = this.#injectTracking(chatable);
} else {
chatable = ChatChatable.createChannel(channel);
chatable.tracking = channel.tracking;
}
return chatable;
})
.filter(Boolean)
.slice(0, MAX_RESULTS);
}
#injectTracking(chatable) {
switch (chatable.type) {
case CHANNEL_TYPE:
return this.chatChannelsManager.allChannels.find(
(channel) => channel.id === chatable.model.id
)?.tracking;
break;
case USER_TYPE:
return this.chatChannelsManager.directMessageChannels.find(
(channel) =>
channel.chatable.users.length === 1 &&
channel.chatable.users[0].id === chatable.model.id
)?.tracking;
break;
}
}
}
export default class ChatMessageCreator extends Component {
@service("chat-api") api;
@service("chat-channel-composer") composer;
@service chat;
@service site;
@service router;
@service currentUser;
@service siteSettings;
@tracked selection = new TrackedArray();
@tracked activeSelection = new TrackedArray();
@tracked query = "";
@tracked queryElement = null;
@tracked loading = false;
@tracked activeSelectionIdentifiers = new TrackedArray();
@tracked selectedIdentifiers = [];
@tracked _activeResultIdentifier = null;
get placeholder() {
if (
this.siteSettings.enable_public_channels &&
this.chat.userCanDirectMessage
) {
if (this.hasSelectedUsers) {
return I18n.t("chat.new_message_modal.user_search_placeholder");
} else {
return I18n.t("chat.new_message_modal.default_search_placeholder");
}
} else if (this.siteSettings.enable_public_channels) {
return I18n.t(
"chat.new_message_modal.default_channel_search_placeholder"
);
} else if (this.chat.userCanDirectMessage) {
if (this.hasSelectedUsers) {
return I18n.t("chat.new_message_modal.user_search_placeholder");
} else {
return I18n.t("chat.new_message_modal.default_user_search_placeholder");
}
}
}
get showFooter() {
return this.showShortcut || this.hasSelectedUsers;
}
get showResults() {
if (this.hasSelectedUsers && !this.query.length) {
return false;
}
return true;
}
get shortcutLabel() {
let username;
if (this.activeResult?.isUser) {
username = this.activeResult.model.username;
} else {
username = this.activeResult.model.chatable.users[0].username;
}
return htmlSafe(
I18n.t("chat.new_message_modal.add_user_long", {
username: escapeExpression(username),
})
);
}
get showShortcut() {
return (
!this.hasSelectedUsers &&
this.searchRequest?.value?.length &&
this.site.desktopView &&
(this.activeResult?.isUser || this.activeResult?.isSingleUserChannel)
);
}
get activeResultIdentifier() {
return (
this._activeResultIdentifier ||
this.searchRequest.value.find((result) => result.enabled)?.identifier
);
}
get hasSelectedUsers() {
return this.selection.some((s) => s.isUser);
}
get activeResult() {
return this.searchRequest.value.findBy(
"identifier",
this.activeResultIdentifier
);
}
set activeResult(result) {
if (!result?.enabled) {
return;
}
this._activeResultIdentifier = result?.identifier;
}
get selectionIdentifiers() {
return this.selection.mapBy("identifier");
}
get openChannelLabel() {
const users = this.selection.mapBy("model");
return I18n.t("chat.placeholder_users", {
commaSeparatedNames: users
.map((u) => u.name || u.username)
.join(I18n.t("word_connector.comma")),
});
}
@cached
get searchRequest() {
let term = this.query;
if (term?.length) {
if (this.hasSelectedUsers && term.startsWith(CHANNEL_PREFIX)) {
term = term.replace(/^#/, USER_PREFIX);
}
if (this.hasSelectedUsers && !term.startsWith(USER_PREFIX)) {
term = USER_PREFIX + term;
}
}
return new Search(getOwner(this), {
term,
preload: !this.selection?.length,
onlyUsers: this.hasSelectedUsers,
excludeUserId: this.hasSelectedUsers ? this.currentUser?.id : null,
});
}
@action
onFilter(term) {
this._activeResultIdentifier = null;
this.activeSelectionIdentifiers = [];
this.query = term;
}
@action
setQueryElement(element) {
this.queryElement = element;
}
@action
focusInput() {
schedule("afterRender", () => {
this.queryElement.focus();
});
}
@action
handleKeydown(event) {
if (event.key === "Escape") {
if (this.activeSelectionIdentifiers.length > 0) {
this.activeSelectionIdentifiers = [];
event.preventDefault();
event.stopPropagation();
return;
}
}
if (event.key === "a" && (event.metaKey || event.ctrlKey)) {
this.activeSelectionIdentifiers = this.selection.mapBy("identifier");
return;
}
if (event.key === "Enter") {
if (this.activeSelectionIdentifiers.length > 0) {
this.activeSelectionIdentifiers.forEach((identifier) => {
this.removeSelection(identifier);
});
this.activeSelectionIdentifiers = [];
event.preventDefault();
return;
} else if (this.activeResultIdentifier) {
this.toggleSelection(this.activeResultIdentifier, {
altSelection: event.shiftKey || event.ctrlKey,
});
event.preventDefault();
return;
} else if (this.query?.length === 0) {
this.openChannel(this.selection);
event.preventDefault();
return;
}
}
if (event.key === "ArrowDown" && this.searchRequest.value.length > 0) {
this.activeSelectionIdentifiers = [];
this._activeResultIdentifier = this.#getNextResult()?.identifier;
event.preventDefault();
return;
}
if (event.key === "ArrowUp" && this.searchRequest.value.length > 0) {
this.activeSelectionIdentifiers = [];
this._activeResultIdentifier = this.#getPreviousResult()?.identifier;
event.preventDefault();
return;
}
const digit = this.#getDigit(event.code);
if (event.ctrlKey && digit) {
this._activeResultIdentifier = this.searchRequest.value.objectAt(
digit - 1
)?.identifier;
event.preventDefault();
return;
}
if (event.target.selectionEnd !== 0 || event.target.selectionStart !== 0) {
return;
}
if (event.key === "Backspace" && this.selection.length) {
if (!this.activeSelectionIdentifiers.length) {
this.activeSelectionIdentifiers = [this.#getLastSelection().identifier];
event.preventDefault();
return;
} else {
this.activeSelectionIdentifiers.forEach((identifier) => {
this.removeSelection(identifier);
});
this.activeSelectionIdentifiers = [];
event.preventDefault();
return;
}
}
if (event.key === "ArrowLeft" && !event.shiftKey) {
this._activeResultIdentifier = null;
this.activeSelectionIdentifiers = [
this.#getPreviousSelection()?.identifier,
].filter(Boolean);
event.preventDefault();
return;
}
if (event.key === "ArrowRight" && !event.shiftKey) {
this._activeResultIdentifier = null;
this.activeSelectionIdentifiers = [
this.#getNextSelection()?.identifier,
].filter(Boolean);
event.preventDefault();
return;
}
}
@action
replaceActiveSelection(selection) {
this.activeSelection.clear();
this.activeSelection.push(selection.identifier);
}
@action
handleInput(event) {
discourseDebounce(this, this.onFilter, event.target.value, INPUT_DELAY);
}
@action
toggleSelection(identifier, options = {}) {
if (this.selectionIdentifiers.includes(identifier)) {
this.removeSelection(identifier, options);
} else {
this.addSelection(identifier, options);
}
this.focusInput();
}
@action
handleRowClick(identifier, event) {
this.toggleSelection(identifier, {
altSelection: event.shiftKey || event.ctrlKey,
});
event.preventDefault();
}
@action
removeSelection(identifier) {
this.selection = this.selection.filter(
(selection) => selection.identifier !== identifier
);
this.#handleSelectionChange();
}
@action
addSelection(identifier, options = {}) {
let selection = this.searchRequest.value.findBy("identifier", identifier);
if (!selection || !selection.enabled) {
return;
}
if (selection.type === CHANNEL_TYPE && !selection.isSingleUserChannel) {
this.openChannel([selection]);
return;
}
if (
!this.hasSelectedUsers &&
!options.altSelection &&
!this.site.mobileView
) {
this.openChannel([selection]);
return;
}
if (selection.isSingleUserChannel) {
const user = selection.model.chatable.users[0];
selection = new ChatChatable({
identifier: `u-${user.id}`,
type: USER_TYPE,
model: user,
});
}
this.selection = [
...this.selection.filter((s) => s.type !== CHANNEL_TYPE),
selection,
];
this.#handleSelectionChange();
}
@action
openChannel(selection) {
if (selection.length === 1 && selection[0].type === CHANNEL_TYPE) {
const channel = selection[0].model;
this.router.transitionTo("chat.channel", ...channel.routeModels);
this.args.onClose?.();
return;
}
const users = selection.filterBy("type", USER_TYPE).mapBy("model");
this.chat
.upsertDmChannelForUsernames(users.mapBy("username"))
.then((channel) => {
if (!channel) {
return;
}
this.router.transitionTo("chat.channel", ...channel.routeModels);
this.args.onClose?.();
});
}
#handleSelectionChange() {
this.query = "";
this.activeSelectionIdentifiers = [];
this._activeResultIdentifier = null;
}
#getPreviousSelection() {
return this.#getPrevious(
this.selection,
this.activeSelectionIdentifiers?.[0]
);
}
#getNextSelection() {
return this.#getNext(this.selection, this.activeSelectionIdentifiers?.[0]);
}
#getLastSelection() {
return this.selection[this.selection.length - 1];
}
#getPreviousResult() {
return this.#getPrevious(
this.searchRequest.value,
this.activeResultIdentifier
);
}
#getNextResult() {
return this.#getNext(this.searchRequest.value, this.activeResultIdentifier);
}
#getNext(list, currentIdentifier = null) {
if (list.length === 0) {
return null;
}
list = list.filterBy("enabled");
if (currentIdentifier) {
const currentIndex = list.mapBy("identifier").indexOf(currentIdentifier);
if (currentIndex < list.length - 1) {
return list.objectAt(currentIndex + 1);
} else {
return list[0];
}
} else {
return list[0];
}
}
#getPrevious(list, currentIdentifier = null) {
if (list.length === 0) {
return null;
}
list = list.filterBy("enabled");
if (currentIdentifier) {
const currentIndex = list.mapBy("identifier").indexOf(currentIdentifier);
if (currentIndex > 0) {
return list.objectAt(currentIndex - 1);
} else {
return list.objectAt(list.length - 1);
}
} else {
return list.objectAt(list.length - 1);
}
}
#getDigit(input) {
if (typeof input === "string") {
const match = input.match(/Digit(\d+)/);
if (match) {
return parseInt(match[1], 10);
}
}
return false;
}
}

View File

@ -0,0 +1,12 @@
import Component from "@glimmer/component";
import DButton from "discourse/components/d-button";
export default class Action extends Component {
<template>
<DButton
class="btn btn-flat"
@icon={{@item.icon}}
@translatedLabel={{@item.label}}
/>
</template>
}

View File

@ -0,0 +1,78 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
import MembersCount from "./members-count";
import MembersSelector from "./members-selector";
export default class AddMembers extends Component {
@service chat;
@service chatApi;
@service router;
@service toasts;
@service siteSettings;
@service loadingSlider;
get membersCount() {
return (
this.args.members?.length + (this.args.channel?.membershipsCount ?? 0)
);
}
@action
async saveGroupMembers() {
try {
this.loadingSlider.transitionStarted();
await this.chatApi.addMembersToChannel(
this.args.channel.id,
this.args.members.mapBy("model.username")
);
this.toasts.success({ data: { message: I18n.t("saved") } });
this.router.transitionTo(
"chat.channel",
...this.args.channel.routeModels
);
} catch (error) {
popupAjaxError(error);
} finally {
this.loadingSlider.transitionEnded();
}
}
<template>
<div class="chat-message-creator__add-members-container">
<div class="chat-message-creator__add-members">
<MembersCount
@count={{this.membersCount}}
@max={{this.siteSettings.chat_max_direct_message_users}}
/>
<MembersSelector
@channel={{@channel}}
@members={{@members}}
@onChange={{@onChangeMembers}}
@close={{@close}}
@cancel={{@cancel}}
/>
{{#if @members.length}}
<div class="chat-message-creator__add-members-footer-container">
<div class="chat-message-creator__add-members-footer">
<DButton class="btn-flat" @label="cancel" @action={{@cancel}} />
<DButton
class="btn-primary"
@label="chat.direct_message_creator.add_to_channel"
@action={{this.saveGroupMembers}}
/>
</div>
</div>
{{/if}}
</div>
</div>
</template>
}

View File

@ -1,11 +0,0 @@
<ChatChannelTitle @channel={{@content.model}} />
{{#if (gt @content.tracking.unreadCount 0)}}
<div
class={{concat-class "unread-indicator" (if this.isUrgent "-urgent")}}
></div>
{{/if}}
{{#if this.site.desktopView}}
<span class="action-indicator">{{this.openChannelLabel}}</span>
{{/if}}

View File

@ -1,20 +0,0 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import I18n from "discourse-i18n";
export default class ChatMessageCreatorChannelRow extends Component {
@service site;
get openChannelLabel() {
return htmlSafe(I18n.t("chat.new_message_modal.open_channel"));
}
get isUrgent() {
return (
this.args.content.model.isDirectMessageChannel ||
(this.args.content.model.isCategoryChannel &&
this.args.content.model.tracking.mentionCount > 0)
);
}
}

View File

@ -0,0 +1,29 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import concatClass from "discourse/helpers/concat-class";
import gt from "truth-helpers/helpers/gt";
import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
export default class Channel extends Component {
@service currentUser;
get isUrgent() {
return (
this.args.item.model.isDirectMessageChannel ||
(this.args.item.model.isCategoryChannel &&
this.args.item.model.tracking.mentionCount > 0)
);
}
<template>
<div class="chat-message-creator__chatable-category-channel">
<ChatChannelTitle @channel={{@item.model}} />
{{#if (gt @item.tracking.unreadCount 0)}}
<div
class={{concatClass "unread-indicator" (if this.isUrgent "-urgent")}}
></div>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,5 @@
export const MODES = {
search: "SEARCH",
new_group: "NEW_GROUP",
add_members: "ADD_MEMBERS",
};

View File

@ -0,0 +1,54 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import AddMembers from "./add-members";
import { MODES } from "./constants";
import NewGroup from "./new-group";
import Search from "./search";
export default class ChatMessageCreator extends Component {
@tracked mode = MODES.search;
@tracked members = [];
get componentForMode() {
switch (this.args.mode ?? this.mode) {
case MODES.search:
return Search;
case MODES.new_group:
return NewGroup;
case MODES.add_members:
return AddMembers;
}
}
@action
changeMode(mode, members = []) {
this.mode = mode;
this.changeMembers(members);
}
@action
changeMembers(members) {
this.members = members;
}
@action
cancelAction() {
return this.args.onCancel?.() || this.changeMode(MODES.search);
}
<template>
<div class="chat-message-creator-container">
<div class="chat-message-creator">
<this.componentForMode
@channel={{@channel}}
@onChangeMode={{this.changeMode}}
@onChangeMembers={{this.changeMembers}}
@close={{@onClose}}
@cancel={{this.cancelAction}}
@members={{this.members}}
/>
</div>
</div>
</template>
}

View File

@ -0,0 +1,67 @@
import { getOwner, setOwner } from "@ember/application";
import { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse-common/utils/decorators";
import ChatChatable from "discourse/plugins/chat/discourse/models/chat-chatable";
const MAX_RESULTS = 10;
export default class ChatablesLoader {
@service chatChannelsManager;
@service loadingSlider;
constructor(context) {
setOwner(this, getOwner(context));
}
@bind
async search(
term,
options = {
includeUsers: true,
includeCategoryChannels: true,
includeDirectMessageChannels: true,
excludedUserIds: null,
}
) {
this.request?.abort();
try {
this.loadingSlider.transitionStarted();
this.request = ajax("/chat/api/chatables", {
data: {
term,
include_users: options.includeUsers,
include_category_channels: options.includeCategoryChannels,
include_direct_message_channels: options.includeDirectMessageChannels,
excluded_memberships_channel_id: options.excludedMembershipsChannelId,
},
});
const results = await this.request;
this.selectedItem = null;
this.loadingSlider.transitionEnded();
return [
...results.users,
...results.direct_message_channels,
...results.category_channels,
]
.map((item) => {
const chatable = ChatChatable.create(item);
chatable.tracking = this.#injectTracking(chatable);
return chatable;
})
.slice(0, MAX_RESULTS);
} catch (e) {
popupAjaxError(e);
}
}
#injectTracking(chatable) {
return this.chatChannelsManager.allChannels.find(
(channel) => channel.id === chatable.model.id
)?.tracking;
}
}

View File

@ -0,0 +1,88 @@
import Component from "@glimmer/component";
import { modifier } from "ember-modifier";
export default class ListHandler extends Component {
handleKeydown = modifier((element) => {
const handler = (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
this.args.onHighlight(
this.#getNext(this.args.items, this.args.highlightedItem?.identifier)
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
this.args.onHighlight(
this.#getPrevious(
this.args.items,
this.args.highlightedItem?.identifier
)
);
} else if (event.key === "Enter" && this.args.highlightedItem) {
event.preventDefault();
event.stopPropagation();
if (event.shiftKey && this.args.onShifSelect) {
this.args.onShifSelect(this.args.highlightedItem);
} else {
this.args.onSelect(this.args.highlightedItem);
}
}
};
element.addEventListener("keydown", handler);
return () => {
element.removeEventListener("keydown", handler);
};
});
#getNext(list, currentIdentifier = null) {
if (list.length === 0) {
return null;
}
list = list.filterBy("enabled");
if (currentIdentifier) {
const currentIndex = list.mapBy("identifier").indexOf(currentIdentifier);
if (currentIndex < list.length - 1) {
return list.objectAt(currentIndex + 1);
} else {
return list[0];
}
} else {
return list[0];
}
}
#getPrevious(list, currentIdentifier = null) {
if (list.length === 0) {
return null;
}
list = list.filterBy("enabled");
if (currentIdentifier) {
const currentIndex = list.mapBy("identifier").indexOf(currentIdentifier);
if (currentIndex > 0) {
return list.objectAt(currentIndex - 1);
} else {
return list.objectAt(list.length - 1);
}
} else {
return list.objectAt(list.length - 1);
}
}
<template>
<span style="display: contents" {{this.handleKeydown}} ...attributes>
{{yield}}
</span>
</template>
}

View File

@ -0,0 +1,109 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import eq from "truth-helpers/helpers/eq";
import Action from "./action";
import Channel from "./channel";
import User from "./user";
export default class List extends Component {
componentForItem(type) {
switch (type) {
case "action":
return Action;
case "user":
return User;
case "channel":
return Channel;
}
}
#getNext(list, currentIdentifier = null) {
if (list.length === 0) {
return null;
}
list = list.filterBy("enabled");
if (currentIdentifier) {
const currentIndex = list.mapBy("identifier").indexOf(currentIdentifier);
if (currentIndex < list.length - 1) {
return list.objectAt(currentIndex + 1);
} else {
return list[0];
}
} else {
return list[0];
}
}
#getPrevious(list, currentIdentifier = null) {
if (list.length === 0) {
return null;
}
list = list.filterBy("enabled");
if (currentIdentifier) {
const currentIndex = list.mapBy("identifier").indexOf(currentIdentifier);
if (currentIndex > 0) {
return list.objectAt(currentIndex - 1);
} else {
return list.objectAt(list.length - 1);
}
} else {
return list.objectAt(list.length - 1);
}
}
@action
handleEnter(item, event) {
if (event.key !== "Enter") {
return;
}
if (event.shiftKey && this.args.onShiftSelect) {
this.args.onShiftSelect?.(item);
} else {
this.args.onSelect?.(item);
}
}
@action
handleClick(item, event) {
if (event.shiftKey && this.args.onShiftSelect) {
this.args.onShiftSelect?.(item);
} else {
this.args.onSelect?.(item);
}
}
<template>
<div class="chat-message-creator__list-container">
<ul class="chat-message-creator__list">
{{#each @items as |item|}}
<li
class={{concatClass
"chat-message-creator__list-item"
(if
(eq item.identifier @highlightedItem.identifier) "-highlighted"
)
}}
{{on "click" (fn this.handleClick item)}}
{{on "keypress" (fn this.handleEnter item)}}
{{on "mouseenter" (fn @onHighlight item)}}
{{on "mouseleave" (fn @onHighlight null)}}
role="button"
tabindex="0"
>
{{component (this.componentForItem item.type) item=item}}
</li>
{{/each}}
</ul>
</div>
</template>
}

View File

@ -0,0 +1,24 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import ChatUserAvatar from "discourse/plugins/chat/discourse/components/chat-user-avatar";
export default class Member extends Component {
<template>
<DButton
class={{concatClass
"chat-message-creator__member btn-default"
(if @highlighted "-highlighted")
}}
@action={{fn @onSelect @member}}
>
<ChatUserAvatar @user={{@member.model}} @interactive={{false}} />
<span class="chat-message-creator__member-username">
{{@member.model.username}}
</span>
{{icon "times"}}
</DButton>
</template>
}

View File

@ -0,0 +1,24 @@
import Component from "@glimmer/component";
import concatClass from "discourse/helpers/concat-class";
import I18n from "discourse-i18n";
import eq from "truth-helpers/helpers/eq";
export default class MembersCount extends Component {
get countLabel() {
return I18n.t("chat.direct_message_creator.members_counter", {
count: this.args.count,
max: this.args.max,
});
}
<template>
<div
class={{concatClass
"chat-message-creator__members-count"
(if (eq @count @max) "-reached-limit")
}}
>
{{this.countLabel}}
</div>
</template>
}

View File

@ -0,0 +1,140 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
import I18n from "discourse-i18n";
import ChatablesLoader from "./lib/chatables-loader";
import List from "./list";
import ListHandler from "./list-handler";
import Members from "./members";
export default class MembersSelector extends Component {
@service siteSettings;
@tracked chatables = [];
@tracked filter = "";
@tracked highlightedMember;
@tracked highlightedChatable;
placeholder = I18n.t("chat.direct_message_creator.group_name");
get items() {
return this.chatables.filter(
(c) => !this.highlightedMemberIds.includes(c.model.id)
);
}
get highlightedMemberIds() {
return this.args.members.map((u) => u.model.id);
}
@action
highlightMember(member) {
this.highlightedMember = member;
}
@action
highlightChatable(chatable) {
this.highlightedChatable = chatable;
}
@action
selectChatable(chatable) {
if (
this.args.members.length + (this.args.channel?.membershipsCount ?? 0) >=
this.siteSettings.chat_max_direct_message_users
) {
return;
}
if (this.highlightedMemberIds.includes(chatable.model.id)) {
this.unselectMember(chatable);
} else {
this.args.onChange?.([...this.args.members, chatable]);
this.highlightedChatable = this.items[0];
}
this.filter = "";
this.focusFilterAction?.();
this.highlightedMember = null;
}
@action
registerFocusFilterAction(actionFn) {
this.focusFilterAction = actionFn;
}
@action
onFilter(event) {
this.searchHandler = discourseDebounce(
this,
this.fetch,
event.target.value,
INPUT_DELAY
);
}
@action
async fetch(term) {
this.highlightedMember = null;
const loader = new ChatablesLoader(this);
this.chatables = await loader.search(term, {
includeCategoryChannels: false,
includeDirectMessageChannels: false,
excludedMembershipsChannelId: this.args.channel?.id,
});
this.highlightedChatable = this.items[0];
}
@action
unselectMember(removedMember) {
this.args.onChange?.(
this.args.members.filter((member) => member !== removedMember)
);
this.highlightedMember = null;
this.highlightedChatable = this.items[0];
this.focusFilterAction?.();
}
<template>
<ListHandler
@items={{this.items}}
@highlightedItem={{this.highlightedChatable}}
@onHighlight={{this.highlightChatable}}
@onSelect={{this.selectChatable}}
>
<div class="chat-message-creator__add-members-header-container">
<div class="chat-message-creator__add-members-header">
<Members
@filter={{this.filter}}
@members={{@members}}
@highlightedMember={{this.highlightedMember}}
@onFilter={{this.onFilter}}
@registerFocusFilterAction={{this.registerFocusFilterAction}}
@onHighlightMember={{this.highlightMember}}
@onSelectMember={{this.unselectMember}}
/>
<DButton
class="btn-flat chat-message-creator__add-members__close-btn"
@action={{@cancel}}
@icon="times"
/>
</div>
</div>
<List
@items={{this.items}}
@highlightedItem={{this.highlightedChatable}}
@onSelect={{this.selectChatable}}
@onHighlight={{this.highlightChatable}}
/>
</ListHandler>
</template>
}

View File

@ -0,0 +1,138 @@
import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import icon from "discourse-common/helpers/d-icon";
import eq from "truth-helpers/helpers/eq";
import Member from "./member";
export default class members extends Component {
@action
onFilter() {
this.args.onFilter(...arguments);
}
@action
registerFocusFilterAction(element) {
this.args.registerFocusFilterAction(() => element.focus());
}
@action
handleKeypress(event) {
if (event.key === "Backspace" && event.target.value === "") {
event.preventDefault();
event.stopPropagation();
if (!this.args.highlightedMember) {
this.args.onHighlightMember(this.args.members.lastObject);
} else {
this.args.onSelectMember(this.args.highlightedMember);
}
return;
}
if (event.key === "ArrowLeft" && event.target.value === "") {
event.preventDefault();
event.stopPropagation();
this.args.onHighlightMember(
this.#getPrevious(this.args.members, this.args.highlightedMember)
);
return;
}
if (event.key === "ArrowRight" && event.target.value === "") {
event.preventDefault();
event.stopPropagation();
this.args.onHighlightMember(
this.#getNext(this.args.members, this.args.highlightedMember)
);
return;
}
if (event.key === "Enter" && this.args.highlightedMember) {
event.preventDefault();
event.stopPropagation();
this.args.onSelectMember(this.args.highlightedMember);
return;
}
this.highlightedMember = null;
}
#getNext(list, current = null) {
if (list.length === 0) {
return null;
}
list = list.filterBy("enabled");
if (current?.identifier) {
const currentIndex = list
.mapBy("identifier")
.indexOf(current?.identifier);
if (currentIndex < list.length - 1) {
return list.objectAt(currentIndex + 1);
} else {
return list[0];
}
} else {
return list[0];
}
}
#getPrevious(list, current = null) {
if (list.length === 0) {
return null;
}
list = list.filterBy("enabled");
if (current?.identifier) {
const currentIndex = list
.mapBy("identifier")
.indexOf(current?.identifier);
if (currentIndex > 0) {
return list.objectAt(currentIndex - 1);
} else {
return list.objectAt(list.length - 1);
}
} else {
return list.objectAt(list.length - 1);
}
}
<template>
<div class="chat-message-creator__members-container">
<div class="chat-message-creator__members">
{{icon "search"}}
{{#each @members as |member|}}
<Member
@member={{member}}
@onSelect={{@onSelectMember}}
@highlighted={{eq member @highlightedMember}}
/>
{{/each}}
<Input
placeholder="...add more users"
class="chat-message-creator__members-input"
@value={{@filter}}
autofocus={{true}}
{{on "input" this.onFilter}}
{{on "keydown" this.handleKeypress}}
{{didInsert this.registerFocusFilterAction}}
/>
</div>
</div>
</template>
}

View File

@ -0,0 +1,85 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
import MembersCount from "./members-count";
import MembersSelector from "./members-selector";
export default class NewGroup extends Component {
@service chat;
@service router;
@service siteSettings;
@tracked newGroupTitle = "";
placeholder = I18n.t("chat.direct_message_creator.group_name");
get membersCount() {
return this.args.members?.length;
}
@action
async createGroup() {
try {
const channel = await this.chat.upsertDmChannelForUsernames(
this.args.members.mapBy("model.username"),
this.newGroupTitle
);
if (!channel) {
return;
}
this.args.close?.();
this.router.transitionTo("chat.channel", ...channel.routeModels);
} catch (error) {
popupAjaxError(error);
}
}
<template>
<div class="chat-message-creator__new-group-container">
<div class="chat-message-creator__new-group">
<div class="chat-message-creator__new-group-header-container">
<div class="chat-message-creator__new-group-header">
<Input
name="channel-name"
class="chat-message-creator__new-group-header__input"
placeholder={{this.placeholder}}
@value={{this.newGroupTitle}}
/>
<MembersCount
@count={{this.membersCount}}
@max={{this.siteSettings.chat_max_direct_message_users}}
/>
</div>
</div>
<MembersSelector
@members={{@members}}
@channel={{@channel}}
@onChange={{@onChangeMembers}}
@close={{@close}}
@cancel={{@cancel}}
/>
{{#if @members.length}}
<div class="chat-message-creator__new-group-footer-container">
<div class="chat-message-creator__new-group-footer">
<DButton
class="btn-primary"
@label="save"
@action={{this.createGroup}}
/>
</div>
</div>
{{/if}}
</div>
</div>
</template>
}

View File

@ -0,0 +1,22 @@
import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import icon from "discourse-common/helpers/d-icon";
export default class ChatMessageCreatorSearchInput extends Component {
<template>
<div class="chat-message-creator__search-input-container">
<div class="chat-message-creator__search-input">
{{icon
"search"
class="chat-message-creator__search-input__search-icon"
}}
<Input
class="chat-message-creator__search-input__input"
placeholder="Filter"
{{on "input" @onFilter}}
/>
</div>
</div>
</template>
}

View File

@ -0,0 +1,125 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
import { MODES } from "./constants";
import ChatablesLoader from "./lib/chatables-loader";
import List from "./list";
import ListHandler from "./list-handler";
import SearchInput from "./search-input";
export default class ChatMessageCreatorSearch extends Component {
@service chat;
@service router;
@tracked chatables = [];
@tracked highlightedChatable;
get items() {
return [
{
identifier: "new-group",
type: "action",
label: "New group chat",
enabled: true,
icon: "users",
},
...this.chatables,
];
}
@action
prefillAddMembers(item) {
this.args.onChangeMode(MODES.new_group, [item]);
}
@action
highlightChatable(chatable) {
this.highlightedChatable = chatable;
}
@action
async selectChatable(item) {
switch (item.type) {
case "action":
this.args.onChangeMode(MODES.new_group);
break;
case "user":
await this.startOneToOneChannel(item.model.username);
break;
default:
this.router.transitionTo("chat.channel", ...item.model.routeModels);
this.args.close();
break;
}
}
@action
onFilter(event) {
this.searchHandler = discourseDebounce(
this,
this.fetch,
event.target.value,
INPUT_DELAY
);
}
@action
async fetch(term) {
const loader = new ChatablesLoader(this);
this.chatables = await loader.search(term);
this.highlightedChatable = this.items[0];
}
async startOneToOneChannel(username) {
try {
const channel = await this.chat.upsertDmChannelForUsernames([username]);
if (!channel) {
return;
}
this.args.close?.();
this.router.transitionTo("chat.channel", ...channel.routeModels);
} catch (error) {
popupAjaxError(error);
}
}
<template>
<ListHandler
@items={{this.items}}
@highlightedItem={{this.highlightedChatable}}
@onHighlight={{this.highlightChatable}}
@onSelect={{this.selectChatable}}
@onShifSelect={{this.prefillAddMembers}}
>
<div class="chat-message-creator__search-container">
<div class="chat-message-creator__search">
<div class="chat-message-creator__section">
<SearchInput @onFilter={{this.onFilter}} />
<DButton
class="btn-flat chat-message-creator__search-input__cancel-button"
@icon="times"
@action={{@close}}
/>
</div>
<List
@items={{this.items}}
@highlightedItem={{this.highlightedChatable}}
@onSelect={{this.selectChatable}}
@onHighlight={{this.highlightChatable}}
@onShiftSelect={{this.prefillAddMembers}}
/>
</div>
</div>
</ListHandler>
</template>
}

View File

@ -1,36 +0,0 @@
<ChatUserAvatar @user={{@content.model}} />
<ChatUserDisplayName @user={{@content.model}} />
{{#if (gt @content.tracking.unreadCount 0)}}
<div class="unread-indicator -urgent"></div>
{{/if}}
{{user-status @content.model currentUser=this.currentUser}}
{{#unless @content.enabled}}
<span class="disabled-text">
{{i18n "chat.new_message_modal.disabled_user"}}
</span>
{{/unless}}
{{#if @selected}}
{{#if this.site.mobileView}}
<span class="selection-indicator -add">
{{d-icon "check"}}
</span>
{{else}}
<span
class={{concat-class "selection-indicator" (if @active "-remove" "-add")}}
>
{{d-icon (if @active "times" "check")}}
</span>
{{/if}}
{{else}}
{{#if this.site.desktopView}}
{{#if @hasSelectedUsers}}
<span class="action-indicator">{{this.addUserLabel}}</span>
{{else}}
<span class="action-indicator">{{this.openChannelLabel}}</span>
{{/if}}
{{/if}}
{{/if}}

View File

@ -1,17 +0,0 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import I18n from "discourse-i18n";
export default class ChatMessageCreatorUserRow extends Component {
@service currentUser;
@service site;
get openChannelLabel() {
return htmlSafe(I18n.t("chat.new_message_modal.open_channel"));
}
get addUserLabel() {
return htmlSafe(I18n.t("chat.new_message_modal.add_user_short"));
}
}

View File

@ -1,5 +0,0 @@
<ChatUserAvatar @user={{@selection.model}} @showPresence={{false}} />
<span class="chat-message-creator__selection-item__username">
{{@selection.model.username}}
</span>

View File

@ -0,0 +1,32 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import userStatus from "discourse/helpers/user-status";
import I18n from "discourse-i18n";
import gt from "truth-helpers/helpers/gt";
import ChatUserAvatar from "discourse/plugins/chat/discourse/components/chat-user-avatar";
import ChatUserDisplayName from "discourse/plugins/chat/discourse/components/chat-user-display-name";
export default class ChatableUser extends Component {
@service currentUser;
disabledUserLabel = I18n.t("chat.new_message_modal.disabled_user");
<template>
<div class="chat-message-creator__chatable-user">
<ChatUserAvatar @user={{@item.model}} @interactive={{false}} />
<ChatUserDisplayName @user={{@item.model}} />
{{#if (gt @item.tracking.unreadCount 0)}}
<div class="unread-indicator -urgent"></div>
{{/if}}
{{userStatus @item.model currentUser=this.currentUser}}
{{#unless @item.enabled}}
<span class="disabled-text">
{{this.disabledUserLabel}}
</span>
{{/unless}}
</div>
</template>
}

View File

@ -4,18 +4,21 @@ import { action } from "@ember/object";
import { cancel } from "@ember/runloop"; import { cancel } from "@ember/runloop";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { extractError } from "discourse/lib/ajax-error"; import { extractError, popupAjaxError } from "discourse/lib/ajax-error";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel";
const SLUG_MAX_LENGTH = 100; const SLUG_MAX_LENGTH = 100;
export default class ChatModalEditChannelName extends Component { export default class ChatModalEditChannelName extends Component {
@service chatApi; @service chatApi;
@service router;
@service siteSettings; @service siteSettings;
@tracked editedName = this.channel.title; @tracked editedName = this.channel.title;
@tracked editedSlug = this.channel.slug; @tracked editedSlug = this.channel.slug;
@tracked autoGeneratedSlug = ""; @tracked
autoGeneratedSlug = this.channel.slug ?? slugifyChannel(this.channel);
@tracked flash; @tracked flash;
#generateSlugHandler = null; #generateSlugHandler = null;
@ -34,17 +37,23 @@ export default class ChatModalEditChannelName extends Component {
} }
@action @action
onSave() { async onSave() {
return this.chatApi try {
.updateChannel(this.channel.id, { const result = await this.chatApi.updateChannel(this.channel.id, {
name: this.editedName, name: this.editedName,
slug: this.editedSlug || this.autoGeneratedSlug || this.channel.slug, slug: this.editedSlug || this.autoGeneratedSlug || this.channel.slug,
}) });
.then((result) => {
this.channel.title = result.channel.title; this.channel.title = result.channel.title;
this.args.closeModal(); this.channel.slug = result.channel.slug;
}) await this.args.closeModal();
.catch((error) => (this.flash = extractError(error))); await this.router.replaceWith(
"chat.channel",
...this.channel.routeModels
);
} catch (error) {
this.flash = extractError(error);
}
} }
@action @action
@ -77,11 +86,15 @@ export default class ChatModalEditChannelName extends Component {
// intentionally not showing AJAX error for this, we will autogenerate // intentionally not showing AJAX error for this, we will autogenerate
// the slug server-side if they leave it blank // the slug server-side if they leave it blank
#generateSlug(name) { async #generateSlug(name) {
return ajax("/slugs.json", { type: "POST", data: { name } }).then( try {
await ajax("/slugs.json", { type: "POST", data: { name } }).then(
(response) => { (response) => {
this.autoGeneratedSlug = response.slug; this.autoGeneratedSlug = response.slug;
} }
); );
} catch (error) {
popupAjaxError(error);
}
} }
} }

View File

@ -0,0 +1,28 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import DModal from "discourse/components/d-modal";
import MessageCreator from "discourse/plugins/chat/discourse/components/chat/message-creator";
export default class ChatModalNewMessage extends Component {
@service chat;
@service siteSettings;
get shouldRender() {
return (
this.siteSettings.enable_public_channels || this.chat.userCanDirectMessage
);
}
<template>
{{#if this.shouldRender}}
<DModal
@closeModal={{@closeModal}}
class="chat-modal-new-message"
@title="chat.new_message_modal.title"
@inline={{@inline}}
>
<MessageCreator @onClose={{@closeModal}} @channel={{@model}} />
</DModal>
{{/if}}
</template>
}

View File

@ -1,10 +0,0 @@
{{#if this.shouldRender}}
<DModal
@closeModal={{@closeModal}}
class="chat-modal-new-message"
@title="chat.new_message_modal.title"
@inline={{@inline}}
>
<Chat::MessageCreator @onClose={{@closeModal}} />
</DModal>
{{/if}}

View File

@ -1,13 +0,0 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class ChatModalNewMessage extends Component {
@service chat;
@service siteSettings;
get shouldRender() {
return (
this.siteSettings.enable_public_channels || this.chat.userCanDirectMessage
);
}
}

View File

@ -0,0 +1,36 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import I18n from "I18n";
export default class ThreadHeaderUnreadIndicator extends Component {
@service currentUser;
unreadCountLabel = I18n.t("chat.unread_threads_count", {
count: this.cappedUnreadCount,
});
get unreadCount() {
return this.args.channel.threadsManager.unreadThreadCount;
}
get showUnreadIndicator() {
return !this.currentUser.isInDoNotDisturb() && this.unreadCount > 0;
}
get cappedUnreadCount() {
return this.unreadCount > 99 ? "99+" : this.unreadCount;
}
<template>
{{#if this.showUnreadIndicator}}
<div
class="chat-thread-header-unread-indicator"
title={{this.unreadCountLabel}}
>
<div
class="chat-thread-header-unread-indicator__number"
>{{this.cappedUnreadCount}}</div>
</div>
{{/if}}
</template>
}

View File

@ -1,11 +0,0 @@
{{#if this.showUnreadIndicator}}
<div
class="chat-thread-header-unread-indicator"
aria-label={{i18n "chat.unread_threads_count" count=this.unreadCountLabel}}
title={{i18n "chat.unread_threads_count" count=this.unreadCountLabel}}
>
<div
class="chat-thread-header-unread-indicator__number"
>{{this.unreadCountLabel}}</div>
</div>
{{/if}}

View File

@ -1,22 +0,0 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class ChatThreadHeaderUnreadIndicator extends Component {
@service currentUser;
get currentUserInDnD() {
return this.currentUser.isInDoNotDisturb();
}
get unreadCount() {
return this.args.channel.threadsManager.unreadThreadCount;
}
get showUnreadIndicator() {
return !this.currentUserInDnD && this.unreadCount > 0;
}
get unreadCountLabel() {
return this.unreadCount > 99 ? "99+" : this.unreadCount;
}
}

View File

@ -0,0 +1,28 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import I18n from "I18n";
import ThreadHeaderUnreadIndicator from "discourse/plugins/chat/discourse/components/chat/thread/header-unread-indicator";
export default class ThreadsListButton extends Component {
threadsListLabel = I18n.t("chat.threads.list");
<template>
<LinkTo
@route="chat.channel.threads"
@models={{@channel.routeModels}}
title={{this.threadsListLabel}}
class={{concatClass
"chat-threads-list-button"
"btn"
"btn-flat"
(if @channel.threadsManager.unreadThreadCount "has-unreads")
}}
>
{{icon "discourse-threads"}}
<ThreadHeaderUnreadIndicator @channel={{@channel}} />
</LinkTo>
</template>
}

View File

@ -1,13 +0,0 @@
<LinkTo
@route="chat.channel.threads"
@models={{@channel.routeModels}}
title={{i18n "chat.threads.list"}}
class={{concat-class
"chat-threads-list-button btn btn-flat"
(if @channel.threadsManager.unreadThreadCount "has-unreads")
}}
>
{{d-icon "discourse-threads"}}
<Chat::Thread::HeaderUnreadIndicator @channel={{@channel}} />
</LinkTo>

View File

@ -1,3 +0,0 @@
import Component from "@glimmer/component";
export default class ChatThreadsListButton extends Component {}

View File

@ -6,7 +6,7 @@
@icon={{this.options.leaveIcon}} @icon={{this.options.leaveIcon}}
@disabled={{this.isLoading}} @disabled={{this.isLoading}}
class={{concat-class class={{concat-class
"toggle-channel-membership-button -leave btn-flat" "toggle-channel-membership-button -leave"
this.options.leaveClass this.options.leaveClass
}} }}
/> />

View File

@ -8,9 +8,9 @@ import { emojiUnescape } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities"; import { escapeExpression } from "discourse/lib/utilities";
import { avatarUrl } from "discourse-common/lib/avatar-utils"; import { avatarUrl } from "discourse-common/lib/avatar-utils";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message"; import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message";
import getFirstUser from "discourse/plugins/chat/discourse/lib/get-first-user";
import { import {
CHAT_PANEL, CHAT_PANEL,
initSidebarState, initSidebarState,
@ -84,10 +84,6 @@ export default {
return htmlSafe(emojiUnescape(this.channel.escapedTitle)); return htmlSafe(emojiUnescape(this.channel.escapedTitle));
} }
get prefixType() {
return "icon";
}
get prefixValue() { get prefixValue() {
return "d-chat"; return "d-chat";
} }
@ -106,6 +102,18 @@ export default {
return this.channel.chatable.read_restricted ? "lock" : ""; return this.channel.chatable.read_restricted ? "lock" : "";
} }
get prefixCSSClass() {
const activeUsers = this.chatService.presenceChannel.users;
const user = this.channel.chatable.users[0];
if (
!!activeUsers?.findBy("id", user?.id) ||
!!activeUsers?.findBy("username", user?.username)
) {
return "active";
}
return "";
}
get suffixType() { get suffixType() {
return "icon"; return "icon";
} }
@ -206,24 +214,29 @@ export default {
api.addSidebarSection( api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
const SidebarChatDirectMessagesSectionLink = class extends BaseCustomSidebarSectionLink { const SidebarChatDirectMessagesSectionLink = class extends BaseCustomSidebarSectionLink {
constructor({ channel, chatService }) { route = "chat.channel";
suffixType = "icon";
suffixCSSClass = "urgent";
hoverType = "icon";
hoverValue = "times";
hoverTitle = I18n.t("chat.direct_messages.leave");
constructor({ channel, chatService, currentUser }) {
super(...arguments); super(...arguments);
this.channel = channel; this.channel = channel;
this.chatService = chatService; this.chatService = chatService;
this.currentUser = currentUser;
if (this.oneOnOneMessage) {
const user = this.channel.chatable.users[0];
if (user.username !== I18n.t("chat.deleted_chat_username")) {
user.trackStatus();
}
}
} }
@bind get contentComponentArgs() {
willDestroy() { return getFirstUser(
if (this.oneOnOneMessage) { this.channel.chatable.users,
this.channel.chatable.users[0].stopTrackingStatus(); this.currentUser
).get("status");
} }
get contentComponent() {
return "user-status-message";
} }
get name() { get name() {
@ -246,10 +259,6 @@ export default {
return classes.join(" "); return classes.join(" ");
} }
get route() {
return "chat.channel";
}
get models() { get models() {
return this.channel.routeModels; return this.channel.routeModels;
} }
@ -260,53 +269,47 @@ export default {
}); });
} }
get oneOnOneMessage() {
return this.channel.chatable.users.length === 1;
}
get contentComponentArgs() {
return this.channel.chatable.users[0].get("status");
}
get contentComponent() {
return "user-status-message";
}
get text() { get text() {
if (this.channel.chatable.group) {
return this.channel.title;
} else {
const username = this.channel.escapedTitle.replaceAll("@", ""); const username = this.channel.escapedTitle.replaceAll("@", "");
if (this.oneOnOneMessage) {
return htmlSafe( return htmlSafe(
`${escapeExpression(username)}${decorateUsername( `${escapeExpression(username)}${decorateUsername(
escapeExpression(username) escapeExpression(username)
)}` )}`
); );
} else {
return username;
} }
} }
get prefixType() { get prefixType() {
if (this.oneOnOneMessage) { if (this.channel.chatable.group) {
return "image";
} else {
return "text"; return "text";
} else {
return "image";
} }
} }
get prefixValue() { get prefixValue() {
if (this.channel.chatable.users.length === 1) { if (this.channel.chatable.group) {
return this.channel.membershipsCount;
} else {
return avatarUrl( return avatarUrl(
this.channel.chatable.users[0].avatar_template, getFirstUser(this.channel.chatable.users, this.currentUser)
.avatar_template,
"tiny" "tiny"
); );
} else {
return this.channel.chatable.users.length;
} }
} }
get prefixCSSClass() { get prefixCSSClass() {
const activeUsers = this.chatService.presenceChannel.users; const activeUsers = this.chatService.presenceChannel.users;
const user = this.channel.chatable.users[0]; console.log(this.channel.chatable);
const user = getFirstUser(
this.channel.chatable.users,
this.currentUser
);
if ( if (
!!activeUsers?.findBy("id", user?.id) || !!activeUsers?.findBy("id", user?.id) ||
!!activeUsers?.findBy("username", user?.username) !!activeUsers?.findBy("username", user?.username)
@ -316,26 +319,10 @@ export default {
return ""; return "";
} }
get suffixType() {
return "icon";
}
get suffixValue() { get suffixValue() {
return this.channel.tracking.unreadCount > 0 ? "circle" : ""; return this.channel.tracking.unreadCount > 0 ? "circle" : "";
} }
get suffixCSSClass() {
return "urgent";
}
get hoverType() {
return "icon";
}
get hoverValue() {
return "times";
}
get hoverAction() { get hoverAction() {
return (event) => { return (event) => {
event.stopPropagation(); event.stopPropagation();
@ -343,16 +330,13 @@ export default {
this.chatService.unfollowChannel(this.channel); this.chatService.unfollowChannel(this.channel);
}; };
} }
get hoverTitle() {
return I18n.t("chat.direct_messages.leave");
}
}; };
const SidebarChatDirectMessagesSection = class extends BaseCustomSidebarSection { const SidebarChatDirectMessagesSection = class extends BaseCustomSidebarSection {
@service site; @service site;
@service modal; @service modal;
@service router; @service router;
@service currentUser;
@tracked @tracked
userCanDirectMessage = this.chatService.userCanDirectMessage; userCanDirectMessage = this.chatService.userCanDirectMessage;
@ -375,6 +359,7 @@ export default {
new SidebarChatDirectMessagesSectionLink({ new SidebarChatDirectMessagesSectionLink({
channel, channel,
chatService: this.chatService, chatService: this.chatService,
currentUser: this.currentUser,
}) })
); );
} }

View File

@ -52,21 +52,29 @@ function messageFabricator(args = {}) {
function channelFabricator(args = {}) { function channelFabricator(args = {}) {
const id = args.id || sequence++; const id = args.id || sequence++;
const chatable = args.chatable || categoryFabricator();
const channel = ChatChannel.create({ const channel = ChatChannel.create({
id, id,
chatable_type: chatable_type:
args.chatable?.type || (chatable instanceof Category
args.chatable_type || ? CHATABLE_TYPES.categoryChannel
CHATABLE_TYPES.categoryChannel, : CHATABLE_TYPES.directMessageChannel) ||
chatable_id: args.chatable?.id || args.chatable_id, chatable?.type ||
title: args.title || "General", args.chatable_type,
chatable_id: chatable?.id || args.chatable_id,
title: args.title
? args.title
: chatable instanceof Category
? "General"
: null,
description: args.description, description: args.description,
chatable: args.chatable || categoryFabricator(), chatable,
status: args.status || CHANNEL_STATUSES.open, status: args.status || CHANNEL_STATUSES.open,
slug: args.chatable?.slug || "general", slug: chatable?.slug || chatable instanceof Category ? "general" : null,
meta: Object.assign({ can_delete_self: true }, args.meta || {}), meta: Object.assign({ can_delete_self: true }, args.meta || {}),
archive_failed: args.archive_failed ?? false, archive_failed: args.archive_failed ?? false,
memberships_count: args.memberships_count ?? 0,
}); });
channel.lastMessage = messageFabricator({ channel }); channel.lastMessage = messageFabricator({ channel });
@ -78,7 +86,7 @@ function categoryFabricator(args = {}) {
return Category.create({ return Category.create({
id: args.id || sequence++, id: args.id || sequence++,
color: args.color || "D56353", color: args.color || "D56353",
read_restricted: false, read_restricted: args.read_restricted ?? false,
name: args.name || "General", name: args.name || "General",
slug: args.slug || "general", slug: args.slug || "general",
}); });
@ -86,8 +94,8 @@ function categoryFabricator(args = {}) {
function directMessageFabricator(args = {}) { function directMessageFabricator(args = {}) {
return ChatDirectMessage.create({ return ChatDirectMessage.create({
id: args.id || sequence++, group: args.group ?? false,
users: args.users || [userFabricator(), userFabricator()], users: args.users ?? [userFabricator(), userFabricator()],
}); });
} }
@ -96,6 +104,8 @@ function directMessageChannelFabricator(args = {}) {
args.chatable || args.chatable ||
directMessageFabricator({ directMessageFabricator({
id: args.chatable_id || sequence++, id: args.chatable_id || sequence++,
group: args.group ?? false,
users: args.users,
}); });
return channelFabricator( return channelFabricator(
@ -103,6 +113,7 @@ function directMessageChannelFabricator(args = {}) {
chatable_type: CHATABLE_TYPES.directMessageChannel, chatable_type: CHATABLE_TYPES.directMessageChannel,
chatable_id: directMessage.id, chatable_id: directMessage.id,
chatable: directMessage, chatable: directMessage,
memberships_count: directMessage.users.length,
}) })
); );
} }

View File

@ -0,0 +1,11 @@
export default function getFirstUsers(users, currentUser) {
return users.sort((a, b) => {
if (a.id === currentUser.id) {
return 1;
}
if (b.id === currentUser.id) {
return -1;
}
return 1;
})[0];
}

View File

@ -61,6 +61,7 @@ export default class ChatChannel {
@tracked status; @tracked status;
@tracked activeThread = null; @tracked activeThread = null;
@tracked meta; @tracked meta;
@tracked chatableId;
@tracked chatableType; @tracked chatableType;
@tracked chatableUrl; @tracked chatableUrl;
@tracked autoJoinUsers = false; @tracked autoJoinUsers = false;
@ -89,12 +90,7 @@ export default class ChatChannel {
this.threadingEnabled = args.threading_enabled; this.threadingEnabled = args.threading_enabled;
this.autoJoinUsers = args.auto_join_users; this.autoJoinUsers = args.auto_join_users;
this.allowChannelWideMentions = args.allow_channel_wide_mentions; this.allowChannelWideMentions = args.allow_channel_wide_mentions;
this.chatable = this.isDirectMessageChannel this.chatable = this.#initChatable(args.chatable || []);
? ChatDirectMessage.create({
id: args.chatable?.id,
users: args.chatable?.users,
})
: Category.create(args.chatable);
this.currentUserMembership = args.current_user_membership; this.currentUserMembership = args.current_user_membership;
if (args.archive_completed || args.archive_failed) { if (args.archive_completed || args.archive_failed) {
@ -245,4 +241,23 @@ export default class ChatChannel {
this._lastMessage = ChatMessage.create(this, message); this._lastMessage = ChatMessage.create(this, message);
} }
} }
#initChatable(chatable) {
if (
!chatable ||
chatable instanceof Category ||
chatable instanceof ChatDirectMessage
) {
return chatable;
} else {
if (this.isDirectMessageChannel) {
return ChatDirectMessage.create({
users: chatable?.users,
group: chatable?.group,
});
} else {
return Category.create(chatable);
}
}
}
} }

View File

@ -1,5 +1,6 @@
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import Category from "discourse/models/category";
import User from "discourse/models/user"; import User from "discourse/models/user";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
@ -66,7 +67,7 @@ export default class ChatChatable {
return this.type === "user"; return this.type === "user";
} }
get isSingleUserChannel() { get isCategory() {
return this.type === "channel" && this.model?.chatable?.users?.length === 1; return this instanceof Category;
} }
} }

View File

@ -7,13 +7,13 @@ export default class ChatDirectMessage {
return new ChatDirectMessage(args); return new ChatDirectMessage(args);
} }
@tracked id;
@tracked users = null; @tracked users = null;
@tracked group = false;
type = CHATABLE_TYPES.directMessageChannel; type = CHATABLE_TYPES.directMessageChannel;
constructor(args = {}) { constructor(args = {}) {
this.id = args.id; this.group = args.group ?? false;
this.users = this.#initUsers(args.users || []); this.users = this.#initUsers(args.users || []);
} }
@ -21,9 +21,9 @@ export default class ChatDirectMessage {
return users.map((user) => { return users.map((user) => {
if (!user || user instanceof User) { if (!user || user instanceof User) {
return user; return user;
} } else {
return User.create(user); return User.create(user);
}
}); });
} }
} }

View File

@ -5,7 +5,7 @@ export default class ChatChannelInfoMembersRoute extends DiscourseRoute {
@service router; @service router;
afterModel(model) { afterModel(model) {
if (!model.isOpen || model.membershipsCount < 1) { if (!model.isOpen) {
return this.router.replaceWith("chat.channel.info.settings"); return this.router.replaceWith("chat.channel.info.settings");
} }
} }

View File

@ -490,6 +490,18 @@ export default class ChatApi extends Service {
return this.#getRequest(`/channels/${channelId}/summarize`, options); return this.#getRequest(`/channels/${channelId}/summarize`, options);
} }
/**
* Add members to a channel.
*
* @param {number} channelId - The ID of the channel.
* @param {Array<string>} usernames - The usernames of the users to add.
*/
addMembersToChannel(channelId, usernames) {
return this.#postRequest(`/channels/${channelId}/memberships`, {
usernames,
});
}
get #basePath() { get #basePath() {
return "/chat/api"; return "/chat/api";
} }

View File

@ -385,10 +385,10 @@ export default class Chat extends Service {
// @param {array} usernames - The usernames to create or fetch the direct message // @param {array} usernames - The usernames to create or fetch the direct message
// channel for. The current user will automatically be included in the channel // channel for. The current user will automatically be included in the channel
// when it is created. // when it is created.
upsertDmChannelForUsernames(usernames) { upsertDmChannelForUsernames(usernames, name = null) {
return ajax("/chat/api/direct-message-channels.json", { return ajax("/chat/api/direct-message-channels.json", {
method: "POST", method: "POST",
data: { target_usernames: usernames.uniq() }, data: { target_usernames: usernames.uniq(), name },
}) })
.then((response) => { .then((response) => {
const channel = this.chatChannelsManager.store(response.channel); const channel = this.chatChannelsManager.store(response.channel);

View File

@ -1,8 +1,12 @@
.chat-channel-info { .chat-channel-info {
display: flex; display: flex;
flex-direction: column;
height: 100%; height: 100%;
padding: 1rem; padding: 1rem;
flex-direction: column;
.chat-message-creator__container {
width: 100%;
}
&__nav { &__nav {
.nav-pills { .nav-pills {
@ -12,6 +16,15 @@
} }
} }
.chat-channel-members__add-members {
display: flex;
gap: 1rem;
.chat-message-creator-container {
width: 100%;
}
}
// Info header // Info header
.chat-channel-info-header { .chat-channel-info-header {
display: flex; display: flex;

View File

@ -1,6 +1,6 @@
.chat-channel-members { .chat-channel-members {
width: 50%; max-width: 500px;
min-width: 320px; width: 100%;
&__filter { &__filter {
margin-bottom: 1rem; margin-bottom: 1rem;
@ -10,18 +10,36 @@
display: flex; display: flex;
margin: 0; margin: 0;
flex-direction: column; flex-direction: column;
gap: 0.5rem;
&-item { &-item {
display: flex; display: flex;
gap: 0.5rem;
list-style: none; list-style: none;
border-bottom: 1px solid var(--primary-low); border-bottom: 1px solid var(--primary-low);
height: 42px;
align-items: center; align-items: center;
&.-no-results {
box-sizing: border-box; box-sizing: border-box;
padding: 0.5rem;
&:hover {
background: var(--primary-very-low);
}
&.-member {
.chat-user-avatar {
margin-right: 0.5rem;
}
}
&.-add-member {
color: var(--tertiary);
cursor: pointer;
.d-icon {
background: var(--primary-low);
color: var(--tertiary);
padding: 5px;
border-radius: 50%;
margin-right: 0.5rem;
}
} }
&:last-child { &:last-child {

View File

@ -1,6 +1,5 @@
.chat-channel-settings { .chat-channel-settings {
width: 50%; width: 100%;
min-width: 320px;
.chat-channel-settings__slug { .chat-channel-settings__slug {
max-width: 250px; max-width: 250px;

View File

@ -74,11 +74,12 @@
width: 22px; width: 22px;
height: 22px; height: 22px;
box-sizing: border-box; box-sizing: border-box;
text-align: center; justify-content: center;
font-weight: 700; font-weight: 700;
font-size: var(--font-down-1); font-size: var(--font-down-1);
align-items: center; align-items: center;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
color: var(--primary-high);
} }
.chat-channel-title__category-badge { .chat-channel-title__category-badge {

View File

@ -29,10 +29,8 @@
} }
&-content { &-content {
background: var(--primary-very-low);
gap: 1rem; gap: 1rem;
display: flex; display: flex;
padding: 1rem;
flex-direction: column; flex-direction: column;
} }
} }

View File

@ -1,3 +1,225 @@
.chat-message-creator__section {
display: flex;
width: 100%;
}
.chat-message-creator__add-members {
display: flex;
gap: 1rem;
flex-direction: column;
}
.chat-message-creator__participants-count {
white-space: nowrap;
}
.chat-message-creator__member {
@media (prefers-reduced-motion: no-preference) {
animation: popIn 0.1s ease-out;
}
}
.chat-message-creator__member {
padding: 0.25rem;
border: 1px solid var(--primary-medium);
border-radius: var(--border-radius);
&.-highlighted {
border-color: var(--tertiary);
}
&-username {
padding-inline: 0.5rem;
}
}
.chat-message-creator__add-members__close-btn {
align-self: flex-start;
margin-top: 10px;
}
.chat-message-creator__add-members-header {
display: flex;
flex-grow: 1;
gap: 0.5rem;
&-container {
display: flex;
}
}
.chat-message-creator__new-group-header {
display: flex;
align-items: center;
flex-grow: 1;
padding: 0.5rem 1rem;
&-container {
display: flex;
flex-grow: 1;
border-bottom: 1px solid var(--primary-low);
}
&__input {
padding-inline: 0 !important;
width: 100% !important;
margin: 0 !important;
border: 0 !important;
outline: 0 !important;
}
}
.chat-message-creator__new-group-footer {
display: flex;
align-items: center;
flex-grow: 1;
padding: 0.5rem 1rem;
&-container {
display: flex;
flex-grow: 1;
border-top: 1px solid var(--primary-low);
}
}
.chat-message-creator__participants-count {
white-space: nowrap;
}
.chat-message-creator__members {
display: flex;
align-items: center;
flex-grow: 1;
padding: 0.5rem;
background: var(--primary-low);
flex-wrap: wrap;
gap: 0.5rem;
border-radius: var(--border-radius);
min-height: 50px;
box-sizing: border-box;
.d-icon-search {
color: var(--primary-high);
}
&-container {
display: flex;
flex-grow: 1;
}
&-input {
background: none !important;
margin: 0 !important;
border: 0 !important;
outline: 0 !important;
min-width: 200px;
flex-grow: 1;
padding-inline: 0 !important;
}
}
.chat-message-creator__participants-count {
white-space: nowrap;
}
.chat-message-creator__members-count {
white-space: nowrap;
&.-reached-limit {
color: var(--danger);
}
}
.chat-message-creator__add-members-footer {
display: flex;
flex-grow: 1;
justify-content: flex-end;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--primary-low);
&-container {
display: flex;
}
}
.chat-message-creator__list {
list-style: none;
margin: 0;
gap: 0.25rem;
display: flex;
flex-direction: column;
&-item {
box-sizing: border-box;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
.d-icon-users {
width: 18px;
height: 22px;
padding: 2px 2px;
box-sizing: border-box;
color: var(--tertiary);
background: var(--primary-low);
border-radius: 100%;
}
.btn {
padding: 0;
}
&.-highlighted {
background: var(--tertiary-very-low);
}
}
}
.chat-message-creator__chatable-user {
display: flex;
align-items: center;
.chat-user-display-name {
padding-left: 0.5rem;
}
}
.chat-message-creator__search-input {
display: flex;
align-items: center;
background: var(--secondary-very-high);
width: 100%;
margin: 0.5rem;
box-sizing: border-box;
height: 42px;
padding: 0 0.5rem;
&-container {
display: flex;
flex-grow: 1;
}
&__search-icon {
background: none !important;
color: var(--primary-medium);
}
&__input {
width: 100% !important;
}
&__input,
&__input:focus {
margin: 0 !important;
border: 0 !important;
appearance: none !important;
outline: none !important;
background: none !important;
}
}
@keyframes popIn { @keyframes popIn {
0% { 0% {
transform: scale(0.1); transform: scale(0.1);
@ -8,326 +230,3 @@
opacity: 1; opacity: 1;
} }
} }
.chat-message-creator {
display: flex;
align-items: center;
width: 100%;
flex-direction: column;
--row-height: 36px;
&__search-icon {
color: var(--primary-medium);
&-container {
display: flex;
align-items: center;
height: var(--row-height);
padding-inline: 0.25rem;
box-sizing: border-box;
}
}
&__container {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
> * {
box-sizing: border-box;
}
}
&__row {
display: flex;
padding-inline: 0.25rem;
align-items: center;
border-radius: var(--d-border-radius);
height: var(--row-height);
.unread-indicator {
background: var(--tertiary);
width: 8px;
height: 8px;
display: flex;
border-radius: 50%;
margin-left: 0.5rem;
&.-urgent {
background: var(--success);
}
}
.selection-indicator {
visibility: hidden;
font-size: var(--font-down-2);
margin-left: auto;
&.-add {
color: var(--success);
}
&.-remove {
color: var(--danger);
}
}
.action-indicator {
display: none;
margin-left: auto;
white-space: nowrap;
font-size: var(--font-down-1);
color: var(--secondary-medium);
align-items: center;
padding-right: 0.25rem;
kbd {
margin-left: 0.25rem;
}
}
&.-active {
.action-indicator {
display: flex;
}
}
.chat-channel-title__name {
margin-left: 0;
}
.chat-channel-title__avatar,
.chat-channel-title__category-badge,
.chat-user-avatar {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.chat-channel-title {
&__users-count {
width: 24px;
height: 24px;
padding: 0;
justify-content: center;
}
}
.chat-channel-title__name,
.chat-user-display-name {
@include ellipsis;
padding-left: 0.5rem;
}
&.-selected {
.selection-indicator {
visibility: visible;
}
}
&.-disabled {
opacity: 0.25;
}
&.-active {
cursor: pointer;
.chat-user-display-name {
color: var(--primary);
}
}
&.-user {
&.-disabled {
.chat-user-display-name__username.-first {
font-weight: normal;
}
}
.disabled-text {
margin-left: auto;
white-space: nowrap;
padding-left: 0.25rem;
}
}
}
&__content {
box-sizing: border-box;
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
&-container {
display: flex;
flex: 1;
width: 100%;
box-sizing: border-box;
padding: 0.25rem 1rem 1rem 1rem;
}
}
&__close-btn {
margin-bottom: auto;
margin-left: 0.25rem;
height: 44px;
width: 44px;
min-width: 44px;
border-radius: var(--d-button-border-radius);
}
&__selection {
flex: 1 1 auto;
flex-direction: row;
flex-wrap: wrap;
display: flex;
background: var(--secondary-very-high);
border-radius: var(--d-input-border-radius);
padding: 3px;
position: relative;
&-container {
display: flex;
box-sizing: border-box;
width: 100%;
align-items: center;
padding: 1rem;
box-sizing: border-box;
}
}
&__input[type="text"],
&__input[type="text"]:focus {
background: none;
appearance: none;
outline: none;
border: 0;
resize: none;
box-sizing: border-box;
min-width: 150px;
height: var(--row-height);
flex: 1;
width: auto;
padding: 0 5px;
margin: 0;
box-sizing: border-box;
display: inline-flex;
}
&__loader {
&-container {
display: flex;
align-items: center;
padding-inline: 0.5rem;
height: var(--row-height);
}
}
&__selection-item {
align-items: center;
box-sizing: border-box;
cursor: pointer;
display: inline-flex;
background: var(--primary-very-low);
border-radius: var(--border-radius);
border: 1px solid var(--primary-low);
height: calc(var(--row-height) - 6);
padding-inline: 0.25rem;
margin: 3px;
@media (prefers-reduced-motion: no-preference) {
animation: popIn 0.1s ease-out;
}
.d-icon-times {
margin-top: 4px;
}
.chat-channel-title__name {
padding-inline: 0.25rem;
}
&__username {
padding-inline: 0.25rem;
}
&.-active {
border-color: var(--secondary-high);
}
&-remove-btn {
padding-inline: 0.25rem;
font-size: var(--font-down-2);
display: flex;
align-items: center;
}
&:hover {
border-color: var(--primary-medium);
.chat-message-creator__selection__remove-btn {
color: var(--danger);
}
}
}
&__no-items {
&-container {
display: flex;
align-items: center;
height: var(--row-height);
margin-left: 0.5rem;
}
}
&__footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-direction: row;
width: 100%;
&-container {
margin-top: auto;
display: flex;
width: 100%;
padding: 1rem;
box-sizing: border-box;
border-top: 1px solid var(--primary-low);
}
}
&__open-dm-btn {
display: flex;
margin-left: auto;
@include ellipsis;
padding: 0.5rem;
max-width: 40%;
.d-button-label {
@include ellipsis;
}
}
&__shortcut {
display: flex;
align-items: center;
font-size: var(--font-down-2);
color: var(--secondary-medium);
flex: 3;
span {
margin-left: 0.25rem;
display: inline-flex;
line-height: 17px;
}
kbd {
margin-inline: 0.25rem;
}
}
}

View File

@ -31,4 +31,22 @@
margin: 10px auto auto auto; margin: 10px auto auto auto;
} }
} }
.chat-message-creator__new-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.chat-message-creator__search {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-bottom: 1rem;
}
.chat-message-creator__add-members-header-container,
.chat-message-creator__list-container {
padding-inline: 1rem;
}
} }

View File

@ -18,6 +18,7 @@
} }
.d-icon { .d-icon {
color: var(--primary-medium);
margin: 0 0.5rem; margin: 0 0.5rem;
} }
} }

View File

@ -0,0 +1,3 @@
.chat-channel-info {
max-width: 500px;
}

View File

@ -1,7 +1,7 @@
.chat-message-creator { // .chat-message-creator {
&__row { // &__row {
&.-active { // &.-active {
background: var(--tertiary-very-low); // background: var(--tertiary-very-low);
} // }
} // }
} // }

View File

@ -5,6 +5,7 @@
@import "chat-index-full-page"; @import "chat-index-full-page";
@import "chat-message-actions"; @import "chat-message-actions";
@import "chat-message"; @import "chat-message";
@import "chat-channel-info";
@import "chat-message-creator"; @import "chat-message-creator";
@import "chat-message-thread-indicator"; @import "chat-message-thread-indicator";
@import "sidebar-extensions"; @import "sidebar-extensions";

View File

@ -1,3 +1,3 @@
.chat-channel-settings { .chat-channel-settings {
width: 100%; min-width: 320px;
} }

View File

@ -0,0 +1,6 @@
.chat-form__section {
&-content {
background: var(--primary-very-low);
padding: 1rem;
}
}

View File

@ -1,40 +0,0 @@
.chat-message-creator {
&__open-dm-btn {
width: 100%;
max-width: 100%;
}
&__row {
padding-block: 0.5rem;
}
.chat-channel-title__name,
.chat-user-display-name {
font-size: var(--font-up-1);
}
.chat-channel-title__category-badge {
display: flex;
align-items: center;
justify-content: center;
padding-inline: 2px;
width: 24px;
height: 24px;
svg:not(.chat-channel-title__restricted-category-icon) {
height: inherit;
width: inherit;
}
}
.chat-user-avatar {
&__container {
padding: 0;
}
img {
width: 28px;
height: 28px;
box-sizing: border-box;
}
}
}

View File

@ -16,3 +16,4 @@
@import "chat-channel-row"; @import "chat-channel-row";
@import "chat-channel-members"; @import "chat-channel-members";
@import "chat-channel-settings"; @import "chat-channel-settings";
@import "chat-form";

View File

@ -333,9 +333,11 @@ en:
default_search_placeholder: "#a-channel, @somebody or anything" default_search_placeholder: "#a-channel, @somebody or anything"
default_channel_search_placeholder: "#a-channel" default_channel_search_placeholder: "#a-channel"
default_user_search_placeholder: "@somebody" default_user_search_placeholder: "@somebody"
user_search_placeholder: "...add more users" user_search_placeholder: "...add more members"
disabled_user: "has disabled chat" disabled_user: "has disabled chat"
no_items: "No items" no_items: "No items"
create_group_placeholder: "Group chat name (optional)"
participants_counter: "%{selection_count}/%{max} participants"
channel_edit_name_slug_modal: channel_edit_name_slug_modal:
title: Edit channel title: Edit channel
@ -350,10 +352,15 @@ en:
description: Tell people what this channel is all about description: Tell people what this channel is all about
direct_message_creator: direct_message_creator:
add_to_channel: "Add to channel"
title: New Message title: New Message
prefix: "To:" prefix: "To:"
no_results: No results no_results: No results
selected_user_title: "Deselect %{username}" selected_user_title: "Deselect %{username}"
group_name: "Group chat name (optional)"
members_counter:
one: "%{count}/%{max} member"
other: "%{count}/%{max} members"
channel: channel:
no_memberships: This channel has no members no_memberships: This channel has no members

View File

@ -130,6 +130,9 @@ en:
transcript_title: "Transcript of previous messages in %{channel_name}" transcript_title: "Transcript of previous messages in %{channel_name}"
transcript_body: "To give you more context, we included a transcript of the previous messages in this conversation (up to ten):\n\n%{transcript}" transcript_body: "To give you more context, we included a transcript of the previous messages in this conversation (up to ten):\n\n%{transcript}"
channel: channel:
users_invited_to_channel:
one: "%{invited_users} has been invited by %{inviting_user}."
other: "%{invited_users} have been invited by %{inviting_user}."
archive: archive:
first_post_raw: "This topic is an archive of the [%{channel_name}](%{channel_url}) chat channel." first_post_raw: "This topic is an archive of the [%{channel_name}](%{channel_url}) chat channel."
messages_moved: messages_moved:

View File

@ -18,6 +18,7 @@ Chat::Engine.routes.draw do
post "/channels/:channel_id/invites" => "channels_invites#create" post "/channels/:channel_id/invites" => "channels_invites#create"
post "/channels/:channel_id/archives" => "channels_archives#create" post "/channels/:channel_id/archives" => "channels_archives#create"
get "/channels/:channel_id/memberships" => "channels_memberships#index" get "/channels/:channel_id/memberships" => "channels_memberships#index"
post "/channels/:channel_id/memberships" => "channels_memberships#create"
delete "/channels/:channel_id/memberships/me" => "channels_current_user_membership#destroy" delete "/channels/:channel_id/memberships/me" => "channels_current_user_membership#destroy"
post "/channels/:channel_id/memberships/me" => "channels_current_user_membership#create" post "/channels/:channel_id/memberships/me" => "channels_current_user_membership#create"
put "/channels/:channel_id/notifications-settings/me" => put "/channels/:channel_id/notifications-settings/me" =>

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddGroupFieldToDirectMessageChannels < ActiveRecord::Migration[7.0]
def change
add_column :direct_message_channels, :group, :boolean, default: false, null: false
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class SetMultiusersDirectMessageChannelsAsGroup < ActiveRecord::Migration[7.0]
def change
execute <<-SQL
UPDATE direct_message_channels
SET "group" = true
WHERE id IN (
SELECT direct_message_channel_id
FROM direct_message_users
GROUP BY direct_message_channel_id
HAVING COUNT(user_id) > 2
);
SQL
end
end

View File

@ -88,7 +88,17 @@ module Chat
allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true)
channels = Chat::Channel.includes(:last_message, chatable: [:topic_only_relative_url]) channels =
Chat::Channel.includes(
:last_message,
chatable: %i[
topic_only_relative_url
uploaded_background
uploaded_background_dark
uploaded_logo
uploaded_logo_dark
],
)
channels = channels.includes(:chat_channel_archive) if options[:include_archives] channels = channels.includes(:chat_channel_archive) if options[:include_archives]
channels = channels =

View File

@ -33,9 +33,13 @@ Fabricator(:private_category_channel, from: :category_channel) do
end end
Fabricator(:direct_message_channel, from: :chat_channel) do Fabricator(:direct_message_channel, from: :chat_channel) do
transient :users, following: true, with_membership: true transient :users, :group, following: true, with_membership: true
chatable do |attrs| chatable do |attrs|
Fabricate(:direct_message, users: attrs[:users] || [Fabricate(:user), Fabricate(:user)]) Fabricate(
:direct_message,
users: attrs[:users] || [Fabricate(:user), Fabricate(:user)],
group: attrs[:group] || false,
)
end end
status { :open } status { :open }
name nil name nil

View File

@ -84,17 +84,19 @@ RSpec.describe "Outgoing chat webhooks" do
expect(payload_channel["chatable_id"]).to eq(direct_message.id) expect(payload_channel["chatable_id"]).to eq(direct_message.id)
expect(payload_channel["chatable_type"]).to eq("DirectMessage") expect(payload_channel["chatable_type"]).to eq("DirectMessage")
expect(payload_channel["chatable_url"]).to be_nil expect(payload_channel["chatable_url"]).to be_nil
expect(payload_channel["chatable"]["users"][0]["id"]).to eq(user2.id)
expect(payload_channel["chatable"]["users"][0]["username"]).to eq(user2.username)
expect(payload_channel["chatable"]["users"][0]["name"]).to eq(user2.name)
expect(payload_channel["chatable"]["users"][0]["avatar_template"]).to eq(
user2.avatar_template,
)
expect(payload_channel["chatable"]["users"][0]["can_chat"]).to eq(true)
expect(payload_channel["chatable"]["users"][0]["has_chat_enabled"]).to eq(true)
expect(payload_channel["title"]).to eq(channel.title(user1)) expect(payload_channel["title"]).to eq(channel.title(user1))
expect(payload_channel["slug"]).to be_nil expect(payload_channel["slug"]).to be_nil
membership =
payload_channel["chatable"]["memberships"].detect { |m| m["user"]["id"] == user2.id }
user = membership["user"]
expect(user["username"]).to eq(user2.username)
expect(user["name"]).to eq(user2.name)
expect(user["avatar_template"]).to eq(user2.avatar_template)
expect(user["can_chat"]).to eq(true)
expect(user["has_chat_enabled"]).to eq(true)
yield(payload_channel) if block_given? yield(payload_channel) if block_given?
end end

View File

@ -85,7 +85,7 @@ RSpec.describe Chat::GuardianExtensions do
end end
it "returns true if the user is part of the direct message" do it "returns true if the user is part of the direct message" do
Chat::DirectMessageUser.create!(user: user, direct_message: chatable) channel.add(user)
expect(guardian.can_join_chat_channel?(channel)).to eq(true) expect(guardian.can_join_chat_channel?(channel)).to eq(true)
end end
end end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
RSpec.describe Chat::Api::ChannelsMembershipsController do
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) do
Fabricate(:direct_message_channel, group: true, users: [current_user, Fabricate(:user)])
end
before do
SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
channel_1.add(current_user)
sign_in(current_user)
end
describe "#create" do
describe "success" do
it "works" do
post "/chat/api/channels/#{channel_1.id}/memberships",
params: {
usernames: [Fabricate(:user).username],
}
expect(response.status).to eq(200)
end
end
context "when users can't be added" do
before { channel_1.chatable.update(group: false) }
it "returns a 422" do
post "/chat/api/channels/#{channel_1.id}/memberships",
params: {
usernames: [Fabricate(:user).username],
}
expect(response.status).to eq(422)
end
end
context "when channel is not found" do
before { channel_1.chatable.update!(group: false) }
it "returns a 404" do
get "/chat/api/channels/-999/messages", params: { usernames: [Fabricate(:user).username] }
expect(response.status).to eq(404)
end
end
end
end

View File

@ -14,13 +14,16 @@ RSpec.describe Chat::Api::ChatablesController do
describe "without chat permissions" do describe "without chat permissions" do
it "errors errors for anon" do it "errors errors for anon" do
get "/chat/api/chatables" get "/chat/api/chatables"
expect(response.status).to eq(403) expect(response.status).to eq(403)
end end
it "errors when user cannot chat" do it "errors when user cannot chat" do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff]
sign_in(current_user) sign_in(current_user)
get "/chat/api/chatables" get "/chat/api/chatables"
expect(response.status).to eq(403) expect(response.status).to eq(403)
end end
end end
@ -28,9 +31,11 @@ RSpec.describe Chat::Api::ChatablesController do
describe "with chat permissions" do describe "with chat permissions" do
fab!(:channel_1) { Fabricate(:chat_channel) } fab!(:channel_1) { Fabricate(:chat_channel) }
before { sign_in(current_user) } before { channel_1.add(current_user) }
it "returns results" do it "returns results" do
sign_in(current_user)
get "/chat/api/chatables", params: { term: channel_1.name } get "/chat/api/chatables", params: { term: channel_1.name }
expect(response.status).to eq(200) expect(response.status).to eq(200)

View File

@ -23,7 +23,10 @@ RSpec.describe Chat::Api::DirectMessagesController do
describe "#create" do describe "#create" do
before { Group.refresh_automatic_groups! } before { Group.refresh_automatic_groups! }
shared_examples "creating dms" do describe "dm with one other user" do
let(:usernames) { user1.username }
let(:direct_message_user_ids) { [current_user.id, user1.id] }
it "creates a new dm channel with username(s) provided" do it "creates a new dm channel with username(s) provided" do
expect { expect {
post "/chat/api/direct-message-channels.json", params: { target_usernames: [usernames] } post "/chat/api/direct-message-channels.json", params: { target_usernames: [usernames] }
@ -35,31 +38,55 @@ RSpec.describe Chat::Api::DirectMessagesController do
it "returns existing dm channel if one exists for username(s)" do it "returns existing dm channel if one exists for username(s)" do
create_dm_channel(direct_message_user_ids) create_dm_channel(direct_message_user_ids)
expect { expect {
post "/chat/api/direct-message-channels.json", params: { target_usernames: [usernames] } post "/chat/api/direct-message-channels.json", params: { target_usernames: [usernames] }
}.not_to change { Chat::DirectMessage.count } }.not_to change { Chat::DirectMessage.count }
end end
end end
describe "dm with one other user" do
let(:usernames) { user1.username }
let(:direct_message_user_ids) { [current_user.id, user1.id] }
include_examples "creating dms"
end
describe "dm with myself" do describe "dm with myself" do
let(:usernames) { [current_user.username] } let(:usernames) { [current_user.username] }
let(:direct_message_user_ids) { [current_user.id] } let(:direct_message_user_ids) { [current_user.id] }
include_examples "creating dms" it "creates a new dm channel with username(s) provided" do
expect {
post "/chat/api/direct-message-channels.json", params: { target_usernames: [usernames] }
}.to change { Chat::DirectMessage.count }.by(1)
expect(Chat::DirectMessage.last.direct_message_users.map(&:user_id)).to match_array(
direct_message_user_ids,
)
end
it "returns existing dm channel if one exists for username(s)" do
create_dm_channel(direct_message_user_ids)
expect {
post "/chat/api/direct-message-channels.json", params: { target_usernames: [usernames] }
}.not_to change { Chat::DirectMessage.count }
end
end end
describe "dm with two other users" do describe "dm with two other users" do
let(:usernames) { [user1, user2, user3].map(&:username) } let(:usernames) { [user1, user2, user3].map(&:username) }
let(:direct_message_user_ids) { [current_user.id, user1.id, user2.id, user3.id] } let(:direct_message_user_ids) { [current_user.id, user1.id, user2.id, user3.id] }
include_examples "creating dms" it "creates a new dm channel with username(s) provided" do
expect {
post "/chat/api/direct-message-channels.json", params: { target_usernames: [usernames] }
}.to change { Chat::DirectMessage.count }.by(1)
expect(Chat::DirectMessage.last.direct_message_users.map(&:user_id)).to match_array(
direct_message_user_ids,
)
end
it "createsa new dm channel" do
create_dm_channel(direct_message_user_ids)
expect {
post "/chat/api/direct-message-channels.json", params: { target_usernames: [usernames] }
}.to change { Chat::DirectMessage.count }.by(1)
end
end end
it "creates Chat::UserChatChannelMembership records" do it "creates Chat::UserChatChannelMembership records" do

Some files were not shown because too many files have changed in this diff Show More