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
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

View File

@ -27,21 +27,5 @@ module Chat
self.slug = Slug.for(self.title.strip, "")
self.slug = "" if duplicate_slug?
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

View File

@ -27,6 +27,7 @@ module Chat
class_name: "Chat::Message",
foreign_key: :last_message_id,
optional: true
def last_message
super || NullMessage.new
end
@ -109,8 +110,28 @@ module Chat
%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)
user_chat_channel_memberships.find_by(user: 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)
end
end
def add(user)
@ -177,6 +198,7 @@ module Chat
AND (users.suspended_till IS NULL OR users.suspended_till <= CURRENT_TIMESTAMP)
AND NOT users.staged
AND user_chat_channel_memberships.following
and users.id > 0
GROUP BY user_chat_channel_memberships.chat_channel_id
) subquery
WHERE channels.id = subquery.chat_channel_id

View File

@ -66,6 +66,7 @@ end
# Table name: direct_message_channels
#
# id :bigint not null, primary key
# group :boolean
# created_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)
end
def ensure_slug_ok
true
end
def generate_auto_slug
self.slug = nil
return if !self.slug.present?
end
end
end

View File

@ -114,7 +114,7 @@ module Chat
return uploads.first.original_filename if cooked.blank? && uploads.present?
# 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
def censored_excerpt(max_length: 50)

View File

@ -7,7 +7,7 @@ module Chat
Chat::UserChatChannelMembership
.joins(: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)
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
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
users = object.direct_message_users.map(&:user).map { |u| u || Chat::NullUser.new }

View File

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

View File

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

View File

@ -2,10 +2,10 @@
module Chat
class UserChannelMembershipSerializer < BaseChannelMembershipSerializer
has_one :user, serializer: BasicUserSerializer, embed: :objects
has_one :user, serializer: ::Chat::BasicUserSerializer, embed: :objects
def user
object.user
object.user || Chat::NullUser.new
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.
#
# @example
# Service::Chat::CreateDirectMessageChannel.call(
# ::Chat::CreateDirectMessageChannel.call(
# guardian: guardian,
# target_usernames: ["bob", "alice"]
# )
@ -32,10 +32,13 @@ module Chat
class_name: Chat::DirectMessageChannel::CanCommunicateAllPartiesPolicy
model :direct_message, :fetch_or_create_direct_message
model :channel, :fetch_or_create_channel
step :set_optional_name
step :update_memberships
step :recompute_users_count
# @!visibility private
class Contract
attribute :name, :string
attribute :target_usernames, :array
validates :target_usernames, presence: true
end
@ -58,17 +61,26 @@ module Chat
!user_comm_screener.actor_disallowing_all_pms?
end
def fetch_or_create_direct_message(target_users:, **)
Chat::DirectMessage.for_user_ids(target_users.map(&:id)) ||
Chat::DirectMessage.create(user_ids: target_users.map(&:id))
def fetch_or_create_direct_message(target_users:, contract:, **)
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
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
def update_memberships(channel:, target_users:, **)
always_level = Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
always_level = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
memberships =
target_users.map do |user|
@ -84,10 +96,17 @@ module Chat
}
end
Chat::UserChatChannelMembership.upsert_all(
::Chat::UserChatChannelMembership.upsert_all(
memberships,
unique_by: %i[user_id chat_channel_id],
)
end
def recompute_users_count(channel:, **)
channel.update!(
user_count: ::Chat::ChannelMembershipsQuery.count(channel),
user_count_stale: false,
)
end
end
end

View File

@ -22,9 +22,9 @@ module Chat
policy :no_silenced_user
contract
model :channel
step :enforce_system_membership
policy :allowed_to_join_channel
policy :allowed_to_create_message_in_channel, class_name: Chat::Channel::MessageCreationPolicy
step :enforce_system_membership
model :channel_membership
model :reply, optional: true
policy :ensure_reply_consistency
@ -76,7 +76,7 @@ module Chat
end
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
def fetch_channel_membership(guardian:, channel:, **)

View File

@ -59,7 +59,7 @@ module Chat
private
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
def fetch_optional_membership(channel:, guardian:, **)

View File

@ -15,78 +15,65 @@ module Chat
# @return [Service::Base::Context]
contract
step :set_mode
step :clean_term
step :fetch_memberships
step :fetch_users
step :fetch_category_channels
step :fetch_direct_message_channels
model :memberships
model :users, optional: true
model :category_channels, optional: true
model :direct_message_channels, optional: true
# @!visibility private
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
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:, **)
context.term = contract.term.downcase&.gsub(/^#+/, "")&.gsub(/^@+/, "")&.strip
end
def fetch_memberships(guardian:, **)
context.memberships = ::Chat::ChannelMembershipManager.all_for_user(guardian.user)
::Chat::ChannelMembershipManager.all_for_user(guardian.user)
end
def fetch_users(guardian:, **)
def fetch_users(guardian:, contract:, **)
return unless contract.include_users
return unless guardian.can_create_direct_message?
return if context.mode == :channel
context.users = search_users(context.term, guardian)
search_users(context, guardian, contract)
end
def fetch_category_channels(guardian:, **)
return if context.mode == :user
def fetch_category_channels(guardian:, contract:, **)
return unless contract.include_category_channels
return if !SiteSetting.enable_public_channels
context.category_channels =
::Chat::ChannelFetcher.secured_public_channel_search(
guardian,
filter_on_category_name: false,
match_filter_on_starts_with: false,
filter: context.term,
status: :open,
limit: 10,
)
::Chat::ChannelFetcher.secured_public_channel_search(
guardian,
filter_on_category_name: false,
match_filter_on_starts_with: false,
filter: context.term,
status: :open,
limit: 10,
)
end
def fetch_direct_message_channels(guardian:, **args)
return if context.mode == :user
user_ids = nil
if context.term.length > 0
user_ids =
(context.users.nil? ? search_users(context.term, guardian) : context.users).map(&:id)
end
def fetch_direct_message_channels(guardian:, users:, contract:, **args)
return unless contract.include_direct_message_channels
channels =
::Chat::ChannelFetcher.secured_direct_message_channels_search(
guardian.user.id,
guardian,
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.reject do |channel|
channel_user_ids = channel.allowed_user_ids - [guardian.user.id]
@ -96,17 +83,31 @@ module Chat
end
end
context.direct_message_channels = channels
channels
end
def search_users(term, guardian)
user_search = ::UserSearch.new(term, limit: 10)
def search_users(context, guardian, contract)
user_search = ::UserSearch.new(context.term, limit: 10)
if term.blank?
user_search.scoped_users.includes(:user_option)
if context.term.blank?
user_search = user_search.scoped_users.real.includes(:user_option)
else
user_search.search.includes(:user_option)
user_search = user_search.search.real.includes(:user_option)
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

View File

@ -7,7 +7,7 @@ module Chat
# and threading_enabled are also editable.
#
# @example
# Service::Chat::UpdateChannel.call(
# ::Chat::UpdateChannel.call(
# channel_id: 2,
# guardian: guardian,
# name: "SuperChannel",
@ -26,13 +26,13 @@ module Chat
# @option params_to_edit [String,nil] name
# @option params_to_edit [String,nil] description
# @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
# 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.
# @return [Service::Base::Context]
model :channel, :fetch_channel
policy :no_direct_message_channel
policy :check_channel_permission
contract default_values_from: :channel
step :update_channel
@ -62,10 +62,6 @@ module Chat
Chat::Channel.find_by(id: channel_id)
end
def no_direct_message_channel(channel:, **)
!channel.direct_message_channel?
end
def check_channel_permission(guardian:, channel:, **)
guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel?
end

View File

@ -1,14 +1,19 @@
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 icon from "discourse-common/helpers/d-icon";
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 ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
export default class ChatChannelMessageEmojiPicker extends Component {
@service chatChannelInfoRouteOriginManager;
@service site;
@service modal;
@service chatGuardian;
membersLabel = I18n.t("chat.channel_info.tabs.members");
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");
get showTabs() {
return this.site.desktopView && this.args.channel.isOpen;
}
get canEditChannel() {
return (
this.site.desktopView &&
this.args.channel.membershipsCount > 1 &&
this.args.channel.isOpen
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>
<div class="chat-full-page-header">
<div class="chat-channel-header-details">
@ -48,6 +65,14 @@ export default class ChatChannelMessageEmojiPicker extends Component {
</div>
<ChatChannelTitle @channel={{@channel}} />
{{#if this.canEditChannel}}
<DButton
@icon="pencil-alt"
class="btn-flat"
@action={{this.editChannelTitle}}
/>
{{/if}}
</div>
</div>

View File

@ -1,24 +1,31 @@
import Component from "@glimmer/component";
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 { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { modifier } from "ember-modifier";
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 icon from "discourse-common/helpers/d-icon";
import discourseDebounce from "discourse-common/lib/debounce";
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 DcFilterInput from "discourse/plugins/chat/discourse/components/dc-filter-input";
import { MODES } from "./chat/message-creator/constants";
export default class ChatChannelMembers extends Component {
@service appEvents;
@service chatApi;
@service modal;
@service loadingSlider;
@tracked filter = "";
@tracked showAddMembers = false;
filterPlaceholder = I18n.t("chat.members_view.filter_placeholder");
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) => {
this.resizeObserver = new ResizeObserver(() => {
if (isElementInViewport(element)) {
@ -78,28 +101,78 @@ export default class ChatChannelMembers extends Component {
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() {
this.loadingSlider.transitionStarted();
await this.members.load({ limit: 20 });
this.loadingSlider.transitionEnded();
}
<template>
<div class="chat-channel-members">
<DcFilterInput
@class="chat-channel-members__filter"
@filterAction={{this.mutFilter}}
@icons={{hash right="search"}}
placeholder={{this.filterPlaceholder}}
{{this.focusInput}}
/>
get addMembersMode() {
return MODES.add_members;
}
<template>
{{#if this.showAddMembers}}
<MessageCreator
@mode={{this.addMembersMode}}
@channel={{@channel}}
@onClose={{this.hideAddMember}}
@onCancel={{this.hideAddMember}}
/>
{{else}}
<div class="chat-channel-members">
<DcFilterInput
@class="chat-channel-members__filter"
@filterAction={{this.mutFilter}}
@icons={{hash right="search"}}
placeholder={{this.filterPlaceholder}}
{{this.focusInput}}
/>
{{#if (gt @channel.membershipsCount 0)}}
<ul class="chat-channel-members__list" {{this.fill}}>
{{#each this.members as |membership|}}
<li class="chat-channel-members__list-item">
<ChatUserInfo @user={{membership.user}} @avatarSize="tiny" />
{{#if @channel.chatable.group}}
<li
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>
{{/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}}
{{#if this.noResults}}
<li
@ -114,11 +187,7 @@ export default class ChatChannelMembers extends Component {
<div {{this.loadMore}}>
<br />
</div>
{{else}}
<p class="alert alert-info">
{{this.noMembershipsLabel}}
</p>
{{/if}}
</div>
</div>
{{/if}}
</template>
}

View File

@ -69,10 +69,6 @@ export default class ChatAboutScreen extends Component {
return this.chatGuardian.canEditChatChannel();
}
get shouldRenderTitleSection() {
return this.args.channel.isCategoryChannel;
}
get shouldRenderDescriptionSection() {
return this.args.channel.isCategoryChannel;
}
@ -293,7 +289,7 @@ export default class ChatAboutScreen extends Component {
}
@action
onEditChannelName() {
onEditChannelTitle() {
return this.modal.show(ChatModalEditChannelName, {
model: this.args.channel,
});
@ -309,39 +305,37 @@ export default class ChatAboutScreen extends Component {
<template>
<div class="chat-channel-settings">
<ChatForm as |form|>
{{#if this.shouldRenderTitleSection}}
<form.section @title={{this.titleSectionTitle}} as |section|>
<section.row>
<:default>
<div class="chat-channel-settings__name">
{{replaceEmoji @channel.title}}
<form.section @title={{this.titleSectionTitle}} as |section|>
<section.row>
<:default>
<div class="chat-channel-settings__name">
{{replaceEmoji @channel.title}}
</div>
{{#if @channel.isCategoryChannel}}
<div class="chat-channel-settings__slug">
<LinkTo
@route="chat.channel"
@models={{@channel.routeModels}}
>
/chat/c/{{@channel.slug}}/{{@channel.id}}
</LinkTo>
</div>
{{/if}}
</:default>
{{#if @channel.isCategoryChannel}}
<div class="chat-channel-settings__slug">
<LinkTo
@route="chat.channel"
@models={{@channel.routeModels}}
>
/chat/c/{{@channel.slug}}/{{@channel.id}}
</LinkTo>
</div>
{{/if}}
</:default>
<:action>
{{#if this.canEditChannel}}
<DButton
@label="chat.channel_settings.edit"
@action={{this.onEditChannelTitle}}
class="edit-name-slug-btn btn-flat"
/>
{{/if}}
</:action>
<:action>
{{#if this.canEditChannel}}
<DButton
@label="chat.channel_settings.edit"
@action={{this.onEditChannelName}}
class="edit-name-slug-btn btn-flat"
/>
{{/if}}
</:action>
</section.row>
</form.section>
{{/if}}
</section.row>
</form.section>
{{#if this.shouldRenderDescriptionSection}}
<form.section @title={{this.descriptionSectionTitle}} as |section|>
@ -567,7 +561,7 @@ export default class ChatAboutScreen extends Component {
@channel={{@channel}}
@options={{hash
joinClass="btn-primary"
leaveClass="btn-flat"
leaveClass="btn-danger"
joinIcon="sign-in-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 concatClass from "discourse/helpers/concat-class";
import { renderAvatar } from "discourse/helpers/user-avatar";
import { userPath } from "discourse/lib/url";
export default class ChatUserAvatar extends Component {
@service chat;
@ -37,19 +38,23 @@ export default class ChatUserAvatar extends Component {
);
}
get userPath() {
return userPath(this.args.user.username);
}
<template>
<div
class={{concatClass "chat-user-avatar" (if this.isOnline "is-online")}}
data-username={{@user.username}}
>
{{#if this.interactive}}
<div
role="button"
class="chat-user-avatar__container clickable"
<a
class="chat-user-avatar__container"
href={{this.userPath}}
data-user-card={{@user.username}}
>
{{this.avatar}}
</div>
</a>
{{else}}
{{this.avatar}}
{{/if}}

View File

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

View File

@ -93,19 +93,25 @@ export default class ChatComposerChannel extends ChatComposer {
#messageRecipients(channel) {
if (channel.isDirectMessageChannel) {
const directMessageRecipients = channel.chatable.users;
if (
directMessageRecipients.length === 1 &&
directMessageRecipients[0].id === this.currentUser.id
) {
return I18n.t("chat.placeholder_self");
}
if (channel.chatable.group && channel.title) {
return I18n.t("chat.placeholder_channel", {
channelName: `#${channel.title}`,
});
} else {
const directMessageRecipients = channel.chatable.users;
if (
directMessageRecipients.length === 1 &&
directMessageRecipients[0].id === this.currentUser.id
) {
return I18n.t("chat.placeholder_self");
}
return I18n.t("chat.placeholder_users", {
commaSeparatedNames: directMessageRecipients
.map((u) => u.name || `@${u.username}`)
.join(I18n.t("word_connector.comma")),
});
return I18n.t("chat.placeholder_users", {
commaSeparatedNames: directMessageRecipients
.map((u) => u.name || `@${u.username}`)
.join(I18n.t("word_connector.comma")),
});
}
} else {
return I18n.t("chat.placeholder_channel", {
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 { inject as service } from "@ember/service";
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 slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel";
const SLUG_MAX_LENGTH = 100;
export default class ChatModalEditChannelName extends Component {
@service chatApi;
@service router;
@service siteSettings;
@tracked editedName = this.channel.title;
@tracked editedSlug = this.channel.slug;
@tracked autoGeneratedSlug = "";
@tracked
autoGeneratedSlug = this.channel.slug ?? slugifyChannel(this.channel);
@tracked flash;
#generateSlugHandler = null;
@ -34,17 +37,23 @@ export default class ChatModalEditChannelName extends Component {
}
@action
onSave() {
return this.chatApi
.updateChannel(this.channel.id, {
async onSave() {
try {
const result = await this.chatApi.updateChannel(this.channel.id, {
name: this.editedName,
slug: this.editedSlug || this.autoGeneratedSlug || this.channel.slug,
})
.then((result) => {
this.channel.title = result.channel.title;
this.args.closeModal();
})
.catch((error) => (this.flash = extractError(error)));
});
this.channel.title = result.channel.title;
this.channel.slug = result.channel.slug;
await this.args.closeModal();
await this.router.replaceWith(
"chat.channel",
...this.channel.routeModels
);
} catch (error) {
this.flash = extractError(error);
}
}
@action
@ -77,11 +86,15 @@ export default class ChatModalEditChannelName extends Component {
// intentionally not showing AJAX error for this, we will autogenerate
// the slug server-side if they leave it blank
#generateSlug(name) {
return ajax("/slugs.json", { type: "POST", data: { name } }).then(
(response) => {
this.autoGeneratedSlug = response.slug;
}
);
async #generateSlug(name) {
try {
await ajax("/slugs.json", { type: "POST", data: { name } }).then(
(response) => {
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}}
@disabled={{this.isLoading}}
class={{concat-class
"toggle-channel-membership-button -leave btn-flat"
"toggle-channel-membership-button -leave"
this.options.leaveClass
}}
/>

View File

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

View File

@ -52,21 +52,29 @@ function messageFabricator(args = {}) {
function channelFabricator(args = {}) {
const id = args.id || sequence++;
const chatable = args.chatable || categoryFabricator();
const channel = ChatChannel.create({
id,
chatable_type:
args.chatable?.type ||
args.chatable_type ||
CHATABLE_TYPES.categoryChannel,
chatable_id: args.chatable?.id || args.chatable_id,
title: args.title || "General",
(chatable instanceof Category
? CHATABLE_TYPES.categoryChannel
: CHATABLE_TYPES.directMessageChannel) ||
chatable?.type ||
args.chatable_type,
chatable_id: chatable?.id || args.chatable_id,
title: args.title
? args.title
: chatable instanceof Category
? "General"
: null,
description: args.description,
chatable: args.chatable || categoryFabricator(),
chatable,
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 || {}),
archive_failed: args.archive_failed ?? false,
memberships_count: args.memberships_count ?? 0,
});
channel.lastMessage = messageFabricator({ channel });
@ -78,7 +86,7 @@ function categoryFabricator(args = {}) {
return Category.create({
id: args.id || sequence++,
color: args.color || "D56353",
read_restricted: false,
read_restricted: args.read_restricted ?? false,
name: args.name || "General",
slug: args.slug || "general",
});
@ -86,8 +94,8 @@ function categoryFabricator(args = {}) {
function directMessageFabricator(args = {}) {
return ChatDirectMessage.create({
id: args.id || sequence++,
users: args.users || [userFabricator(), userFabricator()],
group: args.group ?? false,
users: args.users ?? [userFabricator(), userFabricator()],
});
}
@ -96,6 +104,8 @@ function directMessageChannelFabricator(args = {}) {
args.chatable ||
directMessageFabricator({
id: args.chatable_id || sequence++,
group: args.group ?? false,
users: args.users,
});
return channelFabricator(
@ -103,6 +113,7 @@ function directMessageChannelFabricator(args = {}) {
chatable_type: CHATABLE_TYPES.directMessageChannel,
chatable_id: directMessage.id,
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 activeThread = null;
@tracked meta;
@tracked chatableId;
@tracked chatableType;
@tracked chatableUrl;
@tracked autoJoinUsers = false;
@ -89,12 +90,7 @@ export default class ChatChannel {
this.threadingEnabled = args.threading_enabled;
this.autoJoinUsers = args.auto_join_users;
this.allowChannelWideMentions = args.allow_channel_wide_mentions;
this.chatable = this.isDirectMessageChannel
? ChatDirectMessage.create({
id: args.chatable?.id,
users: args.chatable?.users,
})
: Category.create(args.chatable);
this.chatable = this.#initChatable(args.chatable || []);
this.currentUserMembership = args.current_user_membership;
if (args.archive_completed || args.archive_failed) {
@ -245,4 +241,23 @@ export default class ChatChannel {
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 { inject as service } from "@ember/service";
import Category from "discourse/models/category";
import User from "discourse/models/user";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
@ -66,7 +67,7 @@ export default class ChatChatable {
return this.type === "user";
}
get isSingleUserChannel() {
return this.type === "channel" && this.model?.chatable?.users?.length === 1;
get isCategory() {
return this instanceof Category;
}
}

View File

@ -7,13 +7,13 @@ export default class ChatDirectMessage {
return new ChatDirectMessage(args);
}
@tracked id;
@tracked users = null;
@tracked group = false;
type = CHATABLE_TYPES.directMessageChannel;
constructor(args = {}) {
this.id = args.id;
this.group = args.group ?? false;
this.users = this.#initUsers(args.users || []);
}
@ -21,9 +21,9 @@ export default class ChatDirectMessage {
return users.map((user) => {
if (!user || user instanceof 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;
afterModel(model) {
if (!model.isOpen || model.membershipsCount < 1) {
if (!model.isOpen) {
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);
}
/**
* 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() {
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
// channel for. The current user will automatically be included in the channel
// when it is created.
upsertDmChannelForUsernames(usernames) {
upsertDmChannelForUsernames(usernames, name = null) {
return ajax("/chat/api/direct-message-channels.json", {
method: "POST",
data: { target_usernames: usernames.uniq() },
data: { target_usernames: usernames.uniq(), name },
})
.then((response) => {
const channel = this.chatChannelsManager.store(response.channel);

View File

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

View File

@ -1,6 +1,6 @@
.chat-channel-members {
width: 50%;
min-width: 320px;
max-width: 500px;
width: 100%;
&__filter {
margin-bottom: 1rem;
@ -10,18 +10,36 @@
display: flex;
margin: 0;
flex-direction: column;
gap: 0.5rem;
&-item {
display: flex;
gap: 0.5rem;
list-style: none;
border-bottom: 1px solid var(--primary-low);
height: 42px;
align-items: center;
box-sizing: border-box;
padding: 0.5rem;
&.-no-results {
box-sizing: border-box;
&: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 {

View File

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

View File

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

View File

@ -29,10 +29,8 @@
}
&-content {
background: var(--primary-very-low);
gap: 1rem;
display: flex;
padding: 1rem;
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 {
0% {
transform: scale(0.1);
@ -8,326 +230,3 @@
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;
}
}
.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 {
color: var(--primary-medium);
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 {
&__row {
&.-active {
background: var(--tertiary-very-low);
}
}
}
// .chat-message-creator {
// &__row {
// &.-active {
// background: var(--tertiary-very-low);
// }
// }
// }

View File

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

View File

@ -1,3 +1,3 @@
.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-members";
@import "chat-channel-settings";
@import "chat-form";

View File

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

View File

@ -130,6 +130,9 @@ en:
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}"
channel:
users_invited_to_channel:
one: "%{invited_users} has been invited by %{inviting_user}."
other: "%{invited_users} have been invited by %{inviting_user}."
archive:
first_post_raw: "This topic is an archive of the [%{channel_name}](%{channel_url}) chat channel."
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/archives" => "channels_archives#create"
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"
post "/channels/:channel_id/memberships/me" => "channels_current_user_membership#create"
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)
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 =

View File

@ -33,9 +33,13 @@ Fabricator(:private_category_channel, from: :category_channel) do
end
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|
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
status { :open }
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_type"]).to eq("DirectMessage")
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["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?
end

View File

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

View File

@ -23,7 +23,10 @@ RSpec.describe Chat::Api::DirectMessagesController do
describe "#create" do
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
expect {
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
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
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
let(:usernames) { [current_user.username] }
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
describe "dm with two other users" do
let(:usernames) { [user1, user2, user3].map(&:username) }
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
it "creates Chat::UserChatChannelMembership records" do

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