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 d6fe2fd4ad9..c50a30735e7 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: channel_from_params, + 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 7f087a3158e..6b485036a31 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/c/-/#{self.chat_channel_id}/#{self.id}" + "/chat/message/#{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 e38f09eae1d..a257e0c0697 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: channel, count_only: true) + call(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 deleted file mode 100644 index 0d6e49ba0ea..00000000000 --- a/plugins/chat/app/queries/chat_channel_unreads_query.rb +++ /dev/null @@ -1,40 +0,0 @@ -# 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 d007ca651a2..ffa800496af 100644 --- a/plugins/chat/app/serializers/chat_channel_serializer.rb +++ b/plugins/chat/app/serializers/chat_channel_serializer.rb @@ -110,7 +110,6 @@ 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 4ff2b7e5ff0..9f6dc19a23e 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) - .map do |emoji, reactions| + .each do |emoji, reactions| + users = reactions[0..5].map(&:user).filter { |user| user.id != scope&.user&.id }[0..4] + next unless Emoji.exists?(emoji) - users = reactions.take(5).map(&:user) - - { - emoji: emoji, + reactions_hash[emoji] = { count: reactions.count, users: ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, reacted: users_reactions.include?(emoji), } end - .compact + reactions_hash 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 129cd31f17b..566474ec408 100644 --- a/plugins/chat/app/serializers/chat_view_serializer.rb +++ b/plugins/chat/app/serializers/chat_view_serializer.rb @@ -16,7 +16,6 @@ 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 25c27d08960..a56b841ae99 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[:staged_id] = staged_id + content[:stagedId] = staged_id permissions = permissions(chat_channel) MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) @@ -133,13 +133,9 @@ 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), - data.as_json, + { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id.to_i }.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 3232387e3d5..bb1b660ebcb 100644 --- a/plugins/chat/assets/javascripts/discourse/components/channels-list.js +++ b/plugins/chat/assets/javascripts/discourse/components/channels-list.js @@ -1,30 +1,24 @@ import { bind } from "discourse-common/utils/decorators"; -import Component from "@glimmer/component"; -import { action } from "@ember/object"; +import Component from "@ember/component"; +import { action, computed } 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; - @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; - } + tagName = ""; + inSidebar = false; + toggleSection = null; + @empty("chatChannelsManager.publicMessageChannels") + publicMessageChannelsEmpty; + @and("site.mobileView", "showDirectMessageChannels") + showMobileDirectMessageButton; + @computed("canCreateDirectMessageChannel") get createDirectMessageChannelLabel() { if (!this.canCreateDirectMessageChannel) { return "chat.direct_messages.cannot_create"; @@ -33,6 +27,10 @@ export default class ChannelsList extends Component { return "chat.direct_messages.new"; } + @computed( + "canCreateDirectMessageChannel", + "chatChannelsManager.directMessageChannels" + ) get showDirectMessageChannels() { return ( this.canCreateDirectMessageChannel || @@ -44,12 +42,17 @@ 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 ( @@ -61,6 +64,7 @@ export default class ChannelsList extends Component { return true; } + @computed("inSidebar") get directMessageChannelClasses() { return `channels-list-container direct-message-channels ${ this.inSidebar ? "collapsible-sidebar-section" : "" @@ -69,7 +73,7 @@ export default class ChannelsList extends Component { @action toggleChannelSection(section) { - this.args.toggleSection(section); + this.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 7974d150628..05c16b4e5d2 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 this.unreadIndicator}} + {{#if @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 898f6c7ac89..404cd7ebd4e 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 { - get unreadIndicator() { - return this.args.unreadIndicator ?? false; - } + unreadIndicator = false; get lastMessageFormatedDate() { - return moment(this.args.channel.lastMessageSentAt).calendar(null, { - sameDay: "LT", - nextDay: "[Tomorrow]", - nextWeek: "dddd", - lastDay: "[Yesterday]", - lastWeek: "dddd", - sameElse: "l", - }); + return moment(this.args.channel.get("last_message_sent_at")).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 83ec633c9b8..9002d3f774c 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}}

- {{@channel.description}} + {{this.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 b4213243660..954313febe9 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,15 +1,19 @@ -import Component from "@glimmer/component"; +import Component from "@ember/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 = ""; - get showJoinButton() { - return this.args.channel?.isOpen; - } + channel = null; + @readOnly("channel.isOpen") showJoinButton; + + @computed("channel.description") get hasDescription() { - return !isEmpty(this.args.channel?.description); + return !isEmpty(this.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 935354ea2bf..96f90da74ce 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.lastMessageSentAt) > new Date(b.lastMessageSentAt) + return new Date(a.last_message_sent_at) > new Date(b.last_message_sent_at) ? -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 074cfe86282..2112b9e8b2e 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 @buttons.length}} +{{#if this.buttons.length}}
    - {{#each @buttons as |button|}} + {{#each this.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 5cc5953975e..29b0eed2f0a 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -29,10 +29,11 @@ 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("chatChannel.userSilenced"), + userSilenced: readOnly("details.user_silenced"), chatEmojiReactionStore: service("chat-emoji-reaction-store"), chatEmojiPickerManager: service("chat-emoji-picker-manager"), chatStateManager: service("chat-state-manager"), @@ -219,18 +220,18 @@ export default Component.extend(TextareaTextManipulation, { if ( !this.editingMessage && - this.chatChannel?.draft && + this.draft && this.chatChannel?.canModifyMessages(this.currentUser) ) { // uses uploads from draft here... this.setProperties({ - value: this.chatChannel.draft.message, - replyToMsg: this.chatChannel.draft.replyToMsg, + value: this.draft.value, + replyToMsg: this.draft.replyToMsg, }); this._captureMentions(); - this._syncUploads(this.chatChannel.draft.uploads); - this.setInReplyToMsg(this.chatChannel.draft.replyToMsg); + this._syncUploads(this.draft.uploads); + this.setInReplyToMsg(this.draft.replyToMsg); } if (this.editingMessage && !this.loading) { @@ -243,6 +244,7 @@ export default Component.extend(TextareaTextManipulation, { this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false }); } + this.set("lastChatChannelId", this.chatChannel.id); this.resizeTextarea(); }, @@ -269,6 +271,7 @@ export default Component.extend(TextareaTextManipulation, { } this.set("_uploads", cloneJSON(newUploads)); + this.appEvents.trigger("chat-composer:load-uploads", this._uploads); }, _inProgressUploadsChanged(inProgressUploads) { @@ -304,9 +307,7 @@ export default Component.extend(TextareaTextManipulation, { @bind _captureMentions() { - if (this.value) { - this.chatComposerWarningsTracker.trackMentions(this.value); - } + this.chatComposerWarningsTracker.trackMentions(this.value); }, @bind 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 e64aee7030a..b7d97773a34 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,6 +19,9 @@ /> {{#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 45a1360f5c1..ee06d23f1e9 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 28a9195082f..2c678e9d4bc 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js @@ -65,10 +65,6 @@ 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 deleted file mode 100644 index 2d4bdd0425a..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.hbs +++ /dev/null @@ -1,46 +0,0 @@ -{{#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 deleted file mode 100644 index cb7724a7f4b..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-full-page-header.js +++ /dev/null @@ -1,11 +0,0 @@ -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 4b383ef4c91..d0ca52590b0 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs @@ -1,115 +1,148 @@ -
    - - - - - - -
    - +{{#if (and this.chatStateManager.isFullPageActive this.includeHeader)}}
    - -
    -
    -
    - - {{#if this.loadingMorePast}} - + class="chat-full-page-header + {{unless this.chatChannel.isFollowing '-not-following'}}" + > +
    + {{#if this.site.mobileView}} +
    + +
    {{/if}} - {{#each @channel.messages key="id" as |message|}} - - {{/each}} + + + - {{#if (or this.loadingMoreFuture)}} - + {{#if this.showCloseFullScreenBtn}} +
    + +
    {{/if}}
    +
    - {{#if (and this.loadedOnce (not @channel.canLoadMorePast))}} -
    - {{i18n "chat.all_loaded"}} -
    + +{{/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.selectingMessages}} - + + {{#if this.hasNewMessages}} + {{i18n "chat.scroll_to_new_messages"}} + {{/if}} + {{d-icon "arrow-down"}} + +
    +{{/if}} + +{{#if this.selectingMessages}} + +{{else}} + {{#if (or this.chatChannel.isDraft this.chatChannel.isFollowing)}} + {{else}} - {{#if (or @channel.isDraft @channel.isFollowing)}} - - {{else}} - - {{/if}} + {{/if}} -
    \ No newline at end of file +{{/if}} \ 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 58211e569ae..7c3b5ca291c 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js @@ -1,12 +1,17 @@ -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 ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; -import Component from "@glimmer/component"; -import { bind, debounce } from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import discourseComputed, { + afterRender, + bind, + debounce, + observes, +} 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"; @@ -14,62 +19,85 @@ 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 STICKY_SCROLL_LENIENCE = 100; +const MAX_RECENT_MSGS = 100; +const STICKY_SCROLL_LENIENCE = 50; const PAGE_SIZE = 50; -const SCROLL_HANDLER_THROTTLE_MS = isTesting() ? 0 : 150; + +const SCROLL_HANDLER_THROTTLE_MS = isTesting() ? 0 : 100; const FETCH_MORE_MESSAGES_THROTTLE_MS = isTesting() ? 0 : 500; + const PAST = "past"; const FUTURE = "future"; -const READ_INTERVAL_MS = 1000; -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; +export default Component.extend({ + classNameBindings: [":chat-live-pane", "sendingLoading", "loading"], + chatChannel: null, + registeredChatChannelId: null, // ?Number + loading: false, + loadingMorePast: false, + loadingMoreFuture: false, + hoveredMessageId: null, - @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; + allPastMessagesLoaded: false, + sendingLoading: false, + selectingMessages: false, + stickyScroll: true, + stickyScrollTimer: null, + showChatQuoteSuccess: false, + showCloseFullScreenBtn: false, + includeHeader: true, - _loadedChannelId = null; - _scrollerEl = null; - _previousScrollTop = null; - _lastSelectedMessage = null; - _mentionWarningsSeen = {}; - _unreachableGroupMentions = []; - _overMembersLimitGroupMentions = []; + 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, - @action - setupListeners(element) { - this._scrollerEl = element.querySelector(".chat-messages-scroll"); + 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"); this._scrollerEl.addEventListener("scroll", this.onScrollHandler, { passive: true, }); @@ -78,6 +106,10 @@ export default class ChatLivePane extends Component { passive: true, }); + this.appEvents.on("chat:cancel-message-selection", this, "cancelSelecting"); + + this.set("showCloseFullScreenBtn", !this.site.mobileView); + document.addEventListener("scroll", this._forceBodyScroll, { passive: true, }); @@ -85,52 +117,82 @@ export default class ChatLivePane extends Component { onPresenceChange({ callback: this.onPresenceChangeCallback, }); - } + }, - @action - teardownListeners(element) { - element + willDestroyElement() { + this._super(...arguments); + + this.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); - } + }, - @action - updateChannel() { - if (this._loadedChannelId !== this.args.channel?.id) { - this._unsubscribeToUpdates(this._loadedChannelId); - this.selectingMessages = false; + didReceiveAttrs() { + this._super(...arguments); + + this.currentUserTimezone = this.currentUser?.user_option.timezone; + + if ( + this.chatChannel?.id && + this.registeredChatChannelId !== this.chatChannel.id + ) { + this._resetChannelState(); this.cancelEditing(); - this._loadedChannelId = this.args.channel?.id; - } - this.loadMessages(); - this._subscribeToUpdates(this.args.channel.id); - } - - @action - loadMessages() { - 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(); + if (!this.chatChannel.isDraft) { + this.loadDraftForChannel(this.chatChannel.id); } } - } + + 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, false); - } + throttle(this, this.onScroll, event, SCROLL_HANDLER_THROTTLE_MS, true); + }, @bind onResizeHandler() { @@ -141,431 +203,521 @@ export default class ChatLivePane extends Component { this.details, 250 ); - } + }, @bind onPresenceChangeCallback(present) { if (present) { - this.updateLastReadMessage(); + this.chat.updateLastReadMessage(); } - } - - get capabilities() { - return getOwner(this).lookup("capabilities:main"); - } + }, @debounce(100) - fetchMessages(options = {}) { + fetchMessages(channel, options = {}) { if (this._selfDeleted) { return; } - this.loadingMorePast = true; - this.args.channel.clearMessages(); + this.set("loading", true); - const findArgs = { pageSize: PAGE_SIZE }; - const fetchingFromLastRead = !options.fetchFromLastMessage; - if (this.requestedTargetMessageId) { - findArgs["targetMessageId"] = this.requestedTargetMessageId; - } else if (fetchingFromLastRead) { - findArgs["targetMessageId"] = this._getLastReadId(); + 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(); } - return this.chatApi - .messages(this.args.channel.id, findArgs) - .then((results) => { - if ( - this._selfDeleted || - this.args.channel.id !== results.meta.channel_id - ) { - this.router.transitionTo( - "chat.channel", - "-", - results.meta.channel_id + 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; + } + + 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.appendMessages(messages); - this.args.channel.details = meta; - this.loadedOnce = true; - - if (this.requestedTargetMessageId) { - this.scrollToMessage(findArgs["targetMessageId"], { - highlight: true, - }); - } else if (fetchingFromLastRead) { - this.scrollToMessage(findArgs["targetMessageId"]); - } else if (messages.length) { - this.scrollToMessage(messages.lastObject.id); + 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); } - this.fillPaneAttempt(); + return messages; }) .catch(this._handleErrors) .finally(() => { if (this._selfDeleted) { return; } - - this.requestedTargetMessageId = null; - this.loadingMorePast = false; + this.set(loadingMoreKey, false); + this.ignoreStickyScrolling = false; }); - } + }, - @action - onDestroySkeleton() { - this._iOSFix(); - this._throttleComputeSeparators(); - } - - @action - onDidInsertSkeleton() { - this._computeSeparators(); // this one is not throttled as we need instant feedback - } - - @bind - _fetchMoreMessages({ direction }) { - 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(); - } - - this[loadingMoreKey] = true; - - 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, - messageId, - }; - - return this.chatApi - .messages(this.args.channel.id, findArgs) - .then((results) => { - if ( - this._selfDeleted || - this.args.channel.id !== results.meta.channel_id - ) { - this.router.transitionTo( - "chat.channel", - "-", - results.meta.channel_id - ); - } - - const [messages, meta] = this.afterFetchCallback( - this.args.channel, - results - ); - - loadingPast - ? this.args.channel.prependMessages(messages) - : this.args.channel.appendMessages(messages); - this.args.channel.details = meta; - - if (!messages.length) { - return; - } - - 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; - } - - // safeguard - if (this.args.channel.messages.length > 200) { - return; - } - - 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, - }); - }); - }); - } - - _fetchMoreMessagesThrottled(params) { - throttle( - this, - this._fetchMoreMessages, - params, - FETCH_MORE_MESSAGES_THROTTLE_MS - ); - } - - @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 (this.requestedTargetMessageId === messageData.id) { - messageData.expanded = !messageData.hidden; - } else { - messageData.expanded = !(messageData.hidden || messageData.deleted_at); - } - - // 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 ( - !foundFirstNew && - messageData.id > channel.currentUserMembership.last_read_message_id - ) { - foundFirstNew = true; - messageData.newest = true; - } - - messages.push(ChatMessage.create(channel, messageData)); - }); - - return [messages, results.meta]; - } - - _getLastReadId() { - return this.args.channel.currentUserMembership.last_read_message_id; - } - - @debounce(100) - highlightOrFetchMessage(messageId) { - const message = this.args.channel.findMessage(messageId); - if (message) { - this.scrollToMessage(message.id, { - highlight: true, - position: "start", - autoExpand: true, - }); - this.requestedTargetMessageId = null; - } else { - this.fetchMessages(); - } - } - - scrollToMessage( - messageId, - opts = { highlight: false, position: "start", autoExpand: false } - ) { + fillPaneAttempt(meta) { if (this._selfDeleted) { return; } - const message = this.args.channel.findMessage(messageId); - if (message?.deletedAt && opts.autoExpand) { - message.expanded = true; + // safeguard + if (this.messages.length > 200) { + return; + } + + if (!meta?.can_load_more_past) { + return; } schedule("afterRender", () => { - const messageEl = - this._scrollerEl.querySelector( - `.chat-message-container[data-id='${messageId}']` - ) || - this._scrollerEl.querySelector( - `.chat-message-container[data-staged-id='${messageId}']` - ); + 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", + direction, + FETCH_MORE_MESSAGES_THROTTLE_MS + ); + }, + + 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); + } + }, + + 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, + }); + + schedule("afterRender", () => { + 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(); + } + + this.fillPaneAttempt(messages.resultSetMeta); + }); + + 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() + ); + }, + + _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", + }); + } + } + 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; + } else { + inReplyToMessage = EmberObject.create(messageData.in_reply_to); + this._unloadedReplyIds.push(inReplyToMessage.id); + this.messageLookup[inReplyToMessage.id] = inReplyToMessage; + } + } 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 + + 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 + ) { + messageData.hideUserInfo = 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 + ); + } + + // 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}`; + }, + + _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(); + }, + + 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, { + highlight: true, + position: "top", + autoExpand: true, + }); + } else { + this.fetchMessages(this.chatChannel); + } + }, + + scrollToMessage( + messageId, + opts = { highlight: false, position: "top", autoExpand: false } + ) { + if (this._selfDeleted) { + return; + } + const message = this.messageLookup[messageId]; + if (message?.deleted_at && opts.autoExpand) { + message.set("expanded", true); + } + + schedule("afterRender", () => { + const messageEl = this._scrollerEl.querySelector( + `.chat-message-container[data-id='${messageId}']` + ); if (!messageEl || this._selfDeleted) { return; } - if (opts.highlight) { - messageEl.classList.add("highlighted"); - discourseLater(() => { - messageEl.classList.add("transition-slow"); - }, 2000); - discourseLater(() => { - messageEl.classList.remove("highlighted"); - discourseLater(() => { - messageEl.classList.remove("transition-slow"); - }, 2000); - }, 3000); - } - - this._iOSFix(() => { + this._wrapIOSFix(() => { messageEl.scrollIntoView({ - block: opts.position ?? "center", + block: opts.position === "top" ? "start" : "end", }); }); + + 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); + } + } }); - } + }, - @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) { + @afterRender + _stickScrollToBottom() { + if (this.ignoreStickyScrolling) { return; } - 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); + 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); + }); } - } + }, - @action - scrollToBottom() { - schedule("afterRender", () => { - if (this.args.channel.canLoadMoreFuture) { - this._fetchAndScrollToLatest(); - } else { - const message = - this.args.channel.messages[this.args.channel.messages?.length - 1]; - - if (message?.id) { - this.scrollToMessage(message.id, { highlight: false }); - this.hasNewMessages = false; - } - - if (message?.stagedId) { - this.scrollToMessage(message.stagedId, { highlight: false }); - this.hasNewMessages = false; - } - } - }); - } - - onScroll() { + onScroll(event) { if (this._selfDeleted) { return; } resetIdle(); - if (this.loading || this.loadingMorePast || this.loadingMoreFuture) { - return; + 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); } - const scrollPosition = Math.abs(this._scrollerEl.scrollTop); - const total = this._scrollerEl.scrollHeight - this._scrollerEl.clientHeight; + this._calculateStickScroll(event.forceShowScrollToBottom); + }, - this.isAlmostDocked = scrollPosition / this._scrollerEl.offsetHeight < 0.67; - this.isDocked = scrollPosition <= 1; + _calculateStickScroll(forceShowScrollToBottom) { + const absoluteScrollTop = Math.abs(this._scrollerEl.scrollTop); + const shouldStick = absoluteScrollTop < STICKY_SCROLL_LENIENCE; - if ( - this._previousScrollTop - this._scrollerEl.scrollTop > - this._previousScrollTop - ) { - const atTop = this._isBetween( - scrollPosition, - total - STICKY_SCROLL_LENIENCE, - total + STICKY_SCROLL_LENIENCE - ); - - if (atTop) { - this._fetchMoreMessagesThrottled({ direction: PAST }); - } + if (forceShowScrollToBottom) { + this.set("showScrollToBottomBtn", forceShowScrollToBottom); } else { - const atBottom = this._isBetween( - scrollPosition, - 0 + STICKY_SCROLL_LENIENCE, - 0 - STICKY_SCROLL_LENIENCE + this.set( + "showScrollToBottomBtn", + shouldStick + ? false + : absoluteScrollTop / this._scrollerEl.offsetHeight > 0.67 ); - - if (atBottom) { - this.hasNewMessages = false; - this._fetchMoreMessagesThrottled({ direction: FUTURE }); - } } - this._previousScrollTop = this._scrollerEl.scrollTop; - } + if (!this.showScrollToBottomBtn) { + this.set("hasNewMessages", false); + } - _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; - } + if (shouldStick !== this.stickyScroll) { + if (shouldStick) { + this._stickScrollToBottom(); + } else { + this.set("stickyScroll", false); + } + } + }, + + @observes("chatStateManager.isDrawerActive") + onFloatHiddenChange() { + if (this.chatStateManager.isDrawerActive) { + this.set("expanded", true); + this._markLastReadMessage({ reRender: true }); + this._stickScrollToBottom(); + } + }, removeMessage(msgData) { - const message = this.args.channel.findMessage(msgData.id); - if (message) { - this.args.channel.removeMessage(message); - } - } + delete this.messageLookup[msgData.id]; + }, handleMessage(data) { switch (data.type) { @@ -603,83 +755,92 @@ export default class ChatLivePane extends Component { this.handleFlaggedMessage(data); break; } - } - - _handleOwnSentMessage(data) { - const stagedMessage = this.args.channel.findStagedMessage(data.staged_id); - if (stagedMessage) { - stagedMessage.error = null; - stagedMessage.id = data.chat_message.id; - stagedMessage.stagedId = null; - stagedMessage.excerpt = data.chat_message.excerpt; - stagedMessage.threadId = data.chat_message.thread_id; - stagedMessage.channelId = 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; - } - - // 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; - } - } - } + }, handleSentMessage(data) { - if (this.args.channel.isFollowing) { - this.args.channel.lastMessageSentAt = new Date(); + if (this.chatChannel.isFollowing) { + this.chatChannel.set("last_message_sent_at", new Date()); } - if (data.chat_message.user.id === this.currentUser.id && data.staged_id) { - return this._handleOwnSentMessage(data); + 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.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; + } } - 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.isDocked) { - // 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.appendMessages([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.appendMessages([message]); - this.hasNewMessages = true; + 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()); } - } + this.reStickScrollIfNeeded(); + }, handleProcessedMessage(data) { - const message = this.args.channel.findMessage(data.chat_message.id); + const message = this.messageLookup[data.chat_message.id]; if (message) { - message.cooked = data.chat_message.cooked; - this.scrollToBottom(); + message.set("cooked", data.chat_message.cooked); + this.reStickScrollIfNeeded(); } - } + }, handleRefreshMessage(data) { - const message = this.args.channel.findMessage(data.chat_message.id); + const message = this.messageLookup[data.chat_message.id]; if (message) { - message.version = message.version + 1; + this.appEvents.trigger("chat:refresh-message", message); } - } + }, handleEditMessage(data) { - const message = this.args.channel.findMessage(data.chat_message.id); + const message = this.messageLookup[data.chat_message.id]; if (message) { - 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; + 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, + }); } - } + }, handleBulkDeleteMessage(data) { data.deleted_ids.forEach((deletedId) => { @@ -688,68 +849,109 @@ export default class ChatLivePane extends Component { deleted_at: data.deleted_at, }); }); - } + }, handleDeleteMessage(data) { const deletedId = data.deleted_id; - const targetMsg = this.args.channel.findMessage(deletedId); - - if (!targetMsg) { - return; - } - + const targetMsg = this.messageLookup[deletedId]; if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) { - targetMsg.deletedAt = data.deleted_at; - targetMsg.expanded = false; + targetMsg.setProperties({ + deleted_at: data.deleted_at, + expanded: false, + }); } else { - this.args.channel.removeMessage(targetMsg); + this.messages.removeObject(targetMsg); + this.messageLookup[deletedId] = null; } - } + }, handleReactionMessage(data) { - 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); - } - } - } + this.appEvents.trigger( + `chat-message-${data.chat_message_id}:reaction`, + data + ); + }, handleRestoreMessage(data) { - const message = this.args.channel.findMessage(data.chat_message.id); + let message = this.messageLookup[data.chat_message.id]; if (message) { - message.deletedAt = null; + message.set("deleted_at", null); } else { - this.args.channel.addMessages([ - ChatMessage.create(this.args.channel, data.chat_message), - ]); + // 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; + } + } + return m; + }, + + compareCreatedAt(newMessageCreatedAt, comparatorMessage) { + const compareDate = Date.parse(comparatorMessage.created_at); + if (newMessageCreatedAt > compareDate) { + return 1; + } else if (newMessageCreatedAt < compareDate) { + return -1; + } + return 0; + }, handleMentionWarning(data) { - const message = this.args.channel.findMessage(data.chat_message_id); - if (message) { - message.mentionWarning = EmberObject.create(data); - } - } + this.messageLookup[data.chat_message_id]?.set("mentionWarning", data); + }, handleSelfFlaggedMessage(data) { - const message = this.args.channel.findMessage(data.chat_message_id); - if (message) { - message.userFlagStatus = data.user_flag_status; - } - } + this.messageLookup[data.chat_message_id]?.set( + "user_flag_status", + data.user_flag_status + ); + }, handleFlaggedMessage(data) { - const message = this.args.channel.findMessage(data.chat_message_id); - if (message) { - message.reviewableId = data.reviewable_id; - } - } + this.messageLookup[data.chat_message_id]?.set( + "reviewable_id", + data.reviewable_id + ); + }, get _selfDeleted() { - return this.isDestroying || this.isDestroyed; - } + return !this.element || this.isDestroying || this.isDestroyed; + }, @action sendMessage(message, uploads = []) { @@ -759,8 +961,8 @@ export default class ChatLivePane extends Component { return; } - this.sendingLoading = true; - this.args.channel.draft = ChatMessageDraft.create(); + this.set("sendingLoading", true); + this._setDraftForChannel(null); // TODO: all send message logic is due for massive refactoring // This is all the possible case Im currently aware of @@ -770,61 +972,78 @@ export default class ChatLivePane extends Component { // - message to a direct channel you were tracking (preview = false, not draft) // - message to a public channel you were tracking (preview = false, not draft) // - message to a channel when we haven't loaded all future messages yet. - if (!this.args.channel.isFollowing || this.args.channel.isDraft) { - this.loading = true; + if (!this.chatChannel.isFollowing || this.chatChannel.isDraft) { + this.set("loading", true); return this._upsertChannelWithMessage( - this.args.channel, + this.chatChannel, message, uploads ).finally(() => { if (this._selfDeleted) { return; } - this.loading = false; - this.sendingLoading = false; + this.set("loading", false); + this.set("sendingLoading", false); this._resetAfterSend(); - this.scrollToBottom(); + this._stickScrollToBottom(); }); } - const stagedMessage = ChatMessage.createStagedMessage(this.args.channel, { - message, - created_at: new Date(), - uploads: cloneJSON(uploads), - user: this.currentUser, + 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); }); - - if (this.replyToMsg) { - stagedMessage.inReplyTo = this.replyToMsg; - } - - this.args.channel.appendMessages([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.stagedId, - upload_ids: stagedMessage.uploads.map((upload) => upload.id), - }) - .then(() => { - this.scrollToBottom(); - }) - .catch((error) => { - this._onSendError(stagedMessage.stagedId, error); - }) - .finally(() => { - if (this._selfDeleted) { - return; - } - this.sendingLoading = false; - this._resetAfterSend(); - }); - } + }, async _upsertChannelWithMessage(channel, message, uploads) { let promise = Promise.resolve(channel); @@ -846,37 +1065,37 @@ export default class ChatLivePane extends Component { this.router.transitionTo("chat.channel", "-", c.id); }) ); - } + }, _onSendError(stagedId, error) { - const stagedMessage = this.args.channel.findStagedMessage(stagedId); + const stagedMessage = this.messageLookup[`staged-${stagedId}`]; if (stagedMessage) { if (error.jqXHR?.responseJSON?.errors?.length) { - stagedMessage.error = error.jqXHR.responseJSON.errors[0]; + stagedMessage.set("error", error.jqXHR.responseJSON.errors[0]); } else { this.chat.markNetworkAsUnreliable(); - stagedMessage.error = "network_error"; + stagedMessage.set("error", "network_error"); } } this._resetAfterSend(); - } + }, @action resendStagedMessage(stagedMessage) { - this.sendingLoading = true; + this.set("sendingLoading", true); - stagedMessage.error = null; + stagedMessage.set("error", null); const data = { cooked: stagedMessage.cooked, message: stagedMessage.message, - upload_ids: stagedMessage.uploads.map((upload) => upload.id), + upload_ids: stagedMessage.upload_ids, staged_id: stagedMessage.stagedId, }; this.chatApi - .sendMessage(this.args.channel.id, data) + .sendMessage(this.chatChannel.id, data) .catch((error) => { this._onSendError(data.staged_id, error); }) @@ -887,18 +1106,18 @@ export default class ChatLivePane extends Component { if (this._selfDeleted) { return; } - this.sendingLoading = false; + this.set("sendingLoading", false); }); - } + }, @action editMessage(chatMessage, newContent, uploads) { - this.sendingLoading = true; + this.set("sendingLoading", true); let data = { new_message: newContent, upload_ids: (uploads || []).map((upload) => upload.id), }; - return ajax(`/chat/${this.args.channel.id}/edit/${chatMessage.id}`, { + return ajax(`/chat/${this.chatChannel.id}/edit/${chatMessage.id}`, { type: "PUT", data, }) @@ -910,107 +1129,127 @@ export default class ChatLivePane extends Component { if (this._selfDeleted) { return; } - this.sendingLoading = false; + this.set("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.replyToMsg = null; - this.editingMessage = null; - this.chatComposerPresenceManager.notifyState(this.args.channel.id, false); - this.appEvents.trigger("chat-composer:reply-to-set", null); - } + this.setProperties({ + replyToMsg: null, + editingMessage: null, + }); + this.chatComposerPresenceManager.notifyState(this.chatChannel.id, false); + }, @action editLastMessageRequested() { - const lastUserMessage = this.args.channel.messages.find( - (message) => - message.user.id === this.currentUser.id && + let lastUserMessage = null; + for ( + let messageIndex = this.messages.length - 1; + messageIndex >= 0; + messageIndex-- + ) { + let message = this.messages[messageIndex]; + if ( !message.staged && + message.user.id === this.currentUser.id && !message.error - ); - + ) { + lastUserMessage = message; + break; + } + } if (lastUserMessage) { - this.editingMessage = lastUserMessage; + this.set("editingMessage", lastUserMessage); this._focusComposer(); } - } + }, @action setReplyTo(messageId) { if (messageId) { this.cancelEditing(); - - const message = this.args.channel.findMessage(messageId); - this.replyToMsg = message; - this.appEvents.trigger("chat-composer:reply-to-set", message); + this.set("replyToMsg", this.messageLookup[messageId]); + this.appEvents.trigger("chat-composer:reply-to-set", this.replyToMsg); this._focusComposer(); } else { - this.replyToMsg = null; + this.set("replyToMsg", null); this.appEvents.trigger("chat-composer:reply-to-set", null); } - } + }, @action replyMessageClicked(message) { - const replyMessageFromLookup = this.args.channel.findMessage(message.id); - if (replyMessageFromLookup) { + 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 { this.scrollToMessage(replyMessageFromLookup.id, { highlight: true, - position: "start", + position: "top", 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.args.channel.findMessage(messageId); - this.editingMessage = message; - this.scrollToBottom(); + const message = this.messageLookup[messageId]; + this.set("editingMessage", message); + next(this.reStickScrollIfNeeded.bind(this)); this._focusComposer(); - } + }, - get canInteractWithChat() { - return !this.args.channel?.userSilenced; - } + @discourseComputed("details.user_silenced") + canInteractWithChat(userSilenced) { + return !userSilenced; + }, - get chatProgressBarContainer() { + @discourseComputed + chatProgressBarContainer() { return document.querySelector("#chat-progress-bar-container"); - } + }, - get selectedMessageIds() { - return this.args.channel?.messages - ?.filter((m) => m.selected) - ?.map((m) => m.id); - } + @discourseComputed("messages.@each.selected") + selectedMessageIds(messages) { + return messages.filter((m) => m.selected).map((m) => m.id); + }, @action onStartSelectingMessages(message) { this._lastSelectedMessage = message; - this.selectingMessages = true; - } + this.set("selectingMessages", true); + }, @action cancelSelecting() { - this.selectingMessages = false; - this.args.channel.messages.forEach((message) => { - message.selected = false; - }); - } + this.set("selectingMessages", false); + this.messages.setEach("selected", false); + }, @action onSelectMessage(message) { this._lastSelectedMessage = message; - } + }, + + @action + navigateToIndex() { + this.router.transitionTo("chat.index"); + }, @action bulkSelectMessages(message, checked) { @@ -1023,13 +1262,13 @@ export default class ChatLivePane extends Component { ); for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) { - this.args.channel.messages[i].selected = checked; + this.messages[i].set("selected", checked); } - } + }, _findIndexOfMessage(message) { - return this.args.channel.messages.findIndex((m) => m.id === message.id); - } + return this.messages.findIndex((m) => m.id === message.id); + }, @action onCloseFullScreen() { @@ -1040,58 +1279,52 @@ export default class ChatLivePane extends Component { this.chatStateManager.lastKnownChatURL ); }); - } + }, @action cancelEditing() { - this.editingMessage = null; - } + this.set("editingMessage", null); + }, @action - setInReplyToMsg(inReplyMsg) { - this.replyToMsg = inReplyMsg; - } - - @action - composerValueChanged(value, uploads, replyToMsg) { - if (!this.editingMessage && !this.args.channel.directMessageChannelDraft) { - this.args.channel.draft.message = value; - this.args.channel.draft.uploads = uploads; - this.args.channel.draft.replyToMsg = replyToMsg; - } - - if (!this.args.channel.directMessageChannelDraft) { - this._reportReplyingPresence(value); - } - - this._persistDraft(); - } - - @debounce(2000) - _persistDraft() { - if (!this.args.channel.draft) { + _setDraftForChannel(draft) { + if (this.chatChannel.isDraft) { 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(); - } - }); - } + 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); + }, + + @action + setInReplyToMsg(inReplyMsg) { + this.set("replyToMsg", inReplyMsg); + }, + + @action + composerValueChanged(value, uploads, replyToMsg) { + if (!this.editingMessage && !this.chatChannel.directMessageChannelDraft) { + this._setDraftForChannel({ value, uploads, replyToMsg }); + } + + if (!this.chatChannel.directMessageChannelDraft) { + this._reportReplyingPresence(value); + } + }, + + @action + reStickScrollIfNeeded() { + if (this.stickyScroll) { + this._stickScrollToBottom(); + } + }, @action onHoverMessage(message, options = {}, event) { @@ -1127,7 +1360,7 @@ export default class ChatLivePane extends Component { ".chat-message-actions-desktop-anchor" ) ) { - this.hoveredMessageId = message?.id; + this.set("hoveredMessageId", message?.id); return; } } @@ -1138,7 +1371,7 @@ export default class ChatLivePane extends Component { message, 250 ); - } + }, @bind debouncedOnHoverMessage(message) { @@ -1146,51 +1379,57 @@ export default class ChatLivePane extends Component { return; } - this.hoveredMessageId = - message?.id && message.id !== this.hoveredMessageId ? message.id : null; - } + this.set( + "hoveredMessageId", + message?.id && message.id !== this.hoveredMessageId ? message.id : null + ); + }, _reportReplyingPresence(composerValue) { if (this._selfDeleted) { return; } - if (this.args.channel.isDraft) { + if (this.chatChannel.isDraft) { return; } const replying = !this.editingMessage && !!composerValue; - this.chatComposerPresenceManager.notifyState( - this.args.channel.id, - replying - ); - } + this.chatComposerPresenceManager.notifyState(this.chatChannel.id, replying); + }, + + @action + restickScrolling(event) { + event.preventDefault(); + + return this._fetchAndScrollToLatest(); + }, _focusComposer() { this.appEvents.trigger("chat:focus-composer"); - } + }, _unsubscribeToUpdates(channelId) { this.messageBus.unsubscribe(`/chat/${channelId}`, this.onMessage); - } + }, _subscribeToUpdates(channelId) { this._unsubscribeToUpdates(channelId); this.messageBus.subscribe( `/chat/${channelId}`, this.onMessage, - this.args.channel.channelMessageBusLastId + this.details.channel_message_bus_last_id ); - } + }, @bind onMessage(busData) { - if (!this.args.channel.canLoadMoreFuture || busData.type !== "sent") { + if (!this.details.can_load_more_future || busData.type !== "sent") { this.handleMessage(busData); - } else if (busData.chat_message.user.id !== this.currentUser.id) { - this.hasNewMessages = true; + } else { + this.set("hasNewMessages", true); } - } + }, @bind _forceBodyScroll() { @@ -1203,13 +1442,13 @@ export default class ChatLivePane extends Component { ) { document.documentElement.scrollTo(0, 0); } - } + }, _fetchAndScrollToLatest() { - return this.fetchMessages({ + return this.fetchMessages(this.chatChannel, { fetchFromLastMessage: true, }); - } + }, _handleErrors(error) { switch (error?.jqXHR?.status) { @@ -1220,12 +1459,12 @@ export default class ChatLivePane extends Component { 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 - _iOSFix(callback) { + _wrapIOSFix(callback) { if (!this._scrollerEl) { return; } @@ -1234,7 +1473,7 @@ export default class ChatLivePane extends Component { this._scrollerEl.style.overflow = "hidden"; } - callback?.(); + callback(); if (this.capabilities.isIOS) { discourseLater(() => { @@ -1245,79 +1484,5 @@ export default class ChatLivePane extends Component { 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 10e4858a533..cb665429b06 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 key="emoji" as |reaction|}} + {{#each @emojiReactions 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 111e24b523a..8b62c14c239 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,7 +31,6 @@ 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 be96ed82385..87aeca6861b 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 1d2e9676fbf..8b12b5d09d6 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.chatWebhookEvent.emoji}} - + {{#if @message.chat_webhook_event.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 new file mode 100644 index 00000000000..5b7b32a549a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js @@ -0,0 +1,5 @@ +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 ec613e5902b..97635d8b5e6 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 @cooked}} + {{html-safe this.cooked}}
    - {{#each @uploads as |upload|}} + {{#each this.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 d62df9e639e..ab91763bd8d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js @@ -1,20 +1,28 @@ -import Component from "@glimmer/component"; +import Component from "@ember/component"; +import { computed } from "@ember/object"; 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.args.uploads); + return hasUploads(this.uploads); } + @computed("uploads") get uploadsHeader() { let name = ""; - if (this.args.uploads.length === 1) { - name = this.args.uploads[0].original_filename; + if (this.uploads.length === 1) { + name = this.uploads[0].original_filename; } else { - name = I18n.t("chat.uploaded_files", { count: this.args.uploads.length }); + name = I18n.t("chat.uploaded_files", { count: this.uploads.length }); } return htmlSafe( `${escapeExpression( @@ -23,10 +31,9 @@ export default class ChatMessageCollapser extends Component { ); } + @computed("cooked") get cookedBodies() { - const elements = Array.prototype.slice.call( - domFromString(this.args.cooked) - ); + const elements = Array.prototype.slice.call(domFromString(this.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 deleted file mode 100644 index f5d0bf4f52b..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.hbs +++ /dev/null @@ -1,19 +0,0 @@ -{{#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 deleted file mode 100644 index d60e8212b0c..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js +++ /dev/null @@ -1,32 +0,0 @@ -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 8ba1a34778b..2520c2186bb 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.chatWebhookEvent}} - {{#if @message.chatWebhookEvent.username}} + {{#if @message.chat_webhook_event}} + {{#if @message.chat_webhook_event.username}} - {{@message.chatWebhookEvent.username}} + {{@message.chat_webhook_event.username}} {{/if}} @@ -49,8 +49,8 @@ {{#if this.isFlagged}} - {{#if @message.reviewableId}} - + {{#if @message.reviewable_id}} + {{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 e42be62a896..9347fa2d25d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js @@ -48,7 +48,10 @@ export default class ChatMessageInfo extends Component { } get isFlagged() { - return this.#message?.reviewableId || this.#message?.userFlagStatus === 0; + return ( + this.#message?.get("reviewable_id") || + this.#message?.get("user_flag_status") === 0 + ); } get prioritizeName() { @@ -63,7 +66,7 @@ export default class ChatMessageInfo extends Component { } get #user() { - return this.#message?.user; + return this.#message?.get("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 d83658b4b1f..adbf43b3e0c 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.reviewableId}} + {{#if @message.reviewable_id}} {{d-icon "flag" title="chat.flagged"}} - {{else if (eq @message.userFlagStatus 0)}} + {{else if (eq @message.user_flag_status 0)}}
    {{d-icon "flag" title="chat.you_flagged"}}
    - {{else if this.site.desktopView}} + {{else}} {{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 deleted file mode 100644 index b60adf92b89..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-left-gutter.js +++ /dev/null @@ -1,6 +0,0 @@ -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 c89b7fe5c36..35c8b3c7d39 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={{this.moveMessages}} + @action={{action "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 52f3f07b6f9..576c1a935b2 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.hbs @@ -1,18 +1,17 @@ -{{#if (and @reaction this.emojiUrl)}} +{{#if (and this.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 3f7fda312e6..748143d2ea3 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js @@ -1,73 +1,95 @@ -import Component from "@glimmer/component"; -import { action } from "@ember/object"; +import { guidFor } from "@ember/object/internals"; +import Component from "@ember/component"; +import { action, computed } 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 { - @service currentUser; + reaction = null; + showUsersList = false; + tagName = ""; + react = null; + class = null; - get showCount() { - return this.args.showCount ?? true; - } + didReceiveAttrs() { + this._super(...arguments); - @action - setupTooltip(element) { - if (this.args.showTooltip) { + if (this.showUsersList) { schedule("afterRender", () => { - this._tippyInstance?.destroy(); - this._tippyInstance = setupPopover(element, { - interactive: false, - allowHTML: true, - delay: 250, - }); + this._popover?.destroy(); + this._popover = this._setupPopover(); }); } } - @action - teardownTooltip() { - this._tippyInstance?.destroy(); + willDestroyElement() { + this._super(...arguments); + + this._popover?.destroy(); } - @action - refreshTooltip() { - this._tippyInstance?.setContent(this.popoverContent); + @computed + get componentId() { + return guidFor(this); } + @computed("reaction.emoji") get emojiString() { - return `:${this.args.reaction.emoji}:`; + return `:${this.reaction.emoji}:`; } + @computed("reaction.emoji") get emojiUrl() { - return emojiUrlFor(this.args.reaction.emoji); + return emojiUrlFor(this.reaction.emoji); } @action handleClick() { - this.args.react?.( - this.args.reaction.emoji, - this.args.reaction.reacted ? "remove" : "add" - ); + this?.react(this.reaction.emoji, this.reaction.reacted ? "remove" : "add"); return false; } - get popoverContent() { - if (!this.args.reaction.count || !this.args.reaction.users?.length) { + _setupPopover() { + const target = document.getElementById(this.componentId); + + if (!target) { return; } - return emojiUnescape( - this.args.reaction.reacted - ? this.#reactionTextWithSelf - : this.#reactionText - ); + 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; } - get #reactionTextWithSelf() { - const reactionCount = this.args.reaction.count; + @computed("reaction") + get popoverContent() { + return this.reaction.reacted + ? this._reactionTextWithSelf() + : this._reactionText(); + } + + _reactionTextWithSelf() { + const reactionCount = this.reaction.count; if (reactionCount === 0) { return; @@ -75,55 +97,55 @@ export default class ChatMessageReaction extends Component { if (reactionCount === 1) { return I18n.t("chat.reactions.only_you", { - emoji: this.args.reaction.emoji, + emoji: this.reaction.emoji, }); } - const maxUsernames = 5; - const usernames = this.args.reaction.users - .filter((user) => user.id !== this.currentUser?.id) + const maxUsernames = 4; + const usernames = this.reaction.users .slice(0, maxUsernames) .mapBy("username"); if (reactionCount === 2) { return I18n.t("chat.reactions.you_and_single_user", { - emoji: this.args.reaction.emoji, + emoji: this.reaction.emoji, username: usernames.pop(), }); } - const unnamedUserCount = reactionCount - usernames.length; + // `-1` because the current user ("you") isn't included in `usernames` + const unnamedUserCount = reactionCount - usernames.length - 1; + if (unnamedUserCount > 0) { return I18n.t("chat.reactions.you_multiple_users_and_more", { - emoji: this.args.reaction.emoji, + emoji: this.reaction.emoji, commaSeparatedUsernames: this._joinUsernames(usernames), count: unnamedUserCount, }); } return I18n.t("chat.reactions.you_and_multiple_users", { - emoji: this.args.reaction.emoji, + emoji: this.reaction.emoji, username: usernames.pop(), commaSeparatedUsernames: this._joinUsernames(usernames), }); } - get #reactionText() { - const reactionCount = this.args.reaction.count; + _reactionText() { + const reactionCount = this.reaction.count; if (reactionCount === 0) { return; } const maxUsernames = 5; - const usernames = this.args.reaction.users - .filter((user) => user.id !== this.currentUser?.id) + const usernames = this.reaction.users .slice(0, maxUsernames) .mapBy("username"); if (reactionCount === 1) { return I18n.t("chat.reactions.single_user", { - emoji: this.args.reaction.emoji, + emoji: this.reaction.emoji, username: usernames.pop(), }); } @@ -132,14 +154,14 @@ export default class ChatMessageReaction extends Component { if (unnamedUserCount > 0) { return I18n.t("chat.reactions.multiple_users_and_more", { - emoji: this.args.reaction.emoji, + emoji: this.reaction.emoji, commaSeparatedUsernames: this._joinUsernames(usernames), count: unnamedUserCount, }); } return I18n.t("chat.reactions.multiple_users", { - emoji: this.args.reaction.emoji, + emoji: this.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 deleted file mode 100644 index a27b80a14de..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-date.hbs +++ /dev/null @@ -1,26 +0,0 @@ -{{#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 deleted file mode 100644 index 26607178d86..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator-new.hbs +++ /dev/null @@ -1,13 +0,0 @@ -{{#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 new file mode 100644 index 00000000000..7cb5a5e8898 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.hbs @@ -0,0 +1,15 @@ +{{#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 new file mode 100644 index 00000000000..44494409ab5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js @@ -0,0 +1,5 @@ +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 f6f1eeb80ec..a11a76454b6 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 @cooked}} + {{html-safe this.cooked}} {{/if}} - {{#if this.isEdited}} + {{#if this.edited}} ({{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 042d774ba61..d02c47ba1cc 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js @@ -1,12 +1,15 @@ -import Component from "@glimmer/component"; +import Component from "@ember/component"; +import { computed } from "@ember/object"; import { isCollapsible } from "discourse/plugins/chat/discourse/components/chat-message-collapser"; export default class ChatMessageText extends Component { - get isEdited() { - return this.args.edited ?? false; - } + tagName = ""; + cooked = null; + uploads = null; + edited = false; + @computed("cooked", "uploads.[]") get isCollapsible() { - return isCollapsible(this.args.cooked, this.args.uploads); + return isCollapsible(this.cooked, this.uploads); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs index b5eeac6dd08..d29d73a9d23 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs @@ -1,7 +1,6 @@ {{! template-lint-disable no-invalid-interactive }} - - + {{#if (and @@ -41,22 +40,19 @@ {{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") }} - data-id={{@message.id}} + data-id={{or @message.id @message.stagedId}} data-staged-id={{if @message.staged @message.stagedId}} - {{chat/track-message - (fn @didShowMessage @message) - (fn @didHideMessage @message) - }} > {{#if this.show}} {{#if @selectingMessages}} @@ -89,17 +85,35 @@ class={{concat-class "chat-message" (if @message.staged "chat-message-staged") - (if @message.deletedAt "deleted") - (if (and @message.inReplyTo (not this.hideReplyToInfo)) "is-reply") + (if @message.deleted_at "deleted") + (if @message.in_reply_to "is-reply") (if this.hideUserInfo "user-info-hidden") (if @message.error "errored") (if @message.bookmark "chat-message-bookmarked") (if @isHovered "chat-message-selected") }} > - {{#unless this.hideReplyToInfo}} - - {{/unless}} + {{#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}} {{#if this.hideUserInfo}} @@ -117,7 +131,7 @@ @uploads={{@message.uploads}} @edited={{@message.edited}} > - {{#if @message.reactions.length}} + {{#if this.hasReactions}}
    {{#if this.reactionLabel}}
    @@ -125,13 +139,18 @@
    {{/if}} - {{#each @message.reactions as |reaction|}} + {{#each-in @message.reactions as |emoji reactionAttrs|}} - {{/each}} + {{/each-in}} {{#if @canInteractWithChat}} {{#unless this.site.mobileView}} @@ -170,7 +189,7 @@ {{#if this.mentionWarning}}
    - {{#if this.mentionWarning.invitation_sent}} + {{#if this.mentionWarning.invitationSent}} {{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 cece2adc775..a8865c36510 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -5,7 +5,8 @@ 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 { action } from "@ember/object"; +import { bind } from "discourse-common/utils/decorators"; +import EmberObject, { action } from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import { cancel, schedule } from "@ember/runloop"; import { clipboardCopy } from "discourse/lib/utilities"; @@ -17,7 +18,6 @@ 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,24 +50,37 @@ export default class ChatMessage extends Component { @optionalService adminTools; cachedFavoritesReactions = null; - reacting = false; + + _hasSubscribedToAppEvents = false; + _loadingReactions = []; 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?.deletedAt && this.collapsed; + return this.args.message?.get("deleted_at") && this.collapsed; } get hiddenAndCollapsed() { - return this.args.message?.hidden && this.collapsed; + return this.args.message?.get("hidden") && this.collapsed; } get collapsed() { - return !this.args.message?.expanded; + return !this.args.message?.get("expanded"); } @action @@ -84,9 +97,32 @@ 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", () => { @@ -95,29 +131,45 @@ export default class ChatMessage extends Component { } _chatMessageDecorators.forEach((decorator) => { - decorator.call(this, this.messageContainer, this.args.channel); + decorator.call(this, this.messageContainer, this.args.chatChannel); }); }); } get messageContainer() { - const id = this.args.message?.id; - if (id) { - return document.querySelector(`.chat-message-container[data-id='${id}']`); + 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 stagedId = this.args.message?.stagedId; - if (stagedId) { - return document.querySelector( - `.chat-message-container[data-staged-id='${stagedId}']` - ); - } + 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?.staged && + !this.args.message?.get("staged") && this.args.isHovered ); } @@ -218,16 +270,17 @@ export default class ChatMessage extends Component { get hasThread() { return ( - this.args.channel?.get("threading_enabled") && this.args.message?.threadId + this.args.chatChannel?.get("threading_enabled") && + this.args.message?.get("thread_id") ); } get show() { return ( - !this.args.message?.deletedAt || - this.currentUser.id === this.args.message?.user?.id || + !this.args.message?.get("deleted_at") || + this.currentUser.id === this.args.message?.get("user.id") || this.currentUser.staff || - this.args.channel?.canModerate + this.args.details?.can_moderate ); } @@ -278,97 +331,83 @@ export default class ChatMessage extends Component { get hideUserInfo() { return ( - !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 + this.args.message?.get("hideUserInfo") && + !this.args.message?.get("chat_webhook_event") ); } get showEditButton() { return ( - !this.args.message?.deletedAt && - this.currentUser?.id === this.args.message?.user?.id && - this.args.channel?.canModifyMessages?.(this.currentUser) + !this.args.message?.get("deleted_at") && + this.currentUser?.id === this.args.message?.get("user.id") && + this.args.chatChannel?.canModifyMessages?.(this.currentUser) ); } - get canFlagMessage() { return ( - 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 + 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") ); } get canManageDeletion() { - return this.currentUser?.id === this.args.message.user.id - ? this.args.channel?.canDeleteSelf - : this.args.channel?.canDeleteOthers; + return this.currentUser?.id === this.args.message.get("user.id") + ? this.args.details?.can_delete_self + : this.args.details?.can_delete_others; } get canReply() { return ( - !this.args.message?.deletedAt && - this.args.channel?.canModifyMessages?.(this.currentUser) + !this.args.message?.get("deleted_at") && + this.args.chatChannel?.canModifyMessages?.(this.currentUser) ); } get canReact() { return ( - !this.args.message?.deletedAt && - this.args.channel?.canModifyMessages?.(this.currentUser) + !this.args.message?.get("deleted_at") && + this.args.chatChannel?.canModifyMessages?.(this.currentUser) ); } get showDeleteButton() { return ( this.canManageDeletion && - !this.args.message?.deletedAt && - this.args.channel?.canModifyMessages?.(this.currentUser) + !this.args.message?.get("deleted_at") && + this.args.chatChannel?.canModifyMessages?.(this.currentUser) ); } get showRestoreButton() { return ( this.canManageDeletion && - this.args.message?.deletedAt && - this.args.channel?.canModifyMessages?.(this.currentUser) + this.args.message?.get("deleted_at") && + this.args.chatChannel?.canModifyMessages?.(this.currentUser) ); } get showBookmarkButton() { - return this.args.channel?.canModifyMessages?.(this.currentUser); + return this.args.chatChannel?.canModifyMessages?.(this.currentUser); } get showRebakeButton() { return ( this.currentUser?.staff && - this.args.channel?.canModifyMessages?.(this.currentUser) + this.args.chatChannel?.canModifyMessages?.(this.currentUser) ); } get hasReactions() { - return Object.values(this.args.message.reactions).some((r) => r.count > 0); + return Object.values(this.args.message.get("reactions")).some( + (r) => r.count > 0 + ); } get mentionWarning() { - return this.args.message.mentionWarning; + return this.args.message.get("mentionWarning"); } get mentionedCannotSeeText() { @@ -425,13 +464,13 @@ export default class ChatMessage extends Component { inviteMentioned() { const userIds = this.mentionWarning.without_membership.mapBy("id"); - ajax(`/chat/${this.args.message.channelId}/invite`, { + ajax(`/chat/${this.args.message.chat_channel_id}/invite`, { method: "PUT", data: { user_ids: userIds, chat_message_id: this.args.message.id }, }).then(() => { - this.args.message.mentionWarning.set("invitationSent", true); + this.args.message.set("mentionWarning.invitationSent", true); this._invitationSentTimer = discourseLater(() => { - this.dismissMentionWarning(); + this.args.message.set("mentionWarning", null); }, 3000); }); @@ -440,7 +479,7 @@ export default class ChatMessage extends Component { @action dismissMentionWarning() { - this.args.message.mentionWarning = null; + this.args.message.set("mentionWarning", null); } @action @@ -478,17 +517,27 @@ 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) { - return; - } - - if (this.reacting) { + if ( + !this.args.canInteractWithChat || + this._loadingReactions.includes(emoji) + ) { return; } @@ -500,21 +549,71 @@ 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}:`); } - this.reacting = true; + 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.args.message.react( - emoji, - reactAction, - this.currentUser, - this.currentUser.id - ); + _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; + } + 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.channelId}/react/${this.args.message.id}`, + `/chat/${this.args.message.chat_channel_id}/react/${this.args.message.id}`, { type: "PUT", data: { @@ -522,19 +621,10 @@ export default class ChatMessage extends Component { emoji, }, } - ) - .catch((errResult) => { - popupAjaxError(errResult); - this.args.message.react( - emoji, - REACTIONS.remove, - this.currentUser, - this.currentUser.id - ); - }) - .finally(() => { - this.reacting = false; - }); + ).catch((errResult) => { + popupAjaxError(errResult); + this._updateReactionsList(emoji, REACTIONS.remove, this.currentUser); + }); } // TODO(roman): For backwards-compatibility. @@ -561,6 +651,17 @@ 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); @@ -572,11 +673,12 @@ export default class ChatMessage extends Component { requirejs.entries["discourse/lib/flag-targets/flag"]; if (targetFlagSupported) { - const model = this.args.message; - model.username = model.user?.username; - model.user_id = model.user?.id; + const model = EmberObject.create(this.args.message); + model.set("username", model.get("user.username")); + model.set("user_id", model.get("user.id")); let controller = showModal("flag", { model }); - controller.set("flagTarget", new ChatMessageFlag()); + + controller.setProperties({ flagTarget: new ChatMessageFlag() }); } else { this._legacyFlag(); } @@ -584,13 +686,13 @@ export default class ChatMessage extends Component { @action expand() { - this.args.message.expanded = true; + this.args.message.set("expanded", true); } @action restore() { return ajax( - `/chat/${this.args.message.channelId}/restore/${this.args.message.id}`, + `/chat/${this.args.message.chat_channel_id}/restore/${this.args.message.id}`, { type: "PUT", } @@ -599,7 +701,10 @@ export default class ChatMessage extends Component { @action openThread() { - this.router.transitionTo("chat.channel.thread", this.args.message.threadId); + this.router.transitionTo( + "chat.channel.thread", + this.args.message.thread_id + ); } @action @@ -614,7 +719,7 @@ export default class ChatMessage extends Component { { onAfterSave: (savedData) => { const bookmark = Bookmark.create(savedData); - this.args.message.bookmark = bookmark; + this.args.message.set("bookmark", bookmark); this.appEvents.trigger( "bookmarks:changed", savedData, @@ -622,7 +727,7 @@ export default class ChatMessage extends Component { ); }, onAfterDelete: () => { - this.args.message.bookmark = null; + this.args.message.set("bookmark", null); }, } ); @@ -631,7 +736,7 @@ export default class ChatMessage extends Component { @action rebakeMessage() { return ajax( - `/chat/${this.args.message.channelId}/${this.args.message.id}/rebake`, + `/chat/${this.args.message.chat_channel_id}/${this.args.message.id}/rebake`, { type: "PUT", } @@ -641,7 +746,7 @@ export default class ChatMessage extends Component { @action deleteMessage() { return ajax( - `/chat/${this.args.message.channelId}/${this.args.message.id}`, + `/chat/${this.args.message.chat_channel_id}/${this.args.message.id}`, { type: "DELETE", } @@ -650,7 +755,7 @@ export default class ChatMessage extends Component { @action selectMessage() { - this.args.message.selected = true; + this.args.message.set("selected", true); this.args.onStartSelectingMessages(this.args.message); } @@ -675,7 +780,7 @@ export default class ChatMessage extends Component { const { protocol, host } = window.location; let url = getURL( - `/chat/c/-/${this.args.message.channelId}/${this.args.message.id}` + `/chat/c/-/${this.args.message.chat_channel_id}/${this.args.message.id}` ); url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; clipboardCopy(url); @@ -688,22 +793,25 @@ export default class ChatMessage extends Component { } get emojiReactions() { - let favorites = this.cachedFavoritesReactions; + const 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) => { - return ( - this.args.message.reactions.find( - (reaction) => reaction.emoji === emoji - ) || - ChatMessageReaction.create({ - emoji, - }) - ); + if (userReactions.includes(emoji)) { + return { emoji, reacted: true }; + } else { + return { emoji, reacted: false }; + } }); } } 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 6033b130443..e161a806a96 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.args.channel.isDirectMessageChannel + const field = this.chatChannel.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 deleted file mode 100644 index 1938b88871d..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-scroll-to-bottom-arrow.hbs +++ /dev/null @@ -1,23 +0,0 @@ -
    - - {{#if @hasNewMessages}} - - {{i18n "chat.scroll_to_new_messages"}} - - {{/if}} - - - {{d-icon "arrow-down"}} - - -
    \ 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 fe1dadf34ef..5556ee7841d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js @@ -19,17 +19,16 @@ 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", "chatChannel.canModerate") + @computed("chatChannel.isDirectMessageChannel", "canModerate") get showMoveMessageButton() { - return ( - !this.chatChannel.isDirectMessageChannel && this.chatChannel.canModerate - ); + return !this.chatChannel.isDirectMessageChannel && this.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 0bb2edefab6..227103171b9 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.hbs @@ -1,31 +1,13 @@ -
    - {{#each this.placeholders as |placeholder|}} +
    + {{#each this.placeholders as |rows|}}
    - {{#if placeholder.image}} -
    - {{/if}} - -
    - {{#each placeholder.rows as |row|}} -
    - {{/each}} -
    - - {{#if placeholder.reactions}} -
    - {{#each placeholder.reactions}} -
    - {{/each}} -
    - {{/if}} + {{#each rows as |row|}} +
    + {{/each}}
    diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js index 6af83cf2e41..3710d8dcc9b 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-skeleton.js @@ -4,13 +4,9 @@ import { htmlSafe } from "@ember/template"; export default class ChatSkeleton extends Component { get placeholders() { return Array.from({ length: 15 }, () => { - 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) }), - }; + return Array.from({ length: this.#randomIntFromInterval(1, 5) }, () => { + return htmlSafe(`width: ${this.#randomIntFromInterval(20, 95)}%`); + }); }); } 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 c5c56e5fb51..33661579b6c 100644 --- a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs @@ -1,6 +1,7 @@ {{#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 94d9c7039f7..1fb7ad3c57e 100644 --- a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js @@ -1,6 +1,79 @@ -import Component from "@glimmer/component"; +import Component from "@ember/component"; +import { bind } from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; import { inject as service } from "@ember/service"; -export default class FullPageChat extends Component { - @service chat; -} +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"); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js index 7984545c101..734778d843b 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js @@ -1,11 +1,10 @@ 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; - @tracked targetMessageId = null; + 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 31bc13b5514..17d698cb8db 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js @@ -36,7 +36,6 @@ 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; @@ -154,8 +153,6 @@ 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) => { @@ -197,9 +194,6 @@ 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 5d91f205e4f..c31a86ef042 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.createdAt), tz); - const url = getURL(`/chat/c/-/${message.channelId}/${message.id}`); + const date = moment(new Date(message.created_at), tz); + const url = getURL(`/chat/c/-/${message.chat_channel_id}/${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 deleted file mode 100644 index f628c478633..00000000000 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js +++ /dev/null @@ -1,32 +0,0 @@ -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 b0ad79f345f..69ee6ab84ee 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js @@ -10,7 +10,6 @@ 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"); @@ -20,7 +19,6 @@ 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 9bd86b4ab40..60a20c2206a 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.availableFlags.includes(flag.name_key); + return model.available_flags.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 15be14f7dd7..c6e75e9edd3 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js @@ -7,7 +7,6 @@ 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", @@ -55,16 +54,6 @@ 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)); @@ -85,11 +74,11 @@ export default class ChatChannel extends RestModel { } get isDirectMessageChannel() { - return this.chatableType === CHATABLE_TYPES.directMessageChannel; + return this.chatable_type === CHATABLE_TYPES.directMessageChannel; } get isCategoryChannel() { - return this.chatableType === CHATABLE_TYPES.categoryChannel; + return this.chatable_type === CHATABLE_TYPES.categoryChannel; } get isOpen() { @@ -116,57 +105,6 @@ 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; - } - - appendMessages(messages) { - this.messages.pushObjects(messages); - } - - prependMessages(messages) { - this.messages.unshiftObjects(messages); - } - - 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.stagedId === stagedMessageId - ); - } - canModifyMessages(user) { if (user.staff) { return !STAFF_READONLY_STATUSES.includes(this.status); @@ -189,10 +127,6 @@ 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(() => { @@ -208,17 +142,12 @@ ChatChannel.reopenClass({ this._initUserModels(args); this._initUserMembership(args); - this._remapKey(args, "chatable_type", "chatableType"); - this._remapKey(args, "memberships_count", "membershipsCount"); - this._remapKey(args, "last_message_sent_at", "lastMessageSentAt"); + args.chatableType = args.chatable_type; + args.membershipsCount = args.memberships_count; 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 deleted file mode 100644 index 00709add3f3..00000000000 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index db1b7a6cecb..00000000000 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js +++ /dev/null @@ -1,33 +0,0 @@ -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 c11f9b23c7d..8d0c644b5f7 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js @@ -1,193 +1,26 @@ +import RestModel from "discourse/models/rest"; import User from "discourse/models/user"; -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"; +import EmberObject from "@ember/object"; -export default class ChatMessage { - static cookFunction = null; +export default class ChatMessage extends RestModel {} - static create(channel, args = {}) { - return new ChatMessage(channel, args); - } +ChatMessage.reopenClass({ + create(args = {}) { + this._initReactions(args); + this._initUserModel(args); - static createStagedMessage(channel, args = {}) { - args.staged_id = guid(); - return new ChatMessage(channel, args); - } + return this._super(args); + }, - @tracked id; - @tracked error; - @tracked selected; - @tracked channel; - @tracked stagedId; - @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; + _initReactions(args) { + args.reactions = EmberObject.create(args.reactions || {}); + }, - constructor(channel, args = {}) { - this.channel = channel; - this.id = args.id; - this.newest = args.newest; - 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.stagedId = args.staged_id; - 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); + _initUserModel(args) { + if (!args.user || args.user instanceof User) { + return; } - 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); - } - - get staged() { - return this.stagedId?.length > 0; - } - - 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() - ); - } -} + args.user = User.create(args.user); + }, +}); 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 deleted file mode 100644 index 71a19434521..00000000000 --- a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-separator-date.js +++ /dev/null @@ -1,35 +0,0 @@ -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 new file mode 100644 index 00000000000..10474b067cf --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js @@ -0,0 +1,23 @@ +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 deleted file mode 100644 index 469188eaa32..00000000000 --- a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js +++ /dev/null @@ -1,43 +0,0 @@ -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 34ca9343de6..69ea1c3b3f8 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js @@ -10,10 +10,6 @@ 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 4b51143681b..35e680031b3 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -233,39 +233,6 @@ 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 47a4fb88d8b..e70655e3585 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js @@ -42,14 +42,6 @@ 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; } @@ -146,7 +138,8 @@ 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.lastMessageSentAt) > new Date(b.lastMessageSentAt) + return new Date(a.get("last_message_sent_at")) > + new Date(b.get("last_message_sent_at")) ? -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 new file mode 100644 index 00000000000..a5a77a1d4b0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js @@ -0,0 +1,63 @@ +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 8430d083746..42644d9189c 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.lastMessageSentAt = new Date(); + channel.set("last_message_sent_at", new Date()); }); } @@ -185,14 +185,13 @@ export default class ChatSubscriptionsManager extends Service { _onUserTrackingStateUpdate(busData) { this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => { if ( - !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.last_read_message_id = busData.chat_message_id; - channel.currentUserMembership.unread_count = busData.unread_count; - channel.currentUserMembership.unread_mentions = busData.unread_mentions; + channel.currentUserMembership.unread_count = 0; + channel.currentUserMembership.unread_mentions = 0; } }); } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js index 59a182617e8..e500e84f754 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -3,18 +3,29 @@ 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 ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; +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"; 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; @@ -53,6 +64,13 @@ 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); + }); + } } } @@ -85,16 +103,6 @@ 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); } ); @@ -108,6 +116,33 @@ 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) { @@ -242,6 +277,10 @@ 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); } @@ -288,6 +327,84 @@ 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 ecc01d51c09..db7dc6fc342 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs @@ -54,12 +54,7 @@ /> {{#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 f59c30e85d1..34035311c73 100644 --- a/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss +++ b/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss @@ -5,7 +5,6 @@ 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 057c6014f7b..9ae341f2473 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer.scss @@ -1,8 +1,6 @@ .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 6ed3e37b13f..815d561d643 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss @@ -6,6 +6,10 @@ .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 c9d00b079dc..e918d0c850a 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-separator.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-separator.scss @@ -1,96 +1,42 @@ .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 { - position: relative; - padding: 20px 0; + &.new-message { + 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); - } + .divider { + background-color: var(--danger-medium); } } - &-date { + &.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 { position: absolute; width: 100%; - z-index: 1; - 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; - } - } + height: 1px; + top: 50%; } } diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss index 4b1641b343c..df79c8b70ca 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message.scss @@ -42,10 +42,6 @@ background: var(--primary-low); border-color: var(--primary-low-mid); } - - &:focus { - background: none; - } } .emoji { @@ -61,11 +57,13 @@ background-color: var(--secondary); display: flex; min-width: 0; - content-visibility: auto; - contain-intrinsic-size: auto 200px; .chat-message-reaction { @include chat-reaction; + + &:not(.show) { + display: none; + } } &.chat-action { @@ -88,6 +86,17 @@ 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; @@ -245,10 +254,6 @@ .chat-message.chat-message-bookmarked { background: var(--highlight-bg); - - &:hover { - background: var(--highlight-medium); - } } .not-mobile-device & .chat-message-reaction-list .chat-message-react-btn { @@ -279,6 +284,7 @@ font-style: italic; } +.chat-message-container.is-hovered, .chat-message.chat-message-selected { background: var(--primary-very-low); } diff --git a/plugins/chat/assets/stylesheets/common/chat-skeleton.scss b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss index cd5e79b3eb0..19eed6f1459 100644 --- a/plugins/chat/assets/stylesheets/common/chat-skeleton.scss +++ b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss @@ -1,4 +1,4 @@ -$radius: 3px; +$radius: 10px; .chat-skeleton { height: auto; @@ -55,35 +55,11 @@ $radius: 3px; &__message-content { grid-area: content; width: 100%; - padding: 10px 0; } - - &__message-reactions { - display: flex; - padding: 5px 0 0 0; - } - - &__message-reaction { - background-color: var(--primary-100); - width: 32px; - height: 18px; - border-radius: $radius; - - & + & { - margin-left: 0.5rem; - } - } - - &__message-text { - display: flex; - padding: 5px 0; - flex-direction: column; - } - &__message-msg { height: 13px; border-radius: $radius; - margin: 2px 0; + margin: 5px 0; .chat-skeleton__body:nth-of-type(odd) & { background-color: var(--primary-100); @@ -93,14 +69,6 @@ $radius: 3px; } } - &__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; @@ -110,7 +78,7 @@ $radius: 3px; position: relative; overflow: hidden; - *[class^="chat-skeleton__message-"]:not(.chat-skeleton__message-content):not(.chat-skeleton__message-text):not(.chat-skeleton__message-reactions):after { + *[class^="chat-skeleton__message-"]:not(.chat-skeleton__message-content):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 5f7ee16413a..ba3529b856b 100644 --- a/plugins/chat/assets/stylesheets/common/common.scss +++ b/plugins/chat/assets/stylesheets/common/common.scss @@ -144,7 +144,6 @@ $float-height: 530px; .chat-messages-container { word-wrap: break-word; white-space: normal; - position: relative; .chat-message-container { display: grid; @@ -284,8 +283,6 @@ $float-height: 530px; display: flex; flex-direction: column-reverse; z-index: 1; - margin: 0 3px 0 0; - will-change: transform; &::-webkit-scrollbar { width: 15px; @@ -326,65 +323,37 @@ $float-height: 530px; } .chat-scroll-to-bottom { - left: calc(50% - calc(45px / 2)); - align-items: center; - justify-content: center; + background: var(--primary-medium); + bottom: 1em; + border-radius: 100%; + left: 50%; + opacity: 50%; + padding: 0.5em; position: absolute; - 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: 5px; - - > * { - 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); - border-radius: 3px; - text-align: center; - font-size: var(--font-down-1); - } - - &__arrow { - display: flex; - background: var(--primary-medium); - border-radius: 100%; - align-items: center; - justify-content: center; - height: 35px; - width: 35px; - - .d-icon { - color: var(--secondary); - } - } + transform: translateX(-50%); + z-index: 2; &:hover { - opacity: 1; + background: var(--primary-medium); + opacity: 100%; + } - .chat-scroll-to-bottom__arrow { - .d-icon { - color: var(--secondary); - } + .d-icon { + color: var(--primary); + margin: 0; + } + + &.unread-messages { + opacity: 85%; + border-radius: 0; + transition: border-radius 0.1s linear; + + &:hover { + opacity: 100%; + } + + .d-icon { + margin: 0 0 0 0.5em; } } } diff --git a/plugins/chat/assets/stylesheets/desktop/chat-composer.scss b/plugins/chat/assets/stylesheets/desktop/chat-composer.scss index c6af087e68e..3095d1851ad 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 5px 0 5px; + margin: 0.25rem 10px 0 10px; } 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 3214e03ceb9..ea281cbe347 100644 --- a/plugins/chat/assets/stylesheets/desktop/desktop.scss +++ b/plugins/chat/assets/stylesheets/desktop/desktop.scss @@ -53,25 +53,6 @@ .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 733341f0e28..e8361c052e2 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-message-actions.scss @@ -22,8 +22,6 @@ 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 20c267d2e8c..c3517ab9858 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-message.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-message.scss @@ -4,3 +4,7 @@ .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 1eadba98f47..af2a3c06436 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" - last_visit: "last visit" + new_messages: "new messages" 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 5f85f631e87..d14913e05b3 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -247,7 +247,6 @@ 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 38f43f6d38d..409349c0ef9 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: channel_1)).to eq([]) + expect(described_class.call(channel_1)).to eq([]) end end @@ -28,7 +28,7 @@ describe ChatChannelMembershipsQuery do end it "returns the memberships" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(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: channel_1) + memberships = described_class.call(channel_1) expect(memberships.pluck(:user_id)).to include(user_1.id) end @@ -62,16 +62,14 @@ describe ChatChannelMembershipsQuery do permission_type: CategoryGroup.permission_types[:full], ) - expect(described_class.call(channel: channel_1).pluck(:user_id)).to contain_exactly( - user_1.id, - ) + expect(described_class.call(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: channel_1) + memberships = described_class.call(channel_1) expect(memberships.pluck(:user_id)).to include(user_1.id) end @@ -79,7 +77,7 @@ describe ChatChannelMembershipsQuery do context "when membership doesn’t exist" do it "doesn’t list the user" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(channel_1) expect(memberships.pluck(:user_id)).to be_empty end @@ -93,7 +91,7 @@ describe ChatChannelMembershipsQuery do end it "doesn’t list the user" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(channel_1) expect(memberships).to be_empty end @@ -101,7 +99,7 @@ describe ChatChannelMembershipsQuery do context "when membership doesn’t exist" do it "doesn’t list the user" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(channel_1) expect(memberships).to be_empty end @@ -116,7 +114,7 @@ describe ChatChannelMembershipsQuery do end it "returns an empty array" do - expect(described_class.call(channel: channel_1)).to eq([]) + expect(described_class.call(channel_1)).to eq([]) end end @@ -124,7 +122,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: channel_1) + memberships = described_class.call(channel_1) expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id) end @@ -141,7 +139,7 @@ describe ChatChannelMembershipsQuery do describe "offset param" do it "offsets the results" do - memberships = described_class.call(channel: channel_1, offset: 1) + memberships = described_class.call(channel_1, offset: 1) expect(memberships.length).to eq(1) end @@ -149,7 +147,7 @@ describe ChatChannelMembershipsQuery do describe "limit param" do it "limits the results" do - memberships = described_class.call(channel: channel_1, limit: 1) + memberships = described_class.call(channel_1, limit: 1) expect(memberships.length).to eq(1) end @@ -165,7 +163,7 @@ describe ChatChannelMembershipsQuery do end it "filters the results" do - memberships = described_class.call(channel: channel_1, username: user_1.username) + memberships = described_class.call(channel_1, username: user_1.username) expect(memberships.length).to eq(1) expect(memberships[0].user).to eq(user_1) @@ -184,7 +182,7 @@ describe ChatChannelMembershipsQuery do before { SiteSetting.prioritize_username_in_ux = true } it "is using ascending order on username" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(channel_1) expect(memberships[0].user).to eq(user_1) expect(memberships[1].user).to eq(user_2) @@ -195,7 +193,7 @@ describe ChatChannelMembershipsQuery do before { SiteSetting.prioritize_username_in_ux = false } it "is using ascending order on name" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(channel_1) expect(memberships[0].user).to eq(user_2) expect(memberships[1].user).to eq(user_1) @@ -205,7 +203,7 @@ describe ChatChannelMembershipsQuery do before { SiteSetting.enable_names = false } it "is using ascending order on username" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(channel_1) expect(memberships[0].user).to eq(user_1) expect(memberships[1].user).to eq(user_2) @@ -224,7 +222,7 @@ describe ChatChannelMembershipsQuery do end it "doesn’t list staged users" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(channel_1) expect(memberships).to be_blank end end @@ -244,7 +242,7 @@ describe ChatChannelMembershipsQuery do end it "doesn’t list suspended users" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(channel_1) expect(memberships).to be_blank end end @@ -262,7 +260,7 @@ describe ChatChannelMembershipsQuery do end it "doesn’t list inactive users" do - memberships = described_class.call(channel: channel_1) + memberships = described_class.call(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 deleted file mode 100644 index fea379ceb46..00000000000 --- a/plugins/chat/spec/queries/chat_channel_unreads_query_spec.rb +++ /dev/null @@ -1,51 +0,0 @@ -# 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 92d0517036d..d0fcbfd19ab 100644 --- a/plugins/chat/spec/requests/chat_controller_spec.rb +++ b/plugins/chat/spec/requests/chat_controller_spec.rb @@ -126,17 +126,15 @@ 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"] - 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 + expect(reactions[heart_emoji]["reacted"]).to be true + expect(reactions[smile_emoji]["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 67f11368c2d..ea97d0310de 100644 --- a/plugins/chat/spec/serializer/chat_message_serializer_spec.rb +++ b/plugins/chat/spec/serializer/chat_message_serializer_spec.rb @@ -21,14 +21,12 @@ describe ChatMessageSerializer do it "doesn’t return the reaction" do Emoji.clear_cache - trout_reaction = subject.as_json[:reactions].find { |r| r[:emoji] == "trout" } - expect(trout_reaction).to be_present + expect(subject.as_json[:reactions]["trout"]).to be_present custom_emoji.destroy! Emoji.clear_cache - trout_reaction = subject.as_json[:reactions].find { |r| r[:emoji] == "trout" } - expect(trout_reaction).to_not be_present + expect(subject.as_json[:reactions]["trout"]).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 bacd3a69427..39ecd2b9682 100644 --- a/plugins/chat/spec/system/chat_channel_spec.rb +++ b/plugins/chat/spec/system/chat_channel_spec.rb @@ -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(".chat-message-separator__text", text: "Today") + expect(page).to have_selector(".first-daily-message", 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 f1cfa2e8f87..f6fd4e4da09 100644 --- a/plugins/chat/spec/system/create_channel_spec.rb +++ b/plugins/chat/spec/system/create_channel_spec.rb @@ -81,7 +81,6 @@ 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/flag_message_spec.rb b/plugins/chat/spec/system/flag_message_spec.rb index a7afd6af14b..8b40ba93cd8 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) } + fab!(:message_1) { Fabricate(:chat_message, chat_channel: dm_channel_1, user: current_user) } it "doesn’t allow to flag a message" do chat.visit_channel(dm_channel_1) diff --git a/plugins/chat/spec/system/message_user_info.rb b/plugins/chat/spec/system/message_user_info.rb deleted file mode 100644 index be97ab1c0d9..00000000000 --- a/plugins/chat/spec/system/message_user_info.rb +++ /dev/null @@ -1,60 +0,0 @@ -# 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 c3a3899eb43..9dae4fe8b81 100644 --- a/plugins/chat/spec/system/navigating_to_message_spec.rb +++ b/plugins/chat/spec/system/navigating_to_message_spec.rb @@ -60,9 +60,8 @@ 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_button(class: "chat-scroll-to-bottom") + click_link(I18n.t("js.chat.scroll_to_bottom")) click_link(link) expect(page).to have_css( @@ -150,9 +149,8 @@ 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_button(class: "chat-scroll-to-bottom") + click_link(I18n.t("js.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 a7dd347750e..dddfa46baa9 100644 --- a/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb +++ b/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb @@ -5,7 +5,6 @@ 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 @@ -64,9 +63,8 @@ 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) - find(".chat-composer-input").send_keys(:arrow_up) + within(".chat-composer-input") { |composer| composer.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 deleted file mode 100644 index 9f043afa9f9..00000000000 --- a/plugins/chat/spec/system/sticky_date_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# 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 09dee0e400d..a012d29b86c 100644 --- a/plugins/chat/spec/system/uploads_spec.rb +++ b/plugins/chat/spec/system/uploads_spec.rb @@ -36,21 +36,12 @@ 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 - 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 + attach_file([file_path_1, 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/test/javascripts/components/chat-channel-metadata-test.js b/plugins/chat/test/javascripts/components/chat-channel-metadata-test.js index db01ba779e9..4a8c7f391c8 100644 --- a/plugins/chat/test/javascripts/components/chat-channel-metadata-test.js +++ b/plugins/chat/test/javascripts/components/chat-channel-metadata-test.js @@ -9,17 +9,16 @@ 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").format(); + let lastMessageSentAt = moment().subtract(1, "day"); this.channel = fabricators.directMessageChatChannel({ last_message_sent_at: lastMessageSentAt, }); - await render(hbs``); assert.dom(".chat-channel-metadata__date").hasText("Yesterday"); lastMessageSentAt = moment(); - this.channel.lastMessageSentAt = lastMessageSentAt; + this.channel.set("last_message_sent_at", 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 624d574da95..0c10cad2158 100644 --- a/plugins/chat/test/javascripts/components/chat-channel-row-test.js +++ b/plugins/chat/test/javascripts/components/chat-channel-row-test.js @@ -51,7 +51,9 @@ module("Discourse Chat | Component | chat-channel-row", function (hooks) { assert .dom(".chat-channel-metadata") - .hasText(moment(this.categoryChatChannel.lastMessageSentAt).format("l")); + .hasText( + moment(this.categoryChatChannel.last_message_sent_at).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 1a3872bd2c2..b256887fff8 100644 --- a/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js +++ b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js @@ -8,6 +8,7 @@ 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", @@ -46,11 +47,12 @@ 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) { - this.existingUploads = [fakeUpload]; - await render(hbs` - + `); + + this.appEvents = this.container.lookup("service:appEvents"); + this.appEvents.trigger("chat-composer:load-uploads", [fakeUpload]); await settled(); assert.strictEqual(count(".chat-composer-upload"), 1); @@ -59,7 +61,10 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) { test("upload starts and completes", async function (assert) { setupUploadPretender(); - this.set("onUploadChanged", () => {}); + this.set("changedUploads", null); + this.set("onUploadChanged", (uploads) => { + this.set("changedUploads", uploads); + }); await render(hbs` @@ -75,31 +80,34 @@ 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.dom(".chat-composer-upload").exists({ count: 1 }); + assert.strictEqual(count(".chat-composer-upload"), 1); }); test("removing a completed upload", async function (assert) { this.set("changedUploads", null); - this.set("onUploadChanged", () => {}); - - this.existingUploads = [fakeUpload]; + this.set("onUploadChanged", (uploads) => { + this.set("changedUploads", uploads); + }); await render(hbs` - + `); - assert.dom(".chat-composer-upload").exists({ count: 1 }); + this.appEvents = this.container.lookup("service:appEvents"); + run(() => + this.appEvents.trigger("chat-composer:load-uploads", [fakeUpload]) + ); + assert.strictEqual(count(".chat-composer-upload"), 1); await click(".remove-upload"); - - assert.dom(".chat-composer-upload").exists({ count: 0 }); + assert.strictEqual(count(".chat-composer-upload"), 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 new file mode 100644 index 00000000000..244439eb38e --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-live-pane-test.js @@ -0,0 +1,44 @@ +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 dff366aec8b..56244f9803b 100644 --- a/plugins/chat/test/javascripts/components/chat-message-avatar-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-avatar-test.js @@ -3,16 +3,12 @@ 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.message = ChatMessage.create(fabricators.chatChannel(), { - chat_webhook_event: { emoji: ":heart:" }, - }); + this.set("message", { chat_webhook_event: { emoji: ":heart:" } }); await render(hbs``); @@ -20,9 +16,7 @@ module("Discourse Chat | Component | chat-message-avatar", function (hooks) { }); test("user", async function (assert) { - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { username: "discobot" }, - }); + this.set("message", { 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 f633d71645f..2875e2a73cc 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.message = ChatMessage.create(fabricators.chatChannel(), { - chat_webhook_event: { username: "discobot" }, - }); + this.set( + "message", + ChatMessage.create({ chat_webhook_event: { username: "discobot" } }) + ); await render(hbs``); assert.strictEqual( query(".chat-message-info__username").innerText.trim(), - this.message.chatWebhookEvent.username + this.message.chat_webhook_event.username ); assert.strictEqual( query(".chat-message-info__bot-indicator").textContent.trim(), @@ -29,9 +29,7 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("user", async function (assert) { - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { username: "discobot" }, - }); + this.set("message", ChatMessage.create({ user: { username: "discobot" } })); await render(hbs``); @@ -42,10 +40,13 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("date", async function (assert) { - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { username: "discobot" }, - created_at: moment(), - }); + this.set( + "message", + ChatMessage.create({ + user: { username: "discobot" }, + created_at: moment(), + }) + ); await render(hbs``); @@ -53,13 +54,16 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("bookmark (with reminder)", async function (assert) { - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { username: "discobot" }, - bookmark: Bookmark.create({ - reminder_at: moment(), - name: "some name", - }), - }); + this.set( + "message", + ChatMessage.create({ + user: { username: "discobot" }, + bookmark: Bookmark.create({ + reminder_at: moment(), + name: "some name", + }), + }) + ); await render(hbs``); @@ -69,12 +73,15 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("bookmark (no reminder)", async function (assert) { - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { username: "discobot" }, - bookmark: Bookmark.create({ - name: "some name", - }), - }); + this.set( + "message", + ChatMessage.create({ + user: { username: "discobot" }, + bookmark: Bookmark.create({ + name: "some name", + }), + }) + ); await render(hbs``); @@ -83,9 +90,7 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { test("user status", async function (assert) { const status = { description: "off to dentist", emoji: "tooth" }; - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { status }, - }); + this.set("message", ChatMessage.create({ user: { status } })); await render(hbs``); @@ -93,10 +98,13 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("reviewable", async function (assert) { - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { username: "discobot" }, - user_flag_status: 0, - }); + this.set( + "message", + ChatMessage.create({ + user: { username: "discobot" }, + user_flag_status: 0, + }) + ); await render(hbs``); @@ -105,12 +113,13 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { I18n.t("chat.you_flagged") ); - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { username: "discobot" }, - reviewable_id: 1, - }); - - await render(hbs``); + this.set( + "message", + ChatMessage.create({ + user: { username: "discobot" }, + reviewable_id: 1, + }) + ); assert.strictEqual( query(".chat-message-info__flag a .svg-icon-title").title, @@ -119,15 +128,18 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("with username classes", async function (assert) { - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { - username: "discobot", - admin: true, - moderator: true, - new_user: true, - primary_group_name: "foo", - }, - }); + this.set( + "message", + ChatMessage.create({ + user: { + username: "discobot", + admin: true, + moderator: true, + new_user: true, + primary_group_name: "foo", + }, + }) + ); await render(hbs``); @@ -139,9 +151,7 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) { }); test("without username classes", async function (assert) { - this.message = ChatMessage.create(fabricators.chatChannel(), { - user: { username: "discobot" }, - }); + this.set("message", ChatMessage.create({ 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 4a640b949dc..86f1ca04c7f 100644 --- a/plugins/chat/test/javascripts/components/chat-message-reaction-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-reaction-test.js @@ -7,6 +7,14 @@ 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` @@ -21,6 +29,19 @@ 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 deleted file mode 100644 index 415413df7ac..00000000000 --- a/plugins/chat/test/javascripts/components/chat-message-separator-date-test.js +++ /dev/null @@ -1,24 +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 { 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 deleted file mode 100644 index ee91a122043..00000000000 --- a/plugins/chat/test/javascripts/components/chat-message-separator-new-test.js +++ /dev/null @@ -1,24 +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-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 new file mode 100644 index 00000000000..4b4aad0e565 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-separator-test.js @@ -0,0 +1,35 @@ +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 7ffa9da1d08..3dbaf2737e0 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 } from "@ember/test-helpers"; +import { render, waitFor } 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,16 +21,9 @@ 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, @@ -45,6 +38,14 @@ 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: () => {}, @@ -54,9 +55,8 @@ module("Discourse Chat | Component | chat-message", function (hooks) { onStartSelectingMessages: () => {}, onSelectMessage: () => {}, bulkSelectMessages: () => {}, + afterReactionAdded: () => {}, onHoverMessage: () => {}, - didShowMessage: () => {}, - didHideMessage: () => {}, }; } @@ -64,7 +64,8 @@ module("Discourse Chat | Component | chat-message", function (hooks) { `; @@ -90,7 +90,6 @@ 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" @@ -105,4 +104,16 @@ 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 820e28a8308..cb707d011f5 100644 --- a/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js +++ b/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js @@ -14,7 +14,9 @@ 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 07cf409ee24..924685b6e6e 100644 --- a/plugins/chat/test/javascripts/helpers/fabricators.js +++ b/plugins/chat/test/javascripts/helpers/fabricators.js @@ -3,7 +3,6 @@ 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, @@ -39,7 +38,7 @@ export default { }, }), - chatChannelMessage: Fabricator(ChatMessage, { + chatChannelMessage: Fabricator(EmberObject, { 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 new file mode 100644 index 00000000000..e05981401b3 --- /dev/null +++ b/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js @@ -0,0 +1,36 @@ +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 f6acc11a457..081732a054e 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,19 +3,12 @@ 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) { - const channel = fabricators.chatChannel(); - this.message = ChatMessage.create(channel, { - id: 1, - chat_channel_id: channel.id, - }); - + this.set("message", { id: 1, chat_channel_id: 1 }); await render(hbs`{{format-chat-date this.message}}`); assert.equal(query(".chat-time").getAttribute("href"), "/chat/c/-/1/1");