FEATURE: new jump to channel menu ()

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
plugins/chat
app
assets
config
lib/chat
spec
test/javascripts/components

@ -1,7 +1,11 @@
# frozen_string_literal: true
CHANNEL_EDITABLE_PARAMS = %i[name description slug]
CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions threading_enabled]
CHANNEL_EDITABLE_PARAMS ||= %i[name description slug]
CATEGORY_CHANNEL_EDITABLE_PARAMS ||= %i[
auto_join_users
allow_channel_wide_mentions
threading_enabled
]
class Chat::Api::ChannelsController < Chat::ApiController
def index
@ -12,7 +16,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil
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 =
channels.map do |channel|
Chat::ChannelSerializer.new(

@ -1,83 +1,11 @@
# frozen_string_literal: true
class Chat::Api::ChatablesController < Chat::ApiController
before_action :ensure_logged_in
def index
params.require(:filter)
filter = params[:filter].downcase
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)
with_service(::Chat::SearchChatable) do
on_success { render_serialized(result, ::Chat::ChatablesSerializer, root: false) }
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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -5,6 +5,7 @@ import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import discourseDebounce from "discourse-common/lib/debounce";
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"];
@ -38,6 +39,11 @@ export default class ChatBrowseView extends Component {
return document.querySelector("#chat-progress-bar-container");
}
@action
showChatNewMessageModal() {
this.modal.show(ChatNewMessageModal);
}
@action
onScroll() {
discourseDebounce(

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

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

@ -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();
}
},
});

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

@ -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);
}
});
},
});

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

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

@ -6,7 +6,6 @@ import { action } from "@ember/object";
// TODO (martin) Remove this when the handleSentMessage logic inside chatChannelPaneSubscriptionsManager
// is moved over from this file completely.
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 { cancel, later, next, schedule } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later";
@ -736,33 +735,6 @@ export default class ChatLivePane extends Component {
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);
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) {
const stagedMessage = this.args.channel.findStagedMessage(id);
if (stagedMessage) {
@ -977,14 +929,9 @@ export default class ChatLivePane extends Component {
return;
}
if (!this.args.channel.isDraft) {
event.preventDefault();
this.composer.focus({ addText: event.key });
return;
}
event.preventDefault();
event.stopPropagation();
this.composer.focus({ addText: event.key });
return;
}
@action

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

@ -45,7 +45,7 @@ export default class ChatComposer extends Component {
@tracked presenceChannelName;
get shouldRenderReplyingIndicator() {
return !this.args.channel?.isDraft;
return this.args.channel;
}
get shouldRenderMessageDetails() {
@ -89,7 +89,7 @@ export default class ChatComposer extends Component {
setupTextareaInteractor(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 });
}
}
@ -250,10 +250,6 @@ export default class ChatComposer extends Component {
return;
}
if (this.args.channel.isDraft) {
return;
}
this.chatComposerPresenceManager.notifyState(
this.presenceChannelName,
!this.currentMessage.editing && this.hasContent

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

@ -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),
})
);
}
});
}
}

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

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

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

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

@ -1,12 +1,11 @@
<div
class="chat-user-avatar
{{if (and this.isOnline this.showPresence) 'is-online'}}"
class="chat-user-avatar {{if (and this.isOnline @showPresence) 'is-online'}}"
>
<div
role="button"
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>

@ -1,23 +1,19 @@
import Component from "@ember/component";
import { computed } from "@ember/object";
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class ChatUserAvatar extends Component {
@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() {
const users = this.chat.presenceChannel?.users;
const users = (this.args.chat || this.chat).presenceChannel?.users;
return (
!!users?.findBy("id", this.user?.id) ||
!!users?.findBy("username", this.user?.username)
!!users?.findBy("id", this.args.user?.id) ||
!!users?.findBy("username", this.args.user?.username)
);
}
}

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

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

@ -3,7 +3,6 @@ import { inject as service } from "@ember/service";
import I18n from "I18n";
import discourseDebounce from "discourse-common/lib/debounce";
import { action } from "@ember/object";
import { isEmpty } from "@ember/utils";
export default class ChatComposerChannel extends ChatComposer {
@service("chat-channel-composer") composer;
@ -22,8 +21,6 @@ export default class ChatComposerChannel extends ChatComposer {
get disabled() {
return (
(this.args.channel.isDraft &&
isEmpty(this.args.channel?.chatable?.users)) ||
!this.chat.userCanInteractWithChat ||
!this.args.channel.canModifyMessages(this.currentUser)
);
@ -36,10 +33,6 @@ export default class ChatComposerChannel extends ChatComposer {
@action
persistDraft() {
if (this.args.channel?.isDraft) {
return;
}
this.chatDraftsManager.add(this.currentMessage);
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) {
return I18n.t("chat.placeholder_silenced");
} else {

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

@ -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;
}
}

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

@ -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"));
}
}

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

@ -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"));
}
}

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

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

@ -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
);
}
},
});

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

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

@ -1,5 +1,5 @@
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 =
navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
@ -16,6 +16,7 @@ export default {
const router = container.lookup("service:router");
const appEvents = container.lookup("service:app-events");
const modal = container.lookup("service:modal");
const chatStateManager = container.lookup("service:chat-state-manager");
const chatThreadPane = container.lookup("service:chat-thread-pane");
const chatThreadListPane = container.lookup(
@ -27,11 +28,7 @@ export default {
const openChannelSelector = (e) => {
e.preventDefault();
e.stopPropagation();
if (document.getElementById("chat-channel-selector-modal-inner")) {
appEvents.trigger("chat-channel-selector-modal:close");
} else {
showModal("chat-channel-selector-modal");
}
modal.show(ChatNewMessageModal);
};
const handleMoveUpShortcut = (e) => {

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

@ -56,19 +56,7 @@ export default class ChatChannel {
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 isDraft = false;
@tracked title;
@tracked slug;
@tracked description;

@ -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;
}
}

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

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

@ -408,6 +408,17 @@ export default class ChatApi extends Service {
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
* message ID is provided, then the latest message for the channel is fetched

@ -101,6 +101,16 @@ export default class ChatChannelsManager extends Service {
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() {
return this.channels
.filter(

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

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

@ -1,6 +1,5 @@
import deprecated from "discourse-common/lib/deprecated";
import { tracked } from "@glimmer/tracking";
import userSearch from "discourse/lib/user-search";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Service, { inject as service } from "@ember/service";
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() {
// When user opens chat we need to give them the 'best' channel when they enter.
//

@ -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;
}
}
}
}

@ -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;
}
}

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

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

@ -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;
}
}
}

@ -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;
}
}
}

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

@ -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;
}
}

@ -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;
}
}

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

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

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

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

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

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

@ -324,6 +324,16 @@ en:
members: Members
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:
title: Edit channel
input_placeholder: Add a name
@ -342,10 +352,6 @@ en:
no_results: No results
selected_user_title: "Deselect %{username}"
channel_selector:
title: "Jump to channel"
no_channels: "No channels match your search"
channel:
no_memberships: This channel has no members
no_memberships_found: No members found

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

@ -6,10 +6,8 @@ module Chat
def self.structured(guardian, include_threads: false)
memberships = Chat::ChannelMembershipManager.all_for_user(guardian.user)
public_channels =
secured_public_channels(guardian, memberships, status: :open, following: true)
direct_message_channels =
secured_direct_message_channels(guardian.user.id, memberships, guardian)
public_channels = secured_public_channels(guardian, status: :open, following: true)
direct_message_channels = secured_direct_message_channels(guardian.user.id, guardian)
{
public_channels: public_channels,
direct_message_channels: direct_message_channels,
@ -152,7 +150,7 @@ module Chat
channels.limit(options[:limit]).offset(options[:offset])
end
def self.secured_public_channels(guardian, memberships, options = { following: true })
def self.secured_public_channels(guardian, options = { following: true })
channels =
secured_public_channel_search(
guardian,
@ -174,19 +172,60 @@ module Chat
)
end
def self.secured_direct_message_channels(user_id, memberships, guardian)
query = Chat::Channel.includes(chatable: [{ direct_message_users: :user }, :users])
def self.secured_direct_message_channels(user_id, guardian)
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.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
.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("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})")
.where(chat_channels: { id: scoped_channels })
.order(last_message_sent_at: :desc)
.to_a
channels = query.to_a
preload_fields =
User.allowed_user_custom_fields(guardian) +
UserField.all.pluck(:id).map { |fid| "#{User::USER_FIELD_PREFIX}#{fid}" }

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

@ -8,197 +8,35 @@ RSpec.describe Chat::Api::ChatablesController do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
end
describe "#index" do
fab!(:user) { Fabricate(:user, username: "johndoe", name: "John Doe") }
fab!(:current_user) { Fabricate(:user) }
describe "#index" do
describe "without chat permissions" do
it "errors errors for anon" do
get "/chat/api/chatables", params: { filter: "so" }
get "/chat/api/chatables"
expect(response.status).to eq(403)
end
it "errors when user cannot chat" do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff]
sign_in(user)
get "/chat/api/chatables", params: { filter: "so" }
sign_in(current_user)
get "/chat/api/chatables"
expect(response.status).to eq(403)
end
end
describe "with chat permissions" do
fab!(:other_user) { Fabricate(:user, username: "janemay", name: "Jane May") }
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]) }
fab!(:channel_1) { Fabricate(:chat_channel) }
before do
chat_channel.update(name: "something")
sign_in(user)
end
before { sign_in(current_user) }
it "returns results" do
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.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 '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,
expect(response.parsed_body["category_channels"][0]["identifier"]).to eq(
"c-#{channel_1.id}",
)
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

@ -49,7 +49,7 @@ RSpec.describe Chat::CreateDirectMessageChannel do
)
result.channel.user_chat_channel_memberships.each do |membership|
expect(membership).to have_attributes(
following: true,
following: false,
muted: false,
desktop_notification_level: "always",
mobile_notification_level: "always",
@ -57,12 +57,6 @@ RSpec.describe Chat::CreateDirectMessageChannel do
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
before { described_class.call(params) }

@ -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 "doesn’t 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 "doesn’t 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 "doesn’t 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 "doesn’t 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 "doesn’t return users" do
expect(result.users).to be_blank
end
end
end
end

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

@ -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 doesn’t 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 doesn’t 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

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

@ -121,8 +121,8 @@ RSpec.describe "List channels | mobile", type: :system, mobile: true do
it "has a new dm channel button" do
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

@ -223,37 +223,15 @@ RSpec.describe "Navigation", type: :system do
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
it "opens draft in full page" do
visit("/")
chat_page.open_from_header
chat_drawer_page.maximize
visit("/")
sidebar_page.open_draft_channel
chat_page.open_new_message
expect(page).to have_current_path("/chat/draft-channel")
expect(page).not_to have_css(".chat-drawer.is-expanded")
expect(chat_page.message_creator).to be_opened
end
end

@ -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 "doesn’t 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

@ -3,6 +3,12 @@
module PageObjects
module Pages
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
page.execute_script(
"window.localStorage.setItem('discourse_chat_preferred_mode', '\"FULL_PAGE_CHAT\"');",
@ -17,6 +23,10 @@ module PageObjects
visit("/chat")
end
def open_new_message
send_keys([MODIFIER, "k"])
end
def has_drawer?(channel_id: nil, expanded: true)
drawer?(expectation: true, channel_id: channel_id, expanded: expanded)
end

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

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

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

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

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

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

@ -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"));
});
});