From 6b0aeced7e520915cab40e00f1194cb541ceaa39 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 3 Mar 2023 13:09:25 +0100 Subject: [PATCH] DEV: rework the chat-live-pane (#20519) This PR is introducing glimmer usage in the chat-live-pane, for components but also for models. RestModel usage has been dropped in favor of native classes. Other changes/additions in this PR: sticky dates, scrolling will now keep the date separator of the current section at the top of the screen better unread management, marking a channel as unread will correctly mark the correct message and not mark the whole channel as read. Tracking state will also now correctly return unread count and unread mentions. adds an animation on bottom arrow better scrolling behavior, we should now always correctly keep the scroll position while loading more reactions are now more reactive, and will update their tooltip without needed to close/reopen it skeleton has been improved with placeholder images and reactions when making a reaction on the desktop message actions, the menu won't move anymore simplify logic and stop maintaining a list of unloaded messages --- .../chat_channels_memberships_controller.rb | 2 +- plugins/chat/app/models/chat_message.rb | 2 +- .../queries/chat_channel_memberships_query.rb | 4 +- .../app/queries/chat_channel_unreads_query.rb | 40 + .../serializers/chat_channel_serializer.rb | 1 + .../serializers/chat_message_serializer.rb | 12 +- .../app/serializers/chat_view_serializer.rb | 1 + plugins/chat/app/services/chat_publisher.rb | 8 +- .../discourse/components/channels-list.js | 40 +- .../components/chat-channel-metadata.hbs | 2 +- .../components/chat-channel-metadata.js | 24 +- .../components/chat-channel-preview-card.hbs | 6 +- .../components/chat-channel-preview-card.js | 14 +- .../chat-channel-selector-modal-inner.js | 2 +- .../components/chat-composer-dropdown.hbs | 6 +- .../components/chat-composer-dropdown.js | 7 - .../components/chat-composer-uploads.js | 21 +- .../discourse/components/chat-composer.hbs | 6 +- .../discourse/components/chat-composer.js | 27 +- .../components/chat-draft-channel-screen.hbs | 5 +- .../components/chat-drawer/channel.hbs | 2 +- .../discourse/components/chat-emoji-picker.js | 4 + .../components/chat-full-page-header.hbs | 46 + .../components/chat-full-page-header.js | 11 + .../discourse/components/chat-live-pane.hbs | 250 ++- .../discourse/components/chat-live-pane.js | 1658 ++++++++--------- .../chat-message-actions-desktop.hbs | 4 +- .../chat-message-actions-desktop.js | 1 + .../chat-message-actions-mobile.hbs | 2 +- .../components/chat-message-avatar.hbs | 4 +- .../components/chat-message-avatar.js | 5 - .../components/chat-message-collapser.hbs | 4 +- .../components/chat-message-collapser.js | 23 +- .../chat-message-in-reply-to-indicator.hbs | 19 + .../chat-message-in-reply-to-indicator.js | 32 + .../components/chat-message-info.hbs | 10 +- .../discourse/components/chat-message-info.js | 7 +- .../components/chat-message-left-gutter.hbs | 8 +- .../components/chat-message-left-gutter.js | 6 + ...at-message-move-to-channel-modal-inner.hbs | 2 +- .../components/chat-message-reaction.hbs | 19 +- .../components/chat-message-reaction.js | 128 +- .../chat-message-separator-date.hbs | 26 + .../components/chat-message-separator-new.hbs | 13 + .../components/chat-message-separator.hbs | 15 - .../components/chat-message-separator.js | 5 - .../components/chat-message-text.hbs | 6 +- .../discourse/components/chat-message-text.js | 13 +- .../discourse/components/chat-message.hbs | 58 +- .../discourse/components/chat-message.js | 331 ++-- .../components/chat-retention-reminder.hbs | 2 +- .../components/chat-retention-reminder.js | 35 +- .../chat-scroll-to-bottom-arrow.hbs | 23 + .../components/chat-selection-manager.js | 7 +- .../discourse/components/chat-skeleton.hbs | 28 +- .../discourse/components/chat-skeleton.js | 10 +- .../discourse/components/full-page-chat.hbs | 3 +- .../discourse/components/full-page-chat.js | 81 +- .../discourse/controllers/chat-channel.js | 3 +- .../discourse/controllers/create-channel.js | 6 + .../discourse/helpers/format-chat-date.js | 4 +- .../initializers/chat-cook-function.js | 32 + .../discourse/initializers/chat-setup.js | 2 + .../discourse/lib/chat-message-flag.js | 2 +- .../discourse/models/chat-channel.js | 78 +- .../discourse/models/chat-message-draft.js | 62 + .../discourse/models/chat-message-reaction.js | 33 + .../discourse/models/chat-message.js | 201 +- .../chat/track-message-separator-date.js | 35 + .../chat/track-message-visibility.js | 23 - .../discourse/modifiers/chat/track-message.js | 43 + .../discourse/routes/chat-channel.js | 4 + .../discourse/services/chat-api.js | 33 + .../services/chat-channels-manager.js | 11 +- .../chat-message-visibility-observer.js | 63 - .../services/chat-subscriptions-manager.js | 11 +- .../javascripts/discourse/services/chat.js | 139 +- .../templates/modal/create-channel.hbs | 7 +- .../common/chat-channel-preview-card.scss | 1 + .../stylesheets/common/chat-composer.scss | 2 + .../common/chat-message-actions.scss | 4 - .../common/chat-message-separator.scss | 114 +- .../stylesheets/common/chat-message.scss | 31 +- .../stylesheets/common/chat-skeleton.scss | 42 +- .../assets/stylesheets/common/common.scss | 82 +- .../stylesheets/desktop/chat-composer.scss | 2 +- .../assets/stylesheets/desktop/desktop.scss | 19 + .../mobile/chat-message-actions.scss | 2 + .../stylesheets/mobile/chat-message.scss | 4 - plugins/chat/config/locales/client.en.yml | 2 +- plugins/chat/plugin.rb | 1 + .../chat_channel_memberships_query_spec.rb | 40 +- .../chat_channel_unreads_query_spec.rb | 51 + .../spec/requests/chat_controller_spec.rb | 8 +- .../chat_message_serializer_spec.rb | 6 +- plugins/chat/spec/system/chat_channel_spec.rb | 4 +- .../chat/spec/system/create_channel_spec.rb | 1 + .../chat/spec/system/deleted_message_spec.rb | 2 +- plugins/chat/spec/system/drawer_spec.rb | 36 + plugins/chat/spec/system/flag_message_spec.rb | 2 +- ...message_notifications_with_sidebar_spec.rb | 1 + plugins/chat/spec/system/message_user_info.rb | 60 + .../spec/system/navigating_to_message_spec.rb | 6 +- .../system/shortcuts/chat_composer_spec.rb | 4 +- plugins/chat/spec/system/sticky_date_spec.rb | 32 + plugins/chat/spec/system/uploads_spec.rb | 13 +- .../spec/system/user_chat_preferences_spec.rb | 3 +- .../acceptance/chat-composer-test.js | 2 +- .../components/chat-channel-metadata-test.js | 5 +- .../components/chat-channel-row-test.js | 4 +- .../components/chat-composer-uploads-test.js | 36 +- .../components/chat-live-pane-test.js | 44 - .../components/chat-message-avatar-test.js | 10 +- .../components/chat-message-info-test.js | 110 +- .../components/chat-message-reaction-test.js | 21 - .../chat-message-separator-date-test.js | 24 + .../chat-message-separator-new-test.js | 24 + .../components/chat-message-separator-test.js | 35 - .../components/chat-message-test.js | 39 +- .../chat-retention-reminder-test.js | 4 +- .../test/javascripts/helpers/fabricators.js | 3 +- .../track-message-visibility-test.js | 36 - .../unit/helpers/format-chat-date-test.js | 9 +- 123 files changed, 2601 insertions(+), 2303 deletions(-) create mode 100644 plugins/chat/app/queries/chat_channel_unreads_query.rb delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.js delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-message-separator-new.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-message-separator.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-scroll-to-bottom-arrow.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js create mode 100644 plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js create mode 100644 plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js create mode 100644 plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-separator-date.js delete mode 100644 plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js create mode 100644 plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js delete mode 100644 plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js create mode 100644 plugins/chat/spec/queries/chat_channel_unreads_query_spec.rb create mode 100644 plugins/chat/spec/system/message_user_info.rb create mode 100644 plugins/chat/spec/system/sticky_date_spec.rb delete mode 100644 plugins/chat/test/javascripts/components/chat-live-pane-test.js create mode 100644 plugins/chat/test/javascripts/components/chat-message-separator-date-test.js create mode 100644 plugins/chat/test/javascripts/components/chat-message-separator-new-test.js delete mode 100644 plugins/chat/test/javascripts/components/chat-message-separator-test.js delete mode 100644 plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js diff --git a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb b/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb index c50a30735e7..d6fe2fd4ad9 100644 --- a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb +++ b/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb @@ -9,7 +9,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont memberships = ChatChannelMembershipsQuery.call( - channel_from_params, + channel: channel_from_params, offset: offset, limit: limit, username: params[:username], diff --git a/plugins/chat/app/models/chat_message.rb b/plugins/chat/app/models/chat_message.rb index 6b485036a31..7f087a3158e 100644 --- a/plugins/chat/app/models/chat_message.rb +++ b/plugins/chat/app/models/chat_message.rb @@ -223,7 +223,7 @@ class ChatMessage < ActiveRecord::Base end def url - "/chat/message/#{self.id}" + "/chat/c/-/#{self.chat_channel_id}/#{self.id}" end private diff --git a/plugins/chat/app/queries/chat_channel_memberships_query.rb b/plugins/chat/app/queries/chat_channel_memberships_query.rb index a257e0c0697..e38f09eae1d 100644 --- a/plugins/chat/app/queries/chat_channel_memberships_query.rb +++ b/plugins/chat/app/queries/chat_channel_memberships_query.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ChatChannelMembershipsQuery - def self.call(channel, limit: 50, offset: 0, username: nil, count_only: false) + def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false) query = UserChatChannelMembership .joins(:user) @@ -42,6 +42,6 @@ class ChatChannelMembershipsQuery end def self.count(channel) - call(channel, count_only: true) + call(channel: channel, count_only: true) end end diff --git a/plugins/chat/app/queries/chat_channel_unreads_query.rb b/plugins/chat/app/queries/chat_channel_unreads_query.rb new file mode 100644 index 00000000000..0d6e49ba0ea --- /dev/null +++ b/plugins/chat/app/queries/chat_channel_unreads_query.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ChatChannelUnreadsQuery + def self.call(channel_id:, user_id:) + sql = <<~SQL + SELECT ( + SELECT COUNT(*) AS unread_count + FROM chat_messages + INNER JOIN chat_channels ON chat_channels.id = chat_messages.chat_channel_id + INNER JOIN user_chat_channel_memberships ON user_chat_channel_memberships.chat_channel_id = chat_channels.id + WHERE chat_channels.id = :channel_id + AND chat_messages.user_id != :user_id + AND user_chat_channel_memberships.user_id = :user_id + AND chat_messages.id > COALESCE(user_chat_channel_memberships.last_read_message_id, 0) + AND chat_messages.deleted_at IS NULL + ) AS unread_count, + ( + SELECT COUNT(*) AS mention_count + FROM notifications + INNER JOIN user_chat_channel_memberships ON user_chat_channel_memberships.chat_channel_id = :channel_id + AND user_chat_channel_memberships.user_id = :user_id + WHERE NOT read + AND notifications.user_id = :user_id + AND notifications.notification_type = :notification_type + AND (data::json->>'chat_message_id')::bigint > COALESCE(user_chat_channel_memberships.last_read_message_id, 0) + AND (data::json->>'chat_channel_id')::bigint = :channel_id + ) AS mention_count; + SQL + + DB + .query( + sql, + channel_id: channel_id, + user_id: user_id, + notification_type: Notification.types[:chat_mention], + ) + .first + .to_h + end +end diff --git a/plugins/chat/app/serializers/chat_channel_serializer.rb b/plugins/chat/app/serializers/chat_channel_serializer.rb index ffa800496af..d007ca651a2 100644 --- a/plugins/chat/app/serializers/chat_channel_serializer.rb +++ b/plugins/chat/app/serializers/chat_channel_serializer.rb @@ -110,6 +110,7 @@ class ChatChannelSerializer < ApplicationSerializer def meta { message_bus_last_ids: { + channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.id}"), new_messages: @opts[:new_messages_message_bus_last_id] || MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)), diff --git a/plugins/chat/app/serializers/chat_message_serializer.rb b/plugins/chat/app/serializers/chat_message_serializer.rb index 9f6dc19a23e..4ff2b7e5ff0 100644 --- a/plugins/chat/app/serializers/chat_message_serializer.rb +++ b/plugins/chat/app/serializers/chat_message_serializer.rb @@ -35,23 +35,23 @@ class ChatMessageSerializer < ApplicationSerializer end def reactions - reactions_hash = {} object .reactions .group_by(&:emoji) - .each do |emoji, reactions| - users = reactions[0..5].map(&:user).filter { |user| user.id != scope&.user&.id }[0..4] - + .map do |emoji, reactions| next unless Emoji.exists?(emoji) - reactions_hash[emoji] = { + users = reactions.take(5).map(&:user) + + { + emoji: emoji, count: reactions.count, users: ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, reacted: users_reactions.include?(emoji), } end - reactions_hash + .compact end def include_reactions? diff --git a/plugins/chat/app/serializers/chat_view_serializer.rb b/plugins/chat/app/serializers/chat_view_serializer.rb index 566474ec408..129cd31f17b 100644 --- a/plugins/chat/app/serializers/chat_view_serializer.rb +++ b/plugins/chat/app/serializers/chat_view_serializer.rb @@ -16,6 +16,7 @@ class ChatViewSerializer < ApplicationSerializer def meta meta_hash = { + channel_id: object.chat_channel.id, can_flag: scope.can_flag_in_chat_channel?(object.chat_channel), channel_status: object.chat_channel.status, user_silenced: !scope.can_create_chat_message?, diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb index a56b841ae99..25c27d08960 100644 --- a/plugins/chat/app/services/chat_publisher.rb +++ b/plugins/chat/app/services/chat_publisher.rb @@ -12,7 +12,7 @@ module ChatPublisher { scope: anonymous_guardian, root: :chat_message }, ).as_json content[:type] = :sent - content[:stagedId] = staged_id + content[:staged_id] = staged_id permissions = permissions(chat_channel) MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) @@ -133,9 +133,13 @@ module ChatPublisher end def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) + data = { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id }.merge( + ChatChannelUnreadsQuery.call(channel_id: chat_channel_id, user_id: user.id), + ) + MessageBus.publish( self.user_tracking_state_message_bus_channel(user.id), - { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id.to_i }.as_json, + data.as_json, user_ids: [user.id], ) end diff --git a/plugins/chat/assets/javascripts/discourse/components/channels-list.js b/plugins/chat/assets/javascripts/discourse/components/channels-list.js index bb1b660ebcb..3232387e3d5 100644 --- a/plugins/chat/assets/javascripts/discourse/components/channels-list.js +++ b/plugins/chat/assets/javascripts/discourse/components/channels-list.js @@ -1,24 +1,30 @@ import { bind } from "discourse-common/utils/decorators"; -import Component from "@ember/component"; -import { action, computed } from "@ember/object"; +import Component from "@glimmer/component"; +import { action } from "@ember/object"; import { schedule } from "@ember/runloop"; import { inject as service } from "@ember/service"; -import { and, empty } from "@ember/object/computed"; export default class ChannelsList extends Component { @service chat; @service router; @service chatStateManager; @service chatChannelsManager; - tagName = ""; - inSidebar = false; - toggleSection = null; - @empty("chatChannelsManager.publicMessageChannels") - publicMessageChannelsEmpty; - @and("site.mobileView", "showDirectMessageChannels") - showMobileDirectMessageButton; + @service site; + @service session; + @service currentUser; + + get showMobileDirectMessageButton() { + return this.site.mobileView && this.showDirectMessageChannels; + } + + get inSidebar() { + return this.args.inSidebar ?? false; + } + + get publicMessageChannelsEmpty() { + return this.chatChannelsManager.publicMessageChannels?.length === 0; + } - @computed("canCreateDirectMessageChannel") get createDirectMessageChannelLabel() { if (!this.canCreateDirectMessageChannel) { return "chat.direct_messages.cannot_create"; @@ -27,10 +33,6 @@ export default class ChannelsList extends Component { return "chat.direct_messages.new"; } - @computed( - "canCreateDirectMessageChannel", - "chatChannelsManager.directMessageChannels" - ) get showDirectMessageChannels() { return ( this.canCreateDirectMessageChannel || @@ -42,17 +44,12 @@ export default class ChannelsList extends Component { return this.chat.userCanDirectMessage; } - @computed("inSidebar") get publicChannelClasses() { return `channels-list-container public-channels ${ this.inSidebar ? "collapsible-sidebar-section" : "" }`; } - @computed( - "publicMessageChannelsEmpty", - "currentUser.{staff,has_joinable_public_channels}" - ) get displayPublicChannels() { if (this.publicMessageChannelsEmpty) { return ( @@ -64,7 +61,6 @@ export default class ChannelsList extends Component { return true; } - @computed("inSidebar") get directMessageChannelClasses() { return `channels-list-container direct-message-channels ${ this.inSidebar ? "collapsible-sidebar-section" : "" @@ -73,7 +69,7 @@ export default class ChannelsList extends Component { @action toggleChannelSection(section) { - this.toggleSection(section); + this.args.toggleSection(section); } didRender() { diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs index 05c16b4e5d2..7974d150628 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs @@ -3,7 +3,7 @@ {{this.lastMessageFormatedDate}} - {{#if @unreadIndicator}} + {{#if this.unreadIndicator}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js index 404cd7ebd4e..898f6c7ac89 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js @@ -1,18 +1,18 @@ import Component from "@glimmer/component"; + export default class ChatChannelMetadata extends Component { - unreadIndicator = false; + get unreadIndicator() { + return this.args.unreadIndicator ?? false; + } get lastMessageFormatedDate() { - return moment(this.args.channel.get("last_message_sent_at")).calendar( - null, - { - sameDay: "LT", - nextDay: "[Tomorrow]", - nextWeek: "dddd", - lastDay: "[Yesterday]", - lastWeek: "dddd", - sameElse: "l", - } - ); + return moment(this.args.channel.lastMessageSentAt).calendar(null, { + sameDay: "LT", + nextDay: "[Tomorrow]", + nextWeek: "dddd", + lastDay: "[Yesterday]", + lastWeek: "dddd", + sameElse: "l", + }); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs index 9002d3f774c..83ec633c9b8 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs @@ -4,15 +4,15 @@ (unless this.hasDescription "-no-description") }} > - + {{#if this.hasDescription}}

- {{this.channel.description}} + {{@channel.description}}

{{/if}} {{#if this.showJoinButton}} {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js index 954313febe9..b4213243660 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js @@ -1,19 +1,15 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; import { isEmpty } from "@ember/utils"; -import { computed } from "@ember/object"; -import { readOnly } from "@ember/object/computed"; import { inject as service } from "@ember/service"; export default class ChatChannelPreviewCard extends Component { @service chat; - tagName = ""; - channel = null; + get showJoinButton() { + return this.args.channel?.isOpen; + } - @readOnly("channel.isOpen") showJoinButton; - - @computed("channel.description") get hasDescription() { - return !isEmpty(this.channel.description); + return !isEmpty(this.args.channel?.description); } } 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 index 96f90da74ce..935354ea2bf 100644 --- 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 @@ -194,7 +194,7 @@ export default Component.extend({ getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) { let sortedChannels = this.chatChannelsManager.channels.sort((a, b) => { - return new Date(a.last_message_sent_at) > new Date(b.last_message_sent_at) + return new Date(a.lastMessageSentAt) > new Date(b.lastMessageSentAt) ? -1 : 1; }); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs index 2112b9e8b2e..074cfe86282 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs @@ -1,17 +1,17 @@ -{{#if this.buttons.length}} +{{#if @buttons.length}}
    - {{#each this.buttons as |button|}} + {{#each @buttons as |button|}}
  • {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js index 29b0eed2f0a..b66d897dee1 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -29,11 +29,10 @@ const THROTTLE_MS = 150; export default Component.extend(TextareaTextManipulation, { chatChannel: null, - lastChatChannelId: null, chat: service(), classNames: ["chat-composer-container"], classNameBindings: ["emojiPickerVisible:with-emoji-picker"], - userSilenced: readOnly("details.user_silenced"), + userSilenced: readOnly("chatChannel.userSilenced"), chatEmojiReactionStore: service("chat-emoji-reaction-store"), chatEmojiPickerManager: service("chat-emoji-picker-manager"), chatStateManager: service("chat-state-manager"), @@ -220,18 +219,18 @@ export default Component.extend(TextareaTextManipulation, { if ( !this.editingMessage && - this.draft && + this.chatChannel?.draft && this.chatChannel?.canModifyMessages(this.currentUser) ) { // uses uploads from draft here... this.setProperties({ - value: this.draft.value, - replyToMsg: this.draft.replyToMsg, + value: this.chatChannel.draft.message, + replyToMsg: this.chatChannel.draft.replyToMsg, }); this._captureMentions(); - this._syncUploads(this.draft.uploads); - this.setInReplyToMsg(this.draft.replyToMsg); + this._syncUploads(this.chatChannel.draft.uploads); + this.setInReplyToMsg(this.chatChannel.draft.replyToMsg); } if (this.editingMessage && !this.loading) { @@ -244,7 +243,6 @@ export default Component.extend(TextareaTextManipulation, { this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false }); } - this.set("lastChatChannelId", this.chatChannel.id); this.resizeTextarea(); }, @@ -271,7 +269,6 @@ export default Component.extend(TextareaTextManipulation, { } this.set("_uploads", cloneJSON(newUploads)); - this.appEvents.trigger("chat-composer:load-uploads", this._uploads); }, _inProgressUploadsChanged(inProgressUploads) { @@ -286,7 +283,7 @@ export default Component.extend(TextareaTextManipulation, { _replyToMsgChanged(replyToMsg) { this.set("replyToMsg", replyToMsg); - this.onValueChange?.(this.value, this._uploads, replyToMsg); + this.onValueChange?.({ replyToMsg }); }, @action @@ -302,12 +299,14 @@ export default Component.extend(TextareaTextManipulation, { @bind _handleTextareaInput() { - this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + this.onValueChange?.({ value: this.value }); }, @bind _captureMentions() { - this.chatComposerWarningsTracker.trackMentions(this.value); + if (this.value) { + this.chatComposerWarningsTracker.trackMentions(this.value); + } }, @bind @@ -699,7 +698,7 @@ export default Component.extend(TextareaTextManipulation, { cancelReplyTo() { this.set("replyToMsg", null); this.setInReplyToMsg(null); - this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + this.onValueChange?.({ replyToMsg: null }); }, @action @@ -722,7 +721,7 @@ export default Component.extend(TextareaTextManipulation, { @action uploadsChanged(uploads) { this.set("_uploads", cloneJSON(uploads)); - this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + this.onValueChange?.({ uploads: this._uploads }); }, @action 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 index b7d97773a34..e64aee7030a 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs @@ -19,9 +19,6 @@ /> {{#if this.previewedChannel}} - + {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs index ee06d23f1e9..45a1360f5c1 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs @@ -18,7 +18,7 @@ {{#if this.chat.activeChannel}} {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js index 2c678e9d4bc..28a9195082f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js @@ -65,6 +65,10 @@ export default class ChatEmojiPicker extends Component { } get flatEmojis() { + if (!this.chatEmojiPickerManager.emojis) { + return []; + } + // eslint-disable-next-line no-unused-vars let { favorites, ...rest } = this.chatEmojiPickerManager.emojis; return Object.values(rest).flat(); 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 new file mode 100644 index 00000000000..2d4bdd0425a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs @@ -0,0 +1,46 @@ +{{#if + (and + this.chatStateManager.isFullPageActive this.displayed (not @channel.isDraft) + ) +}} +
    +
    + {{#if this.site.mobileView}} +
    + + {{d-icon "chevron-left"}} + +
    + {{/if}} + + + + + + {{#if this.site.desktopView}} +
    + +
    + {{/if}} +
    +
    + + +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.js b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.js new file mode 100644 index 00000000000..cb7724a7f4b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.js @@ -0,0 +1,11 @@ +import { inject as service } from "@ember/service"; +import Component from "@glimmer/component"; + +export default class ChatFullPageHeader extends Component { + @service site; + @service chatStateManager; + + get displayed() { + return this.args.displayed ?? true; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs index d0ca52590b0..2be52122748 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs @@ -1,148 +1,118 @@ -{{#if (and this.chatStateManager.isFullPageActive this.includeHeader)}} -
    -
    - {{#if this.site.mobileView}} -
    - -
    - {{/if}} - - - - - - {{#if this.showCloseFullScreenBtn}} -
    - -
    - {{/if}} -
    -
    - - -{{/if}} - - - - - -
    -
    - -
    -
    -
    - {{#if (or this.loading this.loadingMorePast)}} - - {{/if}} - - {{#each this.messages as |message|}} - - {{/each}} - - {{#if this.loadingMoreFuture}} - - {{/if}} -
    - - {{#if this.allPastMessagesLoaded}} -
    - {{i18n "chat.all_loaded"}} -
    - {{/if}} -
    - -{{#if this.showScrollToBottomBtn}} - -{{/if}} - -{{#if this.selectingMessages}} - -{{else}} - {{#if (or this.chatChannel.isDraft this.chatChannel.isFollowing)}} - + + + +
    + +
    + +
    +
    +
    + + {{#if this.loadingMorePast}} + + {{/if}} + + {{#each @channel.messages key="id" as |message|}} + + {{/each}} + + {{#if (or this.loadingMoreFuture)}} + + {{/if}} +
    + + {{#if (and this.loadedOnce (not @channel.canLoadMorePast))}} +
    + {{i18n "chat.all_loaded"}} +
    + {{/if}} +
    + + + + {{#if this.selectingMessages}} + {{else}} - + {{#if (or @channel.isDraft @channel.isFollowing)}} + + {{else}} + + {{/if}} {{/if}} -{{/if}} \ No newline at end of file + \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js index 7c3b5ca291c..9bc16a5736d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js @@ -1,17 +1,12 @@ +import { capitalize } from "@ember/string"; import isElementInViewport from "discourse/lib/is-element-in-viewport"; import { cloneJSON } from "discourse-common/lib/object"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; -import Component from "@ember/component"; -import discourseComputed, { - afterRender, - bind, - debounce, - observes, -} from "discourse-common/utils/decorators"; +import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; +import Component from "@glimmer/component"; +import { bind, debounce } from "discourse-common/utils/decorators"; import discourseDebounce from "discourse-common/lib/debounce"; import EmberObject, { action } from "@ember/object"; -import I18n from "I18n"; -import { A } from "@ember/array"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { cancel, next, schedule, throttle } from "@ember/runloop"; @@ -19,85 +14,62 @@ import discourseLater from "discourse-common/lib/later"; import { inject as service } from "@ember/service"; import { Promise } from "rsvp"; import { resetIdle } from "discourse/lib/desktop-notifications"; -import { capitalize } from "@ember/string"; import { onPresenceChange, removeOnPresenceChange, } from "discourse/lib/user-presence"; import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; import { isTesting } from "discourse-common/config/environment"; +import { tracked } from "@glimmer/tracking"; +import { getOwner } from "discourse-common/lib/get-owner"; -const MAX_RECENT_MSGS = 100; -const STICKY_SCROLL_LENIENCE = 50; +const STICKY_SCROLL_LENIENCE = 100; const PAGE_SIZE = 50; - -const SCROLL_HANDLER_THROTTLE_MS = isTesting() ? 0 : 100; +const SCROLL_HANDLER_THROTTLE_MS = isTesting() ? 0 : 150; const FETCH_MORE_MESSAGES_THROTTLE_MS = isTesting() ? 0 : 500; - const PAST = "past"; const FUTURE = "future"; +const READ_INTERVAL_MS = 1000; -export default Component.extend({ - classNameBindings: [":chat-live-pane", "sendingLoading", "loading"], - chatChannel: null, - registeredChatChannelId: null, // ?Number - loading: false, - loadingMorePast: false, - loadingMoreFuture: false, - hoveredMessageId: null, +export default class ChatLivePane extends Component { + @service chat; + @service chatChannelsManager; + @service router; + @service chatEmojiPickerManager; + @service chatComposerPresenceManager; + @service chatStateManager; + @service chatApi; + @service currentUser; + @service appEvents; + @service messageBus; + @service site; - allPastMessagesLoaded: false, - sendingLoading: false, - selectingMessages: false, - stickyScroll: true, - stickyScrollTimer: null, - showChatQuoteSuccess: false, - showCloseFullScreenBtn: false, - includeHeader: true, + @tracked loading = false; + @tracked loadingMorePast = false; + @tracked loadingMoreFuture = false; + @tracked hoveredMessageId = null; + @tracked sendingLoading = false; + @tracked selectingMessages = false; + @tracked showChatQuoteSuccess = false; + @tracked includeHeader = true; + @tracked editingMessage = null; + @tracked replyToMsg = null; + @tracked hasNewMessages = null; + @tracked isDocked = true; + @tracked isAlmostDocked = true; + @tracked loadedOnce = false; - editingMessage: null, // ?Message - replyToMsg: null, // ?Message - details: null, // Object { chat_channel_id, ... } - messages: null, // Array - messageLookup: null, // Object - _unloadedReplyIds: null, // Array - _nextStagedMessageId: 0, // Iterate on every new message - _lastSelectedMessage: null, - targetMessageId: null, - hasNewMessages: null, + _loadedChannelId = null; + _scrollerEl = null; + _previousScrollTop = null; + _lastSelectedMessage = null; + _mentionWarningsSeen = {}; + _unreachableGroupMentions = []; + _overMembersLimitGroupMentions = []; - chat: service(), - chatChannelsManager: service(), - router: service(), - chatEmojiPickerManager: service(), - chatComposerPresenceManager: service(), - chatStateManager: service(), - chatApi: service(), - - getCachedChannelDetails: null, - clearCachedChannelDetails: null, - _scrollerEl: null, - - init() { - this._super(...arguments); - - this.set("messages", []); - this.set("_mentionWarningsSeen", {}); - this.set("unreachableGroupMentions", []); - this.set("overMembersLimitGroupMentions", []); - }, - - didInsertElement() { - this._super(...arguments); - - this._unloadedReplyIds = []; - this.appEvents.on( - "chat-live-pane:highlight-message", - this, - "highlightOrFetchMessage" - ); - - this._scrollerEl = this.element.querySelector(".chat-messages-scroll"); + @action + setupListeners(element) { + this._scrollerEl = element.querySelector(".chat-messages-scroll"); this._scrollerEl.addEventListener("scroll", this.onScrollHandler, { passive: true, }); @@ -106,10 +78,6 @@ export default Component.extend({ passive: true, }); - this.appEvents.on("chat:cancel-message-selection", this, "cancelSelecting"); - - this.set("showCloseFullScreenBtn", !this.site.mobileView); - document.addEventListener("scroll", this._forceBodyScroll, { passive: true, }); @@ -117,82 +85,56 @@ export default Component.extend({ onPresenceChange({ callback: this.onPresenceChangeCallback, }); - }, + } - willDestroyElement() { - this._super(...arguments); - - this.element + @action + teardownListeners(element) { + element .querySelector(".chat-messages-scroll") ?.removeEventListener("scroll", this.onScrollHandler); - window.removeEventListener("resize", this.onResizeHandler); window.removeEventListener("wheel", this.onScrollHandler); - - this.appEvents.off( - "chat-live-pane:highlight-message", - this, - "highlightOrFetchMessage" - ); - - // don't need to removeEventListener from scroller as the DOM element goes away - cancel(this.stickyScrollTimer); - cancel(this.resizeHandler); - - this._resetChannelState(); - this._unloadedReplyIds = null; - this.appEvents.off( - "chat:cancel-message-selection", - this, - "cancelSelecting" - ); - document.removeEventListener("scroll", this._forceBodyScroll); - removeOnPresenceChange(this.onPresenceChangeCallback); - }, + this._unsubscribeToUpdates(this._loadedChannelId); + this.requestedTargetMessageId = null; + } - didReceiveAttrs() { - this._super(...arguments); - - this.currentUserTimezone = this.currentUser?.user_option.timezone; - - if ( - this.chatChannel?.id && - this.registeredChatChannelId !== this.chatChannel.id - ) { - this._resetChannelState(); + @action + updateChannel() { + if (this._loadedChannelId !== this.args.channel?.id) { + this._unsubscribeToUpdates(this._loadedChannelId); + this.selectingMessages = false; this.cancelEditing(); + this._loadedChannelId = this.args.channel?.id; + } - if (!this.chatChannel.isDraft) { - this.loadDraftForChannel(this.chatChannel.id); + this.loadMessages(); + this._subscribeToUpdates(this.args.channel.id); + } + + @action + loadMessages() { + this.loadedOnce = false; + + if (this.args.targetMessageId) { + this.requestedTargetMessageId = parseInt(this.args.targetMessageId, 10); + } + + if (this.args.channel?.id) { + if (this.requestedTargetMessageId) { + this.highlightOrFetchMessage(this.requestedTargetMessageId); + } else { + this.fetchMessages({ fetchFromLastMessage: false }); } } - - if (this.chatChannel?.id) { - this.fetchMessages(this.chatChannel); - } - }, - - @discourseComputed("chatChannel.isDirectMessageChannel") - displayMembers(isDirectMessageChannel) { - return !isDirectMessageChannel; - }, - - @discourseComputed("displayMembers") - infoTabRoute(displayMembers) { - if (displayMembers) { - return "chat.channel.info.members"; - } - - return "chat.channel.info.settings"; - }, + } @bind onScrollHandler(event) { - throttle(this, this.onScroll, event, SCROLL_HANDLER_THROTTLE_MS, true); - }, + throttle(this, this.onScroll, event, SCROLL_HANDLER_THROTTLE_MS, false); + } @bind onResizeHandler() { @@ -203,398 +145,281 @@ export default Component.extend({ this.details, 250 ); - }, + } @bind onPresenceChangeCallback(present) { if (present) { - this.chat.updateLastReadMessage(); + this.updateLastReadMessage(); } - }, + } + + get capabilities() { + return getOwner(this).lookup("capabilities:main"); + } @debounce(100) - fetchMessages(channel, options = {}) { + fetchMessages(options = {}) { if (this._selfDeleted) { return; } - this.set("loading", true); + this.args.channel?.clearMessages(); + this.loadingMorePast = true; - return this.chat.loadCookFunction(this.site.categories).then((cook) => { - if (this._selfDeleted) { - return; - } - - this.set("cook", cook); - - const findArgs = { - channelId: channel.id, - pageSize: PAGE_SIZE, - }; - const fetchingFromLastRead = !options.fetchFromLastMessage; - - if (fetchingFromLastRead) { - findArgs["targetMessageId"] = - this.targetMessageId || this._getLastReadId(); - } - - return this.store - .findAll("chat-message", findArgs) - .then((messages) => { - if (this._selfDeleted || this.chatChannel.id !== channel.id) { - return; - } - this.setMessageProps(messages, fetchingFromLastRead); - - if (options.fetchFromLastMessage) { - this.set("stickyScroll", true); - this._stickScrollToBottom(); - } - - this._focusComposer(); - }) - .catch(this._handleErrors) - .finally(() => { - if (this._selfDeleted || this.chatChannel.id !== channel.id) { - return; - } - - this.set("loading", false); - }); - }); - }, - - loadDraftForChannel(channelId) { - this.set("draft", this.chat.getDraftForChannel(channelId)); - }, - - @bind - _fetchMoreMessages(direction) { - const loadingPast = direction === PAST; - const canLoadMore = loadingPast - ? this.details?.can_load_more_past - : this.details?.can_load_more_future; - const loadingMoreKey = `loadingMore${capitalize(direction)}`; - const loadingMore = this.get(loadingMoreKey); - - if ( - (this.details && !canLoadMore) || - loadingMore || - this.loading || - !this.messages.length - ) { - return Promise.resolve(); + const findArgs = { pageSize: PAGE_SIZE }; + const fetchingFromLastRead = !options.fetchFromLastMessage; + if (this.requestedTargetMessageId) { + findArgs["targetMessageId"] = this.requestedTargetMessageId; + } else if (fetchingFromLastRead) { + findArgs["targetMessageId"] = this._getLastReadId(); } - this.set(loadingMoreKey, true); - this.ignoreStickyScrolling = true; - - const messageIndex = loadingPast ? 0 : this.messages.length - 1; - const messageId = this.messages[messageIndex].id; - const findArgs = { - channelId: this.chatChannel.id, - pageSize: PAGE_SIZE, - direction, - messageId, - }; - const channelId = this.chatChannel.id; - - return this.store - .findAll("chat-message", findArgs) - .then((messages) => { - if (this._selfDeleted || channelId !== this.chatChannel.id) { + return this.chatApi + .messages(this.args.channel.id, findArgs) + .then((results) => { + if ( + this._selfDeleted || + this.args.channel.id !== results.meta.channel_id + ) { return; } - const newMessages = this._prepareMessages(messages || []); - if (newMessages.length) { - this.set( - "messages", - loadingPast - ? newMessages.concat(this.messages) - : this.messages.concat(newMessages) - ); - } - this.setCanLoadMoreDetails(messages.resultSetMeta); + const [messages, meta] = this.afterFetchCallback( + this.args.channel, + results + ); + this.args.channel.addMessages(messages); + this.args.channel.details = meta; + this.loadedOnce = true; - if (!loadingPast && newMessages.length) { - // Adding newer messages also causes a scroll-down, - // firing another event, fetching messages again, and so on. - // Scroll to the first new one to prevent this. - this.scrollToMessage(newMessages.firstObject.messageLookupId); + if (this.requestedTargetMessageId) { + this.scrollToMessage(findArgs["targetMessageId"], { + highlight: true, + }); + } else if (fetchingFromLastRead) { + this.scrollToMessage(findArgs["targetMessageId"]); + } else if (messages.length) { + this.scrollToMessage(messages[messages.length - 1].id); } - return messages; + this.fillPaneAttempt(); }) .catch(this._handleErrors) .finally(() => { if (this._selfDeleted) { return; } - this.set(loadingMoreKey, false); - this.ignoreStickyScrolling = false; + + this.requestedTargetMessageId = null; + this.loadingMorePast = false; }); - }, + } - fillPaneAttempt(meta) { - if (this._selfDeleted) { - return; + @action + onDestroySkeleton() { + this._iOSFix(); + this._throttleComputeSeparators(); + } + + @action + onDidInsertSkeleton() { + this._computeSeparators(); // this one is not throttled as we need instant feedback + } + + @bind + _fetchMoreMessages({ direction, scrollTo = true }) { + const loadingPast = direction === PAST; + const loadingMoreKey = `loadingMore${capitalize(direction)}`; + + const canLoadMore = loadingPast + ? this.args.channel.canLoadMorePast + : this.args.channel.canLoadMoreFuture; + + if ( + !canLoadMore || + this.loading || + this[loadingMoreKey] || + !this.args.channel.messages.length + ) { + return Promise.resolve(); } - // safeguard - if (this.messages.length > 200) { - return; - } + this[loadingMoreKey] = true; - if (!meta?.can_load_more_past) { - return; - } - - schedule("afterRender", () => { - const firstMessageId = this.messages.firstObject?.id; - if (!firstMessageId) { - return; - } - - const scroller = document.querySelector(".chat-messages-container"); - const messageContainer = document.querySelector( - `.chat-message-container[data-id="${firstMessageId}"]` - ); - if ( - !scroller || - !messageContainer || - !isElementInViewport(messageContainer) - ) { - return; - } - - this._fetchMoreMessagesThrottled(PAST); - }); - }, - - _fetchMoreMessagesThrottled(direction) { - throttle( - this, - "_fetchMoreMessages", + const messageIndex = loadingPast + ? 0 + : this.args.channel.messages.length - 1; + const messageId = this.args.channel.messages[messageIndex].id; + const findArgs = { + channelId: this.args.channel.id, + pageSize: PAGE_SIZE, direction, - FETCH_MORE_MESSAGES_THROTTLE_MS - ); - }, + messageId, + }; - setCanLoadMoreDetails(meta) { - const metaKeys = Object.keys(meta); - if (metaKeys.includes("can_load_more_past")) { - this.set("details.can_load_more_past", meta.can_load_more_past); - this.set( - "allPastMessagesLoaded", - this.details.can_load_more_past === false - ); - } - if (metaKeys.includes("can_load_more_future")) { - this.set("details.can_load_more_future", meta.can_load_more_future); - } - }, + return this.chatApi + .messages(this.args.channel.id, findArgs) + .then((results) => { + if ( + this._selfDeleted || + this.args.channel.id !== results.meta.channel_id + ) { + return; + } - setMessageProps(messages, fetchingFromLastRead) { - this._unloadedReplyIds = []; - this.messageLookup = {}; - const meta = messages.resultSetMeta; - this.setProperties({ - messages: this._prepareMessages(messages), - details: { - can_delete_self: meta.can_delete_self, - can_delete_others: meta.can_delete_others, - can_flag: meta.can_flag, - user_silenced: meta.user_silenced, - can_moderate: meta.can_moderate, - channel_message_bus_last_id: meta.channel_message_bus_last_id, - }, - registeredChatChannelId: this.chatChannel.id, - }); + const [messages, meta] = this.afterFetchCallback( + this.args.channel, + results + ); - schedule("afterRender", () => { + this.args.channel.addMessages(messages); + this.args.channel.details = meta; + + if (!messages.length) { + return; + } + + if (scrollTo) { + if (!loadingPast) { + this.scrollToMessage(messageId, { position: "start" }); + } else { + if (this.site.desktopView) { + this.scrollToMessage(messages[messages.length - 1].id); + } + } + } + + this.fillPaneAttempt(); + }) + .catch(() => { + this._handleErrors(); + }) + .finally(() => { + this[loadingMoreKey] = false; + }); + } + + fillPaneAttempt() { + next(() => { if (this._selfDeleted) { return; } - if (this.targetMessageId) { - this.scrollToMessage(this.targetMessageId, { - highlight: true, - position: "top", - autoExpand: true, - }); - - this.set("targetMessageId", null); - } else if (fetchingFromLastRead) { - this._markLastReadMessage(); + // safeguard + if (this.args.channel.messages.length > 200) { + return; } - this.fillPaneAttempt(messages.resultSetMeta); + if (!this.args.channel?.canLoadMorePast) { + return; + } + + schedule("afterRender", () => { + const firstMessageId = this.args.channel?.messages?.[0]?.id; + if (!firstMessageId) { + return; + } + + const scroller = document.querySelector(".chat-messages-container"); + const messageContainer = scroller.querySelector( + `.chat-message-container[data-id="${firstMessageId}"]` + ); + + if ( + !scroller || + !messageContainer || + !isElementInViewport(messageContainer) + ) { + return; + } + + this._fetchMoreMessagesThrottled({ + direction: PAST, + scrollTo: false, + }); + }); }); + } - this.setCanLoadMoreDetails(messages.resultSetMeta); - this._subscribeToUpdates(this.chatChannel.id); - }, - - _prepareMessages(messages) { - const preparedMessages = A(); - let previousMessage; - messages.forEach((currentMessage) => { - let prepared = this._prepareSingleMessage( - currentMessage, - previousMessage - ); - preparedMessages.push(prepared); - previousMessage = prepared; - }); - return preparedMessages; - }, - - _areDatesOnSameDay(a, b) { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() + _fetchMoreMessagesThrottled(params) { + throttle( + this, + this._fetchMoreMessages, + params, + FETCH_MORE_MESSAGES_THROTTLE_MS ); - }, + } - _prepareSingleMessage(messageData, previousMessageData) { - if (previousMessageData) { - if ( - !this._areDatesOnSameDay( - new Date(previousMessageData.created_at), - new Date(messageData.created_at) - ) - ) { - messageData.firstMessageOfTheDayAt = moment( - messageData.created_at - ).calendar(moment(), { - sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`, - lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`, - lastWeek: "LL", - sameElse: "LL", - }); + @bind + afterFetchCallback(channel, results) { + const messages = []; + let foundFirstNew = false; + + results.chat_messages.forEach((messageData) => { + // If a message has been hidden it is because the current user is ignoring + // the user who sent it, so we want to unconditionally hide it, even if + // we are going directly to the target + if (this.currentUser.ignored_users) { + messageData.hidden = this.currentUser.ignored_users.includes( + messageData.user.username + ); } - } - if (messageData.in_reply_to?.id === previousMessageData?.id) { - // Reply-to message is directly above. Remove `in_reply_to` from message. - messageData.in_reply_to = null; - } - if (messageData.in_reply_to) { - let inReplyToMessage = this.messageLookup[messageData.in_reply_to.id]; - if (inReplyToMessage) { - // Reply to message has already been added - messageData.in_reply_to = inReplyToMessage; + if (this.requestedTargetMessageId === messageData.id) { + messageData.expanded = !messageData.hidden; } else { - inReplyToMessage = EmberObject.create(messageData.in_reply_to); - this._unloadedReplyIds.push(inReplyToMessage.id); - this.messageLookup[inReplyToMessage.id] = inReplyToMessage; + messageData.expanded = !(messageData.hidden || messageData.deleted_at); } - } else { - // In reply-to is false. Check if previous message was created by same - // user and if so, no need to repeat avatar and username + // newest has to be in after fetcg callback as we don't want to make it + // dynamic or it will make the pane jump around, it will disappear on reload if ( - previousMessageData && - !previousMessageData.deleted_at && - Math.abs( - new Date(messageData.created_at) - - new Date(previousMessageData.created_at) - ) < 300000 && // If the time between messages is over 5 minutes, break. - messageData.user.id === previousMessageData.user.id + !foundFirstNew && + messageData.id > + this.args.channel.currentUserMembership.last_read_message_id && + !channel.messages.some((m) => m.newest) ) { - messageData.hideUserInfo = true; + foundFirstNew = true; + messageData.newest = true; } - } - this._handleMessageHidingAndExpansion(messageData); - messageData.messageLookupId = this._generateMessageLookupId(messageData); - const prepared = ChatMessage.create(messageData); - this.messageLookup[messageData.messageLookupId] = prepared; - return prepared; - }, - _handleMessageHidingAndExpansion(messageData) { - if (this.currentUser.ignored_users) { - messageData.hidden = this.currentUser.ignored_users.includes( - messageData.user.username - ); - } + messages.push(ChatMessage.create(channel, messageData)); + }); - // If a message has been hidden it is because the current user is ignoring - // the user who sent it, so we want to unconditionally hide it, even if - // we are going directly to the target - if (this.targetMessageId && this.targetMessageId === messageData.id) { - messageData.expanded = !messageData.hidden; - } else { - messageData.expanded = !(messageData.hidden || messageData.deleted_at); - } - }, - - _generateMessageLookupId(message) { - return message.id || `staged-${message.stagedId}`; - }, + return [messages, results.meta]; + } _getLastReadId() { - return this.chatChannel.currentUserMembership.last_read_message_id; - }, - - _markLastReadMessage(opts = { reRender: false }) { - if (opts.reRender) { - this.messages.forEach((m) => { - if (m.newestMessage) { - m.set("newestMessage", false); - } - }); - } - const lastReadId = this._getLastReadId(); - if (!lastReadId) { - return; - } - - const indexOfLastReadMessage = - this.messages.findIndex((m) => m.id === lastReadId) || 0; - let newestUnreadMessage = this.messages[indexOfLastReadMessage + 1]; - - if (newestUnreadMessage && !this.targetMessageId) { - newestUnreadMessage.set("newestMessage", true); - - next(() => this.scrollToMessage(newestUnreadMessage.id)); - - return; - } - this._stickScrollToBottom(); - }, + return this.args.channel.currentUserMembership.last_read_message_id; + } + @debounce(100) highlightOrFetchMessage(messageId) { - if (this._selfDeleted) { - return; - } - - this.set("targetMessageId", messageId); - - if (this.messageLookup[messageId]) { - // We have the message rendered. highlight and scrollTo - this.scrollToMessage(messageId, { + const message = this.args.channel.findMessage(messageId); + if (message) { + this.scrollToMessage(message.id, { highlight: true, - position: "top", + position: "start", autoExpand: true, }); + this.requestedTargetMessageId = null; } else { - this.fetchMessages(this.chatChannel); + this.fetchMessages(); } - }, + } scrollToMessage( messageId, - opts = { highlight: false, position: "top", autoExpand: false } + opts = { highlight: false, position: "start", autoExpand: false } ) { if (this._selfDeleted) { return; } - const message = this.messageLookup[messageId]; - if (message?.deleted_at && opts.autoExpand) { - message.set("expanded", true); + + const message = this.args.channel.findMessage(messageId); + if (message?.deletedAt && opts.autoExpand) { + message.expanded = true; } schedule("afterRender", () => { @@ -606,118 +431,134 @@ export default Component.extend({ return; } - this._wrapIOSFix(() => { + if (opts.highlight) { + message.highlighted = true; + + discourseLater(() => { + if (this._selfDeleted) { + return; + } + + message.highlighted = false; + }, 2000); + } + + this._iOSFix(() => { messageEl.scrollIntoView({ - block: opts.position === "top" ? "start" : "end", + block: opts.position ?? "center", }); }); - - if (opts.highlight) { - messageEl.classList.add("highlighted"); - - // Remove highlighted class, but keep `transition-slow` on for another 2 seconds - // to ensure the background color fades smoothly out - if (opts.highlight) { - discourseLater(() => { - messageEl.classList.add("transition-slow"); - }, 2000); - - discourseLater(() => { - messageEl.classList.remove("highlighted"); - - discourseLater(() => { - messageEl.classList.remove("transition-slow"); - }, 2000); - }, 3000); - } - } }); - }, + } - @afterRender - _stickScrollToBottom() { - if (this.ignoreStickyScrolling) { + @action + didShowMessage(message) { + message.visible = true; + this.updateLastReadMessage(message); + this._throttleComputeSeparators(); + } + + @action + didHideMessage(message) { + message.visible = false; + this._throttleComputeSeparators(); + } + + @debounce(READ_INTERVAL_MS) + updateLastReadMessage() { + if (this._selfDeleted) { return; } - this.set("stickyScroll", true); - - if (this._scrollerEl) { - // Trigger a tiny scrollTop change so Safari scrollbar is placed at bottom. - // Setting to just 0 doesn't work (it's at 0 by default, so there is no change) - // Very hacky, but no way to get around this Safari bug - this._scrollerEl.scrollTop = -1; - - this._wrapIOSFix(() => { - this._scrollerEl.scrollTop = 0; - this.set("showScrollToBottomBtn", false); - }); + const lastReadId = + this.args.channel.currentUserMembership?.last_read_message_id; + const lastUnreadVisibleMessage = this.args.channel.visibleMessages.findLast( + (message) => !lastReadId || message.id > lastReadId + ); + if (lastUnreadVisibleMessage) { + this.args.channel.updateLastReadMessage(lastUnreadVisibleMessage.id); } - }, + } - onScroll(event) { + @action + scrollToBottom() { + schedule("afterRender", () => { + if (this.args.channel.canLoadMoreFuture) { + this._fetchAndScrollToLatest(); + } else { + if (this._scrollerEl) { + // Trigger a tiny scrollTop change so Safari scrollbar is placed at bottom. + // Setting to just 0 doesn't work (it's at 0 by default, so there is no change) + // Very hacky, but no way to get around this Safari bug + this._scrollerEl.scrollTop = -1; + + this._iOSFix(() => { + this._scrollerEl.scrollTop = 0; + this.hasNewMessages = false; + }); + } + } + }); + } + + onScroll() { if (this._selfDeleted) { return; } resetIdle(); - const atTop = - Math.abs( - this._scrollerEl.scrollHeight - - this._scrollerEl.clientHeight + - this._scrollerEl.scrollTop - ) <= STICKY_SCROLL_LENIENCE; - - if (atTop) { - this._fetchMoreMessagesThrottled(PAST); - } else if (Math.abs(this._scrollerEl.scrollTop) <= STICKY_SCROLL_LENIENCE) { - this._fetchMoreMessagesThrottled(FUTURE); + if (this.loading || this.loadingMorePast || this.loadingMoreFuture) { + return; } - this._calculateStickScroll(event.forceShowScrollToBottom); - }, + const scrollPosition = Math.abs(this._scrollerEl.scrollTop); + const total = this._scrollerEl.scrollHeight - this._scrollerEl.clientHeight; - _calculateStickScroll(forceShowScrollToBottom) { - const absoluteScrollTop = Math.abs(this._scrollerEl.scrollTop); - const shouldStick = absoluteScrollTop < STICKY_SCROLL_LENIENCE; + this.isAlmostDocked = scrollPosition / this._scrollerEl.offsetHeight < 0.67; + this.isDocked = scrollPosition <= 1; - if (forceShowScrollToBottom) { - this.set("showScrollToBottomBtn", forceShowScrollToBottom); - } else { - this.set( - "showScrollToBottomBtn", - shouldStick - ? false - : absoluteScrollTop / this._scrollerEl.offsetHeight > 0.67 + if ( + this._previousScrollTop - this._scrollerEl.scrollTop > + this._previousScrollTop + ) { + const atTop = this._isBetween( + scrollPosition, + total - STICKY_SCROLL_LENIENCE, + total + STICKY_SCROLL_LENIENCE ); - } - if (!this.showScrollToBottomBtn) { - this.set("hasNewMessages", false); - } + if (atTop) { + this._fetchMoreMessagesThrottled({ direction: PAST }); + } + } else { + const atBottom = this._isBetween( + scrollPosition, + 0 + STICKY_SCROLL_LENIENCE, + 0 - STICKY_SCROLL_LENIENCE + ); - if (shouldStick !== this.stickyScroll) { - if (shouldStick) { - this._stickScrollToBottom(); - } else { - this.set("stickyScroll", false); + if (atBottom) { + this.hasNewMessages = false; + this._fetchMoreMessagesThrottled({ direction: FUTURE }); } } - }, - @observes("chatStateManager.isDrawerActive") - onFloatHiddenChange() { - if (this.chatStateManager.isDrawerActive) { - this.set("expanded", true); - this._markLastReadMessage({ reRender: true }); - this._stickScrollToBottom(); - } - }, + this._previousScrollTop = this._scrollerEl.scrollTop; + } + + _isBetween(target, a, b) { + const min = Math.min.apply(Math, [a, b]); + const max = Math.max.apply(Math, [a, b]); + return target > min && target < max; + } removeMessage(msgData) { - delete this.messageLookup[msgData.id]; - }, + const message = this.args.channel.findMessage(msgData.id); + if (message) { + this.args.channel.removeMessage(message); + } + } handleMessage(data) { switch (data.type) { @@ -755,92 +596,84 @@ export default Component.extend({ this.handleFlaggedMessage(data); break; } - }, + } - handleSentMessage(data) { - if (this.chatChannel.isFollowing) { - this.chatChannel.set("last_message_sent_at", new Date()); - } + _handleOwnSentMessage(data) { + const stagedMessage = this.args.channel.findStagedMessage(data.staged_id); + if (stagedMessage) { + stagedMessage.error = null; + stagedMessage.id = data.chat_message.id; + stagedMessage.staged = false; + stagedMessage.excerpt = data.chat_message.excerpt; + stagedMessage.threadId = data.chat_message.thread_id; + stagedMessage.channelId = data.chat_message.chat_channel_id; + stagedMessage.createdAt = data.chat_message.created_at; - if (data.chat_message.user.id === this.currentUser.id) { - // User sent this message. Check staged messages to see if this client sent the message. - // If so, need to update the staged message with and id. - const stagedMessage = this.messageLookup[`staged-${data.stagedId}`]; - if (stagedMessage) { - stagedMessage.setProperties({ - error: null, - staged: false, - id: data.chat_message.id, - staged_id: null, - excerpt: data.chat_message.excerpt, - thread_id: data.chat_message.thread_id, - chat_channel_id: data.chat_message.chat_channel_id, - }); + const inReplyToMsg = this.args.channel.findMessage( + data.chat_message.in_reply_to?.id + ); + if (inReplyToMsg && !inReplyToMsg.threadId) { + inReplyToMsg.threadId = data.chat_message.thread_id; + } - const inReplyToMsg = - this.messageLookup[data.chat_message.in_reply_to?.id]; - if (inReplyToMsg && !inReplyToMsg.thread_id) { - inReplyToMsg.set("thread_id", data.chat_message.thread_id); - } - - // some markdown is cooked differently on the server-side, e.g. - // quotes, avatar images etc. - if ( - data.chat_message.cooked && - data.chat_message.cooked !== stagedMessage.cooked - ) { - stagedMessage.set("cooked", data.chat_message.cooked); - } - this.appEvents.trigger( - `chat-message-staged-${data.stagedId}:id-populated` - ); - - this.messageLookup[data.chat_message.id] = stagedMessage; - delete this.messageLookup[`staged-${data.stagedId}`]; - return; + // some markdown is cooked differently on the server-side, e.g. + // quotes, avatar images etc. + if (data.chat_message?.cooked !== stagedMessage.cooked) { + stagedMessage.cooked = data.chat_message.cooked; } } + } - const preparedMessage = this._prepareSingleMessage( - data.chat_message, - this.messages[this.messages.length - 1] - ); - - this.messages.pushObject(preparedMessage); - - if (this.messages.length >= MAX_RECENT_MSGS) { - this.removeMessage(this.messages.shiftObject()); + handleSentMessage(data) { + if (this.args.channel.isFollowing) { + this.args.channel.lastMessageSentAt = new Date(); } - this.reStickScrollIfNeeded(); - }, + + if (data.chat_message.user.id === this.currentUser.id && data.staged_id) { + return this._handleOwnSentMessage(data); + } + + if (this.args.channel.canLoadMoreFuture) { + // If we can load more messages, we just notice the user of new messages + this.hasNewMessages = true; + } else if (this._scrollerEl.scrollTop <= 1) { + // If we are at the bottom, we append the message and scroll to it + const message = ChatMessage.create(this.args.channel, data.chat_message); + this.args.channel.addMessages([message]); + this.scrollToBottom(); + } else { + // If we are almost at the bottom, we append the message and notice the user + const message = ChatMessage.create(this.args.channel, data.chat_message); + this.args.channel.addMessages([message]); + this.hasNewMessages = true; + } + } handleProcessedMessage(data) { - const message = this.messageLookup[data.chat_message.id]; + const message = this.args.channel.findMessage(data.chat_message.id); if (message) { - message.set("cooked", data.chat_message.cooked); - this.reStickScrollIfNeeded(); + message.cooked = data.chat_message.cooked; + this.scrollToBottom(); } - }, + } handleRefreshMessage(data) { - const message = this.messageLookup[data.chat_message.id]; + const message = this.args.channel.findMessage(data.chat_message.id); if (message) { - this.appEvents.trigger("chat:refresh-message", message); + message.version = message.version + 1; } - }, + } handleEditMessage(data) { - const message = this.messageLookup[data.chat_message.id]; + const message = this.args.channel.findMessage(data.chat_message.id); if (message) { - message.setProperties({ - message: data.chat_message.message, - cooked: data.chat_message.cooked, - excerpt: data.chat_message.excerpt, - uploads: cloneJSON(data.chat_message.uploads || []), - edited: true, - }); + message.message = data.chat_message.message; + message.cooked = data.chat_message.cooked; + message.excerpt = data.chat_message.excerpt; + message.uploads = cloneJSON(data.chat_message.uploads || []); + message.edited = true; } - }, + } handleBulkDeleteMessage(data) { data.deleted_ids.forEach((deletedId) => { @@ -849,109 +682,68 @@ export default Component.extend({ deleted_at: data.deleted_at, }); }); - }, + } handleDeleteMessage(data) { const deletedId = data.deleted_id; - const targetMsg = this.messageLookup[deletedId]; - if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) { - targetMsg.setProperties({ - deleted_at: data.deleted_at, - expanded: false, - }); - } else { - this.messages.removeObject(targetMsg); - this.messageLookup[deletedId] = null; + const targetMsg = this.args.channel.findMessage(deletedId); + + if (!targetMsg) { + return; } - }, + + if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) { + targetMsg.deletedAt = data.deleted_at; + targetMsg.expanded = false; + } else { + this.args.channel.removeMessage(targetMsg); + } + } handleReactionMessage(data) { - this.appEvents.trigger( - `chat-message-${data.chat_message_id}:reaction`, - data - ); - }, - - handleRestoreMessage(data) { - let message = this.messageLookup[data.chat_message.id]; - if (message) { - message.set("deleted_at", null); - } else { - // The message isn't present in the list for this user. Find the index - // where we should push the message to. Binary search is O(log(n)) - let newMessageIndex = this.binarySearchForMessagePosition( - this.messages, - message - ); - const previousMessage = - newMessageIndex > 0 ? this.messages[newMessageIndex - 1] : null; - message = this._prepareSingleMessage(data.chat_message, previousMessage); - if (newMessageIndex === 0) { - return; - } // Restored post is too old to show - - this.messages.splice(newMessageIndex, 0, message); - this.notifyPropertyChange("messages"); - } - }, - - binarySearchForMessagePosition(messages, newMessage) { - const newMessageCreatedAt = Date.parse(newMessage.created_at); - if (newMessageCreatedAt < Date.parse(messages[0].created_at)) { - return 0; - } - if ( - newMessageCreatedAt > Date.parse(messages[messages.length - 1].created_at) - ) { - return messages.length; - } - let m = 0; - let n = messages.length - 1; - while (m <= n) { - let k = Math.floor((n + m) / 2); - let comparison = this.compareCreatedAt(newMessageCreatedAt, messages[k]); - if (comparison > 0) { - m = k + 1; - } else if (comparison < 0) { - n = k - 1; - } else { - return k; + if (data.user.id !== this.currentUser.id) { + const message = this.args.channel.findMessage(data.chat_message_id); + if (message) { + message.react(data.emoji, data.action, data.user, this.currentUser.id); } } - return m; - }, + } - compareCreatedAt(newMessageCreatedAt, comparatorMessage) { - const compareDate = Date.parse(comparatorMessage.created_at); - if (newMessageCreatedAt > compareDate) { - return 1; - } else if (newMessageCreatedAt < compareDate) { - return -1; + handleRestoreMessage(data) { + const message = this.args.channel.findMessage(data.chat_message.id); + if (message) { + message.deletedAt = null; + } else { + this.args.channel.addMessages([ + ChatMessage.create(this.args.channel, data.chat_message), + ]); } - return 0; - }, + } handleMentionWarning(data) { - this.messageLookup[data.chat_message_id]?.set("mentionWarning", data); - }, + const message = this.args.channel.findMessage(data.chat_message_id); + if (message) { + message.mentionWarning = EmberObject.create(data); + } + } handleSelfFlaggedMessage(data) { - this.messageLookup[data.chat_message_id]?.set( - "user_flag_status", - data.user_flag_status - ); - }, + const message = this.args.channel.findMessage(data.chat_message_id); + if (message) { + message.userFlagStatus = data.user_flag_status; + } + } handleFlaggedMessage(data) { - this.messageLookup[data.chat_message_id]?.set( - "reviewable_id", - data.reviewable_id - ); - }, + const message = this.args.channel.findMessage(data.chat_message_id); + if (message) { + message.reviewableId = data.reviewable_id; + } + } get _selfDeleted() { - return !this.element || this.isDestroying || this.isDestroyed; - }, + return this.isDestroying || this.isDestroyed; + } @action sendMessage(message, uploads = []) { @@ -961,8 +753,8 @@ export default Component.extend({ return; } - this.set("sendingLoading", true); - this._setDraftForChannel(null); + this.sendingLoading = true; + this.args.channel.draft = ChatMessageDraft.create(); // TODO: all send message logic is due for massive refactoring // This is all the possible case Im currently aware of @@ -972,78 +764,61 @@ export default Component.extend({ // - 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.chatChannel.isFollowing || this.chatChannel.isDraft) { - this.set("loading", true); + if (!this.args.channel.isFollowing || this.args.channel.isDraft) { + this.loading = true; return this._upsertChannelWithMessage( - this.chatChannel, + this.args.channel, message, uploads ).finally(() => { if (this._selfDeleted) { return; } - this.set("loading", false); - this.set("sendingLoading", false); + this.loading = false; + this.sendingLoading = false; this._resetAfterSend(); - this._stickScrollToBottom(); + this.scrollToBottom(); }); } - this.set("_nextStagedMessageId", this._nextStagedMessageId + 1); - return this.chat.loadCookFunction(this.site.categories).then((cook) => { - const cooked = cook(message); - const stagedId = this._nextStagedMessageId; - let data = { - message, - cooked, - staged_id: stagedId, - upload_ids: uploads.map((upload) => upload.id), - }; - if (this.replyToMsg) { - data.in_reply_to_id = this.replyToMsg.id; - } - - // Start ajax request but don't return here, we want to stage the message instantly when all messages are loaded. - // Otherwise, we'll fetch latest and scroll to the one we just created. - // Return a resolved promise below. - const msgCreationPromise = this.chatApi - .sendMessage(this.chatChannel.id, data) - .catch((error) => { - this._onSendError(data.staged_id, error); - }) - .finally(() => { - if (this._selfDeleted) { - return; - } - this.set("sendingLoading", false); - }); - - if (this.details?.can_load_more_future) { - msgCreationPromise.then(() => this._fetchAndScrollToLatest()); - } else { - const stagedMessage = this._prepareSingleMessage( - // We need to add the user and created at for presentation of staged message - { - message, - cooked, - stagedId, - uploads: cloneJSON(uploads), - staged: true, - user: this.currentUser, - in_reply_to: this.replyToMsg, - created_at: new Date(), - }, - this.messages[this.messages.length - 1] - ); - this.messages.pushObject(stagedMessage); - this._stickScrollToBottom(); - } - - this._resetAfterSend(); - this.appEvents.trigger("chat-composer:reply-to-set", null); + const stagedMessage = ChatMessage.createStagedMessage(this.args.channel, { + message, + created_at: new Date(), + uploads: cloneJSON(uploads), + user: this.currentUser, }); - }, + + if (this.replyToMsg) { + stagedMessage.inReplyTo = this.replyToMsg; + } + + this.args.channel.addMessages([stagedMessage]); + if (!this.args.channel.canLoadMoreFuture) { + this.scrollToBottom(); + } + + return this.chatApi + .sendMessage(this.args.channel.id, { + message: stagedMessage.message, + in_reply_to_id: stagedMessage.inReplyTo?.id, + staged_id: stagedMessage.id, + upload_ids: stagedMessage.uploads.map((upload) => upload.id), + }) + .then(() => { + this.scrollToBottom(); + }) + .catch((error) => { + this._onSendError(stagedMessage.id, error); + }) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.sendingLoading = false; + this._resetAfterSend(); + }); + } async _upsertChannelWithMessage(channel, message, uploads) { let promise = Promise.resolve(channel); @@ -1065,37 +840,37 @@ export default Component.extend({ this.router.transitionTo("chat.channel", "-", c.id); }) ); - }, + } - _onSendError(stagedId, error) { - const stagedMessage = this.messageLookup[`staged-${stagedId}`]; + _onSendError(id, error) { + const stagedMessage = this.args.channel.findStagedMessage(id); if (stagedMessage) { if (error.jqXHR?.responseJSON?.errors?.length) { - stagedMessage.set("error", error.jqXHR.responseJSON.errors[0]); + stagedMessage.error = error.jqXHR.responseJSON.errors[0]; } else { this.chat.markNetworkAsUnreliable(); - stagedMessage.set("error", "network_error"); + stagedMessage.error = "network_error"; } } this._resetAfterSend(); - }, + } @action resendStagedMessage(stagedMessage) { - this.set("sendingLoading", true); + this.sendingLoading = true; - stagedMessage.set("error", null); + stagedMessage.error = null; const data = { cooked: stagedMessage.cooked, message: stagedMessage.message, - upload_ids: stagedMessage.upload_ids, - staged_id: stagedMessage.stagedId, + upload_ids: stagedMessage.uploads.map((upload) => upload.id), + staged_id: stagedMessage.id, }; this.chatApi - .sendMessage(this.chatChannel.id, data) + .sendMessage(this.args.channel.id, data) .catch((error) => { this._onSendError(data.staged_id, error); }) @@ -1106,18 +881,18 @@ export default Component.extend({ if (this._selfDeleted) { return; } - this.set("sendingLoading", false); + this.sendingLoading = false; }); - }, + } @action editMessage(chatMessage, newContent, uploads) { - this.set("sendingLoading", true); + this.sendingLoading = true; let data = { new_message: newContent, upload_ids: (uploads || []).map((upload) => upload.id), }; - return ajax(`/chat/${this.chatChannel.id}/edit/${chatMessage.id}`, { + return ajax(`/chat/${this.args.channel.id}/edit/${chatMessage.id}`, { type: "PUT", data, }) @@ -1129,127 +904,107 @@ export default Component.extend({ if (this._selfDeleted) { return; } - this.set("sendingLoading", false); + this.sendingLoading = false; }); - }, - - _resetChannelState() { - this._unsubscribeToUpdates(this.registeredChatChannelId); - this.messages.clear(); - this.messageLookup = {}; - this.set("allPastMessagesLoaded", false); - this.set("registeredChatChannelId", null); - this.set("selectingMessages", false); - }, + } _resetAfterSend() { if (this._selfDeleted) { return; } - this.setProperties({ - replyToMsg: null, - editingMessage: null, - }); - this.chatComposerPresenceManager.notifyState(this.chatChannel.id, false); - }, + + this.replyToMsg = null; + this.editingMessage = null; + this.chatComposerPresenceManager.notifyState(this.args.channel.id, false); + this.appEvents.trigger("chat-composer:reply-to-set", null); + } @action editLastMessageRequested() { - let lastUserMessage = null; - for ( - let messageIndex = this.messages.length - 1; - messageIndex >= 0; - messageIndex-- - ) { - let message = this.messages[messageIndex]; - if ( - !message.staged && + const lastUserMessage = this.args.channel.messages.findLast( + (message) => message.user.id === this.currentUser.id && + !message.staged && !message.error - ) { - lastUserMessage = message; - break; - } - } + ); + if (lastUserMessage) { - this.set("editingMessage", lastUserMessage); + this.editingMessage = lastUserMessage; this._focusComposer(); } - }, + } @action setReplyTo(messageId) { if (messageId) { this.cancelEditing(); - this.set("replyToMsg", this.messageLookup[messageId]); - this.appEvents.trigger("chat-composer:reply-to-set", this.replyToMsg); + + const message = this.args.channel.findMessage(messageId); + this.replyToMsg = message; + this.appEvents.trigger("chat-composer:reply-to-set", message); this._focusComposer(); } else { - this.set("replyToMsg", null); + this.replyToMsg = null; this.appEvents.trigger("chat-composer:reply-to-set", null); } - }, + } @action replyMessageClicked(message) { - const replyMessageFromLookup = this.messageLookup[message.id]; - if (this._unloadedReplyIds.includes(message.id)) { - // Message is not present in the loaded messages. Fetch it! - this.set("targetMessageId", message.id); - this.fetchMessages(this.chatChannel); - } else { + const replyMessageFromLookup = this.args.channel.findMessage(message.id); + if (replyMessageFromLookup) { this.scrollToMessage(replyMessageFromLookup.id, { highlight: true, - position: "top", + position: "start", autoExpand: true, }); + } else { + // Message is not present in the loaded messages. Fetch it! + this.requestedTargetMessageId = message.id; + this.fetchMessages(); } - }, + } @action editButtonClicked(messageId) { - const message = this.messageLookup[messageId]; - this.set("editingMessage", message); - next(this.reStickScrollIfNeeded.bind(this)); + const message = this.args.channel.findMessage(messageId); + this.editingMessage = message; + this.scrollToBottom(); this._focusComposer(); - }, + } - @discourseComputed("details.user_silenced") - canInteractWithChat(userSilenced) { - return !userSilenced; - }, + get canInteractWithChat() { + return !this.args.channel?.userSilenced; + } - @discourseComputed - chatProgressBarContainer() { + get chatProgressBarContainer() { return document.querySelector("#chat-progress-bar-container"); - }, + } - @discourseComputed("messages.@each.selected") - selectedMessageIds(messages) { - return messages.filter((m) => m.selected).map((m) => m.id); - }, + get selectedMessageIds() { + return this.args.channel?.messages + ?.filter((m) => m.selected) + ?.map((m) => m.id); + } @action onStartSelectingMessages(message) { this._lastSelectedMessage = message; - this.set("selectingMessages", true); - }, + this.selectingMessages = true; + } @action cancelSelecting() { - this.set("selectingMessages", false); - this.messages.setEach("selected", false); - }, + this.selectingMessages = false; + this.args.channel.messages.forEach((message) => { + message.selected = false; + }); + } @action onSelectMessage(message) { this._lastSelectedMessage = message; - }, - - @action - navigateToIndex() { - this.router.transitionTo("chat.index"); - }, + } @action bulkSelectMessages(message, checked) { @@ -1262,13 +1017,13 @@ export default Component.extend({ ); for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) { - this.messages[i].set("selected", checked); + this.args.channel.messages[i].selected = checked; } - }, + } _findIndexOfMessage(message) { - return this.messages.findIndex((m) => m.id === message.id); - }, + return this.args.channel.messages.findIndex((m) => m.id === message.id); + } @action onCloseFullScreen() { @@ -1279,52 +1034,68 @@ export default Component.extend({ this.chatStateManager.lastKnownChatURL ); }); - }, + } @action cancelEditing() { - this.set("editingMessage", null); - }, - - @action - _setDraftForChannel(draft) { - if (this.chatChannel.isDraft) { - return; - } - - if (draft?.replyToMsg) { - draft.replyToMsg = { - id: draft.replyToMsg.id, - excerpt: draft.replyToMsg.excerpt, - user: draft.replyToMsg.user, - }; - } - this.chat.setDraftForChannel(this.chatChannel, draft); - this.set("draft", draft); - }, + this.editingMessage = null; + } @action setInReplyToMsg(inReplyMsg) { - this.set("replyToMsg", inReplyMsg); - }, + this.replyToMsg = inReplyMsg; + } @action - composerValueChanged(value, uploads, replyToMsg) { - if (!this.editingMessage && !this.chatChannel.directMessageChannelDraft) { - this._setDraftForChannel({ value, uploads, replyToMsg }); + composerValueChanged({ value, uploads, replyToMsg }) { + if (!this.editingMessage && !this.args.channel.isDraft) { + if (typeof value !== "undefined") { + this.args.channel.draft.message = value; + } + if (typeof uploads !== "undefined") { + this.args.channel.draft.uploads = uploads; + } + if (typeof replyToMsg !== "undefined") { + this.args.channel.draft.replyToMsg = replyToMsg; + } } - if (!this.chatChannel.directMessageChannelDraft) { + if (!this.args.channel.isDraft) { this._reportReplyingPresence(value); } - }, - @action - reStickScrollIfNeeded() { - if (this.stickyScroll) { - this._stickScrollToBottom(); + this._persistDraft(); + } + + @debounce(2000) + _persistDraft() { + if (this._selfDeleted) { + return; } - }, + + if (!this.args.channel.draft) { + return; + } + + ajax("/chat/drafts.json", { + type: "POST", + data: { + chat_channel_id: this.args.channel.id, + data: this.args.channel.draft.toJSON(), + }, + ignoreUnsent: false, + }) + .then(() => { + this.chat.markNetworkAsReliable(); + }) + .catch((error) => { + // we ignore a draft which can't be saved because it's too big + // and only deal with network error for now + if (!error.jqXHR?.responseJSON?.errors?.length) { + this.chat.markNetworkAsUnreliable(); + } + }); + } @action onHoverMessage(message, options = {}, event) { @@ -1360,7 +1131,7 @@ export default Component.extend({ ".chat-message-actions-desktop-anchor" ) ) { - this.set("hoveredMessageId", message?.id); + this.hoveredMessageId = message?.id; return; } } @@ -1371,7 +1142,7 @@ export default Component.extend({ message, 250 ); - }, + } @bind debouncedOnHoverMessage(message) { @@ -1379,57 +1150,53 @@ export default Component.extend({ return; } - this.set( - "hoveredMessageId", - message?.id && message.id !== this.hoveredMessageId ? message.id : null - ); - }, + this.hoveredMessageId = + message?.id && message.id !== this.hoveredMessageId ? message.id : null; + } _reportReplyingPresence(composerValue) { if (this._selfDeleted) { return; } - if (this.chatChannel.isDraft) { + if (this.args.channel.isDraft) { return; } const replying = !this.editingMessage && !!composerValue; - this.chatComposerPresenceManager.notifyState(this.chatChannel.id, replying); - }, - - @action - restickScrolling(event) { - event.preventDefault(); - - return this._fetchAndScrollToLatest(); - }, + this.chatComposerPresenceManager.notifyState( + this.args.channel.id, + replying + ); + } _focusComposer() { this.appEvents.trigger("chat:focus-composer"); - }, + } _unsubscribeToUpdates(channelId) { + if (!channelId) { + return; + } + this.messageBus.unsubscribe(`/chat/${channelId}`, this.onMessage); - }, + } _subscribeToUpdates(channelId) { this._unsubscribeToUpdates(channelId); this.messageBus.subscribe( `/chat/${channelId}`, this.onMessage, - this.details.channel_message_bus_last_id + this.args.channel.channelMessageBusLastId ); - }, + } @bind onMessage(busData) { - if (!this.details.can_load_more_future || busData.type !== "sent") { + if (!this.args.channel.canLoadMoreFuture || busData.type !== "sent") { this.handleMessage(busData); - } else { - this.set("hasNewMessages", true); } - }, + } @bind _forceBodyScroll() { @@ -1442,13 +1209,13 @@ export default Component.extend({ ) { document.documentElement.scrollTo(0, 0); } - }, + } _fetchAndScrollToLatest() { - return this.fetchMessages(this.chatChannel, { + return this.fetchMessages({ fetchFromLastMessage: true, }); - }, + } _handleErrors(error) { switch (error?.jqXHR?.status) { @@ -1459,12 +1226,12 @@ export default Component.extend({ default: throw error; } - }, + } // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling // we now use this hack to disable it @bind - _wrapIOSFix(callback) { + _iOSFix(callback) { if (!this._scrollerEl) { return; } @@ -1473,7 +1240,7 @@ export default Component.extend({ this._scrollerEl.style.overflow = "hidden"; } - callback(); + callback?.(); if (this.capabilities.isIOS) { discourseLater(() => { @@ -1484,5 +1251,76 @@ export default Component.extend({ this._scrollerEl.style.overflow = "auto"; }, 25); } - }, -}); + } + + @action + addAutoFocusEventListener() { + document.addEventListener("keydown", this._autoFocus); + } + + @action + removeAutoFocusEventListener() { + document.removeEventListener("keydown", this._autoFocus); + } + + @bind + _autoFocus(event) { + const { key, metaKey, ctrlKey, code, target } = event; + + if ( + !key || + // Handles things like Enter, Tab, Shift + key.length > 1 || + // Don't need to focus if the user is beginning a shortcut. + metaKey || + ctrlKey || + // Space's key comes through as ' ' so it's not covered by key + code === "Space" || + // ? is used for the keyboard shortcut modal + key === "?" + ) { + return; + } + + if (!target || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const composer = document.querySelector(".chat-composer-input"); + if (composer && !this.args.channel.isDraft) { + this.appEvents.trigger("chat:insert-text", key); + composer.focus(); + } + } + + _throttleComputeSeparators() { + throttle(this, this._computeSeparators, 32, false); + } + + _computeSeparators() { + next(() => { + schedule("afterRender", () => { + const dates = this._scrollerEl.querySelectorAll( + ".chat-message-separator-date" + ); + const scrollHeight = document.querySelector( + ".chat-messages-scroll" + ).scrollHeight; + const reversedDates = [...dates].reverse(); + // TODO (joffrey): optimize this code to trigger less layout computation + reversedDates.forEach((date, index) => { + if (index > 0) { + date.style.bottom = + scrollHeight - reversedDates[index - 1].offsetTop + "px"; + } else { + date.style.bottom = 0; + } + date.style.top = date.nextElementSibling.offsetTop + "px"; + }); + }); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs index cb665429b06..10e4858a533 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs @@ -6,11 +6,11 @@ >
    {{#if this.chatStateManager.isFullPageActive}} - {{#each @emojiReactions as |reaction|}} + {{#each @emojiReactions key="emoji" as |reaction|}} {{/each}} {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js index 8b62c14c239..111e24b523a 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js @@ -31,6 +31,7 @@ export default class ChatMessageActionsDesktop extends Component { ), { placement: "top-end", + strategy: "fixed", modifiers: [ { name: "hide", enabled: true }, { name: "eventListeners", options: { scroll: false } }, diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs index 87aeca6861b..be96ed82385 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs @@ -53,7 +53,7 @@ {{/each}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.hbs index 8b12b5d09d6..1d2e9676fbf 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.hbs @@ -1,6 +1,6 @@
    - {{#if @message.chat_webhook_event.emoji}} - + {{#if @message.chatWebhookEvent.emoji}} + {{else}} {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js deleted file mode 100644 index 5b7b32a549a..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js +++ /dev/null @@ -1,5 +0,0 @@ -import Component from "@ember/component"; - -export default class ChatMessageAvatar extends Component { - tagName = ""; -} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs index 97635d8b5e6..ec613e5902b 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs @@ -1,10 +1,10 @@
    {{#if this.hasUploads}} - {{html-safe this.cooked}} + {{html-safe @cooked}}
    - {{#each this.uploads as |upload|}} + {{#each @uploads as |upload|}} {{/each}}
    diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js index ab91763bd8d..d62df9e639e 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js @@ -1,28 +1,20 @@ -import Component from "@ember/component"; -import { computed } from "@ember/object"; +import Component from "@glimmer/component"; import { htmlSafe } from "@ember/template"; import { escapeExpression } from "discourse/lib/utilities"; import domFromString from "discourse-common/lib/dom-from-string"; import I18n from "I18n"; export default class ChatMessageCollapser extends Component { - tagName = ""; - collapsed = false; - uploads = null; - cooked = null; - - @computed("uploads") get hasUploads() { - return hasUploads(this.uploads); + return hasUploads(this.args.uploads); } - @computed("uploads") get uploadsHeader() { let name = ""; - if (this.uploads.length === 1) { - name = this.uploads[0].original_filename; + if (this.args.uploads.length === 1) { + name = this.args.uploads[0].original_filename; } else { - name = I18n.t("chat.uploaded_files", { count: this.uploads.length }); + name = I18n.t("chat.uploaded_files", { count: this.args.uploads.length }); } return htmlSafe( `${escapeExpression( @@ -31,9 +23,10 @@ export default class ChatMessageCollapser extends Component { ); } - @computed("cooked") get cookedBodies() { - const elements = Array.prototype.slice.call(domFromString(this.cooked)); + const elements = Array.prototype.slice.call( + domFromString(this.args.cooked) + ); if (hasYoutube(elements)) { return this.youtubeCooked(elements); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.hbs new file mode 100644 index 00000000000..f5d0bf4f52b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.hbs @@ -0,0 +1,19 @@ +{{#if @message.inReplyTo}} + + {{d-icon "share" title="chat.in_reply_to"}} + + {{#if @message.inReplyTo.chatWebhookEvent.emoji}} + + {{else}} + + {{/if}} + + + {{replace-emoji @message.inReplyTo.excerpt}} + + +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js new file mode 100644 index 00000000000..d60e8212b0c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js @@ -0,0 +1,32 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatMessageInReplyToIndicator extends Component { + @service router; + + get route() { + if (this.hasThread) { + return "chat.channel.thread"; + } else { + return "chat.channel.near-message"; + } + } + + get model() { + if (this.hasThread) { + return [this.args.message.threadId]; + } else { + return [ + ...this.args.message.channel.routeModels, + this.args.message.inReplyTo.id, + ]; + } + } + + get hasThread() { + return ( + this.args.message?.channel?.get("threading_enabled") && + this.args.message?.threadId + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs index 2520c2186bb..8ba1a34778b 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.hbs @@ -3,15 +3,15 @@ {{did-insert this.trackStatus}} {{will-destroy this.stopTrackingStatus}} > - {{#if @message.chat_webhook_event}} - {{#if @message.chat_webhook_event.username}} + {{#if @message.chatWebhookEvent}} + {{#if @message.chatWebhookEvent.username}} - {{@message.chat_webhook_event.username}} + {{@message.chatWebhookEvent.username}} {{/if}} @@ -49,8 +49,8 @@ {{#if this.isFlagged}} - {{#if @message.reviewable_id}} - + {{#if @message.reviewableId}} + {{d-icon "flag" title="chat.flagged"}} {{else}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js index 9347fa2d25d..e42be62a896 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js @@ -48,10 +48,7 @@ export default class ChatMessageInfo extends Component { } get isFlagged() { - return ( - this.#message?.get("reviewable_id") || - this.#message?.get("user_flag_status") === 0 - ); + return this.#message?.reviewableId || this.#message?.userFlagStatus === 0; } get prioritizeName() { @@ -66,7 +63,7 @@ export default class ChatMessageInfo extends Component { } get #user() { - return this.#message?.get("user"); + return this.#message?.user; } get #message() { diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.hbs index adbf43b3e0c..d83658b4b1f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.hbs @@ -1,17 +1,17 @@
    - {{#if @message.reviewable_id}} + {{#if @message.reviewableId}} {{d-icon "flag" title="chat.flagged"}} - {{else if (eq @message.user_flag_status 0)}} + {{else if (eq @message.userFlagStatus 0)}}
    {{d-icon "flag" title="chat.you_flagged"}}
    - {{else}} + {{else if this.site.desktopView}} {{format-chat-date @message "tiny"}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.js new file mode 100644 index 00000000000..b60adf92b89 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.js @@ -0,0 +1,6 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; + +export default class ChatMessageLeftGutter extends Component { + @service site; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.hbs index 35c8b3c7d39..c89b7fe5c36 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.hbs @@ -19,7 +19,7 @@ @class="btn-primary" @icon="sign-out-alt" @disabled={{this.disableMoveButton}} - @action={{action "moveMessages"}} + @action={{this.moveMessages}} @label="chat.move_to_channel.confirm_move" @id="chat-confirm-move-messages-to-channel" /> diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs index 576c1a935b2..52f3f07b6f9 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs @@ -1,17 +1,18 @@ -{{#if (and this.reaction this.emojiUrl)}} +{{#if (and @reaction this.emojiUrl)}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js index 748143d2ea3..3f7fda312e6 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js @@ -1,95 +1,73 @@ -import { guidFor } from "@ember/object/internals"; -import Component from "@ember/component"; -import { action, computed } from "@ember/object"; +import Component from "@glimmer/component"; +import { action } from "@ember/object"; import { emojiUnescape, emojiUrlFor } from "discourse/lib/text"; -import setupPopover from "discourse/lib/d-popover"; import I18n from "I18n"; import { schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import setupPopover from "discourse/lib/d-popover"; export default class ChatMessageReaction extends Component { - reaction = null; - showUsersList = false; - tagName = ""; - react = null; - class = null; + @service currentUser; - didReceiveAttrs() { - this._super(...arguments); + get showCount() { + return this.args.showCount ?? true; + } - if (this.showUsersList) { + @action + setupTooltip(element) { + if (this.args.showTooltip) { schedule("afterRender", () => { - this._popover?.destroy(); - this._popover = this._setupPopover(); + this._tippyInstance?.destroy(); + this._tippyInstance = setupPopover(element, { + interactive: false, + allowHTML: true, + delay: 250, + }); }); } } - willDestroyElement() { - this._super(...arguments); - - this._popover?.destroy(); + @action + teardownTooltip() { + this._tippyInstance?.destroy(); } - @computed - get componentId() { - return guidFor(this); + @action + refreshTooltip() { + this._tippyInstance?.setContent(this.popoverContent); } - @computed("reaction.emoji") get emojiString() { - return `:${this.reaction.emoji}:`; + return `:${this.args.reaction.emoji}:`; } - @computed("reaction.emoji") get emojiUrl() { - return emojiUrlFor(this.reaction.emoji); + return emojiUrlFor(this.args.reaction.emoji); } @action handleClick() { - this?.react(this.reaction.emoji, this.reaction.reacted ? "remove" : "add"); + this.args.react?.( + this.args.reaction.emoji, + this.args.reaction.reacted ? "remove" : "add" + ); return false; } - _setupPopover() { - const target = document.getElementById(this.componentId); - - if (!target) { + get popoverContent() { + if (!this.args.reaction.count || !this.args.reaction.users?.length) { return; } - const popover = setupPopover(target, { - interactive: false, - allowHTML: true, - delay: 250, - content: emojiUnescape(this.popoverContent), - onClickOutside(instance) { - instance.hide(); - }, - onTrigger(instance, event) { - // ensures we close other reactions popovers when triggering one - document - .querySelectorAll(".chat-message-reaction") - .forEach((chatMessageReaction) => { - chatMessageReaction?._tippy?.hide(); - }); - - event.stopPropagation(); - }, - }); - - return popover?.id ? popover : null; + return emojiUnescape( + this.args.reaction.reacted + ? this.#reactionTextWithSelf + : this.#reactionText + ); } - @computed("reaction") - get popoverContent() { - return this.reaction.reacted - ? this._reactionTextWithSelf() - : this._reactionText(); - } - - _reactionTextWithSelf() { - const reactionCount = this.reaction.count; + get #reactionTextWithSelf() { + const reactionCount = this.args.reaction.count; if (reactionCount === 0) { return; @@ -97,55 +75,55 @@ export default class ChatMessageReaction extends Component { if (reactionCount === 1) { return I18n.t("chat.reactions.only_you", { - emoji: this.reaction.emoji, + emoji: this.args.reaction.emoji, }); } - const maxUsernames = 4; - const usernames = this.reaction.users + const maxUsernames = 5; + const usernames = this.args.reaction.users + .filter((user) => user.id !== this.currentUser?.id) .slice(0, maxUsernames) .mapBy("username"); if (reactionCount === 2) { return I18n.t("chat.reactions.you_and_single_user", { - emoji: this.reaction.emoji, + emoji: this.args.reaction.emoji, username: usernames.pop(), }); } - // `-1` because the current user ("you") isn't included in `usernames` - const unnamedUserCount = reactionCount - usernames.length - 1; - + const unnamedUserCount = reactionCount - usernames.length; if (unnamedUserCount > 0) { return I18n.t("chat.reactions.you_multiple_users_and_more", { - emoji: this.reaction.emoji, + emoji: this.args.reaction.emoji, commaSeparatedUsernames: this._joinUsernames(usernames), count: unnamedUserCount, }); } return I18n.t("chat.reactions.you_and_multiple_users", { - emoji: this.reaction.emoji, + emoji: this.args.reaction.emoji, username: usernames.pop(), commaSeparatedUsernames: this._joinUsernames(usernames), }); } - _reactionText() { - const reactionCount = this.reaction.count; + get #reactionText() { + const reactionCount = this.args.reaction.count; if (reactionCount === 0) { return; } const maxUsernames = 5; - const usernames = this.reaction.users + const usernames = this.args.reaction.users + .filter((user) => user.id !== this.currentUser?.id) .slice(0, maxUsernames) .mapBy("username"); if (reactionCount === 1) { return I18n.t("chat.reactions.single_user", { - emoji: this.reaction.emoji, + emoji: this.args.reaction.emoji, username: usernames.pop(), }); } @@ -154,14 +132,14 @@ export default class ChatMessageReaction extends Component { if (unnamedUserCount > 0) { return I18n.t("chat.reactions.multiple_users_and_more", { - emoji: this.reaction.emoji, + emoji: this.args.reaction.emoji, commaSeparatedUsernames: this._joinUsernames(usernames), count: unnamedUserCount, }); } return I18n.t("chat.reactions.multiple_users", { - emoji: this.reaction.emoji, + emoji: this.args.reaction.emoji, username: usernames.pop(), commaSeparatedUsernames: this._joinUsernames(usernames), }); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.hbs new file mode 100644 index 00000000000..a27b80a14de --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.hbs @@ -0,0 +1,26 @@ +{{#if @message.firstMessageOfTheDayAt}} +
    +
    + + {{@message.firstMessageOfTheDayAt}} + + {{#if @message.newest}} + - + {{i18n "chat.last_visit"}} + {{/if}} + +
    +
    + +
    +
    +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-new.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-new.hbs new file mode 100644 index 00000000000..26607178d86 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-new.hbs @@ -0,0 +1,13 @@ +{{#if (and @message.newest (not @message.firstMessageOfTheDayAt))}} +
    +
    + + {{i18n "chat.last_visit"}} + +
    + +
    +
    +
    +
    +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.hbs deleted file mode 100644 index 7cb5a5e8898..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.hbs +++ /dev/null @@ -1,15 +0,0 @@ -{{#if this.message.newestMessage}} -
    -
    - - {{i18n "chat.new_messages"}} - -
    -{{else if this.message.firstMessageOfTheDayAt}} -
    -
    - - {{this.message.firstMessageOfTheDayAt}} - -
    -{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js deleted file mode 100644 index 44494409ab5..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js +++ /dev/null @@ -1,5 +0,0 @@ -import Component from "@ember/component"; - -export default Component.extend({ - tagName: "", -}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.hbs index a11a76454b6..f6f1eeb80ec 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.hbs @@ -1,11 +1,11 @@
    {{#if this.isCollapsible}} - + {{else}} - {{html-safe this.cooked}} + {{html-safe @cooked}} {{/if}} - {{#if this.edited}} + {{#if this.isEdited}} ({{i18n "chat.edited"}}) {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js index d02c47ba1cc..042d774ba61 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js @@ -1,15 +1,12 @@ -import Component from "@ember/component"; -import { computed } from "@ember/object"; +import Component from "@glimmer/component"; import { isCollapsible } from "discourse/plugins/chat/discourse/components/chat-message-collapser"; export default class ChatMessageText extends Component { - tagName = ""; - cooked = null; - uploads = null; - edited = false; + get isEdited() { + return this.args.edited ?? false; + } - @computed("cooked", "uploads.[]") get isCollapsible() { - return isCollapsible(this.cooked, this.uploads); + return isCollapsible(this.args.cooked, this.args.uploads); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs index d29d73a9d23..5adb036b4f4 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs @@ -1,6 +1,7 @@ {{! template-lint-disable no-invalid-interactive }} - + + {{#if (and @@ -40,19 +41,23 @@ {{did-insert this.setMessageActionsAnchors}} {{did-insert this.decorateCookedMessage}} {{did-update this.decorateCookedMessage @message.id}} + {{did-update this.decorateCookedMessage @message.version}} {{on "touchmove" this.handleTouchMove passive=true}} {{on "touchstart" this.handleTouchStart passive=true}} {{on "touchend" this.handleTouchEnd passive=true}} {{on "mouseenter" (fn @onHoverMessage @message (hash desktopOnly=true))}} {{on "mouseleave" (fn @onHoverMessage null (hash desktopOnly=true))}} - {{chat/track-message-visibility}} class={{concat-class "chat-message-container" (if @isHovered "is-hovered") (if @selectingMessages "selecting-messages") + (if @message.highlighted "highlighted") + }} + data-id={{@message.id}} + {{chat/track-message + (fn @didShowMessage @message) + (fn @didHideMessage @message) }} - data-id={{or @message.id @message.stagedId}} - data-staged-id={{if @message.staged @message.stagedId}} > {{#if this.show}} {{#if @selectingMessages}} @@ -85,35 +90,17 @@ class={{concat-class "chat-message" (if @message.staged "chat-message-staged") - (if @message.deleted_at "deleted") - (if @message.in_reply_to "is-reply") + (if @message.deletedAt "deleted") + (if (and @message.inReplyTo (not this.hideReplyToInfo)) "is-reply") (if this.hideUserInfo "user-info-hidden") (if @message.error "errored") (if @message.bookmark "chat-message-bookmarked") (if @isHovered "chat-message-selected") }} > - {{#if @message.in_reply_to}} -
    - {{d-icon "share" title="chat.in_reply_to"}} - - {{#if @message.in_reply_to.chat_webhook_event.emoji}} - - {{else}} - - {{/if}} - - - {{replace-emoji @message.in_reply_to.excerpt}} - -
    - {{/if}} + {{#unless this.hideReplyToInfo}} + + {{/unless}} {{#if this.hideUserInfo}} @@ -131,7 +118,7 @@ @uploads={{@message.uploads}} @edited={{@message.edited}} > - {{#if this.hasReactions}} + {{#if @message.reactions.length}}
    {{#if this.reactionLabel}}
    @@ -139,18 +126,13 @@
    {{/if}} - {{#each-in @message.reactions as |emoji reactionAttrs|}} + {{#each @message.reactions as |reaction|}} - {{/each-in}} + {{/each}} {{#if @canInteractWithChat}} {{#unless this.site.mobileView}} @@ -189,7 +171,7 @@ {{#if this.mentionWarning}}
    - {{#if this.mentionWarning.invitationSent}} + {{#if this.mentionWarning.invitation_sent}} {{d-icon "check"}} {{i18n diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js index a8865c36510..5d3ae255867 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -5,8 +5,7 @@ import Component from "@glimmer/component"; import I18n from "I18n"; import getURL from "discourse-common/lib/get-url"; import optionalService from "discourse/lib/optional-service"; -import { bind } from "discourse-common/utils/decorators"; -import EmberObject, { action } from "@ember/object"; +import { action } from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import { cancel, schedule } from "@ember/runloop"; import { clipboardCopy } from "discourse/lib/utilities"; @@ -18,6 +17,7 @@ import showModal from "discourse/lib/show-modal"; import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag"; import { tracked } from "@glimmer/tracking"; import { getOwner } from "discourse-common/lib/get-owner"; +import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction"; let _chatMessageDecorators = []; @@ -50,37 +50,24 @@ export default class ChatMessage extends Component { @optionalService adminTools; cachedFavoritesReactions = null; - - _hasSubscribedToAppEvents = false; - _loadingReactions = []; + reacting = false; constructor() { super(...arguments); - this.args.message.id - ? this._subscribeToAppEvents() - : this._waitForIdToBePopulated(); - - if (this.args.message.bookmark) { - this.args.message.set( - "bookmark", - Bookmark.create(this.args.message.bookmark) - ); - } - this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites; } get deletedAndCollapsed() { - return this.args.message?.get("deleted_at") && this.collapsed; + return this.args.message?.deletedAt && this.collapsed; } get hiddenAndCollapsed() { - return this.args.message?.get("hidden") && this.collapsed; + return this.args.message?.hidden && this.collapsed; } get collapsed() { - return !this.args.message?.get("expanded"); + return !this.args.message?.expanded; } @action @@ -97,32 +84,9 @@ export default class ChatMessage extends Component { @action teardownChatMessage() { - if (this.args.message?.stagedId) { - this.appEvents.off( - `chat-message-staged-${this.args.message.stagedId}:id-populated`, - this, - "_subscribeToAppEvents" - ); - } - - this.appEvents.off("chat:refresh-message", this, "_refreshedMessage"); - - this.appEvents.off( - `chat-message-${this.args.message.id}:reaction`, - this, - "_handleReactionMessage" - ); - cancel(this._invitationSentTimer); } - @bind - _refreshedMessage(message) { - if (message.id === this.args.message.id) { - this.decorateCookedMessage(); - } - } - @action decorateCookedMessage() { schedule("afterRender", () => { @@ -131,45 +95,22 @@ export default class ChatMessage extends Component { } _chatMessageDecorators.forEach((decorator) => { - decorator.call(this, this.messageContainer, this.args.chatChannel); + decorator.call(this, this.messageContainer, this.args.channel); }); }); } get messageContainer() { - const id = this.args.message?.id || this.args.message?.stagedId; - return ( - id && document.querySelector(`.chat-message-container[data-id='${id}']`) - ); - } - - _subscribeToAppEvents() { - if (!this.args.message.id || this._hasSubscribedToAppEvents) { - return; + const id = this.args.message?.id; + if (id) { + return document.querySelector(`.chat-message-container[data-id='${id}']`); } - - this.appEvents.on("chat:refresh-message", this, "_refreshedMessage"); - - this.appEvents.on( - `chat-message-${this.args.message.id}:reaction`, - this, - "_handleReactionMessage" - ); - this._hasSubscribedToAppEvents = true; - } - - _waitForIdToBePopulated() { - this.appEvents.on( - `chat-message-staged-${this.args.message.stagedId}:id-populated`, - this, - "_subscribeToAppEvents" - ); } get showActions() { return ( this.args.canInteractWithChat && - !this.args.message?.get("staged") && + !this.args.message?.staged && this.args.isHovered ); } @@ -270,17 +211,16 @@ export default class ChatMessage extends Component { get hasThread() { return ( - this.args.chatChannel?.get("threading_enabled") && - this.args.message?.get("thread_id") + this.args.channel?.get("threading_enabled") && this.args.message?.threadId ); } get show() { return ( - !this.args.message?.get("deleted_at") || - this.currentUser.id === this.args.message?.get("user.id") || + !this.args.message?.deletedAt || + this.currentUser.id === this.args.message?.user?.id || this.currentUser.staff || - this.args.details?.can_moderate + this.args.channel?.canModerate ); } @@ -331,83 +271,97 @@ export default class ChatMessage extends Component { get hideUserInfo() { return ( - this.args.message?.get("hideUserInfo") && - !this.args.message?.get("chat_webhook_event") + !this.args.message?.chatWebhookEvent && + !this.args.message?.inReplyTo && + !this.args.message?.previousMessage?.deletedAt && + Math.abs( + new Date(this.args.message?.createdAt) - + new Date(this.args.message?.createdAt) + ) < 300000 && // If the time between messages is over 5 minutes, break. + this.args.message?.user?.id === + this.args.message?.previousMessage?.user?.id + ); + } + + get hideReplyToInfo() { + return ( + this.args.message?.inReplyTo?.id === + this.args.message?.previousMessage?.id ); } get showEditButton() { return ( - !this.args.message?.get("deleted_at") && - this.currentUser?.id === this.args.message?.get("user.id") && - this.args.chatChannel?.canModifyMessages?.(this.currentUser) + !this.args.message?.deletedAt && + this.currentUser?.id === this.args.message?.user?.id && + this.args.channel?.canModifyMessages?.(this.currentUser) ); } + get canFlagMessage() { return ( - this.currentUser?.id !== this.args.message?.get("user.id") && - this.args.message?.get("user_flag_status") === undefined && - this.args.details?.can_flag && - !this.args.message?.get("chat_webhook_event") && - !this.args.message?.get("deleted_at") + this.currentUser?.id !== this.args.message?.user?.id && + !this.args.channel?.isDirectMessageChannel && + this.args.message?.userFlagStatus === undefined && + this.args.channel?.canFlag && + !this.args.message?.chatWebhookEvent && + !this.args.message?.deletedAt ); } get canManageDeletion() { - return this.currentUser?.id === this.args.message.get("user.id") - ? this.args.details?.can_delete_self - : this.args.details?.can_delete_others; + return this.currentUser?.id === this.args.message.user.id + ? this.args.channel?.canDeleteSelf + : this.args.channel?.canDeleteOthers; } get canReply() { return ( - !this.args.message?.get("deleted_at") && - this.args.chatChannel?.canModifyMessages?.(this.currentUser) + !this.args.message?.deletedAt && + this.args.channel?.canModifyMessages?.(this.currentUser) ); } get canReact() { return ( - !this.args.message?.get("deleted_at") && - this.args.chatChannel?.canModifyMessages?.(this.currentUser) + !this.args.message?.deletedAt && + this.args.channel?.canModifyMessages?.(this.currentUser) ); } get showDeleteButton() { return ( this.canManageDeletion && - !this.args.message?.get("deleted_at") && - this.args.chatChannel?.canModifyMessages?.(this.currentUser) + !this.args.message?.deletedAt && + this.args.channel?.canModifyMessages?.(this.currentUser) ); } get showRestoreButton() { return ( this.canManageDeletion && - this.args.message?.get("deleted_at") && - this.args.chatChannel?.canModifyMessages?.(this.currentUser) + this.args.message?.deletedAt && + this.args.channel?.canModifyMessages?.(this.currentUser) ); } get showBookmarkButton() { - return this.args.chatChannel?.canModifyMessages?.(this.currentUser); + return this.args.channel?.canModifyMessages?.(this.currentUser); } get showRebakeButton() { return ( this.currentUser?.staff && - this.args.chatChannel?.canModifyMessages?.(this.currentUser) + this.args.channel?.canModifyMessages?.(this.currentUser) ); } get hasReactions() { - return Object.values(this.args.message.get("reactions")).some( - (r) => r.count > 0 - ); + return Object.values(this.args.message.reactions).some((r) => r.count > 0); } get mentionWarning() { - return this.args.message.get("mentionWarning"); + return this.args.message.mentionWarning; } get mentionedCannotSeeText() { @@ -464,13 +418,13 @@ export default class ChatMessage extends Component { inviteMentioned() { const userIds = this.mentionWarning.without_membership.mapBy("id"); - ajax(`/chat/${this.args.message.chat_channel_id}/invite`, { + ajax(`/chat/${this.args.message.channelId}/invite`, { method: "PUT", data: { user_ids: userIds, chat_message_id: this.args.message.id }, }).then(() => { - this.args.message.set("mentionWarning.invitationSent", true); + this.args.message.mentionWarning.set("invitationSent", true); this._invitationSentTimer = discourseLater(() => { - this.args.message.set("mentionWarning", null); + this.dismissMentionWarning(); }, 3000); }); @@ -479,7 +433,7 @@ export default class ChatMessage extends Component { @action dismissMentionWarning() { - this.args.message.set("mentionWarning", null); + this.args.message.mentionWarning = null; } @action @@ -517,27 +471,17 @@ export default class ChatMessage extends Component { this.react(emoji, REACTIONS.add); } - @bind - _handleReactionMessage(busData) { - const loadingReactionIndex = this._loadingReactions.indexOf(busData.emoji); - if (loadingReactionIndex > -1) { - return this._loadingReactions.splice(loadingReactionIndex, 1); - } - - this._updateReactionsList(busData.emoji, busData.action, busData.user); - this.args.afterReactionAdded(); - } - get capabilities() { return getOwner(this).lookup("capabilities:main"); } @action react(emoji, reactAction) { - if ( - !this.args.canInteractWithChat || - this._loadingReactions.includes(emoji) - ) { + if (!this.args.canInteractWithChat) { + return; + } + + if (this.reacting) { return; } @@ -549,71 +493,21 @@ export default class ChatMessage extends Component { this.args.onHoverMessage(null); } - this._loadingReactions.push(emoji); - this._updateReactionsList(emoji, reactAction, this.currentUser); - if (reactAction === REACTIONS.add) { this.chatEmojiReactionStore.track(`:${emoji}:`); } - return this._publishReaction(emoji, reactAction).then(() => { - // creating reaction will create a membership if not present - // so we will fully refresh if we were not members of the channel - // already - if (!this.args.chatChannel.isFollowing || this.args.chatChannel.isDraft) { - return this.args.chatChannelsManager - .getChannel(this.args.chatChannel.id) - .then((reactedChannel) => { - this.router.transitionTo("chat.channel", "-", reactedChannel.id); - }); - } - }); - } + this.reacting = true; - _updateReactionsList(emoji, reactAction, user) { - const selfReacted = this.currentUser.id === user.id; - if (this.args.message.reactions[emoji]) { - if ( - selfReacted && - reactAction === REACTIONS.add && - this.args.message.reactions[emoji].reacted - ) { - // User is already has reaction added; do nothing - return false; - } + this.args.message.react( + emoji, + reactAction, + this.currentUser, + this.currentUser.id + ); - let newCount = - reactAction === REACTIONS.add - ? this.args.message.reactions[emoji].count + 1 - : this.args.message.reactions[emoji].count - 1; - - this.args.message.reactions.set(`${emoji}.count`, newCount); - if (selfReacted) { - this.args.message.reactions.set( - `${emoji}.reacted`, - reactAction === REACTIONS.add - ); - } else { - this.args.message.reactions[emoji].users.pushObject(user); - } - - this.args.message.notifyPropertyChange("reactions"); - } else { - if (reactAction === REACTIONS.add) { - this.args.message.reactions.set(emoji, { - count: 1, - reacted: selfReacted, - users: selfReacted ? [] : [user], - }); - } - - this.args.message.notifyPropertyChange("reactions"); - } - } - - _publishReaction(emoji, reactAction) { return ajax( - `/chat/${this.args.message.chat_channel_id}/react/${this.args.message.id}`, + `/chat/${this.args.message.channelId}/react/${this.args.message.id}`, { type: "PUT", data: { @@ -621,10 +515,19 @@ export default class ChatMessage extends Component { emoji, }, } - ).catch((errResult) => { - popupAjaxError(errResult); - this._updateReactionsList(emoji, REACTIONS.remove, this.currentUser); - }); + ) + .catch((errResult) => { + popupAjaxError(errResult); + this.args.message.react( + emoji, + REACTIONS.remove, + this.currentUser, + this.currentUser.id + ); + }) + .finally(() => { + this.reacting = false; + }); } // TODO(roman): For backwards-compatibility. @@ -651,17 +554,6 @@ export default class ChatMessage extends Component { this.args.setReplyTo(this.args.message.id); } - viewReplyOrThread() { - if (this.hasThread) { - this.router.transitionTo( - "chat.channel.thread", - this.args.message.thread_id - ); - } else { - this.args.replyMessageClicked(this.args.message.in_reply_to); - } - } - @action edit() { this.args.editButtonClicked(this.args.message.id); @@ -673,12 +565,11 @@ export default class ChatMessage extends Component { requirejs.entries["discourse/lib/flag-targets/flag"]; if (targetFlagSupported) { - const model = EmberObject.create(this.args.message); - model.set("username", model.get("user.username")); - model.set("user_id", model.get("user.id")); + const model = this.args.message; + model.username = model.user?.username; + model.user_id = model.user?.id; let controller = showModal("flag", { model }); - - controller.setProperties({ flagTarget: new ChatMessageFlag() }); + controller.set("flagTarget", new ChatMessageFlag()); } else { this._legacyFlag(); } @@ -686,13 +577,13 @@ export default class ChatMessage extends Component { @action expand() { - this.args.message.set("expanded", true); + this.args.message.expanded = true; } @action restore() { return ajax( - `/chat/${this.args.message.chat_channel_id}/restore/${this.args.message.id}`, + `/chat/${this.args.message.channelId}/restore/${this.args.message.id}`, { type: "PUT", } @@ -701,10 +592,7 @@ export default class ChatMessage extends Component { @action openThread() { - this.router.transitionTo( - "chat.channel.thread", - this.args.message.thread_id - ); + this.router.transitionTo("chat.channel.thread", this.args.message.threadId); } @action @@ -719,7 +607,7 @@ export default class ChatMessage extends Component { { onAfterSave: (savedData) => { const bookmark = Bookmark.create(savedData); - this.args.message.set("bookmark", bookmark); + this.args.message.bookmark = bookmark; this.appEvents.trigger( "bookmarks:changed", savedData, @@ -727,7 +615,7 @@ export default class ChatMessage extends Component { ); }, onAfterDelete: () => { - this.args.message.set("bookmark", null); + this.args.message.bookmark = null; }, } ); @@ -736,7 +624,7 @@ export default class ChatMessage extends Component { @action rebakeMessage() { return ajax( - `/chat/${this.args.message.chat_channel_id}/${this.args.message.id}/rebake`, + `/chat/${this.args.message.channelId}/${this.args.message.id}/rebake`, { type: "PUT", } @@ -746,7 +634,7 @@ export default class ChatMessage extends Component { @action deleteMessage() { return ajax( - `/chat/${this.args.message.chat_channel_id}/${this.args.message.id}`, + `/chat/${this.args.message.channelId}/${this.args.message.id}`, { type: "DELETE", } @@ -755,7 +643,7 @@ export default class ChatMessage extends Component { @action selectMessage() { - this.args.message.set("selected", true); + this.args.message.selected = true; this.args.onStartSelectingMessages(this.args.message); } @@ -780,7 +668,7 @@ export default class ChatMessage extends Component { const { protocol, host } = window.location; let url = getURL( - `/chat/c/-/${this.args.message.chat_channel_id}/${this.args.message.id}` + `/chat/c/-/${this.args.message.channelId}/${this.args.message.id}` ); url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; clipboardCopy(url); @@ -793,25 +681,22 @@ export default class ChatMessage extends Component { } get emojiReactions() { - const favorites = this.cachedFavoritesReactions; + let favorites = this.cachedFavoritesReactions; // may be a {} if no defaults defined in some production builds if (!favorites || !favorites.slice) { return []; } - const userReactions = Object.keys(this.args.message.reactions || {}).filter( - (key) => { - return this.args.message.reactions[key].reacted; - } - ); - return favorites.slice(0, 3).map((emoji) => { - if (userReactions.includes(emoji)) { - return { emoji, reacted: true }; - } else { - return { emoji, reacted: false }; - } + return ( + this.args.message.reactions.find( + (reaction) => reaction.emoji === emoji + ) || + ChatMessageReaction.create({ + emoji, + }) + ); }); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.hbs index e161a806a96..6033b130443 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.hbs @@ -1,6 +1,6 @@ {{#if this.show}}
    - + { - const field = this.chatChannel.isDirectMessageChannel + const field = this.args.channel.isDirectMessageChannel ? "needs_dm_retention_reminder" : "needs_channel_retention_reminder"; this.currentUser.set(field, false); }) .catch(popupAjaxError); - }, -}); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-scroll-to-bottom-arrow.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-scroll-to-bottom-arrow.hbs new file mode 100644 index 00000000000..7e6a58dd237 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-scroll-to-bottom-arrow.hbs @@ -0,0 +1,23 @@ +
    + + + {{d-icon "arrow-down"}} + + {{#if @hasNewMessages}} + + {{i18n "chat.scroll_to_new_messages"}} + + {{/if}} + + +
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js b/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js index 5556ee7841d..fe1dadf34ef 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js @@ -19,16 +19,17 @@ export default class AdminCustomizeColorsShowController extends Component { chatCopySuccess = false; showChatCopySuccess = false; cancelSelecting = null; - canModerate = false; @computed("selectedMessageIds.length") get anyMessagesSelected() { return this.selectedMessageIds.length > 0; } - @computed("chatChannel.isDirectMessageChannel", "canModerate") + @computed("chatChannel.isDirectMessageChannel", "chatChannel.canModerate") get showMoveMessageButton() { - return !this.chatChannel.isDirectMessageChannel && this.canModerate; + return ( + !this.chatChannel.isDirectMessageChannel && this.chatChannel.canModerate + ); } @bind diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs index 227103171b9..0bb2edefab6 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs @@ -1,13 +1,31 @@ -
    - {{#each this.placeholders as |rows|}} +
    + {{#each this.placeholders as |placeholder|}}
    - {{#each rows as |row|}} -
    - {{/each}} + {{#if placeholder.image}} +
    + {{/if}} + +
    + {{#each placeholder.rows as |row|}} +
    + {{/each}} +
    + + {{#if placeholder.reactions}} +
    + {{#each placeholder.reactions}} +
    + {{/each}} +
    + {{/if}}
    diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js index 3710d8dcc9b..6af83cf2e41 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js @@ -4,9 +4,13 @@ import { htmlSafe } from "@ember/template"; export default class ChatSkeleton extends Component { get placeholders() { return Array.from({ length: 15 }, () => { - return Array.from({ length: this.#randomIntFromInterval(1, 5) }, () => { - return htmlSafe(`width: ${this.#randomIntFromInterval(20, 95)}%`); - }); + return { + image: this.#randomIntFromInterval(1, 10) === 5, + rows: Array.from({ length: this.#randomIntFromInterval(1, 5) }, () => { + return htmlSafe(`width: ${this.#randomIntFromInterval(20, 95)}%`); + }), + reactions: Array.from({ length: this.#randomIntFromInterval(0, 3) }), + }; }); } diff --git a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs index 33661579b6c..c5c56e5fb51 100644 --- a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs @@ -1,7 +1,6 @@ {{#if this.chat.activeChannel}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js index 1fb7ad3c57e..94d9c7039f7 100644 --- a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js @@ -1,79 +1,6 @@ -import Component from "@ember/component"; -import { bind } from "discourse-common/utils/decorators"; -import { action } from "@ember/object"; +import Component from "@glimmer/component"; import { inject as service } from "@ember/service"; -export default Component.extend({ - tagName: "", - router: service(), - chat: service(), - - init() { - this._super(...arguments); - }, - - didInsertElement() { - this._super(...arguments); - - this._scrollSidebarToBottom(); - document.addEventListener("keydown", this._autoFocusChatComposer); - }, - - willDestroyElement() { - this._super(...arguments); - - document.removeEventListener("keydown", this._autoFocusChatComposer); - }, - - @bind - _autoFocusChatComposer(event) { - if ( - !event.key || - // Handles things like Enter, Tab, Shift - event.key.length > 1 || - // Don't need to focus if the user is beginning a shortcut. - event.metaKey || - event.ctrlKey || - // Space's key comes through as ' ' so it's not covered by event.key - event.code === "Space" || - // ? is used for the keyboard shortcut modal - event.key === "?" - ) { - return; - } - - if ( - !event.target || - /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName) - ) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const composer = document.querySelector(".chat-composer-input"); - if (composer && !this.chat.activeChannel.isDraft) { - this.appEvents.trigger("chat:insert-text", event.key); - composer.focus(); - } - }, - - _scrollSidebarToBottom() { - if (!this.teamsSidebarOn) { - return; - } - - const sidebarScroll = document.querySelector( - ".sidebar-container .scroll-wrapper" - ); - if (sidebarScroll) { - sidebarScroll.scrollTop = sidebarScroll.scrollHeight; - } - }, - - @action - navigateToIndex() { - this.router.transitionTo("chat.index"); - }, -}); +export default class FullPageChat extends Component { + @service chat; +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js index 734778d843b..7984545c101 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js @@ -1,10 +1,11 @@ import Controller from "@ember/controller"; import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; export default class ChatChannelController extends Controller { @service chat; - targetMessageId = null; + @tracked targetMessageId = null; // Backwards-compatibility queryParams = ["messageId"]; diff --git a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js index 17d698cb8db..31bc13b5514 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js @@ -36,6 +36,7 @@ export default class CreateChannelController extends Controller.extend( categoryPermissionsHint = null; autoJoinUsers = null; autoJoinWarning = ""; + loadingPermissionHint = false; @notEmpty("category") categorySelected; @gt("siteSettings.max_chat_auto_joined_users", 0) autoJoinAvailable; @@ -153,6 +154,8 @@ export default class CreateChannelController extends Controller.extend( if (category) { const fullSlug = this._buildCategorySlug(category); + this.set("loadingPermissionHint", true); + return this.chatApi .categoryPermissions(category.id) .then((catPermissions) => { @@ -194,6 +197,9 @@ export default class CreateChannelController extends Controller.extend( } this.set("categoryPermissionsHint", htmlSafe(hint)); + }) + .finally(() => { + this.set("loadingPermissionHint", false); }); } else { this.set("categoryPermissionsHint", DEFAULT_HINT); diff --git a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js index c31a86ef042..5d91f205e4f 100644 --- a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js +++ b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js @@ -7,8 +7,8 @@ import User from "discourse/models/user"; registerUnbound("format-chat-date", function (message, mode) { const currentUser = User.current(); const tz = currentUser ? currentUser.user_option.timezone : moment.tz.guess(); - const date = moment(new Date(message.created_at), tz); - const url = getURL(`/chat/c/-/${message.chat_channel_id}/${message.id}`); + const date = moment(new Date(message.createdAt), tz); + const url = getURL(`/chat/c/-/${message.channelId}/${message.id}`); const title = date.format(I18n.t("dates.long_with_year")); const display = diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js new file mode 100644 index 00000000000..f628c478633 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js @@ -0,0 +1,32 @@ +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { generateCookFunction } from "discourse/lib/text"; +import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform"; + +export default { + name: "chat-cook-function", + + before: "chat-setup", + + initialize(container) { + const site = container.lookup("service:site"); + + const markdownOptions = { + featuresOverride: + site.markdown_additional_options?.chat?.limited_pretty_text_features, + markdownItRules: + site.markdown_additional_options?.chat + ?.limited_pretty_text_markdown_rules, + hashtagTypesInPriorityOrder: site.hashtag_configurations["chat-composer"], + hashtagIcons: site.hashtag_icons, + }; + + generateCookFunction(markdownOptions).then((cookFunction) => { + ChatMessage.cookFunction = (raw) => { + return simpleCategoryHashMentionTransform( + cookFunction(raw), + site.categories + ); + }; + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js index 69ee6ab84ee..b0ad79f345f 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js @@ -10,6 +10,7 @@ const MIN_REFRESH_DURATION_MS = 180000; // 3 minutes export default { name: "chat-setup", + initialize(container) { this.chatService = container.lookup("service:chat"); this.siteSettings = container.lookup("service:site-settings"); @@ -19,6 +20,7 @@ export default { if (!this.chatService.userCanChat) { return; } + withPluginApi("0.12.1", (api) => { api.registerChatComposerButton({ id: "chat-upload-btn", diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js index 60a20c2206a..9bd86b4ab40 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js @@ -38,7 +38,7 @@ export default class ChatMessageFlag { let flagsAvailable = site.flagTypes; flagsAvailable = flagsAvailable.filter((flag) => { - return model.available_flags.includes(flag.name_key); + return model.availableFlags.includes(flag.name_key); }); // "message user" option should be at the top diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js index c6e75e9edd3..e1eb2b58405 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js @@ -7,6 +7,7 @@ import { tracked } from "@glimmer/tracking"; import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-threads-manager"; import { getOwner } from "discourse-common/lib/get-owner"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; export const CHATABLE_TYPES = { directMessageChannel: "DirectMessage", @@ -54,6 +55,16 @@ export default class ChatChannel extends RestModel { @tracked chatableType; @tracked status; @tracked activeThread; + @tracked messages = new TrackedArray(); + @tracked lastMessageSentAt; + @tracked canDeleteOthers; + @tracked canDeleteSelf; + @tracked canFlag; + @tracked canLoadMoreFuture; + @tracked canLoadMorePast; + @tracked canModerate; + @tracked userSilenced; + @tracked draft; threadsManager = new ChatThreadsManager(getOwner(this)); @@ -74,11 +85,11 @@ export default class ChatChannel extends RestModel { } get isDirectMessageChannel() { - return this.chatable_type === CHATABLE_TYPES.directMessageChannel; + return this.chatableType === CHATABLE_TYPES.directMessageChannel; } get isCategoryChannel() { - return this.chatable_type === CHATABLE_TYPES.categoryChannel; + return this.chatableType === CHATABLE_TYPES.categoryChannel; } get isOpen() { @@ -105,6 +116,56 @@ export default class ChatChannel extends RestModel { return this.currentUserMembership.following; } + get visibleMessages() { + return this.messages.filter((message) => message.visible); + } + + set details(details) { + this.canDeleteOthers = details.can_delete_others ?? false; + this.canDeleteSelf = details.can_delete_self ?? false; + this.canFlag = details.can_flag ?? false; + this.canModerate = details.can_moderate ?? false; + if (details.can_load_more_future !== undefined) { + this.canLoadMoreFuture = details.can_load_more_future; + } + if (details.can_load_more_past !== undefined) { + this.canLoadMorePast = details.can_load_more_past; + } + this.userSilenced = details.user_silenced ?? false; + this.status = details.channel_status; + this.channelMessageBusLastId = details.channel_message_bus_last_id; + } + + clearMessages() { + this.messages.clear(); + + this.canLoadMoreFuture = null; + this.canLoadMorePast = null; + } + + addMessages(messages = []) { + this.messages = this.messages + .concat(messages) + .uniqBy("id") + .sortBy("createdAt"); + } + + findMessage(messageId) { + return this.messages.find( + (message) => message.id === parseInt(messageId, 10) + ); + } + + removeMessage(message) { + return this.messages.removeObject(message); + } + + findStagedMessage(stagedMessageId) { + return this.messages.find( + (message) => message.staged && message.id === stagedMessageId + ); + } + canModifyMessages(user) { if (user.staff) { return !STAFF_READONLY_STATUSES.includes(this.status); @@ -127,6 +188,10 @@ export default class ChatChannel extends RestModel { return; } + if (this.currentUserMembership.last_read_message_id >= messageId) { + return; + } + return ajax(`/chat/${this.id}/read/${messageId}.json`, { method: "PUT", }).then(() => { @@ -142,12 +207,17 @@ ChatChannel.reopenClass({ this._initUserModels(args); this._initUserMembership(args); - args.chatableType = args.chatable_type; - args.membershipsCount = args.memberships_count; + this._remapKey(args, "chatable_type", "chatableType"); + this._remapKey(args, "memberships_count", "membershipsCount"); + this._remapKey(args, "last_message_sent_at", "lastMessageSentAt"); return this._super(args); }, + _remapKey(obj, oldKey, newKey) { + delete Object.assign(obj, { [newKey]: obj[oldKey] })[oldKey]; + }, + _initUserModels(args) { if (args.chatable?.users?.length) { for (let i = 0; i < args.chatable?.users?.length; i++) { diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js b/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js new file mode 100644 index 00000000000..00709add3f3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js @@ -0,0 +1,62 @@ +import { tracked } from "@glimmer/tracking"; + +export default class ChatMessageDraft { + static create(args = {}) { + return new ChatMessageDraft(args ?? {}); + } + + @tracked uploads; + @tracked message; + @tracked _replyToMsg; + + constructor(args = {}) { + this.message = args.message ?? ""; + this.uploads = args.uploads ?? []; + this.replyToMsg = args.replyToMsg; + } + + get replyToMsg() { + return this._replyToMsg; + } + + set replyToMsg(message) { + this._replyToMsg = message + ? { + id: message.id, + excerpt: message.excerpt, + user: { + id: message.user.id, + name: message.user.name, + avatar_template: message.user.avatar_template, + username: message.user.username, + }, + } + : null; + } + + toJSON() { + if ( + this.message?.length === 0 && + this.uploads?.length === 0 && + !this.replyToMsg + ) { + return null; + } + + const data = {}; + + if (this.uploads?.length > 0) { + data.uploads = this.uploads; + } + + if (this.message?.length > 0) { + data.message = this.message; + } + + if (this.replyToMsg) { + data.replyToMsg = this.replyToMsg; + } + + return JSON.stringify(data); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js b/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js new file mode 100644 index 00000000000..db1b7a6cecb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js @@ -0,0 +1,33 @@ +import { tracked } from "@glimmer/tracking"; +import User from "discourse/models/user"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; + +export default class ChatMessageReaction { + static create(args = {}) { + return new ChatMessageReaction(args); + } + + @tracked count = 0; + @tracked reacted = false; + @tracked users = []; + + constructor(args = {}) { + this.messageId = args.messageId; + this.count = args.count; + this.emoji = args.emoji; + this.users = this.#initUsersModels(args.users); + this.reacted = args.reacted; + } + + #initUsersModels(users = []) { + return new TrackedArray( + users.map((user) => { + if (user instanceof User) { + return user; + } + + return User.create(user); + }) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-message.js index 8d0c644b5f7..28a86dd8680 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js @@ -1,26 +1,191 @@ -import RestModel from "discourse/models/rest"; import User from "discourse/models/user"; -import EmberObject from "@ember/object"; +import { cached, tracked } from "@glimmer/tracking"; +import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins"; +import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction"; +import Bookmark from "discourse/models/bookmark"; +import I18n from "I18n"; +import guid from "pretty-text/guid"; -export default class ChatMessage extends RestModel {} +export default class ChatMessage { + static cookFunction = null; -ChatMessage.reopenClass({ - create(args = {}) { - this._initReactions(args); - this._initUserModel(args); + static create(channel, args = {}) { + return new ChatMessage(channel, args); + } - return this._super(args); - }, + static createStagedMessage(channel, args = {}) { + args.id = guid(); + args.staged = true; + return new ChatMessage(channel, args); + } - _initReactions(args) { - args.reactions = EmberObject.create(args.reactions || {}); - }, + @tracked id; + @tracked error; + @tracked selected; + @tracked channel; + @tracked staged = false; + @tracked channelId; + @tracked createdAt; + @tracked deletedAt; + @tracked uploads; + @tracked excerpt; + @tracked message; + @tracked threadId; + @tracked reactions; + @tracked reviewableId; + @tracked user; + @tracked cooked; + @tracked inReplyTo; + @tracked expanded; + @tracked bookmark; + @tracked userFlagStatus; + @tracked hidden; + @tracked version = 0; + @tracked edited; + @tracked chatWebhookEvent = new TrackedObject(); + @tracked mentionWarning; + @tracked availableFlags; + @tracked newest = false; + @tracked highlighted = false; - _initUserModel(args) { - if (!args.user || args.user instanceof User) { - return; + constructor(channel, args = {}) { + this.channel = channel; + this.id = args.id; + this.newest = args.newest; + this.staged = args.staged; + this.edited = args.edited; + this.availableFlags = args.available_flags; + this.hidden = args.hidden; + this.threadId = args.thread_id; + this.channelId = args.chat_channel_id; + this.chatWebhookEvent = args.chat_webhook_event; + this.createdAt = args.created_at; + this.deletedAt = args.deleted_at; + this.excerpt = args.excerpt; + this.reviewableId = args.reviewable_id; + this.userFlagStatus = args.user_flag_status; + this.inReplyTo = args.in_reply_to + ? ChatMessage.create(channel, args.in_reply_to) + : null; + this.message = args.message; + this.cooked = args.cooked || ChatMessage.cookFunction(this.message); + this.reactions = this.#initChatMessageReactionModel( + args.id, + args.reactions + ); + this.uploads = new TrackedArray(args.uploads || []); + this.user = this.#initUserModel(args.user); + this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null; + } + + get read() { + return this.channel.currentUserMembership?.last_read_message_id >= this.id; + } + + get firstMessageOfTheDayAt() { + if (!this.previousMessage) { + return this.#calendarDate(this.createdAt); } - args.user = User.create(args.user); - }, -}); + if ( + !this.#areDatesOnSameDay( + new Date(this.previousMessage.createdAt), + new Date(this.createdAt) + ) + ) { + return this.#calendarDate(this.createdAt); + } + } + + #calendarDate(date) { + return moment(date).calendar(moment(), { + sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`, + lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`, + lastWeek: "LL", + sameElse: "LL", + }); + } + + @cached + get index() { + return this.channel.messages.indexOf(this); + } + + @cached + get previousMessage() { + return this.channel?.messages?.objectAt?.(this.index - 1); + } + + @cached + get nextMessage() { + return this.channel?.messages?.objectAt?.(this.index + 1); + } + + react(emoji, action, actor, currentUserId) { + const selfReaction = actor.id === currentUserId; + const existingReaction = this.reactions.find( + (reaction) => reaction.emoji === emoji + ); + + if (existingReaction) { + if (action === "add") { + if (selfReaction && existingReaction.reacted) { + return false; + } + + existingReaction.count = existingReaction.count + 1; + if (selfReaction) { + existingReaction.reacted = true; + } + existingReaction.users.pushObject(actor); + } else { + existingReaction.count = existingReaction.count - 1; + + if (selfReaction) { + existingReaction.reacted = false; + } + + if (existingReaction.count === 0) { + this.reactions.removeObject(existingReaction); + } else { + existingReaction.users.removeObject( + existingReaction.users.find((user) => user.id === actor.id) + ); + } + } + } else { + if (action === "add") { + this.reactions.pushObject( + ChatMessageReaction.create({ + count: 1, + emoji, + reacted: selfReaction, + users: [actor], + }) + ); + } + } + } + + #initChatMessageReactionModel(messageId, reactions = []) { + return reactions.map((reaction) => + ChatMessageReaction.create(Object.assign({ messageId }, reaction)) + ); + } + + #initUserModel(user) { + if (!user || user instanceof User) { + return user; + } + + return User.create(user); + } + + #areDatesOnSameDay(a, b) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-separator-date.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-separator-date.js new file mode 100644 index 00000000000..71a19434521 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-separator-date.js @@ -0,0 +1,35 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; + +const IS_PINNED_CLASS = "is-pinned"; + +/* + This modifier is used to track the date separator in the chat message list. + The trick is to have an element with `top: -1px` which will stop fully intersecting + as soon as it's scrolled a little bit. +*/ +export default class ChatTrackMessageSeparatorDate extends Modifier { + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element) { + this.intersectionObserver = new IntersectionObserver( + ([event]) => { + if (event.isIntersecting && event.intersectionRatio < 1) { + event.target.classList.add(IS_PINNED_CLASS); + } else { + event.target.classList.remove(IS_PINNED_CLASS); + } + }, + { threshold: [0, 1] } + ); + + this.intersectionObserver.observe(element); + } + + cleanup() { + this.intersectionObserver?.disconnect(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js deleted file mode 100644 index 10474b067cf..00000000000 --- a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js +++ /dev/null @@ -1,23 +0,0 @@ -import Modifier from "ember-modifier"; -import { inject as service } from "@ember/service"; -import { registerDestructor } from "@ember/destroyable"; - -export default class TrackMessageVisibility extends Modifier { - @service chatMessageVisibilityObserver; - - element = null; - - constructor(owner, args) { - super(owner, args); - registerDestructor(this, (instance) => instance.cleanup()); - } - - modify(element) { - this.element = element; - this.chatMessageVisibilityObserver.observe(element); - } - - cleanup() { - this.chatMessageVisibilityObserver.unobserve(this.element); - } -} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js new file mode 100644 index 00000000000..469188eaa32 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js @@ -0,0 +1,43 @@ +import Modifier from "ember-modifier"; +import { registerDestructor } from "@ember/destroyable"; +import { bind } from "discourse-common/utils/decorators"; + +export default class ChatTrackMessage extends Modifier { + visibleCallback = null; + notVisibleCallback = null; + + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element, [visibleCallback, notVisibleCallback]) { + this.visibleCallback = visibleCallback; + this.notVisibleCallback = notVisibleCallback; + + this.intersectionObserver = new IntersectionObserver( + this._intersectionObserverCallback, + { + root: document, + threshold: 0.9, + } + ); + + this.intersectionObserver.observe(element); + } + + cleanup() { + this.intersectionObserver?.disconnect(); + } + + @bind + _intersectionObserverCallback(entries) { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.visibleCallback?.(); + } else { + this.notVisibleCallback?.(); + } + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js index 69ea1c3b3f8..34ca9343de6 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js @@ -10,6 +10,10 @@ export default class ChatChannelRoute extends DiscourseRoute { @action willTransition(transition) { + // Technically we could keep messages to avoid re-fetching them, but + // it's not worth the complexity for now + this.chat.activeChannel?.clearMessages(); + this.chat.activeChannel.activeThread = null; this.chatStateManager.closeSidePanel(); diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index 35e680031b3..4b51143681b 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -233,6 +233,39 @@ export default class ChatApi extends Service { ); } + /** + * Returns messages of a channel, from the last message or a specificed target. + * @param {number} channelId - The ID of the channel. + * @param {object} data - Params of the query. + * @param {integer} data.targetMessageId - ID of the targeted message. + * @param {integer} data.messageId - ID of the targeted message. + * @param {integer} data.direction - Fetch past or future messages. + * @param {integer} data.pageSize - Max number of messages to fetch. + * @returns {Promise} + */ + async messages(channelId, data = {}) { + let path; + const args = {}; + + if (data.targetMessageId) { + path = `/chat/lookup/${data.targetMessageId}`; + args.chat_channel_id = channelId; + } else { + args.page_size = data.pageSize; + path = `/chat/${channelId}/messages`; + + if (data.messageId) { + args.message_id = data.messageId; + } + + if (data.direction) { + args.direction = data.direction; + } + } + + return ajax(path, { data: args }); + } + /** * Update notifications settings of current user for a channel. * @param {number} channelId - The ID of the channel. 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 e70655e3585..47a4fb88d8b 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js @@ -42,6 +42,14 @@ export default class ChatChannelsManager extends Service { this.#cache(model); } + if ( + channelObject.meta?.message_bus_last_ids?.channel_message_bus_last_id !== + undefined + ) { + model.channelMessageBusLastId = + channelObject.meta.message_bus_last_ids.channel_message_bus_last_id; + } + return model; } @@ -138,8 +146,7 @@ export default class ChatChannelsManager extends Service { const unreadCountA = a.currentUserMembership.unread_count || 0; const unreadCountB = b.currentUserMembership.unread_count || 0; if (unreadCountA === unreadCountB) { - return new Date(a.get("last_message_sent_at")) > - new Date(b.get("last_message_sent_at")) + return new Date(a.lastMessageSentAt) > new Date(b.lastMessageSentAt) ? -1 : 1; } else { diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js b/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js deleted file mode 100644 index a5a77a1d4b0..00000000000 --- a/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js +++ /dev/null @@ -1,63 +0,0 @@ -import Service, { inject as service } from "@ember/service"; -import { isTesting } from "discourse-common/config/environment"; -import { bind } from "discourse-common/utils/decorators"; - -export default class ChatMessageVisibilityObserver extends Service { - @service chat; - - intersectionObserver = new IntersectionObserver( - this._intersectionObserverCallback, - { - root: document, - rootMargin: "-10px", - } - ); - - mutationObserver = new MutationObserver(this._mutationObserverCallback, { - root: document, - rootMargin: "-10px", - }); - - willDestroy() { - this.intersectionObserver.disconnect(); - this.mutationObserver.disconnect(); - } - - @bind - _intersectionObserverCallback(entries) { - entries.forEach((entry) => { - entry.target.dataset.visible = entry.isIntersecting; - - if ( - !entry.target.dataset.stagedId && - entry.isIntersecting && - !isTesting() - ) { - this.chat.updateLastReadMessage(); - } - }); - } - - @bind - _mutationObserverCallback(mutationList) { - mutationList.forEach((mutation) => { - const data = mutation.target.dataset; - if (data.id && data.visible && !data.stagedId) { - this.chat.updateLastReadMessage(); - } - }); - } - - observe(element) { - this.intersectionObserver.observe(element); - this.mutationObserver.observe(element, { - attributes: true, - attributeOldValue: true, - attributeFilter: ["data-staged-id"], - }); - } - - unobserve(element) { - this.intersectionObserver.unobserve(element); - } -} 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 42644d9189c..8430d083746 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-subscriptions-manager.js @@ -154,7 +154,7 @@ export default class ChatSubscriptionsManager extends Service { } } - channel.set("last_message_sent_at", new Date()); + channel.lastMessageSentAt = new Date(); }); } @@ -185,13 +185,14 @@ export default class ChatSubscriptionsManager extends Service { _onUserTrackingStateUpdate(busData) { this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => { if ( - channel?.currentUserMembership?.last_read_message_id <= - busData.chat_message_id + !channel?.currentUserMembership?.last_read_message_id || + parseInt(channel?.currentUserMembership?.last_read_message_id, 10) <= + busData.chat_message_id ) { channel.currentUserMembership.last_read_message_id = busData.chat_message_id; - channel.currentUserMembership.unread_count = 0; - channel.currentUserMembership.unread_mentions = 0; + channel.currentUserMembership.unread_count = busData.unread_count; + channel.currentUserMembership.unread_mentions = busData.unread_mentions; } }); } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js index e500e84f754..59a182617e8 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -3,29 +3,18 @@ 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 Site from "discourse/models/site"; import { ajax } from "discourse/lib/ajax"; -import { generateCookFunction } from "discourse/lib/text"; import { cancel, next } from "@ember/runloop"; import { and } from "@ember/object/computed"; import { computed } from "@ember/object"; -import { Promise } from "rsvp"; -import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform"; -import discourseDebounce from "discourse-common/lib/debounce"; import discourseLater from "discourse-common/lib/later"; -import userPresent from "discourse/lib/user-presence"; - -export const LIST_VIEW = "list_view"; -export const CHAT_VIEW = "chat_view"; -export const DRAFT_CHANNEL_VIEW = "draft_channel_view"; +import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; const CHAT_ONLINE_OPTIONS = { userUnseenTime: 300000, // 5 minutes seconds with no interaction browserHiddenTime: 300000, // Or the browser has been in the background for 5 minutes }; -const READ_INTERVAL = 1000; - export default class Chat extends Service { @service appEvents; @service chatNotificationManager; @@ -64,13 +53,6 @@ export default class Chat extends Service { if (this.userCanChat) { this.presenceChannel = this.presence.getChannel("/chat/online"); - this.draftStore = {}; - - if (this.currentUser.chat_drafts) { - this.currentUser.chat_drafts.forEach((draft) => { - this.draftStore[draft.channel_id] = JSON.parse(draft.data); - }); - } } } @@ -103,6 +85,16 @@ export default class Chat extends Service { [...channels.public_channels, ...channels.direct_message_channels].forEach( (channelObject) => { const channel = this.chatChannelsManager.store(channelObject); + + if (this.currentUser.chat_drafts) { + const storedDraft = this.currentUser.chat_drafts.find( + (draft) => draft.channel_id === channel.id + ); + channel.draft = ChatMessageDraft.create( + storedDraft ? JSON.parse(storedDraft.data) : null + ); + } + return this.chatChannelsManager.follow(channel); } ); @@ -116,33 +108,6 @@ export default class Chat extends Service { } } - loadCookFunction(categories) { - if (this.cook) { - return Promise.resolve(this.cook); - } - - const markdownOptions = { - featuresOverride: Site.currentProp( - "markdown_additional_options.chat.limited_pretty_text_features" - ), - markdownItRules: Site.currentProp( - "markdown_additional_options.chat.limited_pretty_text_markdown_rules" - ), - hashtagTypesInPriorityOrder: - this.site.hashtag_configurations["chat-composer"], - hashtagIcons: this.site.hashtag_icons, - }; - - return generateCookFunction(markdownOptions).then((cookFunction) => { - return this.set("cook", (raw) => { - return simpleCategoryHashMentionTransform( - cookFunction(raw), - categories - ); - }); - }); - } - updatePresence() { next(() => { if (this.isDestroyed || this.isDestroying) { @@ -277,10 +242,6 @@ export default class Chat extends Service { : this.router.transitionTo("chat.channel", ...channel.routeModels); } - _fireOpenMessageAppEvent(messageId) { - this.appEvents.trigger("chat-live-pane:highlight-message", messageId); - } - async followChannel(channel) { return this.chatChannelsManager.follow(channel); } @@ -327,84 +288,6 @@ export default class Chat extends Service { }); } - _saveDraft(channelId, draft) { - const data = { chat_channel_id: channelId }; - if (draft) { - data.data = JSON.stringify(draft); - } - - ajax("/chat/drafts.json", { type: "POST", data, ignoreUnsent: false }) - .then(() => { - this.markNetworkAsReliable(); - }) - .catch((error) => { - // we ignore a draft which can't be saved because it's too big - // and only deal with network error for now - if (!error.jqXHR?.responseJSON?.errors?.length) { - this.markNetworkAsUnreliable(); - } - }); - } - - setDraftForChannel(channel, draft) { - if ( - draft && - (draft.value || draft.uploads.length > 0 || draft.replyToMsg) - ) { - this.draftStore[channel.id] = draft; - } else { - delete this.draftStore[channel.id]; - draft = null; // _saveDraft will destroy draft - } - - discourseDebounce(this, this._saveDraft, channel.id, draft, 2000); - } - - getDraftForChannel(channelId) { - return ( - this.draftStore[channelId] || { - value: "", - uploads: [], - replyToMsg: null, - } - ); - } - - updateLastReadMessage() { - discourseDebounce(this, this._queuedReadMessageUpdate, READ_INTERVAL); - } - - _queuedReadMessageUpdate() { - const visibleMessages = document.querySelectorAll( - ".chat-message-container[data-visible=true]" - ); - const channel = this.activeChannel; - - if ( - !channel?.isFollowing || - visibleMessages?.length === 0 || - !userPresent() - ) { - return; - } - - const latestUnreadMsgId = parseInt( - visibleMessages[visibleMessages.length - 1].dataset.id, - 10 - ); - - const membership = channel.currentUserMembership; - const hasUnreadMessages = - latestUnreadMsgId > membership.last_read_message_id; - if ( - hasUnreadMessages || - membership.unread_count > 0 || - membership.unread_mentions > 0 - ) { - channel.updateLastReadMessage(latestUnreadMsgId); - } - } - addToolbarButton() { deprecated( "Use the new chat API `api.registerChatComposerButton` instead of `chat.addToolbarButton`" diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs index db7dc6fc342..ecc01d51c09 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs @@ -54,7 +54,12 @@ /> {{#if this.categoryPermissionsHint}} -
    +
    {{this.categoryPermissionsHint}}
    {{/if}} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss b/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss index 34035311c73..f59c30e85d1 100644 --- a/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss +++ b/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss @@ -5,6 +5,7 @@ display: flex; flex-direction: column; align-items: center; + z-index: 3; &.-no-description { .chat-channel-title { diff --git a/plugins/chat/assets/stylesheets/common/chat-composer.scss b/plugins/chat/assets/stylesheets/common/chat-composer.scss index 9ae341f2473..057c6014f7b 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer.scss @@ -1,6 +1,8 @@ .chat-composer-container { display: flex; flex-direction: column; + z-index: 3; + background-color: var(--secondary); #chat-full-page-uploader, #chat-widget-uploader { diff --git a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss index 815d561d643..6ed3e37b13f 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss @@ -6,10 +6,6 @@ .chat-message-actions { .chat-message-reaction { @include chat-reaction; - - &:not(.show) { - display: none; - } } } diff --git a/plugins/chat/assets/stylesheets/common/chat-message-separator.scss b/plugins/chat/assets/stylesheets/common/chat-message-separator.scss index e918d0c850a..3fd6cec387a 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-separator.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-separator.scss @@ -1,42 +1,96 @@ .chat-message-separator { @include unselectable; - margin: 0.25rem 0 0.25rem 1rem; display: flex; - font-size: var(--font-down-1); - position: relative; - transform: translateZ(0); - position: relative; - &.new-message { - color: var(--danger-medium); + &-new { + position: relative; + padding: 20px 0; - .divider { - background-color: var(--danger-medium); + .chat-message-separator__text-container { + text-align: center; + position: absolute; + height: 40px; + width: 100%; + box-sizing: border-box; + z-index: 1; + top: 0; + display: flex; + align-items: center; + justify-content: center; + + .chat-message-separator__text { + color: var(--danger-medium); + background-color: var(--secondary); + padding: 0.25rem 0.5rem; + font-size: var(--font-down-1); + } + } + + .chat-message-separator__line-container { + width: 100%; + + .chat-message-separator__line { + border-top: 1px solid var(--danger-medium); + } } } - &.first-daily-message { - .text { - color: var(--secondary-low); - font-weight: 600; - } - - .divider { - background-color: var(--secondary-high); - } - } - - .text { - margin: 0 auto; - padding: 0 0.75rem; - z-index: 1; - background: var(--secondary); - } - - .divider { + &-date { position: absolute; width: 100%; - height: 1px; - top: 50%; + z-index: 2; + display: flex; + align-items: flex-start; + justify-content: center; + pointer-events: none; + + &.last-visit { + .chat-message-separator__text { + color: var(--danger-medium); + } + + & + .chat-message-separator__line-container { + .chat-message-separator__line { + border-color: var(--danger-medium); + } + } + } + + .chat-message-separator__text-container { + padding-top: 7px; + position: sticky; + top: -1px; + + &.is-pinned { + .chat-message-separator__text { + border: 1px solid var(--primary-medium); + border-radius: 3px; + } + } + } + + .chat-message-separator__text { + @include unselectable; + background-color: var(--secondary); + border: 1px solid transparent; + color: var(--secondary-low); + font-size: var(--font-down-1); + padding: 0.25rem 0.5rem; + box-sizing: border-box; + } + + & + .chat-message-separator__line-container { + padding: 20px 0; + box-sizing: border-box; + + .chat-message-separator__line { + border-top: 1px solid var(--secondary-high); + left: 0; + margin: 0 0 -1px; + position: relative; + right: 0; + top: -1px; + } + } } } diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss index df79c8b70ca..5c2b2a91751 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message.scss @@ -42,6 +42,10 @@ background: var(--primary-low); border-color: var(--primary-low-mid); } + + &:focus { + background: none; + } } .emoji { @@ -60,10 +64,6 @@ .chat-message-reaction { @include chat-reaction; - - &:not(.show) { - display: none; - } } &.chat-action { @@ -82,21 +82,6 @@ background-color: var(--danger-hover); } - &.transition-slow { - transition: 2s linear background-color; - } - - &.user-info-hidden { - .chat-time { - color: var(--secondary-medium); - flex-shrink: 0; - font-size: var(--font-down-2); - margin-top: 0.4em; - display: none; - width: var(--message-left-width); - } - } - &.is-reply { display: grid; grid-template-columns: var(--message-left-width) 1fr; @@ -254,6 +239,14 @@ .chat-message.chat-message-bookmarked { background: var(--highlight-bg); + + &:hover { + background: var(--highlight-medium); + } + } + + .chat-message.chat-message-staged { + opacity: 0.6; } .not-mobile-device & .chat-message-reaction-list .chat-message-react-btn { diff --git a/plugins/chat/assets/stylesheets/common/chat-skeleton.scss b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss index 19eed6f1459..4a9be4de307 100644 --- a/plugins/chat/assets/stylesheets/common/chat-skeleton.scss +++ b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss @@ -1,4 +1,4 @@ -$radius: 10px; +$radius: 3px; .chat-skeleton { height: auto; @@ -55,11 +55,35 @@ $radius: 10px; &__message-content { grid-area: content; width: 100%; + padding: 10px 0; } - &__message-msg { - height: 13px; + + &__message-reactions { + display: flex; + padding: 5px 0 0 0; + } + + &__message-reaction { + background-color: var(--primary-100); + width: 32px; + height: 18px; border-radius: $radius; - margin: 5px 0; + + & + & { + margin-left: 0.5rem; + } + } + + &__message-text { + display: flex; + padding: 0; + flex-direction: column; + } + + &__message-msg { + height: 10px; + border-radius: $radius; + margin: 2px 0; .chat-skeleton__body:nth-of-type(odd) & { background-color: var(--primary-100); @@ -69,6 +93,14 @@ $radius: 10px; } } + &__message-img { + height: 80px; + border-radius: $radius; + margin: 2px 0; + width: 200px; + background-color: var(--primary-100); + } + *[class^="chat-skeleton__message-"] { position: relative; overflow: hidden; @@ -78,7 +110,7 @@ $radius: 10px; position: relative; overflow: hidden; - *[class^="chat-skeleton__message-"]:not(.chat-skeleton__message-content):after { + *[class^="chat-skeleton__message-"]:not(.chat-skeleton__message-content):not(.chat-skeleton__message-text):not(.chat-skeleton__message-reactions):after { position: absolute; top: 0; right: 0; diff --git a/plugins/chat/assets/stylesheets/common/common.scss b/plugins/chat/assets/stylesheets/common/common.scss index ba3529b856b..4ef4c7cf17c 100644 --- a/plugins/chat/assets/stylesheets/common/common.scss +++ b/plugins/chat/assets/stylesheets/common/common.scss @@ -144,6 +144,7 @@ $float-height: 530px; .chat-messages-container { word-wrap: break-word; white-space: normal; + position: relative; .chat-message-container { display: grid; @@ -283,6 +284,8 @@ $float-height: 530px; display: flex; flex-direction: column-reverse; z-index: 1; + margin: 0 3px 0 0; + will-change: transform; &::-webkit-scrollbar { width: 15px; @@ -323,37 +326,68 @@ $float-height: 530px; } .chat-scroll-to-bottom { - background: var(--primary-medium); - bottom: 1em; - border-radius: 100%; - left: 50%; - opacity: 50%; - padding: 0.5em; + left: calc(50% - calc(32px / 2)); + align-items: center; + justify-content: center; position: absolute; - transform: translateX(-50%); - z-index: 2; + z-index: 1; + flex-direction: column; + bottom: -75px; + background: none; + opacity: 0; + transition: opacity 0.25s ease, transform 0.5s ease; + transform: scale(0.1); + padding: 0; - &:hover { + > * { + pointer-events: none; + } + + &:hover, + &:active, + &:focus { + background: none !important; + } + + &.visible { + transform: translateY(-75px) scale(1); + opacity: 0.8; + } + + &__text { + color: var(--secondary); + padding: 0.5rem; + margin-bottom: 0.5rem; background: var(--primary-medium); - opacity: 100%; + border-radius: 3px; + text-align: center; + font-size: var(--font-down-1); + bottom: 40px; + position: absolute; } - .d-icon { - color: var(--primary); - margin: 0; - } - - &.unread-messages { - opacity: 85%; - border-radius: 0; - transition: border-radius 0.1s linear; - - &:hover { - opacity: 100%; - } + &__arrow { + display: flex; + background: var(--primary-medium); + border-radius: 100%; + align-items: center; + justify-content: center; + height: 32px; + width: 32px; + position: relative; .d-icon { - margin: 0 0 0 0.5em; + color: var(--secondary); + } + } + + &:hover { + opacity: 1; + + .chat-scroll-to-bottom__arrow { + .d-icon { + color: var(--secondary); + } } } } diff --git a/plugins/chat/assets/stylesheets/desktop/chat-composer.scss b/plugins/chat/assets/stylesheets/desktop/chat-composer.scss index 3095d1851ad..c6af087e68e 100644 --- a/plugins/chat/assets/stylesheets/desktop/chat-composer.scss +++ b/plugins/chat/assets/stylesheets/desktop/chat-composer.scss @@ -1,6 +1,6 @@ .chat-composer-container { .chat-composer { - margin: 0.25rem 10px 0 10px; + margin: 0.25rem 5px 0 5px; } html.keyboard-visible .footer-nav-ipad & { margin: 0.25rem 10px 1rem 10px; diff --git a/plugins/chat/assets/stylesheets/desktop/desktop.scss b/plugins/chat/assets/stylesheets/desktop/desktop.scss index ea281cbe347..3214e03ceb9 100644 --- a/plugins/chat/assets/stylesheets/desktop/desktop.scss +++ b/plugins/chat/assets/stylesheets/desktop/desktop.scss @@ -53,6 +53,25 @@ .chat-message.user-info-hidden { padding: 0.15em 1em; + + .chat-time { + color: var(--secondary-medium); + flex-shrink: 0; + font-size: var(--font-down-2); + margin-top: 0.4em; + display: none; + width: var(--message-left-width); + } + + &:hover { + .chat-message-left-gutter__bookmark { + display: none; + } + + .chat-time { + display: block; + } + } } // Full Page Styling in Core diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss b/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss index e8361c052e2..733341f0e28 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss @@ -22,6 +22,8 @@ border-radius: 8px; .selected-message-reply { + margin-left: 5px; + &:not(.is-expanded) { @include ellipsis; } diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message.scss b/plugins/chat/assets/stylesheets/mobile/chat-message.scss index c3517ab9858..20c267d2e8c 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-message.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-message.scss @@ -4,7 +4,3 @@ .replying-text { @include unselectable; } - -.chat-message-container { - transform: translateZ(0); -} diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index af2a3c06436..1eadba98f47 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -108,7 +108,7 @@ en: in_reply_to: "In reply to" heading: "Chat" join: "Join" - new_messages: "new messages" + last_visit: "last visit" mention_warning: dismiss: "dismiss" cannot_see: "%{username} can't access this channel and was not notified." diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index d14913e05b3..5f85f631e87 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -247,6 +247,7 @@ after_initialize do load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__) load File.expand_path("../app/controllers/api/chat_channel_threads_controller.rb", __FILE__) load File.expand_path("../app/controllers/api/chat_chatables_controller.rb", __FILE__) + load File.expand_path("../app/queries/chat_channel_unreads_query.rb", __FILE__) load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__) if Discourse.allow_dev_populate? diff --git a/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb b/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb index 409349c0ef9..38f43f6d38d 100644 --- a/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb +++ b/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb @@ -17,7 +17,7 @@ describe ChatChannelMembershipsQuery do context "when no memberships exists" do it "returns an empty array" do - expect(described_class.call(channel_1)).to eq([]) + expect(described_class.call(channel: channel_1)).to eq([]) end end @@ -28,7 +28,7 @@ describe ChatChannelMembershipsQuery do end it "returns the memberships" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id) end @@ -49,7 +49,7 @@ describe ChatChannelMembershipsQuery do end it "lists the user" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships.pluck(:user_id)).to include(user_1.id) end @@ -62,14 +62,16 @@ describe ChatChannelMembershipsQuery do permission_type: CategoryGroup.permission_types[:full], ) - expect(described_class.call(channel_1).pluck(:user_id)).to contain_exactly(user_1.id) + expect(described_class.call(channel: channel_1).pluck(:user_id)).to contain_exactly( + user_1.id, + ) end it "returns the membership if the user still has access through a staff group" do chatters_group.remove(user_1) Group.find_by(id: Group::AUTO_GROUPS[:staff]).add(user_1) - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships.pluck(:user_id)).to include(user_1.id) end @@ -77,7 +79,7 @@ describe ChatChannelMembershipsQuery do context "when membership doesn’t exist" do it "doesn’t list the user" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships.pluck(:user_id)).to be_empty end @@ -91,7 +93,7 @@ describe ChatChannelMembershipsQuery do end it "doesn’t list the user" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships).to be_empty end @@ -99,7 +101,7 @@ describe ChatChannelMembershipsQuery do context "when membership doesn’t exist" do it "doesn’t list the user" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships).to be_empty end @@ -114,7 +116,7 @@ describe ChatChannelMembershipsQuery do end it "returns an empty array" do - expect(described_class.call(channel_1)).to eq([]) + expect(described_class.call(channel: channel_1)).to eq([]) end end @@ -122,7 +124,7 @@ describe ChatChannelMembershipsQuery do fab!(:channel_1) { Fabricate(:direct_message_channel, users: [user_1, user_2]) } it "returns the memberships" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id) end @@ -139,7 +141,7 @@ describe ChatChannelMembershipsQuery do describe "offset param" do it "offsets the results" do - memberships = described_class.call(channel_1, offset: 1) + memberships = described_class.call(channel: channel_1, offset: 1) expect(memberships.length).to eq(1) end @@ -147,7 +149,7 @@ describe ChatChannelMembershipsQuery do describe "limit param" do it "limits the results" do - memberships = described_class.call(channel_1, limit: 1) + memberships = described_class.call(channel: channel_1, limit: 1) expect(memberships.length).to eq(1) end @@ -163,7 +165,7 @@ describe ChatChannelMembershipsQuery do end it "filters the results" do - memberships = described_class.call(channel_1, username: user_1.username) + memberships = described_class.call(channel: channel_1, username: user_1.username) expect(memberships.length).to eq(1) expect(memberships[0].user).to eq(user_1) @@ -182,7 +184,7 @@ describe ChatChannelMembershipsQuery do before { SiteSetting.prioritize_username_in_ux = true } it "is using ascending order on username" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships[0].user).to eq(user_1) expect(memberships[1].user).to eq(user_2) @@ -193,7 +195,7 @@ describe ChatChannelMembershipsQuery do before { SiteSetting.prioritize_username_in_ux = false } it "is using ascending order on name" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships[0].user).to eq(user_2) expect(memberships[1].user).to eq(user_1) @@ -203,7 +205,7 @@ describe ChatChannelMembershipsQuery do before { SiteSetting.enable_names = false } it "is using ascending order on username" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships[0].user).to eq(user_1) expect(memberships[1].user).to eq(user_2) @@ -222,7 +224,7 @@ describe ChatChannelMembershipsQuery do end it "doesn’t list staged users" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships).to be_blank end end @@ -242,7 +244,7 @@ describe ChatChannelMembershipsQuery do end it "doesn’t list suspended users" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships).to be_blank end end @@ -260,7 +262,7 @@ describe ChatChannelMembershipsQuery do end it "doesn’t list inactive users" do - memberships = described_class.call(channel_1) + memberships = described_class.call(channel: channel_1) expect(memberships).to be_blank end end diff --git a/plugins/chat/spec/queries/chat_channel_unreads_query_spec.rb b/plugins/chat/spec/queries/chat_channel_unreads_query_spec.rb new file mode 100644 index 00000000000..fea379ceb46 --- /dev/null +++ b/plugins/chat/spec/queries/chat_channel_unreads_query_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatChannelUnreadsQuery do + fab!(:channel_1) { Fabricate(:category_channel) } + fab!(:current_user) { Fabricate(:user) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + channel_1.add(current_user) + end + + context "with unread message" do + it "returns a correct unread count" do + Fabricate(:chat_message, chat_channel: channel_1) + + expect(described_class.call(channel_id: channel_1.id, user_id: current_user.id)).to eq( + { mention_count: 0, unread_count: 1 }, + ) + end + end + + context "with unread mentions" do + before { Jobs.run_immediately! } + + it "returns a correct unread mention" do + message = Fabricate(:chat_message) + notification = + Notification.create!( + notification_type: Notification.types[:chat_mention], + user_id: current_user.id, + data: { chat_message_id: message.id, chat_channel_id: channel_1.id }.to_json, + ) + ChatMention.create!(notification: notification, user: current_user, chat_message: message) + + expect(described_class.call(channel_id: channel_1.id, user_id: current_user.id)).to eq( + { mention_count: 1, unread_count: 0 }, + ) + end + end + + context "with nothing unread" do + it "returns a correct state" do + expect(described_class.call(channel_id: channel_1.id, user_id: current_user.id)).to eq( + { mention_count: 0, unread_count: 0 }, + ) + end + end +end diff --git a/plugins/chat/spec/requests/chat_controller_spec.rb b/plugins/chat/spec/requests/chat_controller_spec.rb index d0fcbfd19ab..92d0517036d 100644 --- a/plugins/chat/spec/requests/chat_controller_spec.rb +++ b/plugins/chat/spec/requests/chat_controller_spec.rb @@ -126,15 +126,17 @@ RSpec.describe Chat::ChatController do it "correctly marks reactions as 'reacted' for the current_user" do heart_emoji = ":heart:" smile_emoji = ":smile" - last_message = chat_channel.chat_messages.last last_message.reactions.create(user: user, emoji: heart_emoji) last_message.reactions.create(user: admin, emoji: smile_emoji) get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + reactions = response.parsed_body["chat_messages"].last["reactions"] - expect(reactions[heart_emoji]["reacted"]).to be true - expect(reactions[smile_emoji]["reacted"]).to be false + heart_reaction = reactions.find { |r| r["emoji"] == heart_emoji } + expect(heart_reaction["reacted"]).to be true + smile_reaction = reactions.find { |r| r["emoji"] == smile_emoji } + expect(smile_reaction["reacted"]).to be false end it "sends the last message bus id for the channel" do diff --git a/plugins/chat/spec/serializer/chat_message_serializer_spec.rb b/plugins/chat/spec/serializer/chat_message_serializer_spec.rb index ea97d0310de..67f11368c2d 100644 --- a/plugins/chat/spec/serializer/chat_message_serializer_spec.rb +++ b/plugins/chat/spec/serializer/chat_message_serializer_spec.rb @@ -21,12 +21,14 @@ describe ChatMessageSerializer do it "doesn’t return the reaction" do Emoji.clear_cache - expect(subject.as_json[:reactions]["trout"]).to be_present + trout_reaction = subject.as_json[:reactions].find { |r| r[:emoji] == "trout" } + expect(trout_reaction).to be_present custom_emoji.destroy! Emoji.clear_cache - expect(subject.as_json[:reactions]["trout"]).to_not be_present + trout_reaction = subject.as_json[:reactions].find { |r| r[:emoji] == "trout" } + expect(trout_reaction).to_not be_present end end end diff --git a/plugins/chat/spec/system/chat_channel_spec.rb b/plugins/chat/spec/system/chat_channel_spec.rb index 39ecd2b9682..34d6a933f53 100644 --- a/plugins/chat/spec/system/chat_channel_spec.rb +++ b/plugins/chat/spec/system/chat_channel_spec.rb @@ -37,7 +37,7 @@ RSpec.describe "Chat channel", type: :system, js: true do chat.visit_channel(channel_1) expect(channel).to have_no_loading_skeleton channel.send_message("aaaaaaaaaaaaaaaaaaaa") - expect(page).to have_no_css("[data-staged-id]") + expect(page).to have_no_css(".chat-message-staged") last_message = find(".chat-message-container:last-child") last_message.hover @@ -183,7 +183,7 @@ RSpec.describe "Chat channel", type: :system, js: true do it "shows a date separator" do chat.visit_channel(channel_1) - expect(page).to have_selector(".first-daily-message", text: "Today") + expect(page).to have_selector(".chat-message-separator__text", text: "Today") end end diff --git a/plugins/chat/spec/system/create_channel_spec.rb b/plugins/chat/spec/system/create_channel_spec.rb index f6fd4e4da09..f1cfa2e8f87 100644 --- a/plugins/chat/spec/system/create_channel_spec.rb +++ b/plugins/chat/spec/system/create_channel_spec.rb @@ -81,6 +81,7 @@ RSpec.describe "Create channel", type: :system, js: true do chat_page.visit_browse chat_page.new_channel_button.click channel_modal.select_category(private_category_1) + expect(page).to have_no_css(".loading-permissions") expect(channel_modal.create_channel_hint["innerHTML"].strip).to include( "<script>e</script>", diff --git a/plugins/chat/spec/system/deleted_message_spec.rb b/plugins/chat/spec/system/deleted_message_spec.rb index b50231d27c7..8bea5d4a17f 100644 --- a/plugins/chat/spec/system/deleted_message_spec.rb +++ b/plugins/chat/spec/system/deleted_message_spec.rb @@ -18,7 +18,7 @@ RSpec.describe "Deleted message", type: :system, js: true do chat_page.visit_channel(channel_1) expect(channel_page).to have_no_loading_skeleton channel_page.send_message("aaaaaaaaaaaaaaaaaaaa") - expect(page).to have_no_css("[data-staged-id]") + expect(page).to have_no_css(".chat-message-staged") last_message = find(".chat-message-container:last-child") channel_page.delete_message(OpenStruct.new(id: last_message["data-id"])) diff --git a/plugins/chat/spec/system/drawer_spec.rb b/plugins/chat/spec/system/drawer_spec.rb index 3726160ff80..60877091745 100644 --- a/plugins/chat/spec/system/drawer_spec.rb +++ b/plugins/chat/spec/system/drawer_spec.rb @@ -3,6 +3,7 @@ RSpec.describe "Drawer", type: :system, js: true 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 @@ -52,4 +53,39 @@ RSpec.describe "Drawer", type: :system, js: true do expect(page.find(".chat-drawer").native.style("height")).to eq("530px") end end + + context "when going from drawer to full page" do + fab!(:channel_1) { Fabricate(:chat_channel) } + fab!(:channel_2) { Fabricate(:chat_channel) } + fab!(:user_1) { Fabricate(:user) } + + before do + channel_1.add(current_user) + channel_2.add(current_user) + channel_1.add(user_1) + channel_2.add(user_1) + end + + it "correctly resets subscriptions" do + visit("/") + + chat_page.open_from_header + drawer.maximize + chat_page.minimize_full_page + drawer.maximize + + using_session("user_1") do |session| + sign_in(user_1) + chat_page.visit_channel(channel_1) + channel_page.send_message("onlyonce") + session.quit + end + + expect(page).to have_content("onlyonce", count: 1) + + chat_page.visit_channel(channel_2) + + expect(page).to have_content("onlyonce", count: 0) + end + end end diff --git a/plugins/chat/spec/system/flag_message_spec.rb b/plugins/chat/spec/system/flag_message_spec.rb index 8b40ba93cd8..a7afd6af14b 100644 --- a/plugins/chat/spec/system/flag_message_spec.rb +++ b/plugins/chat/spec/system/flag_message_spec.rb @@ -32,7 +32,7 @@ RSpec.describe "Flag message", type: :system, js: true do context "when direct message channel" do fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [current_user]) } - fab!(:message_1) { Fabricate(:chat_message, chat_channel: dm_channel_1, user: current_user) } + fab!(:message_1) { Fabricate(:chat_message, chat_channel: dm_channel_1) } it "doesn’t allow to flag a message" do chat.visit_channel(dm_channel_1) diff --git a/plugins/chat/spec/system/message_notifications_with_sidebar_spec.rb b/plugins/chat/spec/system/message_notifications_with_sidebar_spec.rb index 883701896ca..49728193197 100644 --- a/plugins/chat/spec/system/message_notifications_with_sidebar_spec.rb +++ b/plugins/chat/spec/system/message_notifications_with_sidebar_spec.rb @@ -15,6 +15,7 @@ RSpec.describe "Message notifications - with sidebar", type: :system, js: true d sign_in(creator) chat_page.visit_channel(channel) chat_channel_page.send_message(text) + expect(chat_channel_page).to have_no_css(".chat-message-staged") expect(chat_channel_page).to have_message(text: text) end diff --git a/plugins/chat/spec/system/message_user_info.rb b/plugins/chat/spec/system/message_user_info.rb new file mode 100644 index 00000000000..be97ab1c0d9 --- /dev/null +++ b/plugins/chat/spec/system/message_user_info.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +RSpec.describe "Sticky date", type: :system, js: true do + fab!(:current_user) { Fabricate(:user) } + fab!(:channel_1) { Fabricate(:category_channel) } + + let(:chat_page) { PageObjects::Pages::Chat.new } + + before do + chat_system_bootstrap + sign_in(current_user) + end + + context "when previous message is from a different user" do + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) } + fab!(:message_2) { Fabricate(:chat_message, chat_channel: channel_1) } + + it "shows user info on the message" do + chat_page.visit_channel(channel_1) + + expect(page.find("[data-id='#{message_2.id}']")).to have_css(".chat-message-avatar") + end + end + + context "when previous message is from the same user" do + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1, user: current_user) } + fab!(:message_2) { Fabricate(:chat_message, chat_channel: channel_1, user: current_user) } + + it "doesn’t show user info on the message" do + chat_page.visit_channel(channel_1) + + expect(page.find("[data-id='#{message_2.id}']")).to have_no_css(".chat-message-avatar") + end + + context "when previous message is old" do + fab!(:message_1) do + Fabricate( + :chat_message, + chat_channel: channel_1, + user: current_user, + created_at: DateTime.parse("2018-11-10 17:00"), + ) + end + fab!(:message_2) do + Fabricate( + :chat_message, + chat_channel: channel_1, + user: current_user, + created_at: DateTime.parse("2018-11-10 17:30"), + ) + end + + it "shows user info on the message" do + chat_page.visit_channel(channel_1) + + expect(page.find("[data-id='#{message_2.id}']")).to have_no_css(".chat-message-avatar") + end + end + end +end diff --git a/plugins/chat/spec/system/navigating_to_message_spec.rb b/plugins/chat/spec/system/navigating_to_message_spec.rb index 9dae4fe8b81..c3a3899eb43 100644 --- a/plugins/chat/spec/system/navigating_to_message_spec.rb +++ b/plugins/chat/spec/system/navigating_to_message_spec.rb @@ -60,8 +60,9 @@ RSpec.describe "Navigating to message", type: :system, js: true do it "highlights the correct message after using the bottom arrow" do chat_page.visit_channel(channel_1) + click_link(link) - click_link(I18n.t("js.chat.scroll_to_bottom")) + click_button(class: "chat-scroll-to-bottom") click_link(link) expect(page).to have_css( @@ -149,8 +150,9 @@ RSpec.describe "Navigating to message", type: :system, js: true do visit("/") chat_page.open_from_header chat_drawer_page.open_channel(channel_1) + click_link(link) - click_link(I18n.t("js.chat.scroll_to_bottom")) + click_button(class: "chat-scroll-to-bottom") click_link(link) expect(page).to have_css( diff --git a/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb b/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb index dddfa46baa9..a7dd347750e 100644 --- a/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb +++ b/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb @@ -5,6 +5,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do fab!(:current_user) { Fabricate(:user) } let(:chat) { PageObjects::Pages::Chat.new } + let(:channel_page) { PageObjects::Pages::ChatChannel.new } KEY_MODIFIER = RUBY_PLATFORM =~ /darwin/i ? :meta : :control @@ -63,8 +64,9 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do it "edits last editable message" do chat.visit_channel(channel_1) + expect(channel_page).to have_message(id: message_1.id) - within(".chat-composer-input") { |composer| composer.send_keys(:arrow_up) } + find(".chat-composer-input").send_keys(:arrow_up) expect(page.find(".chat-composer-message-details")).to have_content(message_1.message) end diff --git a/plugins/chat/spec/system/sticky_date_spec.rb b/plugins/chat/spec/system/sticky_date_spec.rb new file mode 100644 index 00000000000..9f043afa9f9 --- /dev/null +++ b/plugins/chat/spec/system/sticky_date_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.describe "Sticky date", type: :system, js: true do + fab!(:current_user) { Fabricate(:user) } + fab!(:channel_1) { Fabricate(:category_channel) } + + let(:chat_page) { PageObjects::Pages::Chat.new } + + before do + chat_system_bootstrap + channel_1.add(current_user) + 20.times { Fabricate(:chat_message, chat_channel: channel_1, created_at: 1.day.ago) } + 25.times { Fabricate(:chat_message, chat_channel: channel_1) } + sign_in(current_user) + end + + context "when today separator is out of screen" do + it "shows it as a sticky date" do + chat_page.visit_channel(channel_1) + + expect(page.find(".chat-message-separator__text-container.is-pinned")).to have_content( + I18n.t("js.chat.chat_message_separator.today"), + ) + expect(page).to have_css( + ".chat-message-separator__text-container:not(.is-pinned)", + visible: :hidden, + text: + "#{I18n.t("js.chat.chat_message_separator.yesterday")} - #{I18n.t("js.chat.last_visit")}", + ) + end + end +end diff --git a/plugins/chat/spec/system/uploads_spec.rb b/plugins/chat/spec/system/uploads_spec.rb index a012d29b86c..09dee0e400d 100644 --- a/plugins/chat/spec/system/uploads_spec.rb +++ b/plugins/chat/spec/system/uploads_spec.rb @@ -36,12 +36,21 @@ describe "Uploading files in chat messages", type: :system, js: true do it "allows uploading multiple files" do chat.visit_channel(channel_1) + file_path_1 = file_from_fixtures("logo.png", "images").path - file_path_2 = file_from_fixtures("logo.jpg", "images").path - attach_file([file_path_1, file_path_2]) do + attach_file([file_path_1]) do channel.open_action_menu channel.click_action_button("chat-upload-btn") + find(".chat-composer-input").click end + + file_path_2 = file_from_fixtures("logo.jpg", "images").path + attach_file([file_path_2]) do + channel.open_action_menu + channel.click_action_button("chat-upload-btn") + find(".chat-composer-input").click + end + expect(page).to have_css(".chat-composer-upload .preview .preview-img", count: 2) channel.send_message("upload testing") diff --git a/plugins/chat/spec/system/user_chat_preferences_spec.rb b/plugins/chat/spec/system/user_chat_preferences_spec.rb index 9598271c645..d1180a24b86 100644 --- a/plugins/chat/spec/system/user_chat_preferences_spec.rb +++ b/plugins/chat/spec/system/user_chat_preferences_spec.rb @@ -51,7 +51,8 @@ RSpec.describe "User chat preferences", type: :system, js: true do it "allows to change settings" do visit("/u/#{user_1.username}/preferences") - find(".preferences-chat-link").click + + find(".user-nav__preferences-chat").click expect(page).to have_current_path("/u/#{user_1.username}/preferences/chat") end diff --git a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js index 1f8fcf38462..b842f618814 100644 --- a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js +++ b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js @@ -109,7 +109,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) { await click(".send-btn"); await publishToMessageBus(`/chat/11`, { type: "sent", - stagedId: 1, + staged_id: 1, chat_message: { cooked: "network-error-message", id: 175, diff --git a/plugins/chat/test/javascripts/components/chat-channel-metadata-test.js b/plugins/chat/test/javascripts/components/chat-channel-metadata-test.js index 4a8c7f391c8..db01ba779e9 100644 --- a/plugins/chat/test/javascripts/components/chat-channel-metadata-test.js +++ b/plugins/chat/test/javascripts/components/chat-channel-metadata-test.js @@ -9,16 +9,17 @@ module("Discourse Chat | Component | chat-channel-metadata", function (hooks) { setupRenderingTest(hooks); test("displays last message sent at", async function (assert) { - let lastMessageSentAt = moment().subtract(1, "day"); + let lastMessageSentAt = moment().subtract(1, "day").format(); this.channel = fabricators.directMessageChatChannel({ last_message_sent_at: lastMessageSentAt, }); + await render(hbs``); assert.dom(".chat-channel-metadata__date").hasText("Yesterday"); lastMessageSentAt = moment(); - this.channel.set("last_message_sent_at", lastMessageSentAt); + this.channel.lastMessageSentAt = lastMessageSentAt; await render(hbs``); assert diff --git a/plugins/chat/test/javascripts/components/chat-channel-row-test.js b/plugins/chat/test/javascripts/components/chat-channel-row-test.js index 0c10cad2158..624d574da95 100644 --- a/plugins/chat/test/javascripts/components/chat-channel-row-test.js +++ b/plugins/chat/test/javascripts/components/chat-channel-row-test.js @@ -51,9 +51,7 @@ module("Discourse Chat | Component | chat-channel-row", function (hooks) { assert .dom(".chat-channel-metadata") - .hasText( - moment(this.categoryChatChannel.last_message_sent_at).format("l") - ); + .hasText(moment(this.categoryChatChannel.lastMessageSentAt).format("l")); }); test("renders membership toggling button when necessary", async function (assert) { diff --git a/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js index b256887fff8..1a3872bd2c2 100644 --- a/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js +++ b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js @@ -8,7 +8,6 @@ import { import hbs from "htmlbars-inline-precompile"; import { click, render, settled, waitFor } from "@ember/test-helpers"; import { module, test } from "qunit"; -import { run } from "@ember/runloop"; const fakeUpload = { type: ".png", @@ -47,12 +46,11 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) { setupRenderingTest(hooks); test("loading uploads from an outside source (e.g. draft or editing message)", async function (assert) { - await render(hbs` - - `); + this.existingUploads = [fakeUpload]; - this.appEvents = this.container.lookup("service:appEvents"); - this.appEvents.trigger("chat-composer:load-uploads", [fakeUpload]); + await render(hbs` + + `); await settled(); assert.strictEqual(count(".chat-composer-upload"), 1); @@ -61,10 +59,7 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) { test("upload starts and completes", async function (assert) { setupUploadPretender(); - this.set("changedUploads", null); - this.set("onUploadChanged", (uploads) => { - this.set("changedUploads", uploads); - }); + this.set("onUploadChanged", () => {}); await render(hbs` @@ -80,34 +75,31 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) { done(); } ); - this.appEvents.trigger( "upload-mixin:chat-composer-uploader:add-files", createFile("avatar.png") ); await waitFor(".chat-composer-upload"); - assert.strictEqual(count(".chat-composer-upload"), 1); + + assert.dom(".chat-composer-upload").exists({ count: 1 }); }); test("removing a completed upload", async function (assert) { this.set("changedUploads", null); - this.set("onUploadChanged", (uploads) => { - this.set("changedUploads", uploads); - }); + this.set("onUploadChanged", () => {}); + + this.existingUploads = [fakeUpload]; await render(hbs` - + `); - this.appEvents = this.container.lookup("service:appEvents"); - run(() => - this.appEvents.trigger("chat-composer:load-uploads", [fakeUpload]) - ); - assert.strictEqual(count(".chat-composer-upload"), 1); + assert.dom(".chat-composer-upload").exists({ count: 1 }); await click(".remove-upload"); - assert.strictEqual(count(".chat-composer-upload"), 0); + + assert.dom(".chat-composer-upload").exists({ count: 0 }); }); test("cancelling in progress upload", async function (assert) { diff --git a/plugins/chat/test/javascripts/components/chat-live-pane-test.js b/plugins/chat/test/javascripts/components/chat-live-pane-test.js deleted file mode 100644 index 244439eb38e..00000000000 --- a/plugins/chat/test/javascripts/components/chat-live-pane-test.js +++ /dev/null @@ -1,44 +0,0 @@ -import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { exists } from "discourse/tests/helpers/qunit-helpers"; -import hbs from "htmlbars-inline-precompile"; -import { module, test } from "qunit"; -import fabricators from "../helpers/fabricators"; -import { render } from "@ember/test-helpers"; -import pretender, { response } from "discourse/tests/helpers/create-pretender"; -import MockPresenceChannel from "../helpers/mock-presence-channel"; - -function mockChat(context) { - const mock = context.container.lookup("service:chat"); - mock.draftStore = {}; - mock.currentUser = context.currentUser; - mock.presenceChannel = MockPresenceChannel.create(); - return mock; -} - -module("Discourse Chat | Component | chat-live-pane", function (hooks) { - setupRenderingTest(hooks); - - hooks.beforeEach(function () { - this.set("chat", mockChat(this)); - this.set("channel", fabricators.chatChannel()); - }); - - test("Shows skeleton when loading", async function (assert) { - pretender.get(`/chat/chat_channels.json`, () => response(this.channel)); - pretender.get(`/chat/:id/messages.json`, () => - response({ chat_messages: [], meta: { can_delete_self: true } }) - ); - - await render( - hbs`` - ); - - assert.true(exists(".chat-skeleton")); - - await render( - hbs`` - ); - - assert.true(exists(".chat-skeleton")); - }); -}); diff --git a/plugins/chat/test/javascripts/components/chat-message-avatar-test.js b/plugins/chat/test/javascripts/components/chat-message-avatar-test.js index 56244f9803b..dff366aec8b 100644 --- a/plugins/chat/test/javascripts/components/chat-message-avatar-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-avatar-test.js @@ -3,12 +3,16 @@ import hbs from "htmlbars-inline-precompile"; import { exists, query } from "discourse/tests/helpers/qunit-helpers"; import { module, test } from "qunit"; import { render } from "@ember/test-helpers"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import fabricators from "../helpers/fabricators"; module("Discourse Chat | Component | chat-message-avatar", function (hooks) { setupRenderingTest(hooks); test("chat_webhook_event", async function (assert) { - this.set("message", { chat_webhook_event: { emoji: ":heart:" } }); + this.message = ChatMessage.create(fabricators.chatChannel(), { + chat_webhook_event: { emoji: ":heart:" }, + }); await render(hbs``); @@ -16,7 +20,9 @@ module("Discourse Chat | Component | chat-message-avatar", function (hooks) { }); test("user", async function (assert) { - this.set("message", { user: { username: "discobot" } }); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { username: "discobot" }, + }); await render(hbs``); diff --git a/plugins/chat/test/javascripts/components/chat-message-info-test.js b/plugins/chat/test/javascripts/components/chat-message-info-test.js index 2875e2a73cc..f633d71645f 100644 --- a/plugins/chat/test/javascripts/components/chat-message-info-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-info-test.js @@ -6,21 +6,21 @@ import I18n from "I18n"; import { module, test } from "qunit"; import { render } from "@ember/test-helpers"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import fabricators from "../helpers/fabricators"; module("Discourse Chat | Component | chat-message-info", function (hooks) { setupRenderingTest(hooks); test("chat_webhook_event", async function (assert) { - this.set( - "message", - ChatMessage.create({ chat_webhook_event: { username: "discobot" } }) - ); + this.message = ChatMessage.create(fabricators.chatChannel(), { + chat_webhook_event: { username: "discobot" }, + }); await render(hbs``); assert.strictEqual( query(".chat-message-info__username").innerText.trim(), - this.message.chat_webhook_event.username + this.message.chatWebhookEvent.username ); assert.strictEqual( query(".chat-message-info__bot-indicator").textContent.trim(), @@ -29,7 +29,9 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("user", async function (assert) { - this.set("message", ChatMessage.create({ user: { username: "discobot" } })); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { username: "discobot" }, + }); await render(hbs``); @@ -40,13 +42,10 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("date", async function (assert) { - this.set( - "message", - ChatMessage.create({ - user: { username: "discobot" }, - created_at: moment(), - }) - ); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { username: "discobot" }, + created_at: moment(), + }); await render(hbs``); @@ -54,16 +53,13 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("bookmark (with reminder)", async function (assert) { - this.set( - "message", - ChatMessage.create({ - user: { username: "discobot" }, - bookmark: Bookmark.create({ - reminder_at: moment(), - name: "some name", - }), - }) - ); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { username: "discobot" }, + bookmark: Bookmark.create({ + reminder_at: moment(), + name: "some name", + }), + }); await render(hbs``); @@ -73,15 +69,12 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("bookmark (no reminder)", async function (assert) { - this.set( - "message", - ChatMessage.create({ - user: { username: "discobot" }, - bookmark: Bookmark.create({ - name: "some name", - }), - }) - ); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { username: "discobot" }, + bookmark: Bookmark.create({ + name: "some name", + }), + }); await render(hbs``); @@ -90,7 +83,9 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { test("user status", async function (assert) { const status = { description: "off to dentist", emoji: "tooth" }; - this.set("message", ChatMessage.create({ user: { status } })); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { status }, + }); await render(hbs``); @@ -98,13 +93,10 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("reviewable", async function (assert) { - this.set( - "message", - ChatMessage.create({ - user: { username: "discobot" }, - user_flag_status: 0, - }) - ); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { username: "discobot" }, + user_flag_status: 0, + }); await render(hbs``); @@ -113,13 +105,12 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { I18n.t("chat.you_flagged") ); - this.set( - "message", - ChatMessage.create({ - user: { username: "discobot" }, - reviewable_id: 1, - }) - ); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { username: "discobot" }, + reviewable_id: 1, + }); + + await render(hbs``); assert.strictEqual( query(".chat-message-info__flag a .svg-icon-title").title, @@ -128,18 +119,15 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("with username classes", async function (assert) { - this.set( - "message", - ChatMessage.create({ - user: { - username: "discobot", - admin: true, - moderator: true, - new_user: true, - primary_group_name: "foo", - }, - }) - ); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { + username: "discobot", + admin: true, + moderator: true, + new_user: true, + primary_group_name: "foo", + }, + }); await render(hbs``); @@ -151,7 +139,9 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("without username classes", async function (assert) { - this.set("message", ChatMessage.create({ user: { username: "discobot" } })); + this.message = ChatMessage.create(fabricators.chatChannel(), { + user: { username: "discobot" }, + }); await render(hbs``); diff --git a/plugins/chat/test/javascripts/components/chat-message-reaction-test.js b/plugins/chat/test/javascripts/components/chat-message-reaction-test.js index 86f1ca04c7f..4a640b949dc 100644 --- a/plugins/chat/test/javascripts/components/chat-message-reaction-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-reaction-test.js @@ -7,14 +7,6 @@ import { module, test } from "qunit"; module("Discourse Chat | Component | chat-message-reaction", function (hooks) { setupRenderingTest(hooks); - test("accepts arbitrary class property", async function (assert) { - await render(hbs` - - `); - - assert.true(exists(".chat-message-reaction.foo")); - }); - test("adds reacted class when user reacted", async function (assert) { await render(hbs` @@ -29,19 +21,6 @@ module("Discourse Chat | Component | chat-message-reaction", function (hooks) { assert.true(exists(`.chat-message-reaction[data-emoji-name="heart"]`)); }); - test("adds show class when count is positive", async function (assert) { - this.set("count", 0); - - await render(hbs` - - `); - - assert.false(exists(".chat-message-reaction.show")); - - this.set("count", 1); - assert.true(exists(".chat-message-reaction.show")); - }); - test("title/alt attributes", async function (assert) { await render(hbs``); diff --git a/plugins/chat/test/javascripts/components/chat-message-separator-date-test.js b/plugins/chat/test/javascripts/components/chat-message-separator-date-test.js new file mode 100644 index 00000000000..415413df7ac --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-separator-date-test.js @@ -0,0 +1,24 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module, test } from "qunit"; +import { render } from "@ember/test-helpers"; + +module( + "Discourse Chat | Component | chat-message-separator-date", + function (hooks) { + setupRenderingTest(hooks); + + test("first message of the day", async function (assert) { + this.set("date", moment().format("LLL")); + this.set("message", { firstMessageOfTheDayAt: this.date }); + + await render(hbs``); + + assert.strictEqual( + query(".chat-message-separator-date").innerText.trim(), + this.date + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-message-separator-new-test.js b/plugins/chat/test/javascripts/components/chat-message-separator-new-test.js new file mode 100644 index 00000000000..ee91a122043 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-separator-new-test.js @@ -0,0 +1,24 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import I18n from "I18n"; +import { module, test } from "qunit"; +import { render } from "@ember/test-helpers"; + +module( + "Discourse Chat | Component | chat-message-separator-new", + function (hooks) { + setupRenderingTest(hooks); + + test("newest message", async function (assert) { + this.set("message", { newest: true }); + + await render(hbs``); + + assert.strictEqual( + query(".chat-message-separator-new").innerText.trim(), + I18n.t("chat.last_visit") + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-message-separator-test.js b/plugins/chat/test/javascripts/components/chat-message-separator-test.js deleted file mode 100644 index 4b4aad0e565..00000000000 --- a/plugins/chat/test/javascripts/components/chat-message-separator-test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { query } from "discourse/tests/helpers/qunit-helpers"; -import hbs from "htmlbars-inline-precompile"; -import I18n from "I18n"; -import { module, test } from "qunit"; -import { render } from "@ember/test-helpers"; - -module("Discourse Chat | Component | chat-message-separator", function (hooks) { - setupRenderingTest(hooks); - - test("newest message", async function (assert) { - this.set("message", { newestMessage: true }); - - await render(hbs``); - - assert.strictEqual( - query(".chat-message-separator.new-message .text").innerText.trim(), - I18n.t("chat.new_messages") - ); - }); - - test("first message of the day", async function (assert) { - this.set("date", moment().format("LLL")); - this.set("message", { firstMessageOfTheDayAt: this.date }); - - await render(hbs``); - - assert.strictEqual( - query( - ".chat-message-separator.first-daily-message .text" - ).innerText.trim(), - this.date - ); - }); -}); diff --git a/plugins/chat/test/javascripts/components/chat-message-test.js b/plugins/chat/test/javascripts/components/chat-message-test.js index 3dbaf2737e0..7ffa9da1d08 100644 --- a/plugins/chat/test/javascripts/components/chat-message-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-test.js @@ -1,5 +1,5 @@ import User from "discourse/models/user"; -import { render, waitFor } from "@ember/test-helpers"; +import { render } from "@ember/test-helpers"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import { exists } from "discourse/tests/helpers/qunit-helpers"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; @@ -21,9 +21,16 @@ module("Discourse Chat | Component | chat-message", function (hooks) { unread_count: 0, muted: false, }, + canInteractWithChat: true, + canDeleteSelf: true, + canDeleteOthers: true, + canFlag: true, + userSilenced: false, + canModerate: true, }); return { message: ChatMessage.create( + chatChannel, Object.assign( { id: 178, @@ -38,14 +45,6 @@ module("Discourse Chat | Component | chat-message", function (hooks) { messageData ) ), - canInteractWithChat: true, - details: { - can_delete_self: true, - can_delete_others: true, - can_flag: true, - user_silenced: false, - can_moderate: true, - }, chatChannel, setReplyTo: () => {}, replyMessageClicked: () => {}, @@ -55,8 +54,9 @@ module("Discourse Chat | Component | chat-message", function (hooks) { onStartSelectingMessages: () => {}, onSelectMessage: () => {}, bulkSelectMessages: () => {}, - afterReactionAdded: () => {}, onHoverMessage: () => {}, + didShowMessage: () => {}, + didHideMessage: () => {}, }; } @@ -64,8 +64,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) { `; @@ -90,6 +90,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) { test("Deleted message", async function (assert) { this.setProperties(generateMessageProps({ deleted_at: moment() })); await render(template); + assert.true( exists(".chat-message-deleted .chat-message-expand"), "has the correct deleted css class and expand button within" @@ -104,16 +105,4 @@ module("Discourse Chat | Component | chat-message", function (hooks) { "has the correct hidden css class and expand button within" ); }); - - test("Message marked as visible", async function (assert) { - this.setProperties(generateMessageProps()); - - await render(template); - await waitFor("div[data-visible=true]"); - - assert.true( - exists(".chat-message-container[data-visible=true]"), - "message is marked as visible" - ); - }); }); diff --git a/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js b/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js index cb707d011f5..820e28a8308 100644 --- a/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js +++ b/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js @@ -14,9 +14,7 @@ module( this.channel = ChatChannel.create({ chatable_type: "Category" }); this.currentUser.set("needs_channel_retention_reminder", true); - await render( - hbs`` - ); + await render(hbs``); assert.dom(".chat-retention-reminder").includesText( I18n.t("chat.retention_reminders.public", { diff --git a/plugins/chat/test/javascripts/helpers/fabricators.js b/plugins/chat/test/javascripts/helpers/fabricators.js index 924685b6e6e..07cf409ee24 100644 --- a/plugins/chat/test/javascripts/helpers/fabricators.js +++ b/plugins/chat/test/javascripts/helpers/fabricators.js @@ -3,6 +3,7 @@ import ChatChannel, { } from "discourse/plugins/chat/discourse/models/chat-channel"; import EmberObject from "@ember/object"; import { Fabricator } from "./fabricator"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; const userFabricator = Fabricator(EmberObject, { id: 1, @@ -38,7 +39,7 @@ export default { }, }), - chatChannelMessage: Fabricator(EmberObject, { + chatChannelMessage: Fabricator(ChatMessage, { id: 1, chat_channel_id: 1, user_id: 1, diff --git a/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js b/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js deleted file mode 100644 index e05981401b3..00000000000 --- a/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { render, waitFor } from "@ember/test-helpers"; -import { exists } from "discourse/tests/helpers/qunit-helpers"; -import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import hbs from "htmlbars-inline-precompile"; -import { module, test } from "qunit"; - -module( - "Discourse Chat | Modifier | track-message-visibility", - function (hooks) { - setupRenderingTest(hooks); - - test("Marks message as visible when it intersects with the viewport", async function (assert) { - const template = hbs`
    `; - - await render(template); - await waitFor("div[data-visible=true]"); - - assert.ok( - exists("div[data-visible=true]"), - "message is marked as visible" - ); - }); - - test("Marks message as visible when it doesn't intersect with the viewport", async function (assert) { - const template = hbs`
    `; - - await render(template); - await waitFor("div[data-visible=false]"); - - assert.ok( - exists("div[data-visible=false]"), - "message is not marked as visible" - ); - }); - } -); diff --git a/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js b/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js index 081732a054e..f6acc11a457 100644 --- a/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js +++ b/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js @@ -3,12 +3,19 @@ import hbs from "htmlbars-inline-precompile"; import { render } from "@ember/test-helpers"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { query } from "discourse/tests/helpers/qunit-helpers"; +import fabricators from "../../helpers/fabricators"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; module("Discourse Chat | Unit | Helpers | format-chat-date", function (hooks) { setupRenderingTest(hooks); test("link to chat message", async function (assert) { - this.set("message", { id: 1, chat_channel_id: 1 }); + const channel = fabricators.chatChannel(); + this.message = ChatMessage.create(channel, { + id: 1, + chat_channel_id: channel.id, + }); + await render(hbs`{{format-chat-date this.message}}`); assert.equal(query(".chat-time").getAttribute("href"), "/chat/c/-/1/1");