From d75d64bf16f7475b5d6e7345e62139a414328443 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 5 Jul 2023 18:18:27 +0200 Subject: [PATCH] FEATURE: new jump to channel menu (#22383) This commit replaces two existing screens: - draft - channel selection modal Main features compared to existing solutions - features are now combined, meaning you can for example create multi users DM - it will show users with chat disabled - it shows unread state - hopefully a better look/feel - lots of small details and fixes... Other noticeable fixes - starting a DM with a user, even from the user card and clicking Chat 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 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 --- .../chat/api/channels_controller.rb | 10 +- .../chat/api/chatables_controller.rb | 80 +-- .../app/controllers/chat/chat_controller.rb | 5 +- .../base_channel_membership_serializer.rb | 3 +- .../chat/chatable_user_serializer.rb | 15 + .../serializers/chat/chatables_serializer.rb | 63 +++ .../chat/direct_message_serializer.rb | 2 +- .../chat/create_direct_message_channel.rb | 9 +- plugins/chat/app/services/chat/publisher.rb | 35 +- .../chat/app/services/chat/search_chatable.rb | 109 ++++ plugins/chat/app/services/service/base.rb | 2 + .../javascripts/discourse/chat-route-map.js | 1 - .../discourse/components/channels-list.hbs | 24 +- .../discourse/components/channels-list.js | 8 + .../discourse/components/chat-browse-view.hbs | 8 +- .../discourse/components/chat-browse-view.js | 6 + .../components/chat-channel-members-view.js | 2 +- .../components/chat-channel-selection-row.hbs | 16 - .../components/chat-channel-selection-row.js | 24 - .../chat-channel-selector-modal-inner.hbs | 33 -- .../chat-channel-selector-modal-inner.js | 235 -------- .../components/chat-channel-title.hbs | 72 ++- .../discourse/components/chat-channel.hbs | 6 +- .../discourse/components/chat-channel.js | 57 +- .../discourse/components/chat-composer.hbs | 1 + .../discourse/components/chat-composer.js | 8 +- .../components/chat-draft-channel-screen.hbs | 28 - .../components/chat-draft-channel-screen.js | 64 --- .../components/chat-drawer/draft-channel.hbs | 11 - .../components/chat-drawer/draft-channel.js | 6 - .../components/chat-full-page-header.hbs | 6 +- .../components/chat-retention-reminder.js | 7 +- .../discourse/components/chat-user-avatar.hbs | 7 +- .../discourse/components/chat-user-avatar.js | 18 +- .../components/chat-user-display-name.hbs | 11 +- .../components/chat-user-display-name.js | 16 +- .../components/chat/composer/channel.js | 19 - .../components/chat/message-creator.hbs | 141 +++++ .../components/chat/message-creator.js | 522 ++++++++++++++++++ .../chat/message-creator/channel-row.hbs | 20 + .../chat/message-creator/channel-row.js | 12 + .../chat/message-creator/user-row.hbs | 36 ++ .../chat/message-creator/user-row.js | 17 + .../chat/message-creator/user-selection.hbs | 5 + .../components/direct-message-creator.hbs | 96 ---- .../components/direct-message-creator.js | 331 ----------- .../components/modal/chat-new-message.hbs | 7 + .../components/modal/chat-new-message.js | 6 + .../initializers/chat-keyboard-shortcuts.js | 9 +- .../discourse/initializers/chat-sidebar.js | 4 +- .../discourse/models/chat-channel.js | 12 - .../discourse/models/chat-chatable.js | 72 +++ .../models/user-chat-channel-membership.js | 2 + .../javascripts/discourse/routes/chat.js | 1 - .../discourse/services/chat-api.js | 11 + .../services/chat-channels-manager.js | 10 + .../discourse/services/chat-drawer-router.js | 2 - .../services/chat-subscriptions-manager.js | 1 + .../javascripts/discourse/services/chat.js | 6 - .../common/chat-channel-selector-modal.scss | 63 --- .../common/chat-draft-channel.scss | 43 -- .../stylesheets/common/chat-height-mixin.scss | 12 +- .../assets/stylesheets/common/chat-index.scss | 2 +- .../common/chat-message-creator.scss | 305 ++++++++++ .../common/chat-new-message-modal.scss | 34 ++ .../common/chat-replying-indicator.scss | 1 + .../stylesheets/common/chat-section.scss | 15 + .../common/direct-message-creator.scss | 197 ------- .../chat/assets/stylesheets/common/index.scss | 7 +- .../common/sidebar-extensions.scss | 2 +- .../desktop/chat-message-creator.scss | 7 + .../assets/stylesheets/desktop/index.scss | 1 + .../mobile/chat-message-creator.scss | 6 + .../chat/assets/stylesheets/mobile/index.scss | 1 + plugins/chat/config/locales/client.en.yml | 14 +- plugins/chat/config/routes.rb | 2 - plugins/chat/lib/chat/channel_fetcher.rb | 63 ++- .../spec/lib/chat/channel_fetcher_spec.rb | 54 +- .../chat/api/chatables_controller_spec.rb | 186 +------ .../create_direct_message_channel_spec.rb | 8 +- .../services/chat/search_chatable_spec.rb | 139 +++++ .../schemas/user_chat_channel_membership.json | 4 +- .../system/channel_selector_modal_spec.rb | 80 --- .../chat/spec/system/draft_message_spec.rb | 26 - .../spec/system/list_channels/mobile_spec.rb | 4 +- plugins/chat/spec/system/navigation_spec.rb | 26 +- plugins/chat/spec/system/new_message_spec.rb | 375 +++++++++++++ .../spec/system/page_objects/chat/chat.rb | 10 + .../page_objects/chat/components/composer.rb | 4 + .../chat/components/message_creator.rb | 131 +++++ .../page_objects/chat_drawer/chat_drawer.rb | 4 - .../system/page_objects/sidebar/sidebar.rb | 7 - .../chat/spec/system/visit_channel_spec.rb | 9 +- .../components/chat-user-avatar-test.js | 2 +- .../components/direct-message-creator-test.js | 141 ----- 95 files changed, 2331 insertions(+), 2004 deletions(-) create mode 100644 plugins/chat/app/serializers/chat/chatable_user_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/chatables_serializer.rb create mode 100644 plugins/chat/app/services/chat/search_chatable.rb delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-drawer/draft-channel.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-drawer/draft-channel.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/message-creator.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/message-creator.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-selection.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/components/direct-message-creator.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/modal/chat-new-message.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/modal/chat-new-message.js create mode 100644 plugins/chat/assets/javascripts/discourse/models/chat-chatable.js delete mode 100644 plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss delete mode 100644 plugins/chat/assets/stylesheets/common/chat-draft-channel.scss create mode 100644 plugins/chat/assets/stylesheets/common/chat-message-creator.scss create mode 100644 plugins/chat/assets/stylesheets/common/chat-new-message-modal.scss create mode 100644 plugins/chat/assets/stylesheets/common/chat-section.scss delete mode 100644 plugins/chat/assets/stylesheets/common/direct-message-creator.scss create mode 100644 plugins/chat/assets/stylesheets/desktop/chat-message-creator.scss create mode 100644 plugins/chat/assets/stylesheets/mobile/chat-message-creator.scss create mode 100644 plugins/chat/spec/services/chat/search_chatable_spec.rb delete mode 100644 plugins/chat/spec/system/channel_selector_modal_spec.rb delete mode 100644 plugins/chat/spec/system/draft_message_spec.rb create mode 100644 plugins/chat/spec/system/new_message_spec.rb create mode 100644 plugins/chat/spec/system/page_objects/chat/components/message_creator.rb delete mode 100644 plugins/chat/test/javascripts/components/direct-message-creator-test.js diff --git a/plugins/chat/app/controllers/chat/api/channels_controller.rb b/plugins/chat/app/controllers/chat/api/channels_controller.rb index c4d99c44cbb..9cdd3a42482 100644 --- a/plugins/chat/app/controllers/chat/api/channels_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_controller.rb @@ -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( diff --git a/plugins/chat/app/controllers/chat/api/chatables_controller.rb b/plugins/chat/app/controllers/chat/api/chatables_controller.rb index 8454b74be9a..7940a7221e5 100644 --- a/plugins/chat/app/controllers/chat/api/chatables_controller.rb +++ b/plugins/chat/app/controllers/chat/api/chatables_controller.rb @@ -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 diff --git a/plugins/chat/app/controllers/chat/chat_controller.rb b/plugins/chat/app/controllers/chat/chat_controller.rb index 38b9d34733f..e0d9d3a9a79 100644 --- a/plugins/chat/app/controllers/chat/chat_controller.rb +++ b/plugins/chat/app/controllers/chat/chat_controller.rb @@ -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] diff --git a/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb b/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb index 6ceb0ee522c..3b3b910ceeb 100644 --- a/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb +++ b/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb @@ -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 diff --git a/plugins/chat/app/serializers/chat/chatable_user_serializer.rb b/plugins/chat/app/serializers/chat/chatable_user_serializer.rb new file mode 100644 index 00000000000..1509174439e --- /dev/null +++ b/plugins/chat/app/serializers/chat/chatable_user_serializer.rb @@ -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 diff --git a/plugins/chat/app/serializers/chat/chatables_serializer.rb b/plugins/chat/app/serializers/chat/chatables_serializer.rb new file mode 100644 index 00000000000..eada1f105cf --- /dev/null +++ b/plugins/chat/app/serializers/chat/chatables_serializer.rb @@ -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 diff --git a/plugins/chat/app/serializers/chat/direct_message_serializer.rb b/plugins/chat/app/serializers/chat/direct_message_serializer.rb index 95aa9fca6e8..2362dec1313 100644 --- a/plugins/chat/app/serializers/chat/direct_message_serializer.rb +++ b/plugins/chat/app/serializers/chat/direct_message_serializer.rb @@ -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 } diff --git a/plugins/chat/app/services/chat/create_direct_message_channel.rb b/plugins/chat/app/services/chat/create_direct_message_channel.rb index d49fc7e6f5e..1b5600bcf53 100644 --- a/plugins/chat/app/services/chat/create_direct_message_channel.rb +++ b/plugins/chat/app/services/chat/create_direct_message_channel.rb @@ -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 diff --git a/plugins/chat/app/services/chat/publisher.rb b/plugins/chat/app/services/chat/publisher.rb index 13285dd3e09..08b78083fd5 100644 --- a/plugins/chat/app/services/chat/publisher.rb +++ b/plugins/chat/app/services/chat/publisher.rb @@ -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( diff --git a/plugins/chat/app/services/chat/search_chatable.rb b/plugins/chat/app/services/chat/search_chatable.rb new file mode 100644 index 00000000000..84a1b58dbcf --- /dev/null +++ b/plugins/chat/app/services/chat/search_chatable.rb @@ -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 diff --git a/plugins/chat/app/services/service/base.rb b/plugins/chat/app/services/service/base.rb index 48f8c8e822b..f3902dab9e5 100644 --- a/plugins/chat/app/services/service/base.rb +++ b/plugins/chat/app/services/service/base.rb @@ -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? diff --git a/plugins/chat/assets/javascripts/discourse/chat-route-map.js b/plugins/chat/assets/javascripts/discourse/chat-route-map.js index dad81b407dc..a53c4ed78c9 100644 --- a/plugins/chat/assets/javascripts/discourse/chat-route-map.js +++ b/plugins/chat/assets/javascripts/discourse/chat-route-map.js @@ -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" }); diff --git a/plugins/chat/assets/javascripts/discourse/components/channels-list.hbs b/plugins/chat/assets/javascripts/discourse/components/channels-list.hbs index 6df9197b8f8..70660effd69 100644 --- a/plugins/chat/assets/javascripts/discourse/components/channels-list.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/channels-list.hbs @@ -1,11 +1,10 @@ {{#if this.showMobileDirectMessageButton}} - - {{d-icon "plus"}} - + {{/if}}
- {{d-icon "plus"}} - + /> {{/if}}
{{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/channels-list.js b/plugins/chat/assets/javascripts/discourse/components/channels-list.js index c08543b0013..d25d157e0aa 100644 --- a/plugins/chat/assets/javascripts/discourse/components/channels-list.js +++ b/plugins/chat/assets/javascripts/discourse/components/channels-list.js @@ -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; } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.hbs index d004eb0ceec..22d14b9e2cd 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.hbs @@ -59,10 +59,10 @@ {{i18n "chat.empty_state.title"}}

{{i18n "chat.empty_state.direct_message"}}

- - - {{i18n "chat.empty_state.direct_message_cta"}} - +
{{else if this.channelsCollection.length}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js index 39ea226ef68..2f3547b6146 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js @@ -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( diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js index dea4cad847d..6c56520e2d3 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js @@ -19,7 +19,7 @@ export default class ChatChannelMembersView extends Component { didInsertElement() { this._super(...arguments); - if (!this.channel || this.channel.isDraft) { + if (!this.channel) { return; } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.hbs deleted file mode 100644 index a344bb8056a..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.hbs +++ /dev/null @@ -1,16 +0,0 @@ -
- {{#if this.model.user}} - {{avatar this.model imageSize="tiny"}} - - {{this.model.username}} - - {{else}} - - {{/if}} -
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js deleted file mode 100644 index 540676a2e90..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js +++ /dev/null @@ -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(); - } - }, -}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.hbs deleted file mode 100644 index fbb197de19c..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.hbs +++ /dev/null @@ -1,33 +0,0 @@ - -
-
- - {{d-icon "search"}} - - - -
- -
- - {{#each this.channels as |channel|}} - - {{else}} -
- {{i18n "chat.channel_selector.no_channels"}} -
- {{/each}} -
-
-
-
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js deleted file mode 100644 index bb904b0f1b9..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js +++ /dev/null @@ -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); - } - }); - }, -}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.hbs index 041dc04f742..c03cc344c79 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.hbs @@ -1,14 +1,6 @@ -{{#if @channel.isDraft}} -
- {{@channel.title}} - {{#if (has-block)}} - {{yield}} - {{/if}} -
-{{else}} - {{#if @channel.isDirectMessageChannel}} -
- +{{#if @channel.isDirectMessageChannel}} +
+ {{#if @channel.chatable.users.length}}
{{#if this.multiDm}} @@ -18,9 +10,11 @@ {{/if}}
+ {{/if}} - - {{/if}} + + {{#if (has-block)}} + {{yield}} + {{/if}} +
+{{else if @channel.isCategoryChannel}} +
+ + {{d-icon "d-chat"}} + {{#if @channel.chatable.read_restricted}} + {{d-icon "lock" class="chat-channel-title__restricted-category-icon"}} + {{/if}} + + + {{replace-emoji @channel.title}} + + + {{#if (has-block)}} + {{yield}} + {{/if}} +
{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs index 68219b1c3a7..63b05abccbf 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs @@ -74,14 +74,14 @@ @pane={{this.pane}} /> {{else}} - {{#if (or @channel.isDraft @channel.isFollowing)}} + {{#if (and (not @channel.isFollowing) @channel.isCategoryChannel)}} + + {{else}} - {{else}} - {{/if}} {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js index f1b01248f12..13a6742a494 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js @@ -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 diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs index a926032a91c..2362a01ef15 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs @@ -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}} />
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js index bbbd868c3fa..65a07430b4f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -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 diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs deleted file mode 100644 index 4784c08ddec..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs +++ /dev/null @@ -1,28 +0,0 @@ -
- {{#if this.site.mobileView}} -
- -

- {{d-icon "d-chat"}} - {{i18n "chat.draft_channel_screen.header"}} -

-
- {{/if}} - - - - {{#if this.previewedChannel}} - - {{/if}} -
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js deleted file mode 100644 index a4dd13e2e90..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js +++ /dev/null @@ -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), - }) - ); - } - }); - } -} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/draft-channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/draft-channel.hbs deleted file mode 100644 index 1ead7951144..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/draft-channel.hbs +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - -{{#if this.chatStateManager.isDrawerExpanded}} -
- -
-{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/draft-channel.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/draft-channel.js deleted file mode 100644 index 29c6d21f662..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/draft-channel.js +++ /dev/null @@ -1,6 +0,0 @@ -import Component from "@glimmer/component"; -import { inject as service } from "@ember/service"; - -export default class ChatDrawerDraftChannel extends Component { - @service chatStateManager; -} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs index c987a43933d..304b9bc5549 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs @@ -1,8 +1,4 @@ -{{#if - (and - this.chatStateManager.isFullPageActive this.displayed (not @channel.isDraft) - ) -}} +{{#if (and this.chatStateManager.isFullPageActive this.displayed)}}
- {{avatar this.user imageSize=this.avatarSize}} + {{avatar @user imageSize=this.avatarSize}}
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js b/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js index 810b0deb2c2..eb7f163d9c0 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js @@ -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) ); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.hbs index cf858f4fb04..a68732864ab 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.hbs @@ -1,15 +1,20 @@ {{#if this.shouldShowNameFirst}} - {{this.user.name}} + {{@user.name}} {{/if}} - + {{this.formattedUsername}} {{#if this.shouldShowNameLast}} - {{this.user.name}} + {{@user.name}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js index 3130f9d6de6..3016ce869fa 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js @@ -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; } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js index 726c3caa5b9..29eb42150e4 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js @@ -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 { diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.hbs new file mode 100644 index 00000000000..c0bbc86234e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.hbs @@ -0,0 +1,141 @@ +
+
+
+
+
+ {{d-icon "search" class="chat-message-creator__search-icon"}} +
+ + {{#each this.selection as |selection|}} +
+ {{component + (concat "chat/message-creator/" selection.type "-selection") + selection=selection + }} + +
+ {{/each}} + + +
+ + +
+ + {{#if this.showResults}} + + {{/if}} + + {{#if this.showFooter}} + + {{/if}} +
+
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.js b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.js new file mode 100644 index 00000000000..719f5c3ee71 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator.js @@ -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; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.hbs new file mode 100644 index 00000000000..6420a67eaea --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.hbs @@ -0,0 +1,20 @@ + + +{{#if (gt @content.tracking.unreadCount 0)}} +
+{{/if}} + +{{#if this.site.desktopView}} + {{this.openChannelLabel}} +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.js b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.js new file mode 100644 index 00000000000..5763359c9f4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel-row.js @@ -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")); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.hbs new file mode 100644 index 00000000000..397bb059ffa --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.hbs @@ -0,0 +1,36 @@ + + + +{{#if (gt @content.tracking.unreadCount 0)}} +
+{{/if}} + +{{user-status @content.model currentUser=this.currentUser}} + +{{#unless @content.enabled}} + + {{i18n "chat.new_message_modal.disabled_user"}} + +{{/unless}} + +{{#if @selected}} + {{#if this.site.mobileView}} + + {{d-icon "check"}} + + {{else}} + + {{d-icon (if @active "times" "check")}} + + {{/if}} +{{else}} + {{#if this.site.desktopView}} + {{#if @hasSelectedUsers}} + {{this.addUserLabel}} + {{else}} + {{this.openChannelLabel}} + {{/if}} + {{/if}} +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.js b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.js new file mode 100644 index 00000000000..4ae13f5cafe --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-row.js @@ -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")); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-selection.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-selection.hbs new file mode 100644 index 00000000000..b5d27780cad --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/user-selection.hbs @@ -0,0 +1,5 @@ + + + + {{@selection.model.username}} + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.hbs b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.hbs deleted file mode 100644 index 8936d297107..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.hbs +++ /dev/null @@ -1,96 +0,0 @@ -{{#if this.chatProgressBarContainer}} - {{#in-element this.chatProgressBarContainer}} - - {{/in-element}} -{{/if}} - -{{#if (and this.channel.isDraft (not this.isLoading))}} -
-
- - - {{i18n "chat.direct_message_creator.prefix"}} - - -
- {{#each this.selectedUsers as |selectedUser|}} - - - {{selectedUser.username}} - {{d-icon "times"}} - - {{/each}} - - -
-
- - {{#if this.shouldRenderResults}} - {{#if this.users}} -
-
    - {{#each this.users as |user|}} -
  • - - -
  • - {{/each}} -
-
- {{else}} - {{#if this.term.length}} -
-

- {{i18n "chat.direct_message_creator.no_results"}} -

-
- {{/if}} - {{/if}} - {{/if}} -
-{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js deleted file mode 100644 index a636caf0d73..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js +++ /dev/null @@ -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 - ); - } - }, -}); diff --git a/plugins/chat/assets/javascripts/discourse/components/modal/chat-new-message.hbs b/plugins/chat/assets/javascripts/discourse/components/modal/chat-new-message.hbs new file mode 100644 index 00000000000..d090f4c9fbf --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/modal/chat-new-message.hbs @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/modal/chat-new-message.js b/plugins/chat/assets/javascripts/discourse/components/modal/chat-new-message.js new file mode 100644 index 00000000000..3a4a62b1fbb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/modal/chat-new-message.js @@ -0,0 +1,6 @@ +import Component from "@ember/component"; +import { inject as service } from "@ember/service"; + +export default class ChatNewMessageModal extends Component { + @service chat; +} diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js index f038c14d265..fa0f6cb79e0 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js @@ -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) => { diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js index e1297b9b6cf..7b8ab459fb7 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js @@ -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); }, }, ]; diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js index 0bd1d1f825f..135291c449b 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js @@ -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; diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-chatable.js b/plugins/chat/assets/javascripts/discourse/models/chat-chatable.js new file mode 100644 index 00000000000..63558ca8f2b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-chatable.js @@ -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; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js b/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js index e50ec02d769..538774b5038 100644 --- a/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js +++ b/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js @@ -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); } diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat.js b/plugins/chat/assets/javascripts/discourse/routes/chat.js index f5d9c9f7809..8d810813e13 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat.js @@ -28,7 +28,6 @@ export default class ChatRoute extends DiscourseRoute { "chat.channel-legacy", "chat", "chat.index", - "chat.draft-channel", ]; if ( diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index 421584e5e61..960376a4b39 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -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 diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js index 05480096e2d..03aa219bb91 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js @@ -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( diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js index e8da3782f22..45112d7342d 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js @@ -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, diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js index 641f0e5a7e4..ee172ac0eca 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js @@ -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 && diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js index a676d67dd4e..1b12c8580d2 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -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. // diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss b/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss deleted file mode 100644 index c5935c249aa..00000000000 --- a/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss +++ /dev/null @@ -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; - } - } - } -} diff --git a/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss b/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss deleted file mode 100644 index 772f5270e1a..00000000000 --- a/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss +++ /dev/null @@ -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; - } -} diff --git a/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss b/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss index 0dd66c83f4a..fc0ddb5574f 100644 --- a/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss +++ b/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss @@ -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) ); } diff --git a/plugins/chat/assets/stylesheets/common/chat-index.scss b/plugins/chat/assets/stylesheets/common/chat-index.scss index 05f890a4a95..8cfa5049521 100644 --- a/plugins/chat/assets/stylesheets/common/chat-index.scss +++ b/plugins/chat/assets/stylesheets/common/chat-index.scss @@ -1,4 +1,4 @@ -.btn-floating.open-draft-channel-page-btn { +.btn-floating.open-new-message-btn { position: fixed; background: var(--tertiary); bottom: 2rem; diff --git a/plugins/chat/assets/stylesheets/common/chat-message-creator.scss b/plugins/chat/assets/stylesheets/common/chat-message-creator.scss new file mode 100644 index 00000000000..576d8082f65 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-creator.scss @@ -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; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-new-message-modal.scss b/plugins/chat/assets/stylesheets/common/chat-new-message-modal.scss new file mode 100644 index 00000000000..670766913e2 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-new-message-modal.scss @@ -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; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss b/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss index 24b65bbc61e..5f3c2cce24d 100644 --- a/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss +++ b/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss @@ -5,6 +5,7 @@ &-container { display: flex; + height: 16px; } &:before { diff --git a/plugins/chat/assets/stylesheets/common/chat-section.scss b/plugins/chat/assets/stylesheets/common/chat-section.scss new file mode 100644 index 00000000000..bd4eae7ffc8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-section.scss @@ -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; + } +} diff --git a/plugins/chat/assets/stylesheets/common/direct-message-creator.scss b/plugins/chat/assets/stylesheets/common/direct-message-creator.scss deleted file mode 100644 index f46addc2b96..00000000000 --- a/plugins/chat/assets/stylesheets/common/direct-message-creator.scss +++ /dev/null @@ -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; - } -} diff --git a/plugins/chat/assets/stylesheets/common/index.scss b/plugins/chat/assets/stylesheets/common/index.scss index c78eb2c6693..2d721919ed9 100644 --- a/plugins/chat/assets/stylesheets/common/index.scss +++ b/plugins/chat/assets/stylesheets/common/index.scss @@ -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"; diff --git a/plugins/chat/assets/stylesheets/common/sidebar-extensions.scss b/plugins/chat/assets/stylesheets/common/sidebar-extensions.scss index c72707c3b21..6d2c572e8f6 100644 --- a/plugins/chat/assets/stylesheets/common/sidebar-extensions.scss +++ b/plugins/chat/assets/stylesheets/common/sidebar-extensions.scss @@ -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 { diff --git a/plugins/chat/assets/stylesheets/desktop/chat-message-creator.scss b/plugins/chat/assets/stylesheets/desktop/chat-message-creator.scss new file mode 100644 index 00000000000..c69516aaab6 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-message-creator.scss @@ -0,0 +1,7 @@ +.chat-message-creator { + &__row { + &.-active { + background: var(--tertiary-very-low); + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/index.scss b/plugins/chat/assets/stylesheets/desktop/index.scss index 53952e3807f..859b736a081 100644 --- a/plugins/chat/assets/stylesheets/desktop/index.scss +++ b/plugins/chat/assets/stylesheets/desktop/index.scss @@ -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"; diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message-creator.scss b/plugins/chat/assets/stylesheets/mobile/chat-message-creator.scss new file mode 100644 index 00000000000..4bd4d0ff86a --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-message-creator.scss @@ -0,0 +1,6 @@ +.chat-message-creator { + &__open-dm-btn { + width: 100%; + max-width: 100%; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/index.scss b/plugins/chat/assets/stylesheets/mobile/index.scss index e2ff4030a40..c4de1d951d3 100644 --- a/plugins/chat/assets/stylesheets/mobile/index.scss +++ b/plugins/chat/assets/stylesheets/mobile/index.scss @@ -13,3 +13,4 @@ @import "chat-threads-list"; @import "chat-thread-settings-modal"; @import "chat-message-thread-indicator"; +@import "chat-message-creator"; diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index 2c1e1cf3ffa..ade698a41d9 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -324,6 +324,16 @@ en: members: Members settings: Settings + new_message_modal: + title: Send message + add_user_long: shift + click or shift + enterAdd @%{username} + add_user_short: Add user + open_channel: Open channel + 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 diff --git a/plugins/chat/config/routes.rb b/plugins/chat/config/routes.rb index 96e2189bf75..51119ab3efb 100644 --- a/plugins/chat/config/routes.rb +++ b/plugins/chat/config/routes.rb @@ -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" diff --git a/plugins/chat/lib/chat/channel_fetcher.rb b/plugins/chat/lib/chat/channel_fetcher.rb index 4f86b7e9662..17fca5377a9 100644 --- a/plugins/chat/lib/chat/channel_fetcher.rb +++ b/plugins/chat/lib/chat/channel_fetcher.rb @@ -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}" } diff --git a/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb b/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb index 39bc3971dc0..3c405b54bec 100644 --- a/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb @@ -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 diff --git a/plugins/chat/spec/requests/chat/api/chatables_controller_spec.rb b/plugins/chat/spec/requests/chat/api/chatables_controller_spec.rb index a8c8c39e5f7..8892ef4b680 100644 --- a/plugins/chat/spec/requests/chat/api/chatables_controller_spec.rb +++ b/plugins/chat/spec/requests/chat/api/chatables_controller_spec.rb @@ -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 diff --git a/plugins/chat/spec/services/chat/create_direct_message_channel_spec.rb b/plugins/chat/spec/services/chat/create_direct_message_channel_spec.rb index 48e99a0fbe4..e90ec4c7e5f 100644 --- a/plugins/chat/spec/services/chat/create_direct_message_channel_spec.rb +++ b/plugins/chat/spec/services/chat/create_direct_message_channel_spec.rb @@ -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) } diff --git a/plugins/chat/spec/services/chat/search_chatable_spec.rb b/plugins/chat/spec/services/chat/search_chatable_spec.rb new file mode 100644 index 00000000000..46da6e84fc1 --- /dev/null +++ b/plugins/chat/spec/services/chat/search_chatable_spec.rb @@ -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 diff --git a/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json b/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json index 4cbae7dddf3..8dcf8207d80 100644 --- a/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json +++ b/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json @@ -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"], diff --git a/plugins/chat/spec/system/channel_selector_modal_spec.rb b/plugins/chat/spec/system/channel_selector_modal_spec.rb deleted file mode 100644 index 8be4c8120ad..00000000000 --- a/plugins/chat/spec/system/channel_selector_modal_spec.rb +++ /dev/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 diff --git a/plugins/chat/spec/system/draft_message_spec.rb b/plugins/chat/spec/system/draft_message_spec.rb deleted file mode 100644 index 1e29177620f..00000000000 --- a/plugins/chat/spec/system/draft_message_spec.rb +++ /dev/null @@ -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 diff --git a/plugins/chat/spec/system/list_channels/mobile_spec.rb b/plugins/chat/spec/system/list_channels/mobile_spec.rb index 4d8c4c83fb4..9c0efbb0e9c 100644 --- a/plugins/chat/spec/system/list_channels/mobile_spec.rb +++ b/plugins/chat/spec/system/list_channels/mobile_spec.rb @@ -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 diff --git a/plugins/chat/spec/system/navigation_spec.rb b/plugins/chat/spec/system/navigation_spec.rb index 39bde751aaa..10fe5e6a7ab 100644 --- a/plugins/chat/spec/system/navigation_spec.rb +++ b/plugins/chat/spec/system/navigation_spec.rb @@ -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 diff --git a/plugins/chat/spec/system/new_message_spec.rb b/plugins/chat/spec/system/new_message_spec.rb new file mode 100644 index 00000000000..4da9830989a --- /dev/null +++ b/plugins/chat/spec/system/new_message_spec.rb @@ -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 diff --git a/plugins/chat/spec/system/page_objects/chat/chat.rb b/plugins/chat/spec/system/page_objects/chat/chat.rb index eb74e55e5e0..63e17676b44 100644 --- a/plugins/chat/spec/system/page_objects/chat/chat.rb +++ b/plugins/chat/spec/system/page_objects/chat/chat.rb @@ -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 diff --git a/plugins/chat/spec/system/page_objects/chat/components/composer.rb b/plugins/chat/spec/system/page_objects/chat/components/composer.rb index 824b830153c..0265856d3a6 100644 --- a/plugins/chat/spec/system/page_objects/chat/components/composer.rb +++ b/plugins/chat/spec/system/page_objects/chat/components/composer.rb @@ -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 diff --git a/plugins/chat/spec/system/page_objects/chat/components/message_creator.rb b/plugins/chat/spec/system/page_objects/chat/components/message_creator.rb new file mode 100644 index 00000000000..59fc474c595 --- /dev/null +++ b/plugins/chat/spec/system/page_objects/chat/components/message_creator.rb @@ -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 diff --git a/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb index f0816217482..5366ec7a694 100644 --- a/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb +++ b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb @@ -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 diff --git a/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb b/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb index 4117639a61d..a7e88361d45 100644 --- a/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb +++ b/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb @@ -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", diff --git a/plugins/chat/spec/system/visit_channel_spec.rb b/plugins/chat/spec/system/visit_channel_spec.rb index c9f17585333..96a03b6b6e5 100644 --- a/plugins/chat/spec/system/visit_channel_spec.rb +++ b/plugins/chat/spec/system/visit_channel_spec.rb @@ -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 diff --git a/plugins/chat/test/javascripts/components/chat-user-avatar-test.js b/plugins/chat/test/javascripts/components/chat-user-avatar-test.js index 0cd68f37ab5..dfa8925a2c7 100644 --- a/plugins/chat/test/javascripts/components/chat-user-avatar-test.js +++ b/plugins/chat/test/javascripts/components/chat-user-avatar-test.js @@ -37,7 +37,7 @@ module("Discourse Chat | Component | chat-user-avatar", function (hooks) { }); await render( - hbs`` + hbs`` ); assert.true( diff --git a/plugins/chat/test/javascripts/components/direct-message-creator-test.js b/plugins/chat/test/javascripts/components/direct-message-creator-test.js deleted file mode 100644 index 2122da0f60a..00000000000 --- a/plugins/chat/test/javascripts/components/direct-message-creator-test.js +++ /dev/null @@ -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`` - ); - - 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`` - ); - 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`` - ); - - 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`` - ); - - 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`` - ); - - 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`` - ); - - 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`` - ); - - 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`` - ); - - await fillIn(".filter-usernames", "hawk"); - assert.true(exists(".user-status-message")); - }); -});