diff --git a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb b/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb index c50a30735e7..d6fe2fd4ad9 100644 --- a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb +++ b/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb @@ -9,7 +9,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont memberships = ChatChannelMembershipsQuery.call( - channel_from_params, + channel: channel_from_params, offset: offset, limit: limit, username: params[:username], diff --git a/plugins/chat/app/models/chat_message.rb b/plugins/chat/app/models/chat_message.rb index 6b485036a31..7f087a3158e 100644 --- a/plugins/chat/app/models/chat_message.rb +++ b/plugins/chat/app/models/chat_message.rb @@ -223,7 +223,7 @@ class ChatMessage < ActiveRecord::Base end def url - "/chat/message/#{self.id}" + "/chat/c/-/#{self.chat_channel_id}/#{self.id}" end private diff --git a/plugins/chat/app/queries/chat_channel_memberships_query.rb b/plugins/chat/app/queries/chat_channel_memberships_query.rb index a257e0c0697..e38f09eae1d 100644 --- a/plugins/chat/app/queries/chat_channel_memberships_query.rb +++ b/plugins/chat/app/queries/chat_channel_memberships_query.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ChatChannelMembershipsQuery - def self.call(channel, limit: 50, offset: 0, username: nil, count_only: false) + def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false) query = UserChatChannelMembership .joins(:user) @@ -42,6 +42,6 @@ class ChatChannelMembershipsQuery end def self.count(channel) - call(channel, count_only: true) + call(channel: channel, count_only: true) end end diff --git a/plugins/chat/app/queries/chat_channel_unreads_query.rb b/plugins/chat/app/queries/chat_channel_unreads_query.rb new file mode 100644 index 00000000000..0d6e49ba0ea --- /dev/null +++ b/plugins/chat/app/queries/chat_channel_unreads_query.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ChatChannelUnreadsQuery + def self.call(channel_id:, user_id:) + sql = <<~SQL + SELECT ( + SELECT COUNT(*) AS unread_count + FROM chat_messages + INNER JOIN chat_channels ON chat_channels.id = chat_messages.chat_channel_id + INNER JOIN user_chat_channel_memberships ON user_chat_channel_memberships.chat_channel_id = chat_channels.id + WHERE chat_channels.id = :channel_id + AND chat_messages.user_id != :user_id + AND user_chat_channel_memberships.user_id = :user_id + AND chat_messages.id > COALESCE(user_chat_channel_memberships.last_read_message_id, 0) + AND chat_messages.deleted_at IS NULL + ) AS unread_count, + ( + SELECT COUNT(*) AS mention_count + FROM notifications + INNER JOIN user_chat_channel_memberships ON user_chat_channel_memberships.chat_channel_id = :channel_id + AND user_chat_channel_memberships.user_id = :user_id + WHERE NOT read + AND notifications.user_id = :user_id + AND notifications.notification_type = :notification_type + AND (data::json->>'chat_message_id')::bigint > COALESCE(user_chat_channel_memberships.last_read_message_id, 0) + AND (data::json->>'chat_channel_id')::bigint = :channel_id + ) AS mention_count; + SQL + + DB + .query( + sql, + channel_id: channel_id, + user_id: user_id, + notification_type: Notification.types[:chat_mention], + ) + .first + .to_h + end +end diff --git a/plugins/chat/app/serializers/chat_channel_serializer.rb b/plugins/chat/app/serializers/chat_channel_serializer.rb index ffa800496af..d007ca651a2 100644 --- a/plugins/chat/app/serializers/chat_channel_serializer.rb +++ b/plugins/chat/app/serializers/chat_channel_serializer.rb @@ -110,6 +110,7 @@ class ChatChannelSerializer < ApplicationSerializer def meta { message_bus_last_ids: { + channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.id}"), new_messages: @opts[:new_messages_message_bus_last_id] || MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)), diff --git a/plugins/chat/app/serializers/chat_message_serializer.rb b/plugins/chat/app/serializers/chat_message_serializer.rb index 9f6dc19a23e..4ff2b7e5ff0 100644 --- a/plugins/chat/app/serializers/chat_message_serializer.rb +++ b/plugins/chat/app/serializers/chat_message_serializer.rb @@ -35,23 +35,23 @@ class ChatMessageSerializer < ApplicationSerializer end def reactions - reactions_hash = {} object .reactions .group_by(&:emoji) - .each do |emoji, reactions| - users = reactions[0..5].map(&:user).filter { |user| user.id != scope&.user&.id }[0..4] - + .map do |emoji, reactions| next unless Emoji.exists?(emoji) - reactions_hash[emoji] = { + users = reactions.take(5).map(&:user) + + { + emoji: emoji, count: reactions.count, users: ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, reacted: users_reactions.include?(emoji), } end - reactions_hash + .compact end def include_reactions? diff --git a/plugins/chat/app/serializers/chat_view_serializer.rb b/plugins/chat/app/serializers/chat_view_serializer.rb index 566474ec408..129cd31f17b 100644 --- a/plugins/chat/app/serializers/chat_view_serializer.rb +++ b/plugins/chat/app/serializers/chat_view_serializer.rb @@ -16,6 +16,7 @@ class ChatViewSerializer < ApplicationSerializer def meta meta_hash = { + channel_id: object.chat_channel.id, can_flag: scope.can_flag_in_chat_channel?(object.chat_channel), channel_status: object.chat_channel.status, user_silenced: !scope.can_create_chat_message?, diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb index a56b841ae99..25c27d08960 100644 --- a/plugins/chat/app/services/chat_publisher.rb +++ b/plugins/chat/app/services/chat_publisher.rb @@ -12,7 +12,7 @@ module ChatPublisher { scope: anonymous_guardian, root: :chat_message }, ).as_json content[:type] = :sent - content[:stagedId] = staged_id + content[:staged_id] = staged_id permissions = permissions(chat_channel) MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) @@ -133,9 +133,13 @@ module ChatPublisher end def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) + data = { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id }.merge( + ChatChannelUnreadsQuery.call(channel_id: chat_channel_id, user_id: user.id), + ) + MessageBus.publish( self.user_tracking_state_message_bus_channel(user.id), - { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id.to_i }.as_json, + data.as_json, user_ids: [user.id], ) end diff --git a/plugins/chat/assets/javascripts/discourse/components/channels-list.js b/plugins/chat/assets/javascripts/discourse/components/channels-list.js index bb1b660ebcb..3232387e3d5 100644 --- a/plugins/chat/assets/javascripts/discourse/components/channels-list.js +++ b/plugins/chat/assets/javascripts/discourse/components/channels-list.js @@ -1,24 +1,30 @@ import { bind } from "discourse-common/utils/decorators"; -import Component from "@ember/component"; -import { action, computed } from "@ember/object"; +import Component from "@glimmer/component"; +import { action } from "@ember/object"; import { schedule } from "@ember/runloop"; import { inject as service } from "@ember/service"; -import { and, empty } from "@ember/object/computed"; export default class ChannelsList extends Component { @service chat; @service router; @service chatStateManager; @service chatChannelsManager; - tagName = ""; - inSidebar = false; - toggleSection = null; - @empty("chatChannelsManager.publicMessageChannels") - publicMessageChannelsEmpty; - @and("site.mobileView", "showDirectMessageChannels") - showMobileDirectMessageButton; + @service site; + @service session; + @service currentUser; + + get showMobileDirectMessageButton() { + return this.site.mobileView && this.showDirectMessageChannels; + } + + get inSidebar() { + return this.args.inSidebar ?? false; + } + + get publicMessageChannelsEmpty() { + return this.chatChannelsManager.publicMessageChannels?.length === 0; + } - @computed("canCreateDirectMessageChannel") get createDirectMessageChannelLabel() { if (!this.canCreateDirectMessageChannel) { return "chat.direct_messages.cannot_create"; @@ -27,10 +33,6 @@ export default class ChannelsList extends Component { return "chat.direct_messages.new"; } - @computed( - "canCreateDirectMessageChannel", - "chatChannelsManager.directMessageChannels" - ) get showDirectMessageChannels() { return ( this.canCreateDirectMessageChannel || @@ -42,17 +44,12 @@ export default class ChannelsList extends Component { return this.chat.userCanDirectMessage; } - @computed("inSidebar") get publicChannelClasses() { return `channels-list-container public-channels ${ this.inSidebar ? "collapsible-sidebar-section" : "" }`; } - @computed( - "publicMessageChannelsEmpty", - "currentUser.{staff,has_joinable_public_channels}" - ) get displayPublicChannels() { if (this.publicMessageChannelsEmpty) { return ( @@ -64,7 +61,6 @@ export default class ChannelsList extends Component { return true; } - @computed("inSidebar") get directMessageChannelClasses() { return `channels-list-container direct-message-channels ${ this.inSidebar ? "collapsible-sidebar-section" : "" @@ -73,7 +69,7 @@ export default class ChannelsList extends Component { @action toggleChannelSection(section) { - this.toggleSection(section); + this.args.toggleSection(section); } didRender() { diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs index 05c16b4e5d2..7974d150628 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.hbs @@ -3,7 +3,7 @@ {{this.lastMessageFormatedDate}} - {{#if @unreadIndicator}} + {{#if this.unreadIndicator}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js index 404cd7ebd4e..898f6c7ac89 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-metadata.js @@ -1,18 +1,18 @@ import Component from "@glimmer/component"; + export default class ChatChannelMetadata extends Component { - unreadIndicator = false; + get unreadIndicator() { + return this.args.unreadIndicator ?? false; + } get lastMessageFormatedDate() { - return moment(this.args.channel.get("last_message_sent_at")).calendar( - null, - { - sameDay: "LT", - nextDay: "[Tomorrow]", - nextWeek: "dddd", - lastDay: "[Yesterday]", - lastWeek: "dddd", - sameElse: "l", - } - ); + return moment(this.args.channel.lastMessageSentAt).calendar(null, { + sameDay: "LT", + nextDay: "[Tomorrow]", + nextWeek: "dddd", + lastDay: "[Yesterday]", + lastWeek: "dddd", + sameElse: "l", + }); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs index 9002d3f774c..83ec633c9b8 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.hbs @@ -4,15 +4,15 @@ (unless this.hasDescription "-no-description") }} > - + {{#if this.hasDescription}}

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

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