FEATURE: new jump to channel menu (#22383)

This commit replaces two existing screens:
- draft
- channel selection modal

Main features compared to existing solutions
- features are now combined, meaning you can for example create multi users DM
- it will show users with chat disabled
- it shows unread state
- hopefully a better look/feel
- lots of small details and fixes...

Other noticeable fixes
- starting a DM with a user, even from the user card and clicking <kbd>Chat</kbd> will not show a green dot for the target user (or even the channel) until a message is actually sent
- it should almost never do a full page reload anymore

---------

Co-authored-by: Martin Brennan <mjrbrennan@gmail.com>
Co-authored-by: Jordan Vidrine <30537603+jordanvidrine@users.noreply.github.com>
Co-authored-by: chapoi <101828855+chapoi@users.noreply.github.com>
Co-authored-by: Mark VanLandingham <markvanlan@gmail.com>
This commit is contained in:
Joffrey JAFFEUX 2023-07-05 18:18:27 +02:00 committed by GitHub
parent e72153dd1a
commit d75d64bf16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 2331 additions and 2004 deletions

View File

@ -1,7 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
CHANNEL_EDITABLE_PARAMS = %i[name description slug] CHANNEL_EDITABLE_PARAMS ||= %i[name description slug]
CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions threading_enabled] CATEGORY_CHANNEL_EDITABLE_PARAMS ||= %i[
auto_join_users
allow_channel_wide_mentions
threading_enabled
]
class Chat::Api::ChannelsController < Chat::ApiController class Chat::Api::ChannelsController < Chat::ApiController
def index def index
@ -12,7 +16,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil
memberships = Chat::ChannelMembershipManager.all_for_user(current_user) memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChannelFetcher.secured_public_channels(guardian, memberships, options) channels = Chat::ChannelFetcher.secured_public_channels(guardian, options)
serialized_channels = serialized_channels =
channels.map do |channel| channels.map do |channel|
Chat::ChannelSerializer.new( Chat::ChannelSerializer.new(

View File

@ -1,83 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ChatablesController < Chat::ApiController class Chat::Api::ChatablesController < Chat::ApiController
before_action :ensure_logged_in
def index def index
params.require(:filter) with_service(::Chat::SearchChatable) do
filter = params[:filter].downcase on_success { render_serialized(result, ::Chat::ChatablesSerializer, root: false) }
memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
public_channels =
Chat::ChannelFetcher.secured_public_channels(
guardian,
memberships,
filter: filter,
status: :open,
)
users = User.joins(:user_option).where.not(id: current_user.id)
if !Chat.allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone])
users =
users
.joins(:groups)
.where(groups: { id: Chat.allowed_group_ids })
.or(users.joins(:groups).staff)
end end
users = users.where(user_option: { chat_enabled: true })
like_filter = "%#{filter}%"
if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names
users = users.where("users.username_lower ILIKE ?", like_filter)
else
users =
users.where(
"LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?",
like_filter,
like_filter,
)
end
users = users.limit(25).uniq
direct_message_channels =
if users.count > 0
# FIXME: investigate the cost of this query
Chat::DirectMessageChannel
.includes(chatable: :users)
.joins(direct_message: :direct_message_users)
.group(1)
.having(
"ARRAY[?] <@ ARRAY_AGG(user_id) AND ARRAY[?] && ARRAY_AGG(user_id)",
[current_user.id],
users.map(&:id),
)
else
[]
end
user_ids_with_channel = []
direct_message_channels.each do |dm_channel|
user_ids = dm_channel.chatable.users.map(&:id)
user_ids_with_channel.concat(user_ids) if user_ids.count < 3
end
users_without_channel = users.filter { |u| !user_ids_with_channel.include?(u.id) }
if current_user.username.downcase.start_with?(filter)
# We filtered out the current user for the query earlier, but check to see
# if they should be included, and add.
users_without_channel << current_user
end
render_serialized(
{
public_channels: public_channels,
direct_message_channels: direct_message_channels,
users: users_without_channel,
memberships: memberships,
},
Chat::ChannelSearchSerializer,
root: false,
)
end end
end end

View File

@ -83,10 +83,7 @@ module Chat
Chat::MessageRateLimiter.run!(current_user) Chat::MessageRateLimiter.run!(current_user)
@user_chat_channel_membership = @user_chat_channel_membership =
Chat::ChannelMembershipManager.new(@chat_channel).find_for_user( Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(current_user)
current_user,
following: true,
)
raise Discourse::InvalidAccess unless @user_chat_channel_membership raise Discourse::InvalidAccess unless @user_chat_channel_membership
reply_to_msg_id = params[:in_reply_to_id] reply_to_msg_id = params[:in_reply_to_id]

View File

@ -7,6 +7,7 @@ module Chat
:desktop_notification_level, :desktop_notification_level,
:mobile_notification_level, :mobile_notification_level,
:chat_channel_id, :chat_channel_id,
:last_read_message_id :last_read_message_id,
:last_viewed_at
end end
end end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Chat
class ChatableUserSerializer < ::Chat::UserWithCustomFieldsAndStatusSerializer
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

@ -0,0 +1,63 @@
# frozen_string_literal: true
module Chat
class ChatablesSerializer < ::ApplicationSerializer
attributes :users
attributes :direct_message_channels
attributes :category_channels
def users
(object.users || [])
.map do |user|
{
identifier: "u-#{user.id}",
model: ::Chat::ChatableUserSerializer.new(user, scope: scope, root: false),
type: "user",
}
end
.as_json
end
def direct_message_channels
(object.direct_message_channels || [])
.map do |channel|
{
identifier: "c-#{channel.id}",
type: "channel",
model:
::Chat::ChannelSerializer.new(
channel,
scope: scope,
root: false,
membership: channel_membership(channel.id),
),
}
end
.as_json
end
def category_channels
(object.category_channels || [])
.map do |channel|
{
identifier: "c-#{channel.id}",
type: "channel",
model:
::Chat::ChannelSerializer.new(
channel,
scope: scope,
root: false,
membership: channel_membership(channel.id),
),
}
end
.as_json
end
private
def channel_membership(channel_id)
object.memberships.find { |membership| membership.chat_channel_id == channel_id }
end
end
end

View File

@ -4,7 +4,7 @@ module Chat
class DirectMessageSerializer < ApplicationSerializer class DirectMessageSerializer < ApplicationSerializer
attributes :id attributes :id
has_many :users, serializer: Chat::UserWithCustomFieldsAndStatusSerializer, embed: :objects has_many :users, serializer: Chat::ChatableUserSerializer, embed: :objects
def users def users
users = object.direct_message_users.map(&:user).map { |u| u || Chat::DeletedUser.new } users = object.direct_message_users.map(&:user).map { |u| u || Chat::DeletedUser.new }

View File

@ -33,7 +33,6 @@ module Chat
model :direct_message, :fetch_or_create_direct_message model :direct_message, :fetch_or_create_direct_message
model :channel, :fetch_or_create_channel model :channel, :fetch_or_create_channel
step :update_memberships step :update_memberships
step :publish_channel
# @!visibility private # @!visibility private
class Contract class Contract
@ -68,7 +67,7 @@ module Chat
Chat::DirectMessageChannel.find_or_create_by(chatable: direct_message) Chat::DirectMessageChannel.find_or_create_by(chatable: direct_message)
end end
def update_memberships(guardian:, channel:, target_users:, **) def update_memberships(channel:, target_users:, **)
always_level = Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always] always_level = Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
memberships = memberships =
@ -77,7 +76,7 @@ module Chat
user_id: user.id, user_id: user.id,
chat_channel_id: channel.id, chat_channel_id: channel.id,
muted: false, muted: false,
following: true, following: false,
desktop_notification_level: always_level, desktop_notification_level: always_level,
mobile_notification_level: always_level, mobile_notification_level: always_level,
created_at: Time.zone.now, created_at: Time.zone.now,
@ -90,9 +89,5 @@ module Chat
unique_by: %i[user_id chat_channel_id], unique_by: %i[user_id chat_channel_id],
) )
end end
def publish_channel(channel:, target_users:, **)
Chat::Publisher.publish_new_channel(channel, target_users)
end
end end
end end

View File

@ -364,23 +364,26 @@ module Chat
NEW_CHANNEL_MESSAGE_BUS_CHANNEL = "/chat/new-channel" NEW_CHANNEL_MESSAGE_BUS_CHANNEL = "/chat/new-channel"
def self.publish_new_channel(chat_channel, users) def self.publish_new_channel(chat_channel, users)
users.each do |user| Chat::UserChatChannelMembership
# FIXME: This could generate a lot of queries depending on the amount of users .includes(:user)
membership = chat_channel.membership_for(user) .where(chat_channel: chat_channel, user: users)
.find_in_batches do |memberships|
memberships.each do |membership|
serialized_channel =
Chat::ChannelSerializer.new(
chat_channel,
scope: Guardian.new(membership.user), # We need a guardian here for direct messages
root: :channel,
membership: membership,
).as_json
# TODO: this event is problematic as some code will update the membership before calling it MessageBus.publish(
# and other code will update it after calling it NEW_CHANNEL_MESSAGE_BUS_CHANNEL,
# it means frontend must handle logic for both cases serialized_channel,
serialized_channel = user_ids: [membership.user.id],
Chat::ChannelSerializer.new( )
chat_channel, end
scope: Guardian.new(user), # We need a guardian here for direct messages end
root: :channel,
membership: membership,
).as_json
MessageBus.publish(NEW_CHANNEL_MESSAGE_BUS_CHANNEL, serialized_channel, user_ids: [user.id])
end
end end
def self.publish_inaccessible_mentions( def self.publish_inaccessible_mentions(

View File

@ -0,0 +1,109 @@
# frozen_string_literal: true
module Chat
# Returns a list of chatables (users, category channels, direct message channels) that can be chatted with.
#
# @example
# Chat::SearchChatable.call(term: "@bob", guardian: guardian)
#
class SearchChatable
include Service::Base
# @!method call(term:, guardian:)
# @param [String] term
# @param [Guardian] guardian
# @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
# @!visibility private
class Contract
attribute :term, default: ""
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)
end
def fetch_users(guardian:, **)
return unless guardian.can_create_direct_message?
return if context.mode == :channel
context.users = search_users(context.term, guardian)
end
def fetch_category_channels(guardian:, **)
return if context.mode == :user
context.category_channels =
Chat::ChannelFetcher.secured_public_channels(
guardian,
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
channels =
Chat::ChannelFetcher.secured_direct_message_channels_search(
guardian.user.id,
guardian,
limit: 10,
user_ids: user_ids,
) || []
if user_ids.present? && context.mode == :all
channels =
channels.reject do |channel|
channel_user_ids = channel.allowed_user_ids - [guardian.user.id]
channel.allowed_user_ids.length == 1 &&
user_ids.include?(channel.allowed_user_ids.first) ||
channel_user_ids.length == 1 && user_ids.include?(channel_user_ids.first)
end
end
context.direct_message_channels = channels
end
def search_users(term, guardian)
user_search = UserSearch.new(term, limit: 10)
if term.blank?
user_search.scoped_users.includes(:user_option)
else
user_search.search.includes(:user_option)
end
end
end
end

View File

@ -18,6 +18,8 @@ module Service
# Simple structure to hold the context of the service during its whole lifecycle. # Simple structure to hold the context of the service during its whole lifecycle.
class Context < OpenStruct class Context < OpenStruct
include ActiveModel::Serialization
# @return [Boolean] returns +true+ if the context is set as successful (default) # @return [Boolean] returns +true+ if the context is set as successful (default)
def success? def success?
!failure? !failure?

View File

@ -21,7 +21,6 @@ export default function () {
} }
); );
this.route("draft-channel", { path: "/draft-channel" });
this.route("browse", { path: "/browse" }, function () { this.route("browse", { path: "/browse" }, function () {
this.route("all", { path: "/all" }); this.route("all", { path: "/all" });
this.route("closed", { path: "/closed" }); this.route("closed", { path: "/closed" });

View File

@ -1,11 +1,10 @@
{{#if this.showMobileDirectMessageButton}} {{#if this.showMobileDirectMessageButton}}
<LinkTo <DButton
@route="chat.draft-channel" @icon="plus"
class="btn-flat open-draft-channel-page-btn keep-mobile-sidebar-open btn-floating" class="no-text btn-flat open-new-message-btn keep-mobile-sidebar-open btn-floating"
title={{i18n "chat.direct_messages.new"}} @action={{this.openNewMessageModal}}
> title={{i18n this.createDirectMessageChannelLabel}}
{{d-icon "plus"}} />
</LinkTo>
{{/if}} {{/if}}
<div <div
@ -95,13 +94,12 @@
(not this.showMobileDirectMessageButton) (not this.showMobileDirectMessageButton)
) )
}} }}
<LinkTo <DButton
@route="chat.draft-channel" @icon="plus"
class="btn no-text btn-flat open-draft-channel-page-btn" class="no-text btn-flat open-new-message-btn"
@action={{this.openNewMessageModal}}
title={{i18n this.createDirectMessageChannelLabel}} title={{i18n this.createDirectMessageChannelLabel}}
> />
{{d-icon "plus"}}
</LinkTo>
{{/if}} {{/if}}
</div> </div>
{{/if}} {{/if}}

View File

@ -4,6 +4,8 @@ import { action } from "@ember/object";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import ChatNewMessageModal from "discourse/plugins/chat/discourse/components/modal/chat-new-message";
export default class ChannelsList extends Component { export default class ChannelsList extends Component {
@service chat; @service chat;
@service router; @service router;
@ -12,6 +14,7 @@ export default class ChannelsList extends Component {
@service site; @service site;
@service session; @service session;
@service currentUser; @service currentUser;
@service modal;
@tracked hasScrollbar = false; @tracked hasScrollbar = false;
@ -25,6 +28,11 @@ export default class ChannelsList extends Component {
this.computeHasScrollbar(entries[0].target); this.computeHasScrollbar(entries[0].target);
} }
@action
openNewMessageModal() {
this.modal.show(ChatNewMessageModal);
}
get showMobileDirectMessageButton() { get showMobileDirectMessageButton() {
return this.site.mobileView && this.canCreateDirectMessageChannel; return this.site.mobileView && this.canCreateDirectMessageChannel;
} }

View File

@ -59,10 +59,10 @@
<span class="empty-state-title">{{i18n "chat.empty_state.title"}}</span> <span class="empty-state-title">{{i18n "chat.empty_state.title"}}</span>
<div class="empty-state-body"> <div class="empty-state-body">
<p>{{i18n "chat.empty_state.direct_message"}}</p> <p>{{i18n "chat.empty_state.direct_message"}}</p>
<DButton
<LinkTo @route={{concat "chat.draft-channel"}}> @action={{this.showChatNewMessageModal}}
{{i18n "chat.empty_state.direct_message_cta"}} label="chat.empty_state.direct_message_cta"
</LinkTo> />
</div> </div>
</div> </div>
{{else if this.channelsCollection.length}} {{else if this.channelsCollection.length}}

View File

@ -5,6 +5,7 @@ import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import ChatNewMessageModal from "discourse/plugins/chat/discourse/components/modal/chat-new-message";
const TABS = ["all", "open", "closed", "archived"]; const TABS = ["all", "open", "closed", "archived"];
@ -38,6 +39,11 @@ export default class ChatBrowseView extends Component {
return document.querySelector("#chat-progress-bar-container"); return document.querySelector("#chat-progress-bar-container");
} }
@action
showChatNewMessageModal() {
this.modal.show(ChatNewMessageModal);
}
@action @action
onScroll() { onScroll() {
discourseDebounce( discourseDebounce(

View File

@ -19,7 +19,7 @@ export default class ChatChannelMembersView extends Component {
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
if (!this.channel || this.channel.isDraft) { if (!this.channel) {
return; return;
} }

View File

@ -1,16 +0,0 @@
<div
class={{this.rowClassNames}}
role="button"
tabindex="0"
{{on "click" this.handleClick}}
data-id={{this.model.id}}
>
{{#if this.model.user}}
{{avatar this.model imageSize="tiny"}}
<span class="username">
{{this.model.username}}
</span>
{{else}}
<ChatChannelTitle @channel={{this.model}} />
{{/if}}
</div>

View File

@ -1,24 +0,0 @@
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
import { action } from "@ember/object";
export default Component.extend({
tagName: "",
isFocused: false,
@discourseComputed("model", "isFocused")
rowClassNames(model, isFocused) {
return `chat-channel-selection-row ${isFocused ? "focused" : ""} ${
this.model.user ? "user-row" : "channel-row"
}`;
},
@action
handleClick(event) {
if (this.onClick) {
this.onClick(this.model);
event.preventDefault();
}
},
});

View File

@ -1,33 +0,0 @@
<DModalBody @title="chat.channel_selector.title">
<div id="chat-channel-selector-modal-inner">
<div class="chat-channel-selector-input-container">
<span class="search-icon">
{{d-icon "search"}}
</span>
<Input
id="chat-channel-selector-input"
@type="text"
@value={{this.filter}}
autocomplete="off"
{{on "input" (action "search" value="target.value")}}
/>
</div>
<div class="channels">
<ConditionalLoadingSpinner @condition={{this.loading}}>
{{#each this.channels as |channel|}}
<ChatChannelSelectionRow
@isFocused={{eq channel this.focusedRow}}
@model={{channel}}
@onClick={{this.switchChannel}}
/>
{{else}}
<div class="no-channels-notice">
{{i18n "chat.channel_selector.no_channels"}}
</div>
{{/each}}
</ConditionalLoadingSpinner>
</div>
</div>
</DModalBody>

View File

@ -1,235 +0,0 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { bind } from "discourse-common/utils/decorators";
import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseDebounce from "discourse-common/lib/debounce";
import { INPUT_DELAY } from "discourse-common/config/environment";
import { isPresent } from "@ember/utils";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import User from "discourse/models/user";
export default Component.extend({
chat: service(),
tagName: "",
filter: "",
channels: null,
searchIndex: 0,
loading: false,
chatChannelsManager: service(),
router: service(),
focusedRow: null,
didInsertElement() {
this._super(...arguments);
this.appEvents.on("chat-channel-selector-modal:close", this.close);
document.addEventListener("keyup", this.onKeyUp);
document
.getElementById("chat-channel-selector-modal-inner")
?.addEventListener("mouseover", this.mouseover);
document.getElementById("chat-channel-selector-input")?.focus();
this.getInitialChannels();
},
willDestroyElement() {
this._super(...arguments);
this.appEvents.off("chat-channel-selector-modal:close", this.close);
document.removeEventListener("keyup", this.onKeyUp);
document
.getElementById("chat-channel-selector-modal-inner")
?.removeEventListener("mouseover", this.mouseover);
},
@bind
mouseover(e) {
if (e.target.classList.contains("chat-channel-selection-row")) {
let channel;
const id = parseInt(e.target.dataset.id, 10);
if (e.target.classList.contains("channel-row")) {
channel = this.channels.findBy("id", id);
} else {
channel = this.channels.find((c) => c.user && c.id === id);
}
if (channel) {
this.set("focusedRow", channel);
}
}
},
@bind
onKeyUp(e) {
if (e.key === "Enter") {
let focusedChannel = this.channels.find((c) => c === this.focusedRow);
this.switchChannel(focusedChannel);
e.preventDefault();
} else if (e.key === "ArrowDown") {
this.arrowNavigateChannels("down");
e.preventDefault();
} else if (e.key === "ArrowUp") {
this.arrowNavigateChannels("up");
e.preventDefault();
}
},
arrowNavigateChannels(direction) {
const indexOfFocused = this.channels.findIndex(
(c) => c === this.focusedRow
);
if (indexOfFocused > -1) {
const nextIndex = direction === "down" ? 1 : -1;
const nextChannel = this.channels[indexOfFocused + nextIndex];
if (nextChannel) {
this.set("focusedRow", nextChannel);
}
} else {
this.set("focusedRow", this.channels[0]);
}
schedule("afterRender", () => {
let focusedChannel = document.querySelector(
"#chat-channel-selector-modal-inner .chat-channel-selection-row.focused"
);
focusedChannel?.scrollIntoView({ block: "nearest", inline: "start" });
});
},
@action
switchChannel(channel) {
if (channel instanceof User) {
return this.fetchOrCreateChannelForUser(channel).then((response) => {
const newChannel = this.chatChannelsManager.store(response.channel);
return this.chatChannelsManager.follow(newChannel).then((c) => {
this.router.transitionTo("chat.channel", ...c.routeModels);
this.close();
});
});
} else {
return this.chatChannelsManager.follow(channel).then((c) => {
this.router.transitionTo("chat.channel", ...c.routeModels);
this.close();
});
}
},
@action
search(value) {
if (isPresent(value?.trim())) {
discourseDebounce(
this,
this.fetchChannelsFromServer,
value?.trim(),
INPUT_DELAY
);
} else {
discourseDebounce(this, this.getInitialChannels, INPUT_DELAY);
}
},
@action
fetchChannelsFromServer(filter) {
if (this.isDestroyed || this.isDestroying) {
return;
}
this.setProperties({
loading: true,
searchIndex: this.searchIndex + 1,
});
const thisSearchIndex = this.searchIndex;
ajax("/chat/api/chatables", { data: { filter } })
.then((searchModel) => {
if (this.searchIndex === thisSearchIndex) {
this.set("searchModel", searchModel);
let channels = searchModel.public_channels
.concat(searchModel.direct_message_channels, searchModel.users)
.map((c) => {
if (
c.chatable_type === "DirectMessage" ||
c.chatable_type === "Category"
) {
return ChatChannel.create(c);
}
return User.create(c);
});
this.setProperties({
channels,
loading: false,
});
this.focusFirstChannel(this.channels);
}
})
.catch(popupAjaxError);
},
@action
getInitialChannels() {
if (this.isDestroyed || this.isDestroying) {
return;
}
const channels = this.getChannelsWithFilter(this.filter);
this.set("channels", channels);
this.focusFirstChannel(channels);
},
@action
fetchOrCreateChannelForUser(user) {
return ajax("/chat/api/direct-message-channels.json", {
method: "POST",
data: { target_usernames: [user.username] },
}).catch(popupAjaxError);
},
focusFirstChannel(channels) {
if (channels[0]) {
this.set("focusedRow", channels[0]);
} else {
this.set("focusedRow", null);
}
},
getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) {
let sortedChannels = this.chatChannelsManager.channels.sort((a, b) => {
return new Date(a.lastMessageSentAt) > new Date(b.lastMessageSentAt)
? -1
: 1;
});
const trimmedFilter = filter.trim();
const lowerCasedFilter = filter.toLowerCase();
return sortedChannels.filter((channel) => {
if (
opts.excludeActiveChannel &&
this.chat.activeChannel?.id === channel.id
) {
return false;
}
if (!trimmedFilter.length) {
return true;
}
if (channel.isDirectMessageChannel) {
let userFound = false;
channel.chatable.users.forEach((user) => {
if (
user.username.toLowerCase().includes(lowerCasedFilter) ||
user.name?.toLowerCase().includes(lowerCasedFilter)
) {
return (userFound = true);
}
});
return userFound;
} else {
return channel.title.toLowerCase().includes(lowerCasedFilter);
}
});
},
});

View File

@ -1,14 +1,6 @@
{{#if @channel.isDraft}} {{#if @channel.isDirectMessageChannel}}
<div class="chat-channel-title is-draft"> <div class="chat-channel-title is-dm">
<span class="chat-channel-title__name">{{@channel.title}}</span> {{#if @channel.chatable.users.length}}
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
{{else}}
{{#if @channel.isDirectMessageChannel}}
<div class="chat-channel-title is-dm">
<div class="chat-channel-title__avatar"> <div class="chat-channel-title__avatar">
{{#if this.multiDm}} {{#if this.multiDm}}
<span class="chat-channel-title__users-count"> <span class="chat-channel-title__users-count">
@ -18,9 +10,11 @@
<ChatUserAvatar @user={{@channel.chatable.users.firstObject}} /> <ChatUserAvatar @user={{@channel.chatable.users.firstObject}} />
{{/if}} {{/if}}
</div> </div>
{{/if}}
<div class="chat-channel-title__user-info"> <div class="chat-channel-title__user-info">
<div class="chat-channel-title__usernames"> <div class="chat-channel-title__usernames">
{{#if @channel.chatable.users.length}}
{{#if this.multiDm}} {{#if this.multiDm}}
<span class="chat-channel-title__name">{{this.usernames}}</span> <span class="chat-channel-title__name">{{this.usernames}}</span>
{{else}} {{else}}
@ -41,31 +35,33 @@
/> />
{{/let}} {{/let}}
{{/if}} {{/if}}
</div> {{else}}
</div> <span class="chat-channel-title__name">Add users</span>
{{#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}} {{/if}}
</span> </div>
<span class="chat-channel-title__name">
{{replace-emoji @channel.title}}
</span>
{{#if (has-block)}}
{{yield}}
{{/if}}
</div> </div>
{{/if}}
{{#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}} {{/if}}

View File

@ -74,14 +74,14 @@
@pane={{this.pane}} @pane={{this.pane}}
/> />
{{else}} {{else}}
{{#if (or @channel.isDraft @channel.isFollowing)}} {{#if (and (not @channel.isFollowing) @channel.isCategoryChannel)}}
<ChatChannelPreviewCard @channel={{@channel}} />
{{else}}
<Chat::Composer::Channel <Chat::Composer::Channel
@channel={{@channel}} @channel={{@channel}}
@uploadDropZone={{this.uploadDropZone}} @uploadDropZone={{this.uploadDropZone}}
@onSendMessage={{this.onSendMessage}} @onSendMessage={{this.onSendMessage}}
/> />
{{else}}
<ChatChannelPreviewCard @channel={{@channel}} />
{{/if}} {{/if}}
{{/if}} {{/if}}

View File

@ -6,7 +6,6 @@ import { action } from "@ember/object";
// TODO (martin) Remove this when the handleSentMessage logic inside chatChannelPaneSubscriptionsManager // TODO (martin) Remove this when the handleSentMessage logic inside chatChannelPaneSubscriptionsManager
// is moved over from this file completely. // is moved over from this file completely.
import { handleStagedMessage } from "discourse/plugins/chat/discourse/services/chat-pane-base-subscriptions-manager"; import { handleStagedMessage } from "discourse/plugins/chat/discourse/services/chat-pane-base-subscriptions-manager";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { cancel, later, next, schedule } from "@ember/runloop"; import { cancel, later, next, schedule } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
@ -736,33 +735,6 @@ export default class ChatLivePane extends Component {
resetIdle(); resetIdle();
// TODO: all send message logic is due for massive refactoring
// This is all the possible case Im currently aware of
// - messaging to a public channel where you are not a member yet (preview = true)
// - messaging to an existing direct channel you were not tracking yet through dm creator (channel draft)
// - messaging to a new direct channel through DM creator (channel draft)
// - message to a direct channel you were tracking (preview = false, not draft)
// - message to a public channel you were tracking (preview = false, not draft)
// - message to a channel when we haven't loaded all future messages yet.
if (!this.args.channel.isFollowing || this.args.channel.isDraft) {
const data = {
message: message.message,
upload_ids: message.uploads.map((upload) => upload.id),
};
this.resetComposerMessage();
return this._upsertChannelWithMessage(this.args.channel, data).finally(
() => {
if (this._selfDeleted) {
return;
}
this.pane.sending = false;
this.scrollToLatestMessage();
}
);
}
await this.args.channel.stageMessage(message); await this.args.channel.stageMessage(message);
this.resetComposerMessage(); this.resetComposerMessage();
@ -790,26 +762,6 @@ export default class ChatLivePane extends Component {
} }
} }
async _upsertChannelWithMessage(channel, data) {
let promise = Promise.resolve(channel);
if (channel.isDirectMessageChannel || channel.isDraft) {
promise = this.chat.upsertDmChannelForUsernames(
channel.chatable.users.mapBy("username")
);
}
return promise.then((c) =>
ajax(`/chat/${c.id}.json`, {
type: "POST",
data,
}).then(() => {
this.pane.sending = false;
this.router.transitionTo("chat.channel", "-", c.id);
})
);
}
_onSendError(id, error) { _onSendError(id, error) {
const stagedMessage = this.args.channel.findStagedMessage(id); const stagedMessage = this.args.channel.findStagedMessage(id);
if (stagedMessage) { if (stagedMessage) {
@ -977,14 +929,9 @@ export default class ChatLivePane extends Component {
return; return;
} }
if (!this.args.channel.isDraft) {
event.preventDefault();
this.composer.focus({ addText: event.key });
return;
}
event.preventDefault(); event.preventDefault();
event.stopPropagation(); this.composer.focus({ addText: event.key });
return;
} }
@action @action

View File

@ -65,6 +65,7 @@
{{on "focusin" (fn this.computeIsFocused true)}} {{on "focusin" (fn this.computeIsFocused true)}}
{{on "focusout" (fn this.computeIsFocused false)}} {{on "focusout" (fn this.computeIsFocused false)}}
{{did-insert this.setupAutocomplete}} {{did-insert this.setupAutocomplete}}
{{did-insert this.composer.focus}}
data-chat-composer-context={{this.context}} data-chat-composer-context={{this.context}}
/> />
</div> </div>

View File

@ -45,7 +45,7 @@ export default class ChatComposer extends Component {
@tracked presenceChannelName; @tracked presenceChannelName;
get shouldRenderReplyingIndicator() { get shouldRenderReplyingIndicator() {
return !this.args.channel?.isDraft; return this.args.channel;
} }
get shouldRenderMessageDetails() { get shouldRenderMessageDetails() {
@ -89,7 +89,7 @@ export default class ChatComposer extends Component {
setupTextareaInteractor(textarea) { setupTextareaInteractor(textarea) {
this.composer.textarea = new TextareaInteractor(getOwner(this), textarea); this.composer.textarea = new TextareaInteractor(getOwner(this), textarea);
if (this.site.desktopView) { if (this.site.desktopView && this.args.autofocus) {
this.composer.focus({ ensureAtEnd: true, refreshHeight: true }); this.composer.focus({ ensureAtEnd: true, refreshHeight: true });
} }
} }
@ -250,10 +250,6 @@ export default class ChatComposer extends Component {
return; return;
} }
if (this.args.channel.isDraft) {
return;
}
this.chatComposerPresenceManager.notifyState( this.chatComposerPresenceManager.notifyState(
this.presenceChannelName, this.presenceChannelName,
!this.currentMessage.editing && this.hasContent !this.currentMessage.editing && this.hasContent

View File

@ -1,28 +0,0 @@
<div class="chat-draft">
{{#if this.site.mobileView}}
<header
class="chat-draft-header"
{{did-insert this.setChatDraftHeaderHeight}}
{{will-destroy this.unsetChatDraftHeaderHeight}}
>
<FlatButton
@class="chat-draft-header__btn btn"
@icon="chevron-left"
@title="chat.draft_channel_screen.cancel"
@action={{action "onCancelChatDraft"}}
/>
<h2 class="chat-draft-header__title">
{{d-icon "d-chat"}}
{{i18n "chat.draft_channel_screen.header"}}
</h2>
</header>
{{/if}}
<DirectMessageCreator
@onChangeSelectedUsers={{action "onChangeSelectedUsers"}}
/>
{{#if this.previewedChannel}}
<ChatChannel @channel={{this.previewedChannel}} @includeHeader={{false}} />
{{/if}}
</div>

View File

@ -1,64 +0,0 @@
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import { inject as service } from "@ember/service";
import Component from "@ember/component";
import { action } from "@ember/object";
import { cloneJSON } from "discourse-common/lib/object";
export default class ChatDraftChannelScreen extends Component {
@service chat;
@service router;
tagName = "";
@action
onCancelChatDraft() {
return this.router.transitionTo("chat.index");
}
@action
setChatDraftHeaderHeight(element) {
document.documentElement.style.setProperty(
"--chat-draft-header-height",
`${element.clientHeight}px`
);
}
@action
unsetChatDraftHeaderHeight() {
document.documentElement.style.setProperty(
"--chat-draft-header-height",
"0px"
);
}
@action
onChangeSelectedUsers(users) {
this._fetchPreviewedChannel(users);
}
@action
onSwitchFromDraftChannel(channel) {
channel.isDraft = false;
}
_fetchPreviewedChannel(users) {
this.set("previewedChannel", null);
return this.chat
.getDmChannelForUsernames(users.mapBy("username"))
.then((response) => {
const channel = ChatChannel.create(response.channel);
channel.isDraft = true;
this.set("previewedChannel", channel);
})
.catch((error) => {
if (error?.jqXHR?.status === 404) {
this.set(
"previewedChannel",
ChatChannel.createDirectMessageChannelDraft({
users: cloneJSON(users),
})
);
}
});
}
}

View File

@ -1,11 +0,0 @@
<ChatDrawer::Header @toggleExpand={{@drawerActions.toggleExpand}}>
<ChatDrawer::Header::LeftActions />
<ChatDrawer::Header::Title @title="chat.direct_message_creator.title" />
<ChatDrawer::Header::RightActions @drawerActions={{@drawerActions}} />
</ChatDrawer::Header>
{{#if this.chatStateManager.isDrawerExpanded}}
<div class="chat-drawer-content">
<ChatDraftChannelScreen />
</div>
{{/if}}

View File

@ -1,6 +0,0 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class ChatDrawerDraftChannel extends Component {
@service chatStateManager;
}

View File

@ -1,8 +1,4 @@
{{#if {{#if (and this.chatStateManager.isFullPageActive this.displayed)}}
(and
this.chatStateManager.isFullPageActive this.displayed (not @channel.isDraft)
)
}}
<div <div
class={{concat-class class={{concat-class
"chat-full-page-header" "chat-full-page-header"

View File

@ -9,11 +9,10 @@ export default class ChatRetentionReminder extends Component {
get show() { get show() {
return ( return (
!this.args.channel?.isDraft && (this.args.channel?.isDirectMessageChannel &&
((this.args.channel?.isDirectMessageChannel &&
this.currentUser?.get("needs_dm_retention_reminder")) || this.currentUser?.get("needs_dm_retention_reminder")) ||
(this.args.channel?.isCategoryChannel && (this.args.channel?.isCategoryChannel &&
this.currentUser?.get("needs_channel_retention_reminder"))) this.currentUser?.get("needs_channel_retention_reminder"))
); );
} }

View File

@ -1,12 +1,11 @@
<div <div
class="chat-user-avatar class="chat-user-avatar {{if (and this.isOnline @showPresence) 'is-online'}}"
{{if (and this.isOnline this.showPresence) 'is-online'}}"
> >
<div <div
role="button" role="button"
class="chat-user-avatar-container clickable" class="chat-user-avatar-container clickable"
data-user-card={{this.user.username}} data-user-card={{@user.username}}
> >
{{avatar this.user imageSize=this.avatarSize}} {{avatar @user imageSize=this.avatarSize}}
</div> </div>
</div> </div>

View File

@ -1,23 +1,19 @@
import Component from "@ember/component"; import Component from "@glimmer/component";
import { computed } from "@ember/object";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
export default class ChatUserAvatar extends Component { export default class ChatUserAvatar extends Component {
@service chat; @service chat;
tagName = "";
user = null; get avatarSize() {
return this.args.avatarSize || "tiny";
}
avatarSize = "tiny";
showPresence = true;
@computed("chat.presenceChannel.users.[]", "user.{id,username}")
get isOnline() { get isOnline() {
const users = this.chat.presenceChannel?.users; const users = (this.args.chat || this.chat).presenceChannel?.users;
return ( return (
!!users?.findBy("id", this.user?.id) || !!users?.findBy("id", this.args.user?.id) ||
!!users?.findBy("username", this.user?.username) !!users?.findBy("username", this.args.user?.username)
); );
} }
} }

View File

@ -1,15 +1,20 @@
<span class="chat-user-display-name"> <span class="chat-user-display-name">
{{#if this.shouldShowNameFirst}} {{#if this.shouldShowNameFirst}}
<span class="chat-user-display-name__name">{{this.user.name}}</span> <span class="chat-user-display-name__name -first">{{@user.name}}</span>
<span class="separator">—</span> <span class="separator">—</span>
{{/if}} {{/if}}
<span class="chat-user-display-name__username"> <span
class={{concat-class
"chat-user-display-name__username"
(unless this.shouldShowNameFirst "-first")
}}
>
{{this.formattedUsername}} {{this.formattedUsername}}
</span> </span>
{{#if this.shouldShowNameLast}} {{#if this.shouldShowNameLast}}
<span class="separator">—</span> <span class="separator">—</span>
<span class="chat-user-display-name__name">{{this.user.name}}</span> <span class="chat-user-display-name__name">{{@user.name}}</span>
{{/if}} {{/if}}
</span> </span>

View File

@ -1,32 +1,26 @@
import Component from "@ember/component"; import Component from "@glimmer/component";
import { computed } from "@ember/object";
import { formatUsername } from "discourse/lib/utilities"; import { formatUsername } from "discourse/lib/utilities";
import { inject as service } from "@ember/service";
export default class ChatUserDisplayName extends Component { export default class ChatUserDisplayName extends Component {
tagName = ""; @service siteSettings;
user = null;
@computed
get shouldPrioritizeNameInUx() { get shouldPrioritizeNameInUx() {
return !this.siteSettings.prioritize_username_in_ux; return !this.siteSettings.prioritize_username_in_ux;
} }
@computed("user.name")
get hasValidName() { get hasValidName() {
return this.user?.name && this.user?.name.trim().length > 0; return this.args.user?.name && this.args.user.name.trim().length > 0;
} }
@computed("user.username")
get formattedUsername() { get formattedUsername() {
return formatUsername(this.user?.username); return formatUsername(this.args.user?.username);
} }
@computed("shouldPrioritizeNameInUx", "hasValidName")
get shouldShowNameFirst() { get shouldShowNameFirst() {
return this.shouldPrioritizeNameInUx && this.hasValidName; return this.shouldPrioritizeNameInUx && this.hasValidName;
} }
@computed("shouldPrioritizeNameInUx", "hasValidName")
get shouldShowNameLast() { get shouldShowNameLast() {
return !this.shouldPrioritizeNameInUx && this.hasValidName; return !this.shouldPrioritizeNameInUx && this.hasValidName;
} }

View File

@ -3,7 +3,6 @@ import { inject as service } from "@ember/service";
import I18n from "I18n"; import I18n from "I18n";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { isEmpty } from "@ember/utils";
export default class ChatComposerChannel extends ChatComposer { export default class ChatComposerChannel extends ChatComposer {
@service("chat-channel-composer") composer; @service("chat-channel-composer") composer;
@ -22,8 +21,6 @@ export default class ChatComposerChannel extends ChatComposer {
get disabled() { get disabled() {
return ( return (
(this.args.channel.isDraft &&
isEmpty(this.args.channel?.chatable?.users)) ||
!this.chat.userCanInteractWithChat || !this.chat.userCanInteractWithChat ||
!this.args.channel.canModifyMessages(this.currentUser) !this.args.channel.canModifyMessages(this.currentUser)
); );
@ -36,10 +33,6 @@ export default class ChatComposerChannel extends ChatComposer {
@action @action
persistDraft() { persistDraft() {
if (this.args.channel?.isDraft) {
return;
}
this.chatDraftsManager.add(this.currentMessage); this.chatDraftsManager.add(this.currentMessage);
this._persistHandler = discourseDebounce( this._persistHandler = discourseDebounce(
@ -75,18 +68,6 @@ export default class ChatComposerChannel extends ChatComposer {
); );
} }
if (this.args.channel.isDraft) {
if (this.args.channel?.chatable?.users?.length) {
return I18n.t("chat.placeholder_start_conversation_users", {
commaSeparatedUsernames: this.args.channel.chatable.users
.mapBy("username")
.join(I18n.t("word_connector.comma")),
});
} else {
return I18n.t("chat.placeholder_start_conversation");
}
}
if (!this.chat.userCanInteractWithChat) { if (!this.chat.userCanInteractWithChat) {
return I18n.t("chat.placeholder_silenced"); return I18n.t("chat.placeholder_silenced");
} else { } else {

View File

@ -0,0 +1,141 @@
<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

@ -0,0 +1,522 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { schedule } from "@ember/runloop";
import discourseDebounce from "discourse-common/lib/debounce";
import { getOwner, setOwner } from "@ember/application";
import { INPUT_DELAY } from "discourse-common/config/environment";
import I18n from "I18n";
import ChatChatable from "discourse/plugins/chat/discourse/models/chat-chatable";
import { escapeExpression } from "discourse/lib/utilities";
import { htmlSafe } from "@ember/template";
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) => {
if (channel.chatable?.users?.length === 1) {
return ChatChatable.createUser(channel.chatable.users[0]);
}
const 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;
@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.hasSelectedUsers) {
return I18n.t("chat.new_message_modal.user_search_placeholder");
} else {
return I18n.t("chat.new_message_modal.default_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);
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) => {
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,20 @@
<ChatChannelTitle @channel={{@content.model}} />
{{#if (gt @content.tracking.unreadCount 0)}}
<div
class={{concat-class
"unread-indicator"
(if
(or
@content.model.isDirectMessageChannel
(gt @content.model.tracking.mentionCount 0)
)
"-urgent"
)
}}
></div>
{{/if}}
{{#if this.site.desktopView}}
<span class="action-indicator">{{this.openChannelLabel}}</span>
{{/if}}

View File

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

View File

@ -0,0 +1,36 @@
<ChatUserAvatar @user={{@content.model}} @showPresence={{true}} />
<ChatUserDisplayName @user={{@content.model}} />
{{#if (gt @content.tracking.unreadCount 0)}}
<div class="unread-indicator"></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

@ -0,0 +1,17 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import I18n from "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

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

View File

@ -1,96 +0,0 @@
{{#if this.chatProgressBarContainer}}
{{#in-element this.chatProgressBarContainer}}
<DProgressBar @key="dm-recipients-loader" @isLoading={{this.isFiltering}} />
{{/in-element}}
{{/if}}
{{#if (and this.channel.isDraft (not this.isLoading))}}
<div
class="direct-message-creator"
{{did-insert this.setDirectMessageCreatorHeight}}
{{will-destroy this.unsetDirectMessageCreatorHeight}}
{{did-update this.setDirectMessageCreatorHeight this.selectedUsers}}
{{did-update this.setDirectMessageCreatorHeight this.users}}
>
<div
class="filter-area {{if this.isFilterFocused 'is-focused'}}"
role="button"
{{on "click" this.focusFilter}}
>
<span class="prefix">
{{i18n "chat.direct_message_creator.prefix"}}
</span>
<div class="recipients">
{{#each this.selectedUsers as |selectedUser|}}
<DButton
@class={{concat
"selected-user"
(if
(eq this.highlightedSelectedUser selectedUser) " is-highlighted"
)
}}
@action={{action "deselectUser" selectedUser}}
@translatedTitle={{i18n
"chat.direct_message_creator.selected_user_title"
username=selectedUser.username
}}
>
<ChatUserAvatar @user={{selectedUser}} />
<span class="username">{{selectedUser.username}}</span>
{{d-icon "times"}}
</DButton>
{{/each}}
<Input
class="filter-usernames"
@value={{this.term}}
autofocus="autofocus"
{{on "input" (action "onFilterInput" value="target.value")}}
{{on "focusin" (action (mut this.isFilterFocused) true)}}
{{on "focusout" (action "onFilterInputFocusOut")}}
{{on "keyup" (action "handleFilterKeyUp")}}
/>
</div>
</div>
{{#if this.shouldRenderResults}}
{{#if this.users}}
<div class="results-container">
<ul class="results">
{{#each this.users as |user|}}
<li
class="user {{if (eq this.focusedUser user) 'is-focused'}}"
data-username={{user.username}}
role="button"
tabindex="-1"
{{on "click" (action "selectUser" user)}}
{{on "mouseenter" (action (mut this.focusedUser) user)}}
{{on "focus" (action (mut this.focusedUser) user)}}
{{on "keyup" (action "handleUserKeyUp" user)}}
>
<ChatUserAvatar @user={{user}} @avatarSize="medium" />
<UserInfo
@user={{user}}
@includeLink={{false}}
@includeAvatar={{false}}
@showStatus={{true}}
@showStatusDescription={{true}}
/>
</li>
{{/each}}
</ul>
</div>
{{else}}
{{#if this.term.length}}
<div class="no-results-container">
<p class="no-results">
{{i18n "chat.direct_message_creator.no_results"}}
</p>
</div>
{{/if}}
{{/if}}
{{/if}}
</div>
{{/if}}

View File

@ -1,331 +0,0 @@
import { caretPosition } from "discourse/lib/utilities";
import { isEmpty } from "@ember/utils";
import Component from "@ember/component";
import { action } from "@ember/object";
import discourseDebounce from "discourse-common/lib/debounce";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
import { INPUT_DELAY } from "discourse-common/config/environment";
import { inject as service } from "@ember/service";
import { schedule } from "@ember/runloop";
import { gt, not } from "@ember/object/computed";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
export default Component.extend({
tagName: "",
users: null,
selectedUsers: null,
term: null,
isFiltering: false,
isFilterFocused: false,
highlightedSelectedUser: null,
focusedUser: null,
chat: service(),
router: service(),
chatStateManager: service(),
isLoading: false,
init() {
this._super(...arguments);
this.set("users", []);
this.set("selectedUsers", []);
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
},
didInsertElement() {
this._super(...arguments);
this.filterUsernames();
},
didReceiveAttrs() {
this._super(...arguments);
this.set("term", null);
this.focusFilter();
if (!this.hasSelection) {
this.filterUsernames();
}
},
hasSelection: gt("channel.chatable.users.length", 0),
@discourseComputed
chatProgressBarContainer() {
return document.querySelector("#chat-progress-bar-container");
},
@bind
filterUsernames(term = null) {
this.set("isFiltering", true);
this.chat
.searchPossibleDirectMessageUsers({
term,
limit: 6,
exclude: this.channel.chatable?.users?.mapBy("username") || [],
lastSeenUsers: isEmpty(term) ? true : false,
})
.then((r) => {
if (this.isDestroying || this.isDestroyed) {
return;
}
if (r !== "__CANCELLED") {
this.set("users", r.users || []);
this.set("focusedUser", this.users.firstObject);
}
})
.finally(() => {
if (this.isDestroying || this.isDestroyed) {
return;
}
this.set("isFiltering", false);
});
},
shouldRenderResults: not("isFiltering"),
@action
selectUser(user) {
this.selectedUsers.pushObject(user);
this.users.removeObject(user);
this.set("users", []);
this.set("focusedUser", null);
this.set("highlightedSelectedUser", null);
this.set("term", null);
this.focusFilter();
this.onChangeSelectedUsers?.(this.selectedUsers);
},
@action
deselectUser(user) {
this.users.removeObject(user);
this.selectedUsers.removeObject(user);
this.set("focusedUser", this.users.firstObject);
this.set("highlightedSelectedUser", null);
this.set("term", null);
if (isEmpty(this.selectedUsers)) {
this.filterUsernames();
}
this.focusFilter();
this.onChangeSelectedUsers?.(this.selectedUsers);
},
@action
focusFilter() {
this.set("isFilterFocused", true);
schedule("afterRender", () => {
document.querySelector(".filter-usernames")?.focus();
});
},
@action
setDirectMessageCreatorHeight(element) {
document.documentElement.style.setProperty(
"--chat-direct-message-creator-height",
`${element.clientHeight}px`
);
},
@action
unsetDirectMessageCreatorHeight() {
document.documentElement.style.setProperty(
"--chat-direct-message-creator-height",
"0px"
);
},
@action
onFilterInput(term) {
this.set("term", term);
this.set("users", []);
if (!term?.length) {
return;
}
this.set("isFiltering", true);
discourseDebounce(this, this.filterUsernames, term, INPUT_DELAY);
},
@action
handleUserKeyUp(user, event) {
if (event.key === "Enter") {
event.stopPropagation();
event.preventDefault();
this.selectUser(user);
}
},
@action
onFilterInputFocusOut() {
this.set("isFilterFocused", false);
this.set("highlightedSelectedUser", null);
},
@action
leaveChannel() {
this.router.transitionTo("chat.index");
},
@action
handleFilterKeyUp(event) {
if (event.key === "Tab") {
const enabledComposer = document.querySelector(".chat-composer__input");
if (enabledComposer && !enabledComposer.disabled) {
event.preventDefault();
event.stopPropagation();
enabledComposer.focus();
}
}
if (
(event.key === "Enter" || event.key === "Backspace") &&
this.highlightedSelectedUser
) {
event.preventDefault();
event.stopPropagation();
this.deselectUser(this.highlightedSelectedUser);
return;
}
if (event.key === "Backspace" && isEmpty(this.term) && this.hasSelection) {
event.preventDefault();
event.stopPropagation();
this.deselectUser(this.channel.chatable.users.lastObject);
}
if (event.key === "Enter" && this.focusedUser) {
event.preventDefault();
event.stopPropagation();
this.selectUser(this.focusedUser);
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
this._handleVerticalArrowKeys(event);
}
if (event.key === "Escape" && this.highlightedSelectedUser) {
this.set("highlightedSelectedUser", null);
}
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
this._handleHorizontalArrowKeys(event);
}
},
_firstSelectWithArrows(event) {
if (event.key === "ArrowRight") {
return;
}
if (event.key === "ArrowLeft") {
const position = caretPosition(
document.querySelector(".filter-usernames")
);
if (position > 0) {
return;
} else {
event.preventDefault();
event.stopPropagation();
this.set(
"highlightedSelectedUser",
this.channel.chatable.users.lastObject
);
}
}
},
_changeSelectionWithArrows(event) {
if (event.key === "ArrowRight") {
if (
this.highlightedSelectedUser === this.channel.chatable.users.lastObject
) {
this.set("highlightedSelectedUser", null);
return;
}
if (this.channel.chatable.users.length === 1) {
return;
}
this._highlightNextSelectedUser(event.key === "ArrowLeft" ? -1 : 1);
}
if (event.key === "ArrowLeft") {
if (this.channel.chatable.users.length === 1) {
return;
}
this._highlightNextSelectedUser(event.key === "ArrowLeft" ? -1 : 1);
}
},
_highlightNextSelectedUser(modifier) {
const newIndex =
this.channel.chatable.users.indexOf(this.highlightedSelectedUser) +
modifier;
if (this.channel.chatable.users.objectAt(newIndex)) {
this.set(
"highlightedSelectedUser",
this.channel.chatable.users.objectAt(newIndex)
);
} else {
this.set(
"highlightedSelectedUser",
event.key === "ArrowLeft"
? this.channel.chatable.users.lastObject
: this.channel.chatable.users.firstObject
);
}
},
_handleHorizontalArrowKeys(event) {
const position = caretPosition(document.querySelector(".filter-usernames"));
if (position > 0) {
return;
}
if (!this.highlightedSelectedUser) {
this._firstSelectWithArrows(event);
} else {
this._changeSelectionWithArrows(event);
}
},
_handleVerticalArrowKeys(event) {
if (isEmpty(this.users)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (!this.focusedUser) {
this.set("focusedUser", this.users.firstObject);
return;
}
const modifier = event.key === "ArrowUp" ? -1 : 1;
const newIndex = this.users.indexOf(this.focusedUser) + modifier;
if (this.users.objectAt(newIndex)) {
this.set("focusedUser", this.users.objectAt(newIndex));
} else {
this.set(
"focusedUser",
event.key === "ArrowUp" ? this.users.lastObject : this.users.firstObject
);
}
},
});

View File

@ -0,0 +1,7 @@
<DModal
@closeModal={{@closeModal}}
class="chat-new-message-modal"
@title="chat.new_message_modal.title"
>
<Chat::MessageCreator @onClose={{route-action "closeModal"}} />
</DModal>

View File

@ -0,0 +1,6 @@
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default class ChatNewMessageModal extends Component {
@service chat;
}

View File

@ -1,5 +1,5 @@
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import showModal from "discourse/lib/show-modal"; import ChatNewMessageModal from "discourse/plugins/chat/discourse/components/modal/chat-new-message";
const APPLE = const APPLE =
navigator.platform.startsWith("Mac") || navigator.platform === "iPhone"; navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
@ -16,6 +16,7 @@ export default {
const router = container.lookup("service:router"); const router = container.lookup("service:router");
const appEvents = container.lookup("service:app-events"); const appEvents = container.lookup("service:app-events");
const modal = container.lookup("service:modal");
const chatStateManager = container.lookup("service:chat-state-manager"); const chatStateManager = container.lookup("service:chat-state-manager");
const chatThreadPane = container.lookup("service:chat-thread-pane"); const chatThreadPane = container.lookup("service:chat-thread-pane");
const chatThreadListPane = container.lookup( const chatThreadListPane = container.lookup(
@ -27,11 +28,7 @@ export default {
const openChannelSelector = (e) => { const openChannelSelector = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (document.getElementById("chat-channel-selector-modal-inner")) { modal.show(ChatNewMessageModal);
appEvents.trigger("chat-channel-selector-modal:close");
} else {
showModal("chat-channel-selector-modal");
}
}; };
const handleMoveUpShortcut = (e) => { const handleMoveUpShortcut = (e) => {

View File

@ -9,6 +9,7 @@ import { emojiUnescape } from "discourse/lib/text";
import { decorateUsername } from "discourse/helpers/decorate-username-selector"; import { decorateUsername } from "discourse/helpers/decorate-username-selector";
import { until } from "discourse/lib/formatter"; import { until } from "discourse/lib/formatter";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import ChatNewMessageModal from "discourse/plugins/chat/discourse/components/modal/chat-new-message";
export default { export default {
name: "chat-sidebar", name: "chat-sidebar",
@ -329,6 +330,7 @@ export default {
const SidebarChatDirectMessagesSection = class extends BaseCustomSidebarSection { const SidebarChatDirectMessagesSection = class extends BaseCustomSidebarSection {
@service site; @service site;
@service modal;
@service router; @service router;
@tracked userCanDirectMessage = @tracked userCanDirectMessage =
this.chatService.userCanDirectMessage; this.chatService.userCanDirectMessage;
@ -377,7 +379,7 @@ export default {
id: "startDm", id: "startDm",
title: I18n.t("chat.direct_messages.new"), title: I18n.t("chat.direct_messages.new"),
action: () => { action: () => {
this.router.transitionTo("chat.draft-channel"); this.modal.show(ChatNewMessageModal);
}, },
}, },
]; ];

View File

@ -56,19 +56,7 @@ export default class ChatChannel {
return new ChatChannel(args); return new ChatChannel(args);
} }
static createDirectMessageChannelDraft(args = {}) {
const channel = ChatChannel.create({
chatable_type: CHATABLE_TYPES.directMessageChannel,
chatable: {
users: args.users || [],
},
});
channel.isDraft = true;
return channel;
}
@tracked currentUserMembership = null; @tracked currentUserMembership = null;
@tracked isDraft = false;
@tracked title; @tracked title;
@tracked slug; @tracked slug;
@tracked description; @tracked description;

View File

@ -0,0 +1,72 @@
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import User from "discourse/models/user";
import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service";
export default class ChatChatable {
static create(args = {}) {
return new ChatChatable(args);
}
static createUser(model) {
return new ChatChatable({
type: "user",
model,
identifier: `u-${model.id}`,
});
}
static createChannel(model) {
return new ChatChatable({
type: "channel",
model,
identifier: `c-${model.id}`,
});
}
@service chatChannelsManager;
@tracked identifier;
@tracked type;
@tracked model;
@tracked enabled = true;
@tracked tracking;
constructor(args = {}) {
this.identifier = args.identifier;
this.type = args.type;
switch (this.type) {
case "channel":
if (args.model.chatable?.users?.length === 1) {
this.enabled = args.model.chatable?.users[0].has_chat_enabled;
}
if (args.model instanceof ChatChannel) {
this.model = args.model;
break;
}
this.model = ChatChannel.create(args.model);
break;
case "user":
this.enabled = args.model.has_chat_enabled;
if (args.model instanceof User) {
this.model = args.model;
break;
}
this.model = User.create(args.model);
break;
}
}
get isUser() {
return this.type === "user";
}
get isSingleUserChannel() {
return this.type === "channel" && this.model?.chatable?.users?.length === 1;
}
}

View File

@ -12,6 +12,7 @@ export default class UserChatChannelMembership {
@tracked mobileNotificationLevel = null; @tracked mobileNotificationLevel = null;
@tracked lastReadMessageId = null; @tracked lastReadMessageId = null;
@tracked user = null; @tracked user = null;
@tracked lastViewedAt = null;
constructor(args = {}) { constructor(args = {}) {
this.following = args.following; this.following = args.following;
@ -19,6 +20,7 @@ export default class UserChatChannelMembership {
this.desktopNotificationLevel = args.desktop_notification_level; this.desktopNotificationLevel = args.desktop_notification_level;
this.mobileNotificationLevel = args.mobile_notification_level; this.mobileNotificationLevel = args.mobile_notification_level;
this.lastReadMessageId = args.last_read_message_id; this.lastReadMessageId = args.last_read_message_id;
this.lastViewedAt = args.last_viewed_at;
this.user = this.#initUserModel(args.user); this.user = this.#initUserModel(args.user);
} }

View File

@ -28,7 +28,6 @@ export default class ChatRoute extends DiscourseRoute {
"chat.channel-legacy", "chat.channel-legacy",
"chat", "chat",
"chat.index", "chat.index",
"chat.draft-channel",
]; ];
if ( if (

View File

@ -408,6 +408,17 @@ export default class ChatApi extends Service {
return this.#putRequest(`/channels/read`); return this.#putRequest(`/channels/read`);
} }
/**
* Lists all possible chatables.
*
* @param {term} string - The term to search for. # prefix will scope to channels, @ to users.
*
* @returns {Promise}
*/
chatables(args = {}) {
return this.#getRequest("/chatables", args);
}
/** /**
* Marks messages for a single user chat channel membership as read. If no * Marks messages for a single user chat channel membership as read. If no
* message ID is provided, then the latest message for the channel is fetched * message ID is provided, then the latest message for the channel is fetched

View File

@ -101,6 +101,16 @@ export default class ChatChannelsManager extends Service {
delete this._cached[model.id]; delete this._cached[model.id];
} }
get allChannels() {
return [...this.publicMessageChannels, ...this.directMessageChannels].sort(
(a, b) => {
return b?.currentUserMembership?.lastViewedAt?.localeCompare?.(
a?.currentUserMembership?.lastViewedAt
);
}
);
}
get publicMessageChannels() { get publicMessageChannels() {
return this.channels return this.channels
.filter( .filter(

View File

@ -1,13 +1,11 @@
import Service, { inject as service } from "@ember/service"; import Service, { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import ChatDrawerDraftChannel from "discourse/plugins/chat/discourse/components/chat-drawer/draft-channel";
import ChatDrawerChannel from "discourse/plugins/chat/discourse/components/chat-drawer/channel"; import ChatDrawerChannel from "discourse/plugins/chat/discourse/components/chat-drawer/channel";
import ChatDrawerThread from "discourse/plugins/chat/discourse/components/chat-drawer/thread"; import ChatDrawerThread from "discourse/plugins/chat/discourse/components/chat-drawer/thread";
import ChatDrawerThreads from "discourse/plugins/chat/discourse/components/chat-drawer/threads"; import ChatDrawerThreads from "discourse/plugins/chat/discourse/components/chat-drawer/threads";
import ChatDrawerIndex from "discourse/plugins/chat/discourse/components/chat-drawer/index"; import ChatDrawerIndex from "discourse/plugins/chat/discourse/components/chat-drawer/index";
const ROUTES = { const ROUTES = {
"chat.draft-channel": { name: ChatDrawerDraftChannel },
"chat.channel": { name: ChatDrawerChannel }, "chat.channel": { name: ChatDrawerChannel },
"chat.channel.thread": { "chat.channel.thread": {
name: ChatDrawerThread, name: ChatDrawerThread,

View File

@ -354,6 +354,7 @@ export default class ChatSubscriptionsManager extends Service {
this.chatChannelsManager.find(data.channel.id).then((channel) => { this.chatChannelsManager.find(data.channel.id).then((channel) => {
// we need to refresh here to have correct last message ids // we need to refresh here to have correct last message ids
channel.meta = data.channel.meta; channel.meta = data.channel.meta;
channel.updateMembership(data.channel.current_user_membership);
if ( if (
channel.isDirectMessageChannel && channel.isDirectMessageChannel &&

View File

@ -1,6 +1,5 @@
import deprecated from "discourse-common/lib/deprecated"; import deprecated from "discourse-common/lib/deprecated";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import userSearch from "discourse/lib/user-search";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import Service, { inject as service } from "@ember/service"; import Service, { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
@ -282,11 +281,6 @@ export default class Chat extends Service {
} }
} }
searchPossibleDirectMessageUsers(options) {
// TODO: implement a chat specific user search function
return userSearch(options);
}
getIdealFirstChannelId() { getIdealFirstChannelId() {
// When user opens chat we need to give them the 'best' channel when they enter. // When user opens chat we need to give them the 'best' channel when they enter.
// //

View File

@ -1,63 +0,0 @@
:root {
--chat-channel-selector-input-height: 40px;
}
.chat-channel-selector-modal-modal.modal.in {
animation: none;
}
#chat-channel-selector-modal-inner {
width: 500px;
height: 350px;
.chat-channel-selector-input-container {
position: relative;
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--primary-high);
}
#chat-channel-selector-input {
width: 100%;
height: var(--chat-channel-selector-input-height);
padding-left: 30px;
margin: 0 0 1px;
}
}
.channels {
height: calc(100% - var(--chat-channel-selector-input-height));
overflow: auto;
.no-channels-notice {
padding: 0.5em;
}
.chat-channel-selection-row {
display: flex;
align-items: center;
height: 2.5em;
padding-left: 0.5em;
&.focused {
background: var(--primary-low);
}
.username {
margin-left: 0.5em;
}
.chat-channel-title {
color: var(--primary-high);
}
.chat-channel-unread-indicator {
border: none;
margin-left: 0.5em;
height: 12px;
width: 12px;
}
}
}
}

View File

@ -1,43 +0,0 @@
.full-page-chat.teams-sidebar-on {
.chat-draft {
grid-template-columns: 1fr;
}
}
.chat-draft {
height: 100%;
min-height: 1px;
width: 100%;
display: flex;
flex-direction: column;
flex: 1;
&-header {
display: flex;
align-items: center;
padding: 0.75em 10px;
border-bottom: 1px solid var(--primary-low);
&__title {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0;
margin-left: 0.5rem;
font-size: var(--font-0);
font-weight: normal;
color: var(--primary);
@include ellipsis;
.d-icon {
height: 1.5em;
width: 1.5em;
color: var(--primary-medium);
}
}
}
.chat-composer__wrapper {
padding-bottom: 1rem;
}
}

View File

@ -2,26 +2,18 @@
// desktop and mobile // desktop and mobile
height: calc( height: calc(
var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) - var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) -
var(--chat-draft-header-height, 0px) - var(--composer-height, 0px)
var(--chat-direct-message-creator-height, 0px) -
var(--composer-height, 0px) - $inset
); );
// mobile with keyboard opened // mobile with keyboard opened
.keyboard-visible & { .keyboard-visible & {
height: calc( height: calc(var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px));
var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) -
var(--chat-draft-header-height, 0px) -
var(--chat-direct-message-creator-height, 0px)
);
} }
// ipad // ipad
.footer-nav-ipad & { .footer-nav-ipad & {
height: calc( height: calc(
var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) - var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) -
var(--chat-draft-header-height, 0px) -
var(--chat-direct-message-creator-height, 0px) -
var(--composer-height, 0px) var(--composer-height, 0px)
); );
} }

View File

@ -1,4 +1,4 @@
.btn-floating.open-draft-channel-page-btn { .btn-floating.open-new-message-btn {
position: fixed; position: fixed;
background: var(--tertiary); background: var(--tertiary);
bottom: 2rem; bottom: 2rem;

View File

@ -0,0 +1,305 @@
.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: 5px;
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 {
visibility: hidden;
margin-left: auto;
font-size: var(--font-down-1);
color: var(--secondary-medium);
display: flex;
align-items: center;
padding-right: 0.25rem;
kbd {
margin-left: 0.25rem;
}
}
&.-active {
.action-indicator {
visibility: visible;
}
}
.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__name,
.chat-user-display-name {
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 {
padding-left: 0.25rem;
}
}
}
&__content {
box-sizing: border-box;
display: flex;
flex-direction: column;
flex: 1;
&-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: 5px;
}
&__selection {
flex: 1 1 auto;
flex-direction: row;
flex-wrap: wrap;
display: flex;
background: var(--secondary-very-high);
border-radius: 5px;
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-low);
border-radius: 5px;
border: 1px solid var(--primary-very-low);
height: calc(var(--row-height) - 6);
padding-inline: 0.25rem;
margin: 3px;
.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);
}
}
&__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

@ -0,0 +1,34 @@
.chat-new-message-modal {
& + .modal-backdrop {
opacity: 1;
background: transparent;
}
.modal-body {
padding: 0;
}
.modal-header {
display: none;
}
.modal-inner-container {
width: var(--modal-max-width);
box-shadow: var(--shadow-dropdown);
overflow: hidden;
}
.mobile-device & {
.modal-inner-container {
border-radius: 0;
margin: 0 auto auto auto;
box-shadow: var(--shadow-modal);
}
}
.not-mobile-device & {
.modal-inner-container {
margin: 10px auto auto auto;
}
}
}

View File

@ -5,6 +5,7 @@
&-container { &-container {
display: flex; display: flex;
height: 16px;
} }
&:before { &:before {

View File

@ -0,0 +1,15 @@
.chat-section {
border-bottom: 1px solid var(--primary-low);
padding: 1rem;
align-items: center;
display: flex;
flex-shrink: 0;
box-sizing: border-box;
&__text {
align-items: baseline;
display: flex;
flex: 1 1 0;
min-width: 0;
}
}

View File

@ -1,197 +0,0 @@
.direct-message-creator {
display: flex;
flex-direction: column;
.title-area {
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--primary-low);
.title {
font-weight: 700;
font-size: var(--font-up-1);
line-height: var(--font-up-1);
}
}
.filter-area {
padding: 1rem;
display: flex;
align-items: flex-start;
border-bottom: 1px solid var(--primary-low);
cursor: text;
position: relative;
&.is-focused {
background: var(--primary-very-low);
}
}
.prefix {
line-height: 34px;
padding-right: 0.25rem;
}
.selected-user {
list-style: none;
padding: 0;
margin: 1px 0.25rem 0.25rem 1px;
padding: 0.25rem 0.5rem 0.25rem 0.25rem;
background: var(--primary-very-low);
border-radius: 8px;
border: 1px solid var(--primary-300);
align-items: center;
display: flex;
&:last-child {
margin-right: 0;
}
&.is-highlighted {
border-color: var(--tertiary);
.d-icon {
color: var(--danger);
}
}
.username {
margin: 0 0.5em;
}
& * {
pointer-events: none;
}
&:hover,
&:focus {
background: var(--primary-very-low);
color: var(--primary);
&:not(.is-highlighted) {
border-color: var(--tertiary);
}
.d-icon {
color: var(--danger);
}
}
}
.recipients {
display: flex;
flex-wrap: wrap;
margin-bottom: -0.25rem;
flex: 1;
min-width: 0;
align-items: center;
& + .btn {
margin-left: 1em;
}
.filter-usernames {
flex: 1 0 auto;
min-width: 80px;
margin: 1px 0 0 0;
appearance: none;
border: 0;
outline: 0;
background: none;
width: unset;
}
}
.results-container {
display: flex;
position: relative;
}
.results {
display: flex;
margin: 0;
flex-wrap: wrap;
border-bottom: 1px solid var(--primary-low);
box-shadow: var(--shadow-card);
position: absolute;
width: 100%;
z-index: z("dropdown");
background: var(--secondary);
.user {
display: flex;
width: 100%;
list-style: none;
cursor: pointer;
outline: 0;
padding: 0.25em 0.5em;
margin: 0.25rem;
align-items: center;
border-radius: 4px;
.user-info {
margin: 0;
width: 100%;
}
&.is-focused {
background: var(--tertiary-very-low);
}
* {
pointer-events: none;
}
.username {
margin-left: 0.25em;
color: var(--primary-high);
font-size: var(--font-up-1);
}
& + .user {
margin-top: 0.25em;
}
.user-status-message {
margin-left: 0.3em;
.emoji {
width: 15px;
height: 15px;
}
}
}
.btn {
padding: 0.25em;
&:last-child {
margin: 0;
}
}
}
.no-results-container {
position: relative;
}
.no-results {
text-align: center;
padding: 1rem;
width: 100%;
box-shadow: var(--shadow-card);
background: var(--secondary);
margin: 0;
box-sizing: border-box;
}
.fetching-preview-message {
padding: 1rem;
text-align: center;
}
.join-existing-channel {
margin: 1rem auto;
}
}

View File

@ -1,5 +1,6 @@
@import "chat-unread-indicator"; @import "chat-unread-indicator";
@import "chat-height-mixin"; @import "chat-height-mixin";
@import "chat-thread-header-buttons";
@import "base-common"; @import "base-common";
@import "sidebar-extensions"; @import "sidebar-extensions";
@import "chat-browse"; @import "chat-browse";
@ -7,7 +8,6 @@
@import "chat-channel-card"; @import "chat-channel-card";
@import "chat-channel-info"; @import "chat-channel-info";
@import "chat-channel-preview-card"; @import "chat-channel-preview-card";
@import "chat-channel-selector-modal";
@import "chat-channel-settings-saved-indicator"; @import "chat-channel-settings-saved-indicator";
@import "chat-channel-title"; @import "chat-channel-title";
@import "chat-composer-dropdown"; @import "chat-composer-dropdown";
@ -15,7 +15,6 @@
@import "chat-composer-uploads"; @import "chat-composer-uploads";
@import "chat-composer"; @import "chat-composer";
@import "chat-composer-button"; @import "chat-composer-button";
@import "chat-draft-channel";
@import "chat-drawer"; @import "chat-drawer";
@import "chat-emoji-picker"; @import "chat-emoji-picker";
@import "chat-form"; @import "chat-form";
@ -45,14 +44,12 @@
@import "create-channel-modal"; @import "create-channel-modal";
@import "d-progress-bar"; @import "d-progress-bar";
@import "dc-filter-input"; @import "dc-filter-input";
@import "direct-message-creator";
@import "full-page-chat-header"; @import "full-page-chat-header";
@import "incoming-chat-webhooks"; @import "incoming-chat-webhooks";
@import "reviewable-chat-message"; @import "reviewable-chat-message";
@import "chat-thread-list-item"; @import "chat-thread-list-item";
@import "chat-threads-list"; @import "chat-threads-list";
@import "chat-composer-separator"; @import "chat-composer-separator";
@import "chat-thread-header-buttons";
@import "chat-thread-header"; @import "chat-thread-header";
@import "chat-thread-list-header"; @import "chat-thread-list-header";
@import "chat-thread-unread-indicator"; @import "chat-thread-unread-indicator";
@ -60,3 +57,5 @@
@import "channel-summary-modal"; @import "channel-summary-modal";
@import "chat-message-mention-warning"; @import "chat-message-mention-warning";
@import "chat-message-error"; @import "chat-message-error";
@import "chat-new-message-modal";
@import "chat-message-creator";

View File

@ -77,7 +77,7 @@
} }
} }
.open-draft-channel-page-btn, .open-new-message-btn,
.open-browse-page-btn, .open-browse-page-btn,
.edit-channels-dropdown .select-kit-header, .edit-channels-dropdown .select-kit-header,
.chat-channel-leave-btn { .chat-channel-leave-btn {

View File

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

View File

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

View File

@ -0,0 +1,6 @@
.chat-message-creator {
&__open-dm-btn {
width: 100%;
max-width: 100%;
}
}

View File

@ -13,3 +13,4 @@
@import "chat-threads-list"; @import "chat-threads-list";
@import "chat-thread-settings-modal"; @import "chat-thread-settings-modal";
@import "chat-message-thread-indicator"; @import "chat-message-thread-indicator";
@import "chat-message-creator";

View File

@ -324,6 +324,16 @@ en:
members: Members members: Members
settings: Settings settings: Settings
new_message_modal:
title: Send message
add_user_long: <kbd>shift + click</kbd> or <kbd>shift + enter</kbd><span>Add @%{username}</span>
add_user_short: <span>Add user</span>
open_channel: <span>Open channel</span>
default_search_placeholder: "#a-channel, @somebody or anything"
user_search_placeholder: "...add more users"
disabled_user: "has disabled chat"
no_items: "No items"
channel_edit_name_slug_modal: channel_edit_name_slug_modal:
title: Edit channel title: Edit channel
input_placeholder: Add a name input_placeholder: Add a name
@ -342,10 +352,6 @@ en:
no_results: No results no_results: No results
selected_user_title: "Deselect %{username}" selected_user_title: "Deselect %{username}"
channel_selector:
title: "Jump to channel"
no_channels: "No channels match your search"
channel: channel:
no_memberships: This channel has no members no_memberships: This channel has no members
no_memberships_found: No members found no_memberships_found: No members found

View File

@ -51,7 +51,6 @@ Chat::Engine.routes.draw do
# direct_messages_controller routes # direct_messages_controller routes
get "/direct_messages" => "direct_messages#index" get "/direct_messages" => "direct_messages#index"
post "/direct_messages/create" => "direct_messages#create"
# incoming_webhooks_controller routes # incoming_webhooks_controller routes
post "/hooks/:key" => "incoming_webhooks#create_message" post "/hooks/:key" => "incoming_webhooks#create_message"
@ -66,7 +65,6 @@ Chat::Engine.routes.draw do
get "/browse/closed" => "chat#respond" get "/browse/closed" => "chat#respond"
get "/browse/open" => "chat#respond" get "/browse/open" => "chat#respond"
get "/browse/archived" => "chat#respond" get "/browse/archived" => "chat#respond"
get "/draft-channel" => "chat#respond"
post "/enable" => "chat#enable_chat" post "/enable" => "chat#enable_chat"
post "/disable" => "chat#disable_chat" post "/disable" => "chat#disable_chat"
post "/dismiss-retention-reminder" => "chat#dismiss_retention_reminder" post "/dismiss-retention-reminder" => "chat#dismiss_retention_reminder"

View File

@ -6,10 +6,8 @@ module Chat
def self.structured(guardian, include_threads: false) def self.structured(guardian, include_threads: false)
memberships = Chat::ChannelMembershipManager.all_for_user(guardian.user) memberships = Chat::ChannelMembershipManager.all_for_user(guardian.user)
public_channels = public_channels = secured_public_channels(guardian, status: :open, following: true)
secured_public_channels(guardian, memberships, status: :open, following: true) direct_message_channels = secured_direct_message_channels(guardian.user.id, guardian)
direct_message_channels =
secured_direct_message_channels(guardian.user.id, memberships, guardian)
{ {
public_channels: public_channels, public_channels: public_channels,
direct_message_channels: direct_message_channels, direct_message_channels: direct_message_channels,
@ -152,7 +150,7 @@ module Chat
channels.limit(options[:limit]).offset(options[:offset]) channels.limit(options[:limit]).offset(options[:offset])
end end
def self.secured_public_channels(guardian, memberships, options = { following: true }) def self.secured_public_channels(guardian, options = { following: true })
channels = channels =
secured_public_channel_search( secured_public_channel_search(
guardian, guardian,
@ -174,19 +172,60 @@ module Chat
) )
end end
def self.secured_direct_message_channels(user_id, memberships, guardian) def self.secured_direct_message_channels(user_id, guardian)
query = Chat::Channel.includes(chatable: [{ direct_message_users: :user }, :users]) secured_direct_message_channels_search(user_id, guardian, following: true)
end
def self.secured_direct_message_channels_search(user_id, guardian, options = {})
query =
Chat::Channel.strict_loading.includes(
chatable: [{ direct_message_users: [user: :user_option] }, :users],
)
query = query.includes(chatable: [{ users: :user_status }]) if SiteSetting.enable_user_status query = query.includes(chatable: [{ users: :user_status }]) if SiteSetting.enable_user_status
query = query.joins(:user_chat_channel_memberships)
channels = scoped_channels =
Chat::Channel
.joins(
"INNER JOIN direct_message_channels ON direct_message_channels.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'DirectMessage'",
)
.joins(
"INNER JOIN direct_message_users ON direct_message_users.direct_message_channel_id = direct_message_channels.id",
)
.where("direct_message_users.user_id = :user_id", user_id: user_id)
if options[:user_ids]
scoped_channels =
scoped_channels.where(
"EXISTS (
SELECT 1
FROM direct_message_channels AS dmc
INNER JOIN direct_message_users AS dmu ON dmu.direct_message_channel_id = dmc.id
WHERE dmc.id = chat_channels.chatable_id AND dmu.user_id IN (:user_ids)
)",
user_ids: options[:user_ids],
)
end
if options.key?(:following)
query =
query.where(
user_chat_channel_memberships: {
user_id: user_id,
following: options[:following],
},
)
else
query = query.where(user_chat_channel_memberships: { user_id: user_id })
end
query =
query query
.joins(:user_chat_channel_memberships)
.where(user_chat_channel_memberships: { user_id: user_id, following: true })
.where(chatable_type: Chat::Channel.direct_channel_chatable_types) .where(chatable_type: Chat::Channel.direct_channel_chatable_types)
.where("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})") .where(chat_channels: { id: scoped_channels })
.order(last_message_sent_at: :desc) .order(last_message_sent_at: :desc)
.to_a
channels = query.to_a
preload_fields = preload_fields =
User.allowed_user_custom_fields(guardian) + User.allowed_user_custom_fields(guardian) +
UserField.all.pluck(:id).map { |fid| "#{User::USER_FIELD_PREFIX}#{fid}" } UserField.all.pluck(:id).map { |fid| "#{User::USER_FIELD_PREFIX}#{fid}" }

View File

@ -197,9 +197,7 @@ describe Chat::ChannelFetcher do
it "does not include DM channels" do it "does not include DM channels" do
expect( expect(
described_class.secured_public_channels(guardian, memberships, following: following).map( described_class.secured_public_channels(guardian, following: following).map(&:id),
&:id
),
).to match_array([category_channel.id]) ).to match_array([category_channel.id])
end end
@ -207,7 +205,6 @@ describe Chat::ChannelFetcher do
expect( expect(
described_class.secured_public_channels( described_class.secured_public_channels(
guardian, guardian,
memberships,
following: following, following: following,
filter: "support", filter: "support",
).map(&:id), ).map(&:id),
@ -218,7 +215,6 @@ describe Chat::ChannelFetcher do
expect( expect(
described_class.secured_public_channels( described_class.secured_public_channels(
guardian, guardian,
memberships,
following: following, following: following,
filter: "cool stuff", filter: "cool stuff",
).map(&:id), ).map(&:id),
@ -227,33 +223,29 @@ describe Chat::ChannelFetcher do
it "can filter by an array of slugs" do it "can filter by an array of slugs" do
expect( expect(
described_class.secured_public_channels(guardian, memberships, slugs: ["support"]).map( described_class.secured_public_channels(guardian, slugs: ["support"]).map(&:id),
&:id
),
).to match_array([category_channel.id]) ).to match_array([category_channel.id])
end end
it "returns nothing if the array of slugs is empty" do it "returns nothing if the array of slugs is empty" do
expect( expect(described_class.secured_public_channels(guardian, slugs: []).map(&:id)).to eq([])
described_class.secured_public_channels(guardian, memberships, slugs: []).map(&:id),
).to eq([])
end end
it "can filter by status" do it "can filter by status" do
expect( expect(
described_class.secured_public_channels(guardian, memberships, status: "closed").map(&:id), described_class.secured_public_channels(guardian, status: "closed").map(&:id),
).to match_array([]) ).to match_array([])
category_channel.closed!(Discourse.system_user) category_channel.closed!(Discourse.system_user)
expect( expect(
described_class.secured_public_channels(guardian, memberships, status: "closed").map(&:id), described_class.secured_public_channels(guardian, status: "closed").map(&:id),
).to match_array([category_channel.id]) ).to match_array([category_channel.id])
end end
it "can filter by following" do it "can filter by following" do
expect( expect(
described_class.secured_public_channels(guardian, memberships, following: true).map(&:id), described_class.secured_public_channels(guardian, following: true).map(&:id),
).to be_blank ).to be_blank
end end
@ -262,21 +254,19 @@ describe Chat::ChannelFetcher do
another_channel = Fabricate(:category_channel) another_channel = Fabricate(:category_channel)
expect( expect(
described_class.secured_public_channels(guardian, memberships, following: false).map(&:id), described_class.secured_public_channels(guardian, following: false).map(&:id),
).to match_array([category_channel.id, another_channel.id]) ).to match_array([category_channel.id, another_channel.id])
end end
it "ensures offset is >= 0" do it "ensures offset is >= 0" do
expect( expect(
described_class.secured_public_channels(guardian, memberships, offset: -235).map(&:id), described_class.secured_public_channels(guardian, offset: -235).map(&:id),
).to match_array([category_channel.id]) ).to match_array([category_channel.id])
end end
it "ensures limit is > 0" do it "ensures limit is > 0" do
expect( expect(
described_class.secured_public_channels(guardian, memberships, limit: -1, offset: 0).map( described_class.secured_public_channels(guardian, limit: -1, offset: 0).map(&:id),
&:id
),
).to match_array([category_channel.id]) ).to match_array([category_channel.id])
end end
@ -284,17 +274,15 @@ describe Chat::ChannelFetcher do
over_limit = Chat::ChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS + 1 over_limit = Chat::ChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS + 1
over_limit.times { Fabricate(:category_channel) } over_limit.times { Fabricate(:category_channel) }
expect( expect(described_class.secured_public_channels(guardian, limit: over_limit).length).to eq(
described_class.secured_public_channels(guardian, memberships, limit: over_limit).length, Chat::ChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS,
).to eq(Chat::ChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS) )
end end
it "does not show the user category channels they cannot access" do it "does not show the user category channels they cannot access" do
category_channel.update!(chatable: private_category) category_channel.update!(chatable: private_category)
expect( expect(
described_class.secured_public_channels(guardian, memberships, following: following).map( described_class.secured_public_channels(guardian, following: following).map(&:id),
&:id
),
).to be_empty ).to be_empty
end end
@ -303,9 +291,7 @@ describe Chat::ChannelFetcher do
it "only returns channels where the user is a member and is following the channel" do it "only returns channels where the user is a member and is following the channel" do
expect( expect(
described_class.secured_public_channels(guardian, memberships, following: following).map( described_class.secured_public_channels(guardian, following: following).map(&:id),
&:id
),
).to be_empty ).to be_empty
Chat::UserChatChannelMembership.create!( Chat::UserChatChannelMembership.create!(
@ -315,9 +301,7 @@ describe Chat::ChannelFetcher do
) )
expect( expect(
described_class.secured_public_channels(guardian, memberships, following: following).map( described_class.secured_public_channels(guardian, following: following).map(&:id),
&:id
),
).to match_array([category_channel.id]) ).to match_array([category_channel.id])
end end
@ -369,9 +353,9 @@ describe Chat::ChannelFetcher do
direct_message_channel1.update!(last_message_sent_at: 1.day.ago) direct_message_channel1.update!(last_message_sent_at: 1.day.ago)
direct_message_channel2.update!(last_message_sent_at: 1.hour.ago) direct_message_channel2.update!(last_message_sent_at: 1.hour.ago)
expect( expect(described_class.secured_direct_message_channels(user1.id, guardian).map(&:id)).to eq(
described_class.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), [direct_message_channel2.id, direct_message_channel1.id],
).to eq([direct_message_channel2.id, direct_message_channel1.id]) )
end end
it "does not include direct message channels where the user is a member but not a direct_message_user" do it "does not include direct message channels where the user is a member but not a direct_message_user" do
@ -384,7 +368,7 @@ describe Chat::ChannelFetcher do
Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user2) Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user2)
expect( expect(
described_class.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), described_class.secured_direct_message_channels(user1.id, guardian).map(&:id),
).not_to include(direct_message_channel1.id) ).not_to include(direct_message_channel1.id)
end end

View File

@ -8,197 +8,35 @@ RSpec.describe Chat::Api::ChatablesController do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
end end
describe "#index" do fab!(:current_user) { Fabricate(:user) }
fab!(:user) { Fabricate(:user, username: "johndoe", name: "John Doe") }
describe "#index" do
describe "without chat permissions" do describe "without chat permissions" do
it "errors errors for anon" do it "errors errors for anon" do
get "/chat/api/chatables", params: { filter: "so" } get "/chat/api/chatables"
expect(response.status).to eq(403) expect(response.status).to eq(403)
end end
it "errors when user cannot chat" do it "errors when user cannot chat" do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff]
sign_in(user) sign_in(current_user)
get "/chat/api/chatables", params: { filter: "so" } get "/chat/api/chatables"
expect(response.status).to eq(403) expect(response.status).to eq(403)
end end
end end
describe "with chat permissions" do describe "with chat permissions" do
fab!(:other_user) { Fabricate(:user, username: "janemay", name: "Jane May") } fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:admin) { Fabricate(:admin, username: "andyjones", name: "Andy Jones") }
fab!(:category) { Fabricate(:category) }
fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) }
fab!(:dm_chat_channel) { Fabricate(:direct_message_channel, users: [user, admin]) }
before do before { sign_in(current_user) }
chat_channel.update(name: "something")
sign_in(user) it "returns results" do
end get "/chat/api/chatables", params: { term: channel_1.name }
it "returns the correct channels with filter 'so'" do
get "/chat/api/chatables", params: { filter: "so" }
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) expect(response.parsed_body["category_channels"][0]["identifier"]).to eq(
expect(response.parsed_body["direct_message_channels"].count).to eq(0) "c-#{channel_1.id}",
expect(response.parsed_body["users"].count).to eq(0)
end
it "returns the correct channels with filter 'something'" do
get "/chat/api/chatables", params: { filter: "something" }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id)
expect(response.parsed_body["direct_message_channels"].count).to eq(0)
expect(response.parsed_body["users"].count).to eq(0)
end
it "returns the correct channels with filter 'andyjones'" do
get "/chat/api/chatables", params: { filter: "andyjones" }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"].count).to eq(0)
expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id)
expect(response.parsed_body["users"].count).to eq(0)
end
it "returns the current user inside the users array if their username matches the filter too" do
user.update!(username: "andysmith")
get "/chat/api/chatables", params: { filter: "andy" }
expect(response.status).to eq(200)
expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id)
expect(response.parsed_body["users"].map { |u| u["id"] }).to match_array([user.id])
end
it "returns no channels with a whacky filter" do
get "/chat/api/chatables", params: { filter: "hello good sir" }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"].count).to eq(0)
expect(response.parsed_body["direct_message_channels"].count).to eq(0)
expect(response.parsed_body["users"].count).to eq(0)
end
it "only returns open channels" do
chat_channel.update(status: Chat::Channel.statuses[:closed])
get "/chat/api/chatables", params: { filter: "so" }
expect(response.parsed_body["public_channels"].count).to eq(0)
chat_channel.update(status: Chat::Channel.statuses[:read_only])
get "/chat/api/chatables", params: { filter: "so" }
expect(response.parsed_body["public_channels"].count).to eq(0)
chat_channel.update(status: Chat::Channel.statuses[:archived])
get "/chat/api/chatables", params: { filter: "so" }
expect(response.parsed_body["public_channels"].count).to eq(0)
# Now set status to open and the channel is there!
chat_channel.update(status: Chat::Channel.statuses[:open])
get "/chat/api/chatables", params: { filter: "so" }
expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id)
end
it "only finds users by username_lower if not enable_names" do
SiteSetting.enable_names = false
get "/chat/api/chatables", params: { filter: "Andy J" }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"].count).to eq(0)
expect(response.parsed_body["direct_message_channels"].count).to eq(0)
get "/chat/api/chatables", params: { filter: "andyjones" }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"].count).to eq(0)
expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id)
end
it "only finds users by username if prioritize_username_in_ux" do
SiteSetting.prioritize_username_in_ux = true
get "/chat/api/chatables", params: { filter: "Andy J" }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"].count).to eq(0)
expect(response.parsed_body["direct_message_channels"].count).to eq(0)
get "/chat/api/chatables", params: { filter: "andyjones" }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"].count).to eq(0)
expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id)
end
it "can find users by name or username if not prioritize_username_in_ux and enable_names" do
SiteSetting.prioritize_username_in_ux = false
SiteSetting.enable_names = true
get "/chat/api/chatables", params: { filter: "Andy J" }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"].count).to eq(0)
expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id)
get "/chat/api/chatables", params: { filter: "andyjones" }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"].count).to eq(0)
expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id)
end
it "does not return DM channels for users who do not have chat enabled" do
admin.user_option.update!(chat_enabled: false)
get "/chat/api/chatables", params: { filter: "andyjones" }
expect(response.status).to eq(200)
expect(response.parsed_body["direct_message_channels"].count).to eq(0)
end
xit "does not return DM channels for users who are not in the chat allowed group" do
group = Fabricate(:group, name: "chatpeeps")
SiteSetting.chat_allowed_groups = group.id
GroupUser.create(user: user, group: group)
dm_chat_channel_2 = Fabricate(:direct_message_channel, users: [user, other_user])
get "/chat/api/chatables", params: { filter: "janemay" }
expect(response.status).to eq(200)
expect(response.parsed_body["direct_message_channels"].count).to eq(0)
GroupUser.create(user: other_user, group: group)
get "/chat/api/chatables", params: { filter: "janemay" }
if response.status == 500
puts "ERROR in ChatablesController spec:\n"
puts response.body
end
expect(response.status).to eq(200)
expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel_2.id)
end
it "returns DM channels for staff users even if they are not in chat_allowed_groups" do
group = Fabricate(:group, name: "chatpeeps")
SiteSetting.chat_allowed_groups = group.id
GroupUser.create(user: user, group: group)
get "/chat/api/chatables", params: { filter: "andyjones" }
expect(response.status).to eq(200)
expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id)
end
it "returns followed channels" do
Fabricate(
:user_chat_channel_membership,
user: user,
chat_channel: chat_channel,
following: true,
) )
get "/chat/api/chatables", params: { filter: chat_channel.name }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id)
end
it "returns not followed channels" do
Fabricate(
:user_chat_channel_membership,
user: user,
chat_channel: chat_channel,
following: false,
)
get "/chat/api/chatables", params: { filter: chat_channel.name }
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id)
end end
end end
end end

View File

@ -49,7 +49,7 @@ RSpec.describe Chat::CreateDirectMessageChannel do
) )
result.channel.user_chat_channel_memberships.each do |membership| result.channel.user_chat_channel_memberships.each do |membership|
expect(membership).to have_attributes( expect(membership).to have_attributes(
following: true, following: false,
muted: false, muted: false,
desktop_notification_level: "always", desktop_notification_level: "always",
mobile_notification_level: "always", mobile_notification_level: "always",
@ -57,12 +57,6 @@ RSpec.describe Chat::CreateDirectMessageChannel do
end end
end end
it "publishes the new channel" do
messages =
MessageBus.track_publish(Chat::Publisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL) { result }
expect(messages.first.data[:channel][:title]).to eq("@elaine, @lechuck")
end
context "when there is an existing direct message channel for the target users" do context "when there is an existing direct message channel for the target users" do
before { described_class.call(params) } before { described_class.call(params) }

View File

@ -0,0 +1,139 @@
# frozen_string_literal: true
RSpec.describe Chat::SearchChatable do
describe ".call" do
subject(:result) { described_class.call(params) }
fab!(:current_user) { Fabricate(:user, username: "bob-user") }
fab!(:sam) { Fabricate(:user, username: "sam-user") }
fab!(:charlie) { Fabricate(:user, username: "charlie-user") }
fab!(:channel_1) { Fabricate(:chat_channel, name: "bob-channel") }
fab!(:channel_2) { Fabricate(:direct_message_channel, users: [current_user, sam]) }
fab!(:channel_3) { Fabricate(:direct_message_channel, users: [current_user, sam, charlie]) }
fab!(:channel_4) { Fabricate(:direct_message_channel, users: [sam, charlie]) }
let(:guardian) { Guardian.new(current_user) }
let(:params) { { guardian: guardian, term: term } }
let(:term) { "" }
before do
SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:everyone]
# simpler user search without having to worry about user search data
SiteSetting.enable_names = false
return unless guardian.can_create_direct_message?
channel_1.add(current_user)
end
context "when all steps pass" do
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "returns chatables" do
expect(result.memberships).to contain_exactly(
channel_1.membership_for(current_user),
channel_2.membership_for(current_user),
channel_3.membership_for(current_user),
)
expect(result.category_channels).to contain_exactly(channel_1)
expect(result.direct_message_channels).to contain_exactly(channel_2, channel_3)
expect(result.users).to include(current_user, sam)
end
it "doesnt return direct message of other users" do
expect(result.direct_message_channels).to_not include(channel_4)
end
context "with private channel" do
fab!(:private_channel_1) { Fabricate(:private_category_channel, name: "private") }
let(:term) { "#private" }
it "doesnt return category channels you can't access" do
expect(result.category_channels).to_not include(private_channel_1)
end
end
end
context "when term is prefixed with #" do
let(:term) { "#" }
it "doesnt return users" do
expect(result.users).to be_blank
expect(result.category_channels).to contain_exactly(channel_1)
expect(result.direct_message_channels).to contain_exactly(channel_2, channel_3)
end
end
context "when term is prefixed with @" do
let(:term) { "@" }
it "doesnt return channels" do
expect(result.users).to include(current_user, sam)
expect(result.category_channels).to be_blank
expect(result.direct_message_channels).to be_blank
end
end
context "when filtering" do
context "with full match" do
let(:term) { "bob" }
it "returns matching channels" do
expect(result.users).to contain_exactly(current_user)
expect(result.category_channels).to contain_exactly(channel_1)
expect(result.direct_message_channels).to contain_exactly(channel_2, channel_3)
end
end
context "with partial match" do
let(:term) { "cha" }
it "returns matching channels" do
expect(result.users).to contain_exactly(charlie)
expect(result.category_channels).to contain_exactly(channel_1)
expect(result.direct_message_channels).to contain_exactly(channel_3)
end
end
end
context "when filtering with non existing term" do
let(:term) { "xxxxxxxxxx" }
it "returns matching channels" do
expect(result.users).to be_blank
expect(result.category_channels).to be_blank
expect(result.direct_message_channels).to be_blank
end
end
context "when filtering with @prefix" do
let(:term) { "@bob" }
it "returns matching channels" do
expect(result.users).to contain_exactly(current_user)
expect(result.category_channels).to be_blank
expect(result.direct_message_channels).to be_blank
end
end
context "when filtering with #prefix" do
let(:term) { "#bob" }
it "returns matching channels" do
expect(result.users).to be_blank
expect(result.category_channels).to contain_exactly(channel_1)
expect(result.direct_message_channels).to contain_exactly(channel_2, channel_3)
end
end
context "when current user can't created direct messages" do
let(:term) { "@bob" }
before { SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:staff] }
it "doesnt return users" do
expect(result.users).to be_blank
end
end
end
end

View File

@ -6,7 +6,8 @@
"muted", "muted",
"desktop_notification_level", "desktop_notification_level",
"mobile_notification_level", "mobile_notification_level",
"following" "following",
"last_viewed_at"
], ],
"properties": { "properties": {
"chat_channel_id": { "type": "number" }, "chat_channel_id": { "type": "number" },
@ -14,6 +15,7 @@
"muted": { "type": "boolean" }, "muted": { "type": "boolean" },
"desktop_notification_level": { "type": "string" }, "desktop_notification_level": { "type": "string" },
"mobile_notification_level": { "type": "string" }, "mobile_notification_level": { "type": "string" },
"last_viewed_at": { "type": "string" },
"following": { "type": "boolean" }, "following": { "type": "boolean" },
"user": { "user": {
"type": ["object", "null"], "type": ["object", "null"],

View File

@ -1,80 +0,0 @@
# frozen_string_literal: true
RSpec.describe "Channel selector modal", type: :system do
fab!(:current_user) { Fabricate(:user) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:key_modifier) { RUBY_PLATFORM =~ /darwin/i ? :meta : :control }
before do
chat_system_bootstrap
sign_in(current_user)
visit("/")
end
context "when used with public channel" do
fab!(:channel_1) { Fabricate(:category_channel) }
it "works" do
find("body").send_keys([key_modifier, "k"])
find("#chat-channel-selector-input").fill_in(with: channel_1.title)
find(".chat-channel-selection-row[data-id='#{channel_1.id}']").click
channel_page.send_message("Hello world")
expect(channel_page).to have_message(text: "Hello world")
end
end
context "when used with user" do
fab!(:user_1) { Fabricate(:user) }
it "works" do
find("body").send_keys([key_modifier, "k"])
find("#chat-channel-selector-input").fill_in(with: user_1.username)
find(".chat-channel-selection-row[data-id='#{user_1.id}']").click
channel_page.send_message("Hello world")
expect(channel_page).to have_message(text: "Hello world")
end
end
context "when used with dm channel" do
fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [current_user]) }
it "works" do
find("body").send_keys([key_modifier, "k"])
find("#chat-channel-selector-input").fill_in(with: current_user.username)
find(".chat-channel-selection-row[data-id='#{dm_channel_1.id}']").click
channel_page.send_message("Hello world")
expect(channel_page).to have_message(text: "Hello world")
end
end
context "when on a channel" do
fab!(:channel_1) { Fabricate(:category_channel) }
it "it doesnt include current channel" do
chat_page.visit_channel(channel_1)
find("body").send_keys([key_modifier, "k"])
find("#chat-channel-selector-input").click
expect(page).to have_no_css(".chat-channel-selection-row[data-id='#{channel_1.id}']")
end
end
context "with limited access channels" do
fab!(:group_1) { Fabricate(:group) }
fab!(:channel_1) { Fabricate(:private_category_channel, group: group_1) }
it "it doesnt include limited access channel" do
find("body").send_keys([key_modifier, "k"])
find("#chat-channel-selector-input").fill_in(with: channel_1.title)
expect(page).to have_no_css(".chat-channel-selection-row[data-id='#{channel_1.id}']")
end
end
end

View File

@ -1,26 +0,0 @@
# frozen_string_literal: true
RSpec.describe "Draft message", type: :system do
fab!(:current_user) { Fabricate(:admin) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:drawer) { PageObjects::Pages::ChatDrawer.new }
before do
chat_system_bootstrap
sign_in(current_user)
end
context "when current user never interacted with other user" do
fab!(:user) { Fabricate(:user) }
it "opens channel info page" do
visit("/chat/draft-channel")
expect(page).to have_selector(".results")
find(".results .user:nth-child(1)").click
expect(channel_page).to have_no_loading_skeleton
end
end
end

View File

@ -121,8 +121,8 @@ RSpec.describe "List channels | mobile", type: :system, mobile: true do
it "has a new dm channel button" do it "has a new dm channel button" do
visit("/chat") visit("/chat")
find(".open-draft-channel-page-btn").click find(".open-new-message-btn").click
expect(page).to have_current_path("/chat/draft-channel") expect(chat.message_creator).to be_opened
end end
end end

View File

@ -223,37 +223,15 @@ RSpec.describe "Navigation", type: :system do
end end
end end
context "when starting draft from sidebar with drawer preferred" do
it "opens draft in drawer" do
visit("/")
sidebar_page.open_draft_channel
expect(page).to have_current_path("/")
expect(page).to have_css(".chat-drawer.is-expanded .direct-message-creator")
end
end
context "when starting draft from drawer with drawer preferred" do
it "opens draft in drawer" do
visit("/")
chat_page.open_from_header
chat_drawer_page.open_draft_channel
expect(page).to have_current_path("/")
expect(page).to have_css(".chat-drawer.is-expanded .direct-message-creator")
end
end
context "when starting draft from sidebar with full page preferred" do context "when starting draft from sidebar with full page preferred" do
it "opens draft in full page" do it "opens draft in full page" do
visit("/") visit("/")
chat_page.open_from_header chat_page.open_from_header
chat_drawer_page.maximize chat_drawer_page.maximize
visit("/") visit("/")
sidebar_page.open_draft_channel chat_page.open_new_message
expect(page).to have_current_path("/chat/draft-channel") expect(chat_page.message_creator).to be_opened
expect(page).not_to have_css(".chat-drawer.is-expanded")
end end
end end

View File

@ -0,0 +1,375 @@
# frozen_string_literal: true
RSpec.describe "New message", type: :system do
fab!(:current_user) { Fabricate(:admin) }
let(:chat_page) { PageObjects::Pages::Chat.new }
before do
# simpler user search without having to worry about user search data
SiteSetting.enable_names = false
chat_system_bootstrap
sign_in(current_user)
end
it "cmd + k opens new message" do
visit("/")
chat_page.open_new_message
expect(chat_page.message_creator).to be_opened
end
context "when the the content is not filtered" do
fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:channel_2) { Fabricate(:chat_channel) }
fab!(:user_1) { Fabricate(:user) }
fab!(:user_2) { Fabricate(:user) }
fab!(:direct_message_channel_1) do
Fabricate(:direct_message_channel, users: [current_user, user_1])
end
fab!(:direct_message_channel_2) { Fabricate(:direct_message_channel, users: [user_1, user_2]) }
before { channel_1.add(current_user) }
it "lists channels the user is following" do
visit("/")
chat_page.open_new_message
expect(chat_page.message_creator).to be_listing(channel_1)
# it lists user_1 instead of this channel as it's a 1:1 channel
expect(chat_page.message_creator).to be_not_listing(channel_2)
expect(chat_page.message_creator).to be_not_listing(
direct_message_channel_1,
current_user: current_user,
)
expect(chat_page.message_creator).to be_not_listing(
direct_message_channel_2,
current_user: current_user,
)
expect(chat_page.message_creator).to be_listing(user_1)
expect(chat_page.message_creator).to be_not_listing(user_2)
end
end
context "with no selection" do
context "when clicking a row" do
context "when the row is a channel" do
fab!(:channel_1) { Fabricate(:chat_channel) }
before { channel_1.add(current_user) }
it "opens the channel" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.click_row(channel_1)
expect(chat_page).to have_drawer(channel_id: channel_1.id)
end
end
context "when the row is a user" do
fab!(:user_1) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:direct_message_channel, users: [current_user, user_1]) }
it "opens the channel" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.click_row(user_1)
expect(chat_page).to have_drawer(channel_id: channel_1.id)
end
end
end
context "when shift clicking a row" do
context "when the row is a channel" do
fab!(:channel_1) { Fabricate(:chat_channel) }
before { channel_1.add(current_user) }
it "opens the channel" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.shift_click_row(channel_1)
expect(chat_page).to have_drawer(channel_id: channel_1.id)
end
end
context "when the row is a user" do
fab!(:user_1) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:direct_message_channel, users: [current_user, user_1]) }
it "adds the user" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.shift_click_row(user_1)
expect(chat_page.message_creator).to be_selecting(user_1)
end
end
end
context "when pressing enter" do
context "when the row is a channel" do
fab!(:channel_1) { Fabricate(:chat_channel) }
before { channel_1.add(current_user) }
it "opens the channel" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.click_row(channel_1)
expect(chat_page).to have_drawer(channel_id: channel_1.id)
end
end
context "when the row is a user" do
fab!(:user_1) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:direct_message_channel, users: [current_user, user_1]) }
it "opens the channel" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.click_row(user_1)
expect(chat_page).to have_drawer(channel_id: channel_1.id)
end
end
end
context "when pressing shift+enter" do
context "when the row is a channel" do
fab!(:channel_1) { Fabricate(:chat_channel) }
before { channel_1.add(current_user) }
it "opens the channel" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.shift_enter_shortcut
expect(chat_page).to have_drawer(channel_id: channel_1.id)
end
end
context "when the row is a user" do
fab!(:user_1) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:direct_message_channel, users: [current_user, user_1]) }
it "adds the user" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.shift_enter_shortcut
expect(chat_page.message_creator).to be_selecting(user_1)
end
end
end
context "when navigating content with arrows" do
fab!(:channel_1) { Fabricate(:chat_channel, name: "channela") }
fab!(:channel_2) { Fabricate(:chat_channel, name: "channelb") }
before do
channel_1.add(current_user)
channel_2.add(current_user)
end
it "changes active content" do
visit("/")
chat_page.open_new_message
expect(chat_page.message_creator).to be_listing(channel_1, active: true)
chat_page.message_creator.arrow_down_shortcut
expect(chat_page.message_creator).to be_listing(channel_2, active: true)
chat_page.message_creator.arrow_down_shortcut
expect(chat_page.message_creator).to be_listing(channel_1, active: true)
chat_page.message_creator.arrow_up_shortcut
expect(chat_page.message_creator).to be_listing(channel_2, active: true)
end
end
context "with disabled content" do
fab!(:user_1) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:direct_message_channel, users: [current_user, user_1]) }
before { user_1.user_option.update!(chat_enabled: false) }
it "doesnt make the content active" do
visit("/")
chat_page.open_new_message
expect(chat_page.message_creator).to be_listing(user_1, inactive: true, disabled: true)
end
end
end
context "when filtering" do
fab!(:channel_1) { Fabricate(:chat_channel, name: "bob-channel") }
fab!(:user_1) { Fabricate(:user, username: "bob-user") }
fab!(:user_2) { Fabricate(:user) }
fab!(:channel_2) { Fabricate(:direct_message_channel, users: [current_user, user_1]) }
fab!(:channel_3) { Fabricate(:direct_message_channel, users: [current_user, user_1, user_2]) }
before { channel_1.add(current_user) }
context "with no prefix" do
it "lists all matching content" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.filter("bob")
expect(chat_page.message_creator).to be_listing(channel_1)
expect(chat_page.message_creator).to be_not_listing(channel_2)
expect(chat_page.message_creator).to be_listing(channel_3)
expect(chat_page.message_creator).to be_listing(user_1)
expect(chat_page.message_creator).to be_not_listing(user_2)
end
end
context "with channel prefix" do
it "lists matching channel" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.filter("#bob")
expect(chat_page.message_creator).to be_listing(channel_1)
expect(chat_page.message_creator).to be_not_listing(channel_2)
expect(chat_page.message_creator).to be_listing(channel_3)
expect(chat_page.message_creator).to be_not_listing(user_1)
expect(chat_page.message_creator).to be_not_listing(user_2)
end
end
context "with user prefix" do
it "lists matching users" do
visit("/")
chat_page.open_new_message
chat_page.message_creator.filter("@bob")
expect(chat_page.message_creator).to be_not_listing(channel_1)
expect(chat_page.message_creator).to be_not_listing(channel_2)
expect(chat_page.message_creator).to be_not_listing(channel_3)
expect(chat_page.message_creator).to be_listing(user_1)
expect(chat_page.message_creator).to be_not_listing(user_2)
end
end
end
context "with selection" do
fab!(:channel_1) { Fabricate(:chat_channel, name: "bob-channel") }
fab!(:user_1) { Fabricate(:user, username: "bob-user") }
fab!(:user_2) { Fabricate(:user, username: "bobby-user") }
fab!(:user_3) { Fabricate(:user, username: "sam-user") }
fab!(:channel_2) { Fabricate(:direct_message_channel, users: [current_user, user_1]) }
fab!(:channel_3) { Fabricate(:direct_message_channel, users: [current_user, user_2]) }
before do
channel_1.add(current_user)
visit("/")
chat_page.open_new_message
chat_page.message_creator.shift_click_row(user_1)
end
context "when pressing enter" do
it "opens the channel" do
chat_page.message_creator.enter_shortcut
expect(chat_page).to have_drawer(channel_id: channel_2.id)
end
end
context "when clicking cta" do
it "opens the channel" do
chat_page.message_creator.click_cta
expect(chat_page).to have_drawer(channel_id: channel_2.id)
end
end
context "when filtering" do
it "shows only matching users regarless of prefix" do
chat_page.message_creator.filter("#bob")
expect(chat_page.message_creator).to be_listing(user_1)
expect(chat_page.message_creator).to be_listing(user_2)
expect(chat_page.message_creator).to be_not_listing(user_3)
expect(chat_page.message_creator).to be_not_listing(channel_1)
expect(chat_page.message_creator).to be_not_listing(channel_2)
expect(chat_page.message_creator).to be_not_listing(channel_3)
end
it "shows selected user as selected in content" do
chat_page.message_creator.filter("@bob")
expect(chat_page.message_creator).to be_listing(user_1, selected: true)
expect(chat_page.message_creator).to be_listing(user_2, selected: false)
end
end
context "when clicking another user" do
it "adds it to the selection" do
chat_page.message_creator.filter("@bob")
chat_page.message_creator.click_row(user_2)
expect(chat_page.message_creator).to be_selecting(user_1)
expect(chat_page.message_creator).to be_selecting(user_2)
end
end
context "when pressing backspace" do
it "removes it" do
chat_page.message_creator.backspace_shortcut
expect(chat_page.message_creator).to be_selecting(user_1, active: true)
chat_page.message_creator.backspace_shortcut
expect(chat_page.message_creator).to be_not_selecting(user_1)
end
end
context "when navigating selection with arrow left/right" do
it "changes active item" do
chat_page.message_creator.filter("@bob")
chat_page.message_creator.click_row(user_2)
chat_page.message_creator.arrow_left_shortcut
expect(chat_page.message_creator).to be_selecting(user_2, active: true)
chat_page.message_creator.arrow_left_shortcut
expect(chat_page.message_creator).to be_selecting(user_1, active: true)
chat_page.message_creator.arrow_left_shortcut
expect(chat_page.message_creator).to be_selecting(user_2, active: true)
chat_page.message_creator.arrow_right_shortcut
expect(chat_page.message_creator).to be_selecting(user_1, active: true)
end
end
context "when clicking selection" do
it "removes it" do
chat_page.message_creator.click_item(user_1)
expect(chat_page.message_creator).to be_not_selecting(user_1)
end
end
end
end

View File

@ -3,6 +3,12 @@
module PageObjects module PageObjects
module Pages module Pages
class Chat < PageObjects::Pages::Base class Chat < PageObjects::Pages::Base
MODIFIER = RUBY_PLATFORM =~ /darwin/i ? :meta : :control
def message_creator
@message_creator ||= PageObjects::Components::Chat::MessageCreator.new
end
def prefers_full_page def prefers_full_page
page.execute_script( page.execute_script(
"window.localStorage.setItem('discourse_chat_preferred_mode', '\"FULL_PAGE_CHAT\"');", "window.localStorage.setItem('discourse_chat_preferred_mode', '\"FULL_PAGE_CHAT\"');",
@ -17,6 +23,10 @@ module PageObjects
visit("/chat") visit("/chat")
end end
def open_new_message
send_keys([MODIFIER, "k"])
end
def has_drawer?(channel_id: nil, expanded: true) def has_drawer?(channel_id: nil, expanded: true)
drawer?(expectation: true, channel_id: channel_id, expanded: expanded) drawer?(expectation: true, channel_id: channel_id, expanded: expanded)
end end

View File

@ -18,6 +18,10 @@ module PageObjects
input.value.blank? input.value.blank?
end end
def enabled?
component.has_css?(".chat-composer.is-enabled")
end
def has_saved_draft? def has_saved_draft?
component.has_css?(".chat-composer.is-draft-saved") component.has_css?(".chat-composer.is-draft-saved")
end end

View File

@ -0,0 +1,131 @@
# frozen_string_literal: true
module PageObjects
module Components
module Chat
class MessageCreator < PageObjects::Components::Base
attr_reader :context
SELECTOR = ".chat-new-message-modal"
def component
find(SELECTOR)
end
def input
component.find(".chat-message-creator__input")
end
def filter(query = "")
input.fill_in(with: query)
end
def opened?
page.has_css?(SELECTOR)
end
def enter_shortcut
input.send_keys(:enter)
end
def backspace_shortcut
input.send_keys(:backspace)
end
def shift_enter_shortcut
input.send_keys(:shift, :enter)
end
def click_cta
component.find(".chat-message-creator__open-dm-btn").click
end
def arrow_left_shortcut
input.send_keys(:arrow_left)
end
def arrow_right_shortcut
input.send_keys(:arrow_right)
end
def arrow_down_shortcut
input.send_keys(:arrow_down)
end
def arrow_up_shortcut
input.send_keys(:arrow_up)
end
def listing?(chatable, **args)
component.has_css?(build_row_selector(chatable, **args))
end
def not_listing?(chatable, **args)
component.has_no_css?(build_row_selector(chatable, **args))
end
def selecting?(chatable, **args)
component.has_css?(build_item_selector(chatable, **args))
end
def not_selecting?(chatable, **args)
component.has_no_css?(build_item_selector(chatable, **args))
end
def click_item(chatable, **args)
component.find(build_item_selector(chatable, **args)).click
end
def click_row(chatable, **args)
component.find(build_row_selector(chatable, **args)).click
end
def shift_click_row(chatable, **args)
component.find(build_row_selector(chatable, **args)).click(:shift)
end
def build_item_selector(chatable, **args)
selector = ".chat-message-creator__selection-item"
selector += content_selector(**args)
selector += chatable_selector(chatable)
selector
end
def build_row_selector(chatable, **args)
selector = ".chat-message-creator__row"
selector += content_selector(**args)
selector += chatable_selector(chatable)
selector
end
def content_selector(**args)
selector = ""
selector = ".-disabled" if args[:disabled]
selector = ".-selected" if args[:selected]
selector = ":not(.-disabled)" if args[:enabled]
if args[:active]
selector += ".-active"
elsif args[:inactive]
selector += ":not(.-active)"
end
selector
end
def chatable_selector(chatable)
selector = ""
if chatable.try(:category_channel?)
selector += ".-channel"
selector += "[data-id='c-#{chatable.id}']"
elsif chatable.try(:direct_message_channel?)
selector += ".-channel"
selector += "[data-id='c-#{chatable.id}']"
else
selector += ".-user"
selector += "[data-id='u-#{chatable.id}']"
end
selector
end
end
end
end
end

View File

@ -8,10 +8,6 @@ module PageObjects
find("#{VISIBLE_DRAWER} .open-browse-page-btn").click find("#{VISIBLE_DRAWER} .open-browse-page-btn").click
end end
def open_draft_channel
find("#{VISIBLE_DRAWER} .open-draft-channel-page-btn").click
end
def close def close
find("#{VISIBLE_DRAWER} .chat-drawer-header__close-btn").click find("#{VISIBLE_DRAWER} .chat-drawer-header__close-btn").click
end end

View File

@ -11,13 +11,6 @@ module PageObjects
find(".sidebar-section[data-section-name='chat-dms']") find(".sidebar-section[data-section-name='chat-dms']")
end end
def open_draft_channel
find(
".sidebar-section[data-section-name='chat-dms'] .sidebar-section-header-button",
visible: false,
).click
end
def open_browse def open_browse
find( find(
".sidebar-section[data-section-name='chat-channels'] .sidebar-section-header-button", ".sidebar-section[data-section-name='chat-channels'] .sidebar-section-header-button",

View File

@ -11,6 +11,7 @@ RSpec.describe "Visit channel", type: :system do
fab!(:inaccessible_dm_channel_1) { Fabricate(:direct_message_channel) } fab!(:inaccessible_dm_channel_1) { Fabricate(:direct_message_channel) }
let(:chat) { PageObjects::Pages::Chat.new } let(:chat) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
before { chat_system_bootstrap } before { chat_system_bootstrap }
@ -143,13 +144,7 @@ RSpec.describe "Visit channel", type: :system do
it "allows to join it" do it "allows to join it" do
chat.visit_channel(dm_channel_1) chat.visit_channel(dm_channel_1)
expect(page).to have_content(I18n.t("js.chat.channel_settings.join_channel")) expect(channel_page.composer).to be_enabled
end
it "shows a preview of the channel" do
chat.visit_channel(dm_channel_1)
expect(chat).to have_message(message_1)
end end
end end
end end

View File

@ -37,7 +37,7 @@ module("Discourse Chat | Component | chat-user-avatar", function (hooks) {
}); });
await render( await render(
hbs`<ChatUserAvatar @chat={{this.chat}} @user={{this.user}} />` hbs`<ChatUserAvatar @showPresence={{true}} @chat={{this.chat}} @user={{this.user}} />`
); );
assert.true( assert.true(

View File

@ -1,141 +0,0 @@
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { click, fillIn, render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import { Promise } from "rsvp";
import fabricators from "discourse/plugins/chat/discourse/lib/fabricators";
import { module, test } from "qunit";
function mockChat(context, options = {}) {
const mock = context.container.lookup("service:chat");
mock.searchPossibleDirectMessageUsers = () => {
return Promise.resolve({
users: options.users || [{ username: "hawk" }, { username: "mark" }],
});
};
mock.getDmChannelForUsernames = () => {
return Promise.resolve({ chat_channel: fabricators.channel() });
};
return mock;
}
module("Discourse Chat | Component | direct-message-creator", function (hooks) {
setupRenderingTest(hooks);
test("search", async function (assert) {
this.set("chat", mockChat(this));
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
await render(
hbs`<DirectMessageCreator @channel={{this.channel}} @chat={{this.chat}} />`
);
await fillIn(".filter-usernames", "hawk");
assert.true(exists("li.user[data-username='hawk']"));
});
test("select/deselect", async function (assert) {
this.set("chat", mockChat(this));
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
await render(
hbs`<DirectMessageCreator @channel={{this.channel}} @chat={{this.chat}} />`
);
assert.false(exists(".selected-user"));
await fillIn(".filter-usernames", "hawk");
await click("li.user[data-username='hawk']");
assert.true(exists(".selected-user"));
await click(".selected-user");
assert.false(exists(".selected-user"));
});
test("no search results", async function (assert) {
this.set("chat", mockChat(this, { users: [] }));
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
await render(
hbs`<DirectMessageCreator @channel={{this.channel}} @chat={{this.chat}} />`
);
await fillIn(".filter-usernames", "bad cat");
assert.true(exists(".no-results"));
});
test("loads user on first load", async function (assert) {
this.set("chat", mockChat(this));
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
await render(
hbs`<DirectMessageCreator @channel={{this.channel}} @chat={{this.chat}} />`
);
assert.true(exists("li.user[data-username='hawk']"));
assert.true(exists("li.user[data-username='mark']"));
});
test("do not load more users after selection", async function (assert) {
this.set("chat", mockChat(this));
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
await render(
hbs`<DirectMessageCreator @channel={{this.channel}} @chat={{this.chat}} />`
);
await click("li.user[data-username='hawk']");
assert.false(exists("li.user[data-username='mark']"));
});
test("apply is-focused to filter-area on focus input", async function (assert) {
this.set("chat", mockChat(this));
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
await render(
hbs`<DirectMessageCreator @channel={{this.channel}} @chat={{this.chat}} /><button class="test-blur">blur</button>`
);
await click(".filter-usernames");
assert.true(exists(".filter-area.is-focused"));
await click(".test-blur");
assert.false(exists(".filter-area.is-focused"));
});
test("state is reset on channel change", async function (assert) {
this.set("chat", mockChat(this));
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
await render(
hbs`<DirectMessageCreator @channel={{this.channel}} @chat={{this.chat}} />`
);
await fillIn(".filter-usernames", "hawk");
assert.strictEqual(query(".filter-usernames").value, "hawk");
this.set("channel", fabricators.channel());
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
assert.strictEqual(query(".filter-usernames").value, "");
assert.true(exists(".filter-area.is-focused"));
assert.true(exists("li.user[data-username='hawk']"));
});
test("shows user status", async function (assert) {
const userWithStatus = {
username: "hawk",
status: { emoji: "tooth", description: "off to dentist" },
};
const chat = mockChat(this, { users: [userWithStatus] });
this.set("chat", chat);
this.set("channel", ChatChannel.createDirectMessageChannelDraft());
await render(
hbs`<DirectMessageCreator @channel={{this.channel}} @chat={{this.chat}} />`
);
await fillIn(".filter-usernames", "hawk");
assert.true(exists(".user-status-message"));
});
});