DEV: rework the chat-live-pane (#20519)

This PR is introducing glimmer usage in the chat-live-pane, for components but also for models. RestModel usage has been dropped in favor of native classes.

Other changes/additions in this PR:

sticky dates, scrolling will now keep the date separator of the current section at the top of the screen
better unread management, marking a channel as unread will correctly mark the correct message and not mark the whole channel as read. Tracking state will also now correctly return unread count and unread mentions.
adds an animation on bottom arrow
better scrolling behavior, we should now always correctly keep the scroll position while loading more
reactions are now more reactive, and will update their tooltip without needed to close/reopen it
skeleton has been improved with placeholder images and reactions
when making a reaction on the desktop message actions, the menu won't move anymore
simplify logic and stop maintaining a list of unloaded messages
This commit is contained in:
Joffrey JAFFEUX 2023-03-03 13:09:25 +01:00 committed by GitHub
parent e08a0b509d
commit 6b0aeced7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
123 changed files with 2601 additions and 2303 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

@ -3,7 +3,7 @@
{{this.lastMessageFormatedDate}}
</div>
{{#if @unreadIndicator}}
{{#if this.unreadIndicator}}
<ChatChannelUnreadIndicator @channel={{@channel}} />
{{/if}}
</div>

View File

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

View File

@ -4,15 +4,15 @@
(unless this.hasDescription "-no-description")
}}
>
<ChatChannelTitle @channel={{this.channel}} />
<ChatChannelTitle @channel={{@channel}} />
{{#if this.hasDescription}}
<p class="chat-channel-preview-card__description">
{{this.channel.description}}
{{@channel.description}}
</p>
{{/if}}
{{#if this.showJoinButton}}
<ToggleChannelMembershipButton
@channel={{this.channel}}
@channel={{@channel}}
@options={{hash joinClass="btn-primary"}}
/>
{{/if}}

View File

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

View File

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

View File

@ -1,17 +1,17 @@
{{#if this.buttons.length}}
{{#if @buttons.length}}
<DPopover
@class="chat-composer-dropdown"
@options={{hash arrow=null}}
as |state|
>
<FlatButton
@disabled={{this.isDisabled}}
@disabled={{@isDisabled}}
@class="chat-composer-dropdown__trigger-btn d-popover-trigger"
@title="chat.composer.toggle_toolbar"
@icon={{if state.isExpanded "times" "plus"}}
/>
<ul class="chat-composer-dropdown__list">
{{#each this.buttons as |button|}}
{{#each @buttons as |button|}}
<li class="chat-composer-dropdown__item {{button.id}}">
<DButton
@class={{concat "chat-composer-dropdown__action-btn " button.id}}

View File

@ -1,7 +0,0 @@
import Component from "@ember/component";
export default class ChatComposerDropdown extends Component {
tagName = "";
buttons = null;
isDisabled = false;
}

View File

@ -5,6 +5,7 @@ import { inject as service } from "@ember/service";
import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
import UppyUploadMixin from "discourse/mixins/uppy-upload";
import { cloneJSON } from "discourse-common/lib/object";
export default Component.extend(UppyUploadMixin, {
classNames: ["chat-composer-uploads"],
@ -12,16 +13,25 @@ export default Component.extend(UppyUploadMixin, {
chatStateManager: service(),
id: "chat-composer-uploader",
type: "chat-composer",
existingUploads: null,
uploads: null,
useMultipartUploadsIfAvailable: true,
init() {
this._super(...arguments);
this.setProperties({
uploads: [],
fileInputSelector: `#${this.fileUploadElementId}`,
});
this.appEvents.on("chat-composer:load-uploads", this, "_loadUploads");
},
didReceiveAttrs() {
this._super(...arguments);
this.set(
"uploads",
this.existingUploads ? cloneJSON(this.existingUploads) : []
);
this._uppyInstance?.cancelAll();
},
didInsertElement() {
@ -32,7 +42,7 @@ export default Component.extend(UppyUploadMixin, {
willDestroyElement() {
this._super(...arguments);
this.appEvents.off("chat-composer:load-uploads", this, "_loadUploads");
this.composerInputEl?.removeEventListener(
"paste",
this._pasteEventListener
@ -81,11 +91,6 @@ export default Component.extend(UppyUploadMixin, {
};
},
_loadUploads(uploads) {
this._uppyInstance?.cancelAll();
this.set("uploads", uploads);
},
_uppyReady() {
if (this.siteSettings.composer_media_optimization_image_enabled) {
this._useUploadPlugin(UppyMediaOptimization, {

View File

@ -79,7 +79,11 @@
{{#if this.canAttachUploads}}
<ChatComposerUploads
@fileUploadElementId={{this.fileUploadElementId}}
@onUploadChanged={{action "uploadsChanged"}}
@onUploadChanged={{this.uploadsChanged}}
@existingUploads={{or
this.chatChannel.draft.uploads
this.editingMessage.uploads
}}
/>
{{/if}}

View File

@ -29,11 +29,10 @@ const THROTTLE_MS = 150;
export default Component.extend(TextareaTextManipulation, {
chatChannel: null,
lastChatChannelId: null,
chat: service(),
classNames: ["chat-composer-container"],
classNameBindings: ["emojiPickerVisible:with-emoji-picker"],
userSilenced: readOnly("details.user_silenced"),
userSilenced: readOnly("chatChannel.userSilenced"),
chatEmojiReactionStore: service("chat-emoji-reaction-store"),
chatEmojiPickerManager: service("chat-emoji-picker-manager"),
chatStateManager: service("chat-state-manager"),
@ -220,18 +219,18 @@ export default Component.extend(TextareaTextManipulation, {
if (
!this.editingMessage &&
this.draft &&
this.chatChannel?.draft &&
this.chatChannel?.canModifyMessages(this.currentUser)
) {
// uses uploads from draft here...
this.setProperties({
value: this.draft.value,
replyToMsg: this.draft.replyToMsg,
value: this.chatChannel.draft.message,
replyToMsg: this.chatChannel.draft.replyToMsg,
});
this._captureMentions();
this._syncUploads(this.draft.uploads);
this.setInReplyToMsg(this.draft.replyToMsg);
this._syncUploads(this.chatChannel.draft.uploads);
this.setInReplyToMsg(this.chatChannel.draft.replyToMsg);
}
if (this.editingMessage && !this.loading) {
@ -244,7 +243,6 @@ export default Component.extend(TextareaTextManipulation, {
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false });
}
this.set("lastChatChannelId", this.chatChannel.id);
this.resizeTextarea();
},
@ -271,7 +269,6 @@ export default Component.extend(TextareaTextManipulation, {
}
this.set("_uploads", cloneJSON(newUploads));
this.appEvents.trigger("chat-composer:load-uploads", this._uploads);
},
_inProgressUploadsChanged(inProgressUploads) {
@ -286,7 +283,7 @@ export default Component.extend(TextareaTextManipulation, {
_replyToMsgChanged(replyToMsg) {
this.set("replyToMsg", replyToMsg);
this.onValueChange?.(this.value, this._uploads, replyToMsg);
this.onValueChange?.({ replyToMsg });
},
@action
@ -302,12 +299,14 @@ export default Component.extend(TextareaTextManipulation, {
@bind
_handleTextareaInput() {
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
this.onValueChange?.({ value: this.value });
},
@bind
_captureMentions() {
this.chatComposerWarningsTracker.trackMentions(this.value);
if (this.value) {
this.chatComposerWarningsTracker.trackMentions(this.value);
}
},
@bind
@ -699,7 +698,7 @@ export default Component.extend(TextareaTextManipulation, {
cancelReplyTo() {
this.set("replyToMsg", null);
this.setInReplyToMsg(null);
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
this.onValueChange?.({ replyToMsg: null });
},
@action
@ -722,7 +721,7 @@ export default Component.extend(TextareaTextManipulation, {
@action
uploadsChanged(uploads) {
this.set("_uploads", cloneJSON(uploads));
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
this.onValueChange?.({ uploads: this._uploads });
},
@action

View File

@ -19,9 +19,6 @@
/>
{{#if this.previewedChannel}}
<ChatLivePane
@chatChannel={{this.previewedChannel}}
@includeHeader={{false}}
/>
<ChatLivePane @channel={{this.previewedChannel}} @includeHeader={{false}} />
{{/if}}
</div>

View File

@ -18,7 +18,7 @@
{{#if this.chat.activeChannel}}
<ChatLivePane
@targetMessageId={{readonly @params.messageId}}
@chatChannel={{this.chat.activeChannel}}
@channel={{this.chat.activeChannel}}
/>
{{/if}}
</div>

View File

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

View File

@ -0,0 +1,46 @@
{{#if
(and
this.chatStateManager.isFullPageActive this.displayed (not @channel.isDraft)
)
}}
<div
class={{concat-class
"chat-full-page-header"
(unless @channel.isFollowing "-not-following")
}}
>
<div class="chat-channel-header-details">
{{#if this.site.mobileView}}
<div class="chat-full-page-header__left-actions">
<LinkTo
@route="chat"
class="chat-full-page-header__back-btn no-text btn-flat"
>
{{d-icon "chevron-left"}}
</LinkTo>
</div>
{{/if}}
<LinkTo
@route="chat.channel.info"
@models={{@channel.routeModels}}
class="chat-channel-title-wrapper"
>
<ChatChannelTitle @channel={{@channel}} />
</LinkTo>
{{#if this.site.desktopView}}
<div class="chat-full-page-header__right-actions">
<DButton
@icon="discourse-compress"
@title="chat.close_full_page"
class="open-drawer-btn btn-flat no-text"
@action={{@onCloseFullScreen}}
/>
</div>
{{/if}}
</div>
</div>
<ChatChannelStatus @channel={{@channel}} />
{{/if}}

View File

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

View File

@ -1,148 +1,118 @@
{{#if (and this.chatStateManager.isFullPageActive this.includeHeader)}}
<div
class="chat-full-page-header
{{unless this.chatChannel.isFollowing '-not-following'}}"
>
<div class="chat-channel-header-details">
{{#if this.site.mobileView}}
<div class="chat-full-page-header__left-actions">
<DButton
@class="chat-full-page-header__back-btn no-text btn-flat"
@icon="chevron-left"
@action={{this.onBackClick}}
/>
</div>
{{/if}}
<LinkTo
@route="chat.channel.info"
@models={{this.chatChannel.routeModels}}
class="chat-channel-title-wrapper"
>
<ChatChannelTitle @channel={{this.chatChannel}} />
</LinkTo>
{{#if this.showCloseFullScreenBtn}}
<div class="chat-full-page-header__right-actions">
<DButton
@icon="discourse-compress"
@title="chat.close_full_page"
class="open-drawer-btn btn-flat no-text"
@action={{action this.onCloseFullScreen}}
/>
</div>
{{/if}}
</div>
</div>
<ChatChannelStatus @channel={{this.chatChannel}} />
{{/if}}
<ChatRetentionReminder @chatChannel={{this.chatChannel}} />
<ChatMentionWarnings />
<div class="chat-message-actions-mobile-anchor"></div>
<div
class={{concat-class
"chat-message-emoji-picker-anchor"
(if
(and
this.chatEmojiPickerManager.opened
(eq this.chatEmojiPickerManager.context "chat-message")
)
"-opened"
)
"chat-live-pane"
(if this.loading "loading")
(if this.sendingLoading "sending-loading")
}}
{{did-insert this.setupListeners}}
{{will-destroy this.teardownListeners}}
{{did-insert this.updateChannel}}
{{did-update this.loadMessages @targetMessageId}}
{{did-update this.updateChannel @channel.id}}
{{did-insert this.addAutoFocusEventListener}}
{{will-destroy this.removeAutoFocusEventListener}}
>
</div>
<div class="chat-messages-scroll chat-messages-container">
<div class="chat-message-actions-desktop-anchor"></div>
<div class="chat-messages-container">
{{#if (or this.loading this.loadingMorePast)}}
<ChatSkeleton @tagName="" />
{{/if}}
{{#each this.messages as |message|}}
<ChatMessage
@message={{message}}
@canInteractWithChat={{this.canInteractWithChat}}
@details={{this.details}}
@chatChannel={{this.chatChannel}}
@setReplyTo={{action "setReplyTo"}}
@replyMessageClicked={{action "replyMessageClicked"}}
@editButtonClicked={{action "editButtonClicked"}}
@selectingMessages={{this.selectingMessages}}
@onStartSelectingMessages={{this.onStartSelectingMessages}}
@onSelectMessage={{this.onSelectMessage}}
@bulkSelectMessages={{this.bulkSelectMessages}}
@fullPage={{this.fullPage}}
@afterReactionAdded={{action "reStickScrollIfNeeded"}}
@isHovered={{eq message.id this.hoveredMessageId}}
@onHoverMessage={{this.onHoverMessage}}
@resendStagedMessage={{this.resendStagedMessage}}
/>
{{/each}}
{{#if this.loadingMoreFuture}}
<ChatSkeleton @tagName="" />
{{/if}}
</div>
{{#if this.allPastMessagesLoaded}}
<div class="all-loaded-message">
{{i18n "chat.all_loaded"}}
</div>
{{/if}}
</div>
{{#if this.showScrollToBottomBtn}}
<div class="scroll-stick-wrap">
<a
href
title={{i18n "chat.scroll_to_bottom"}}
class={{concat-class
"btn"
"btn-flat"
"chat-scroll-to-bottom"
(if this.hasNewMessages "unread-messages")
}}
{{on "click" (action "restickScrolling")}}
>
{{#if this.hasNewMessages}}
{{i18n "chat.scroll_to_new_messages"}}
{{/if}}
{{d-icon "arrow-down"}}
</a>
</div>
{{/if}}
{{#if this.selectingMessages}}
<ChatSelectionManager
@selectedMessageIds={{this.selectedMessageIds}}
@chatChannel={{this.chatChannel}}
@canModerate={{this.details.can_moderate}}
@cancelSelecting={{action "cancelSelecting"}}
<ChatFullPageHeader
@channel={{@channel}}
@onCloseFullScreen={{this.onCloseFullScreen}}
@displayed={{this.includeHeader}}
/>
{{else}}
{{#if (or this.chatChannel.isDraft this.chatChannel.isFollowing)}}
<ChatComposer
@draft={{this.draft}}
@details={{this.details}}
@canInteractWithChat={{this.canInteractWithChat}}
@sendMessage={{action "sendMessage"}}
@editMessage={{action "editMessage"}}
@setReplyTo={{action "setReplyTo"}}
@loading={{this.sendingLoading}}
@editingMessage={{readonly this.editingMessage}}
@onCancelEditing={{this.cancelEditing}}
@setInReplyToMsg={{this.setInReplyToMsg}}
@onEditLastMessageRequested={{this.editLastMessageRequested}}
@onValueChange={{action "composerValueChanged"}}
@chatChannel={{this.chatChannel}}
<ChatRetentionReminder @channel={{@channel}} />
<ChatMentionWarnings />
<div class="chat-message-actions-mobile-anchor"></div>
<div
class={{concat-class
"chat-message-emoji-picker-anchor"
(if
(and
this.chatEmojiPickerManager.opened
(eq this.chatEmojiPickerManager.context "chat-message")
)
"-opened"
)
}}
></div>
<div class="chat-messages-scroll chat-messages-container">
<div class="chat-message-actions-desktop-anchor"></div>
<div class="chat-messages-container">
{{#if this.loadingMorePast}}
<ChatSkeleton
@onInsert={{this.onDidInsertSkeleton}}
@onDestroy={{this.onDestroySkeleton}}
/>
{{/if}}
{{#each @channel.messages key="id" as |message|}}
<ChatMessage
@message={{message}}
@canInteractWithChat={{this.canInteractWithChat}}
@channel={{@channel}}
@setReplyTo={{this.setReplyTo}}
@replyMessageClicked={{this.replyMessageClicked}}
@editButtonClicked={{this.editButtonClicked}}
@selectingMessages={{this.selectingMessages}}
@onStartSelectingMessages={{this.onStartSelectingMessages}}
@onSelectMessage={{this.onSelectMessage}}
@bulkSelectMessages={{this.bulkSelectMessages}}
@isHovered={{eq message.id this.hoveredMessageId}}
@onHoverMessage={{this.onHoverMessage}}
@resendStagedMessage={{this.resendStagedMessage}}
@didShowMessage={{this.didShowMessage}}
@didHideMessage={{this.didHideMessage}}
/>
{{/each}}
{{#if (or this.loadingMoreFuture)}}
<ChatSkeleton
@onInsert={{this.onDidInsertSkeleton}}
@onDestroy={{this.onDestroySkeleton}}
/>
{{/if}}
</div>
{{#if (and this.loadedOnce (not @channel.canLoadMorePast))}}
<div class="all-loaded-message">
{{i18n "chat.all_loaded"}}
</div>
{{/if}}
</div>
<ChatScrollToBottomArrow
@scrollToBottom={{this.scrollToBottom}}
@hasNewMessages={{this.hasNewMessages}}
@isAlmostDocked={{this.isAlmostDocked}}
@channel={{@channel}}
/>
{{#if this.selectingMessages}}
<ChatSelectionManager
@selectedMessageIds={{this.selectedMessageIds}}
@chatChannel={{@channel}}
@cancelSelecting={{this.cancelSelecting}}
/>
{{else}}
<ChatChannelPreviewCard @channel={{this.chatChannel}} />
{{#if (or @channel.isDraft @channel.isFollowing)}}
<ChatComposer
@canInteractWithChat={{this.canInteractWithChat}}
@sendMessage={{this.sendMessage}}
@editMessage={{this.editMessage}}
@setReplyTo={{this.setReplyTo}}
@loading={{this.sendingLoading}}
@editingMessage={{readonly this.editingMessage}}
@onCancelEditing={{this.cancelEditing}}
@setInReplyToMsg={{this.setInReplyToMsg}}
@onEditLastMessageRequested={{this.editLastMessageRequested}}
@onValueChange={{this.composerValueChanged}}
@chatChannel={{@channel}}
/>
{{else}}
<ChatChannelPreviewCard @channel={{@channel}} />
{{/if}}
{{/if}}
{{/if}}
</div>

View File

@ -6,11 +6,11 @@
>
<div class="chat-message-actions">
{{#if this.chatStateManager.isFullPageActive}}
{{#each @emojiReactions as |reaction|}}
{{#each @emojiReactions key="emoji" as |reaction|}}
<ChatMessageReaction
@reaction={{reaction}}
@react={{@messageActions.react}}
@class="show"
@showCount={{false}}
/>
{{/each}}
{{/if}}

View File

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

View File

@ -53,7 +53,7 @@
<ChatMessageReaction
@reaction={{reaction}}
@react={{@messageActions.react}}
@class="show"
@showCount={{false}}
/>
{{/each}}

View File

@ -1,6 +1,6 @@
<div class="chat-message-avatar">
{{#if @message.chat_webhook_event.emoji}}
<ChatEmojiAvatar @emoji={{@message.chat_webhook_event.emoji}} />
{{#if @message.chatWebhookEvent.emoji}}
<ChatEmojiAvatar @emoji={{@message.chatWebhookEvent.emoji}} />
{{else}}
<ChatUserAvatar @user={{@message.user}} @avatarSize="medium" />
{{/if}}

View File

@ -1,5 +0,0 @@
import Component from "@ember/component";
export default class ChatMessageAvatar extends Component {
tagName = "";
}

View File

@ -1,10 +1,10 @@
<div class="chat-message-collapser">
{{#if this.hasUploads}}
{{html-safe this.cooked}}
{{html-safe @cooked}}
<Collapser @header={{this.uploadsHeader}}>
<div class="chat-uploads">
{{#each this.uploads as |upload|}}
{{#each @uploads as |upload|}}
<ChatUpload @upload={{upload}} />
{{/each}}
</div>

View File

@ -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(
`<span class="chat-message-collapser-link-small">${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);

View File

@ -0,0 +1,19 @@
{{#if @message.inReplyTo}}
<LinkTo
@route={{this.route}}
@models={{this.model}}
class="chat-reply is-direct-reply"
>
{{d-icon "share" title="chat.in_reply_to"}}
{{#if @message.inReplyTo.chatWebhookEvent.emoji}}
<ChatEmojiAvatar @emoji={{@message.inReplyTo.chatWebhookEvent.emoji}} />
{{else}}
<ChatUserAvatar @user={{@message.inReplyTo.user}} />
{{/if}}
<span class="chat-reply__excerpt">
{{replace-emoji @message.inReplyTo.excerpt}}
</span>
</LinkTo>
{{/if}}

View File

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

View File

@ -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}}
<span
class={{concat-class
"chat-message-info__username"
this.usernameClasses
}}
>
{{@message.chat_webhook_event.username}}
{{@message.chatWebhookEvent.username}}
</span>
{{/if}}
@ -49,8 +49,8 @@
{{#if this.isFlagged}}
<span class="chat-message-info__flag">
{{#if @message.reviewable_id}}
<LinkTo @route="review.show" @model={{@message.reviewable_id}}>
{{#if @message.reviewableId}}
<LinkTo @route="review.show" @model={{@message.reviewableId}}>
{{d-icon "flag" title="chat.flagged"}}
</LinkTo>
{{else}}

View File

@ -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() {

View File

@ -1,17 +1,17 @@
<div class="chat-message-left-gutter">
{{#if @message.reviewable_id}}
{{#if @message.reviewableId}}
<LinkTo
@route="review.show"
@model={{@message.reviewable_id}}
@model={{@message.reviewableId}}
class="chat-message-left-gutter__flag"
>
{{d-icon "flag" title="chat.flagged"}}
</LinkTo>
{{else if (eq @message.user_flag_status 0)}}
{{else if (eq @message.userFlagStatus 0)}}
<div class="chat-message-left-gutter__flag">
{{d-icon "flag" title="chat.you_flagged"}}
</div>
{{else}}
{{else if this.site.desktopView}}
<span class="chat-message-left-gutter__date">
{{format-chat-date @message "tiny"}}
</span>

View File

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

View File

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

View File

@ -1,17 +1,18 @@
{{#if (and this.reaction this.emojiUrl)}}
{{#if (and @reaction this.emojiUrl)}}
<button
id={{this.componentId}}
type="button"
{{on "click" (action "handleClick")}}
{{on "click" this.handleClick}}
tabindex="0"
class={{concat-class
this.class
"chat-message-reaction"
(if this.reaction.reacted "reacted")
(if this.reaction.count "show")
(if @reaction.reacted "reacted")
}}
data-emoji-name={{this.reaction.emoji}}
data-emoji-name={{@reaction.emoji}}
data-tippy-content={{this.popoverContent}}
title={{this.emojiString}}
{{did-insert this.setupTooltip}}
{{will-destroy this.teardownTooltip}}
{{did-update this.refreshTooltip this.popoverContent}}
>
<img
loading="lazy"
@ -22,8 +23,8 @@
src={{this.emojiUrl}}
/>
{{#if this.reaction.count}}
<span class="count">{{this.reaction.count}}</span>
{{#if (and this.showCount @reaction.count)}}
<span class="count">{{@reaction.count}}</span>
{{/if}}
</button>
{{/if}}

View File

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

View File

@ -0,0 +1,26 @@
{{#if @message.firstMessageOfTheDayAt}}
<div
class={{concat-class
"chat-message-separator-date"
(if @message.newest "last-visit")
}}
>
<div
class="chat-message-separator__text-container"
{{chat/track-message-separator-date}}
>
<span class="chat-message-separator__text">
{{@message.firstMessageOfTheDayAt}}
{{#if @message.newest}}
-
{{i18n "chat.last_visit"}}
{{/if}}
</span>
</div>
</div>
<div class="chat-message-separator__line-container">
<div class="chat-message-separator__line"></div>
</div>
{{/if}}

View File

@ -0,0 +1,13 @@
{{#if (and @message.newest (not @message.firstMessageOfTheDayAt))}}
<div class="chat-message-separator-new">
<div class="chat-message-separator__text-container">
<span class="chat-message-separator__text">
{{i18n "chat.last_visit"}}
</span>
</div>
<div class="chat-message-separator__line-container">
<div class="chat-message-separator__line"></div>
</div>
</div>
{{/if}}

View File

@ -1,15 +0,0 @@
{{#if this.message.newestMessage}}
<div class="chat-message-separator new-message">
<div class="divider"></div>
<span class="text">
{{i18n "chat.new_messages"}}
</span>
</div>
{{else if this.message.firstMessageOfTheDayAt}}
<div class="chat-message-separator first-daily-message">
<div class="divider"></div>
<span class="text">
{{this.message.firstMessageOfTheDayAt}}
</span>
</div>
{{/if}}

View File

@ -1,5 +0,0 @@
import Component from "@ember/component";
export default Component.extend({
tagName: "",
});

View File

@ -1,11 +1,11 @@
<div class="chat-message-text">
{{#if this.isCollapsible}}
<ChatMessageCollapser @cooked={{this.cooked}} @uploads={{this.uploads}} />
<ChatMessageCollapser @cooked={{@cooked}} @uploads={{@uploads}} />
{{else}}
{{html-safe this.cooked}}
{{html-safe @cooked}}
{{/if}}
{{#if this.edited}}
{{#if this.isEdited}}
<span class="chat-message-edited">({{i18n "chat.edited"}})</span>
{{/if}}

View File

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

View File

@ -1,6 +1,7 @@
{{! template-lint-disable no-invalid-interactive }}
<ChatMessageSeparator @message={{@message}} />
<ChatMessageSeparatorDate @message={{@message}} />
<ChatMessageSeparatorNew @message={{@message}} />
{{#if
(and
@ -40,19 +41,23 @@
{{did-insert this.setMessageActionsAnchors}}
{{did-insert this.decorateCookedMessage}}
{{did-update this.decorateCookedMessage @message.id}}
{{did-update this.decorateCookedMessage @message.version}}
{{on "touchmove" this.handleTouchMove passive=true}}
{{on "touchstart" this.handleTouchStart passive=true}}
{{on "touchend" this.handleTouchEnd passive=true}}
{{on "mouseenter" (fn @onHoverMessage @message (hash desktopOnly=true))}}
{{on "mouseleave" (fn @onHoverMessage null (hash desktopOnly=true))}}
{{chat/track-message-visibility}}
class={{concat-class
"chat-message-container"
(if @isHovered "is-hovered")
(if @selectingMessages "selecting-messages")
(if @message.highlighted "highlighted")
}}
data-id={{@message.id}}
{{chat/track-message
(fn @didShowMessage @message)
(fn @didHideMessage @message)
}}
data-id={{or @message.id @message.stagedId}}
data-staged-id={{if @message.staged @message.stagedId}}
>
{{#if this.show}}
{{#if @selectingMessages}}
@ -85,35 +90,17 @@
class={{concat-class
"chat-message"
(if @message.staged "chat-message-staged")
(if @message.deleted_at "deleted")
(if @message.in_reply_to "is-reply")
(if @message.deletedAt "deleted")
(if (and @message.inReplyTo (not this.hideReplyToInfo)) "is-reply")
(if this.hideUserInfo "user-info-hidden")
(if @message.error "errored")
(if @message.bookmark "chat-message-bookmarked")
(if @isHovered "chat-message-selected")
}}
>
{{#if @message.in_reply_to}}
<div
role="button"
onclick={{action this.viewReplyOrThread}}
class="chat-reply is-direct-reply"
>
{{d-icon "share" title="chat.in_reply_to"}}
{{#if @message.in_reply_to.chat_webhook_event.emoji}}
<ChatEmojiAvatar
@emoji={{@message.in_reply_to.chat_webhook_event.emoji}}
/>
{{else}}
<ChatUserAvatar @user={{@message.in_reply_to.user}} />
{{/if}}
<span class="chat-reply__excerpt">
{{replace-emoji @message.in_reply_to.excerpt}}
</span>
</div>
{{/if}}
{{#unless this.hideReplyToInfo}}
<ChatMessageInReplyToIndicator @message={{@message}} />
{{/unless}}
{{#if this.hideUserInfo}}
<ChatMessageLeftGutter @message={{@message}} />
@ -131,7 +118,7 @@
@uploads={{@message.uploads}}
@edited={{@message.edited}}
>
{{#if this.hasReactions}}
{{#if @message.reactions.length}}
<div class="chat-message-reaction-list">
{{#if this.reactionLabel}}
<div class="reaction-users-list">
@ -139,18 +126,13 @@
</div>
{{/if}}
{{#each-in @message.reactions as |emoji reactionAttrs|}}
{{#each @message.reactions as |reaction|}}
<ChatMessageReaction
@reaction={{hash
emoji=emoji
users=reactionAttrs.users
count=reactionAttrs.count
reacted=reactionAttrs.reacted
}}
@reaction={{reaction}}
@react={{this.react}}
@showUsersList={{true}}
@showTooltip={{true}}
/>
{{/each-in}}
{{/each}}
{{#if @canInteractWithChat}}
{{#unless this.site.mobileView}}
@ -189,7 +171,7 @@
{{#if this.mentionWarning}}
<div class="alert alert-info chat-message-mention-warning">
{{#if this.mentionWarning.invitationSent}}
{{#if this.mentionWarning.invitation_sent}}
{{d-icon "check"}}
<span>
{{i18n

View File

@ -5,8 +5,7 @@ import Component from "@glimmer/component";
import I18n from "I18n";
import getURL from "discourse-common/lib/get-url";
import optionalService from "discourse/lib/optional-service";
import { bind } from "discourse-common/utils/decorators";
import EmberObject, { action } from "@ember/object";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { cancel, schedule } from "@ember/runloop";
import { clipboardCopy } from "discourse/lib/utilities";
@ -18,6 +17,7 @@ import showModal from "discourse/lib/show-modal";
import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag";
import { tracked } from "@glimmer/tracking";
import { getOwner } from "discourse-common/lib/get-owner";
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
let _chatMessageDecorators = [];
@ -50,37 +50,24 @@ export default class ChatMessage extends Component {
@optionalService adminTools;
cachedFavoritesReactions = null;
_hasSubscribedToAppEvents = false;
_loadingReactions = [];
reacting = false;
constructor() {
super(...arguments);
this.args.message.id
? this._subscribeToAppEvents()
: this._waitForIdToBePopulated();
if (this.args.message.bookmark) {
this.args.message.set(
"bookmark",
Bookmark.create(this.args.message.bookmark)
);
}
this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites;
}
get deletedAndCollapsed() {
return this.args.message?.get("deleted_at") && this.collapsed;
return this.args.message?.deletedAt && this.collapsed;
}
get hiddenAndCollapsed() {
return this.args.message?.get("hidden") && this.collapsed;
return this.args.message?.hidden && this.collapsed;
}
get collapsed() {
return !this.args.message?.get("expanded");
return !this.args.message?.expanded;
}
@action
@ -97,32 +84,9 @@ export default class ChatMessage extends Component {
@action
teardownChatMessage() {
if (this.args.message?.stagedId) {
this.appEvents.off(
`chat-message-staged-${this.args.message.stagedId}:id-populated`,
this,
"_subscribeToAppEvents"
);
}
this.appEvents.off("chat:refresh-message", this, "_refreshedMessage");
this.appEvents.off(
`chat-message-${this.args.message.id}:reaction`,
this,
"_handleReactionMessage"
);
cancel(this._invitationSentTimer);
}
@bind
_refreshedMessage(message) {
if (message.id === this.args.message.id) {
this.decorateCookedMessage();
}
}
@action
decorateCookedMessage() {
schedule("afterRender", () => {
@ -131,45 +95,22 @@ export default class ChatMessage extends Component {
}
_chatMessageDecorators.forEach((decorator) => {
decorator.call(this, this.messageContainer, this.args.chatChannel);
decorator.call(this, this.messageContainer, this.args.channel);
});
});
}
get messageContainer() {
const id = this.args.message?.id || this.args.message?.stagedId;
return (
id && document.querySelector(`.chat-message-container[data-id='${id}']`)
);
}
_subscribeToAppEvents() {
if (!this.args.message.id || this._hasSubscribedToAppEvents) {
return;
const id = this.args.message?.id;
if (id) {
return document.querySelector(`.chat-message-container[data-id='${id}']`);
}
this.appEvents.on("chat:refresh-message", this, "_refreshedMessage");
this.appEvents.on(
`chat-message-${this.args.message.id}:reaction`,
this,
"_handleReactionMessage"
);
this._hasSubscribedToAppEvents = true;
}
_waitForIdToBePopulated() {
this.appEvents.on(
`chat-message-staged-${this.args.message.stagedId}:id-populated`,
this,
"_subscribeToAppEvents"
);
}
get showActions() {
return (
this.args.canInteractWithChat &&
!this.args.message?.get("staged") &&
!this.args.message?.staged &&
this.args.isHovered
);
}
@ -270,17 +211,16 @@ export default class ChatMessage extends Component {
get hasThread() {
return (
this.args.chatChannel?.get("threading_enabled") &&
this.args.message?.get("thread_id")
this.args.channel?.get("threading_enabled") && this.args.message?.threadId
);
}
get show() {
return (
!this.args.message?.get("deleted_at") ||
this.currentUser.id === this.args.message?.get("user.id") ||
!this.args.message?.deletedAt ||
this.currentUser.id === this.args.message?.user?.id ||
this.currentUser.staff ||
this.args.details?.can_moderate
this.args.channel?.canModerate
);
}
@ -331,83 +271,97 @@ export default class ChatMessage extends Component {
get hideUserInfo() {
return (
this.args.message?.get("hideUserInfo") &&
!this.args.message?.get("chat_webhook_event")
!this.args.message?.chatWebhookEvent &&
!this.args.message?.inReplyTo &&
!this.args.message?.previousMessage?.deletedAt &&
Math.abs(
new Date(this.args.message?.createdAt) -
new Date(this.args.message?.createdAt)
) < 300000 && // If the time between messages is over 5 minutes, break.
this.args.message?.user?.id ===
this.args.message?.previousMessage?.user?.id
);
}
get hideReplyToInfo() {
return (
this.args.message?.inReplyTo?.id ===
this.args.message?.previousMessage?.id
);
}
get showEditButton() {
return (
!this.args.message?.get("deleted_at") &&
this.currentUser?.id === this.args.message?.get("user.id") &&
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
!this.args.message?.deletedAt &&
this.currentUser?.id === this.args.message?.user?.id &&
this.args.channel?.canModifyMessages?.(this.currentUser)
);
}
get canFlagMessage() {
return (
this.currentUser?.id !== this.args.message?.get("user.id") &&
this.args.message?.get("user_flag_status") === undefined &&
this.args.details?.can_flag &&
!this.args.message?.get("chat_webhook_event") &&
!this.args.message?.get("deleted_at")
this.currentUser?.id !== this.args.message?.user?.id &&
!this.args.channel?.isDirectMessageChannel &&
this.args.message?.userFlagStatus === undefined &&
this.args.channel?.canFlag &&
!this.args.message?.chatWebhookEvent &&
!this.args.message?.deletedAt
);
}
get canManageDeletion() {
return this.currentUser?.id === this.args.message.get("user.id")
? this.args.details?.can_delete_self
: this.args.details?.can_delete_others;
return this.currentUser?.id === this.args.message.user.id
? this.args.channel?.canDeleteSelf
: this.args.channel?.canDeleteOthers;
}
get canReply() {
return (
!this.args.message?.get("deleted_at") &&
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
!this.args.message?.deletedAt &&
this.args.channel?.canModifyMessages?.(this.currentUser)
);
}
get canReact() {
return (
!this.args.message?.get("deleted_at") &&
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
!this.args.message?.deletedAt &&
this.args.channel?.canModifyMessages?.(this.currentUser)
);
}
get showDeleteButton() {
return (
this.canManageDeletion &&
!this.args.message?.get("deleted_at") &&
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
!this.args.message?.deletedAt &&
this.args.channel?.canModifyMessages?.(this.currentUser)
);
}
get showRestoreButton() {
return (
this.canManageDeletion &&
this.args.message?.get("deleted_at") &&
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
this.args.message?.deletedAt &&
this.args.channel?.canModifyMessages?.(this.currentUser)
);
}
get showBookmarkButton() {
return this.args.chatChannel?.canModifyMessages?.(this.currentUser);
return this.args.channel?.canModifyMessages?.(this.currentUser);
}
get showRebakeButton() {
return (
this.currentUser?.staff &&
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
this.args.channel?.canModifyMessages?.(this.currentUser)
);
}
get hasReactions() {
return Object.values(this.args.message.get("reactions")).some(
(r) => r.count > 0
);
return Object.values(this.args.message.reactions).some((r) => r.count > 0);
}
get mentionWarning() {
return this.args.message.get("mentionWarning");
return this.args.message.mentionWarning;
}
get mentionedCannotSeeText() {
@ -464,13 +418,13 @@ export default class ChatMessage extends Component {
inviteMentioned() {
const userIds = this.mentionWarning.without_membership.mapBy("id");
ajax(`/chat/${this.args.message.chat_channel_id}/invite`, {
ajax(`/chat/${this.args.message.channelId}/invite`, {
method: "PUT",
data: { user_ids: userIds, chat_message_id: this.args.message.id },
}).then(() => {
this.args.message.set("mentionWarning.invitationSent", true);
this.args.message.mentionWarning.set("invitationSent", true);
this._invitationSentTimer = discourseLater(() => {
this.args.message.set("mentionWarning", null);
this.dismissMentionWarning();
}, 3000);
});
@ -479,7 +433,7 @@ export default class ChatMessage extends Component {
@action
dismissMentionWarning() {
this.args.message.set("mentionWarning", null);
this.args.message.mentionWarning = null;
}
@action
@ -517,27 +471,17 @@ export default class ChatMessage extends Component {
this.react(emoji, REACTIONS.add);
}
@bind
_handleReactionMessage(busData) {
const loadingReactionIndex = this._loadingReactions.indexOf(busData.emoji);
if (loadingReactionIndex > -1) {
return this._loadingReactions.splice(loadingReactionIndex, 1);
}
this._updateReactionsList(busData.emoji, busData.action, busData.user);
this.args.afterReactionAdded();
}
get capabilities() {
return getOwner(this).lookup("capabilities:main");
}
@action
react(emoji, reactAction) {
if (
!this.args.canInteractWithChat ||
this._loadingReactions.includes(emoji)
) {
if (!this.args.canInteractWithChat) {
return;
}
if (this.reacting) {
return;
}
@ -549,71 +493,21 @@ export default class ChatMessage extends Component {
this.args.onHoverMessage(null);
}
this._loadingReactions.push(emoji);
this._updateReactionsList(emoji, reactAction, this.currentUser);
if (reactAction === REACTIONS.add) {
this.chatEmojiReactionStore.track(`:${emoji}:`);
}
return this._publishReaction(emoji, reactAction).then(() => {
// creating reaction will create a membership if not present
// so we will fully refresh if we were not members of the channel
// already
if (!this.args.chatChannel.isFollowing || this.args.chatChannel.isDraft) {
return this.args.chatChannelsManager
.getChannel(this.args.chatChannel.id)
.then((reactedChannel) => {
this.router.transitionTo("chat.channel", "-", reactedChannel.id);
});
}
});
}
this.reacting = true;
_updateReactionsList(emoji, reactAction, user) {
const selfReacted = this.currentUser.id === user.id;
if (this.args.message.reactions[emoji]) {
if (
selfReacted &&
reactAction === REACTIONS.add &&
this.args.message.reactions[emoji].reacted
) {
// User is already has reaction added; do nothing
return false;
}
this.args.message.react(
emoji,
reactAction,
this.currentUser,
this.currentUser.id
);
let newCount =
reactAction === REACTIONS.add
? this.args.message.reactions[emoji].count + 1
: this.args.message.reactions[emoji].count - 1;
this.args.message.reactions.set(`${emoji}.count`, newCount);
if (selfReacted) {
this.args.message.reactions.set(
`${emoji}.reacted`,
reactAction === REACTIONS.add
);
} else {
this.args.message.reactions[emoji].users.pushObject(user);
}
this.args.message.notifyPropertyChange("reactions");
} else {
if (reactAction === REACTIONS.add) {
this.args.message.reactions.set(emoji, {
count: 1,
reacted: selfReacted,
users: selfReacted ? [] : [user],
});
}
this.args.message.notifyPropertyChange("reactions");
}
}
_publishReaction(emoji, reactAction) {
return ajax(
`/chat/${this.args.message.chat_channel_id}/react/${this.args.message.id}`,
`/chat/${this.args.message.channelId}/react/${this.args.message.id}`,
{
type: "PUT",
data: {
@ -621,10 +515,19 @@ export default class ChatMessage extends Component {
emoji,
},
}
).catch((errResult) => {
popupAjaxError(errResult);
this._updateReactionsList(emoji, REACTIONS.remove, this.currentUser);
});
)
.catch((errResult) => {
popupAjaxError(errResult);
this.args.message.react(
emoji,
REACTIONS.remove,
this.currentUser,
this.currentUser.id
);
})
.finally(() => {
this.reacting = false;
});
}
// TODO(roman): For backwards-compatibility.
@ -651,17 +554,6 @@ export default class ChatMessage extends Component {
this.args.setReplyTo(this.args.message.id);
}
viewReplyOrThread() {
if (this.hasThread) {
this.router.transitionTo(
"chat.channel.thread",
this.args.message.thread_id
);
} else {
this.args.replyMessageClicked(this.args.message.in_reply_to);
}
}
@action
edit() {
this.args.editButtonClicked(this.args.message.id);
@ -673,12 +565,11 @@ export default class ChatMessage extends Component {
requirejs.entries["discourse/lib/flag-targets/flag"];
if (targetFlagSupported) {
const model = EmberObject.create(this.args.message);
model.set("username", model.get("user.username"));
model.set("user_id", model.get("user.id"));
const model = this.args.message;
model.username = model.user?.username;
model.user_id = model.user?.id;
let controller = showModal("flag", { model });
controller.setProperties({ flagTarget: new ChatMessageFlag() });
controller.set("flagTarget", new ChatMessageFlag());
} else {
this._legacyFlag();
}
@ -686,13 +577,13 @@ export default class ChatMessage extends Component {
@action
expand() {
this.args.message.set("expanded", true);
this.args.message.expanded = true;
}
@action
restore() {
return ajax(
`/chat/${this.args.message.chat_channel_id}/restore/${this.args.message.id}`,
`/chat/${this.args.message.channelId}/restore/${this.args.message.id}`,
{
type: "PUT",
}
@ -701,10 +592,7 @@ export default class ChatMessage extends Component {
@action
openThread() {
this.router.transitionTo(
"chat.channel.thread",
this.args.message.thread_id
);
this.router.transitionTo("chat.channel.thread", this.args.message.threadId);
}
@action
@ -719,7 +607,7 @@ export default class ChatMessage extends Component {
{
onAfterSave: (savedData) => {
const bookmark = Bookmark.create(savedData);
this.args.message.set("bookmark", bookmark);
this.args.message.bookmark = bookmark;
this.appEvents.trigger(
"bookmarks:changed",
savedData,
@ -727,7 +615,7 @@ export default class ChatMessage extends Component {
);
},
onAfterDelete: () => {
this.args.message.set("bookmark", null);
this.args.message.bookmark = null;
},
}
);
@ -736,7 +624,7 @@ export default class ChatMessage extends Component {
@action
rebakeMessage() {
return ajax(
`/chat/${this.args.message.chat_channel_id}/${this.args.message.id}/rebake`,
`/chat/${this.args.message.channelId}/${this.args.message.id}/rebake`,
{
type: "PUT",
}
@ -746,7 +634,7 @@ export default class ChatMessage extends Component {
@action
deleteMessage() {
return ajax(
`/chat/${this.args.message.chat_channel_id}/${this.args.message.id}`,
`/chat/${this.args.message.channelId}/${this.args.message.id}`,
{
type: "DELETE",
}
@ -755,7 +643,7 @@ export default class ChatMessage extends Component {
@action
selectMessage() {
this.args.message.set("selected", true);
this.args.message.selected = true;
this.args.onStartSelectingMessages(this.args.message);
}
@ -780,7 +668,7 @@ export default class ChatMessage extends Component {
const { protocol, host } = window.location;
let url = getURL(
`/chat/c/-/${this.args.message.chat_channel_id}/${this.args.message.id}`
`/chat/c/-/${this.args.message.channelId}/${this.args.message.id}`
);
url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url;
clipboardCopy(url);
@ -793,25 +681,22 @@ export default class ChatMessage extends Component {
}
get emojiReactions() {
const favorites = this.cachedFavoritesReactions;
let favorites = this.cachedFavoritesReactions;
// may be a {} if no defaults defined in some production builds
if (!favorites || !favorites.slice) {
return [];
}
const userReactions = Object.keys(this.args.message.reactions || {}).filter(
(key) => {
return this.args.message.reactions[key].reacted;
}
);
return favorites.slice(0, 3).map((emoji) => {
if (userReactions.includes(emoji)) {
return { emoji, reacted: true };
} else {
return { emoji, reacted: false };
}
return (
this.args.message.reactions.find(
(reaction) => reaction.emoji === emoji
) ||
ChatMessageReaction.create({
emoji,
})
);
});
}
}

View File

@ -1,6 +1,6 @@
{{#if this.show}}
<div class="chat-retention-reminder">
<ChatRetentionReminderText @channel={{this.chatChannel}} />
<ChatRetentionReminderText @channel={{@channel}} />
<DButton
@class="btn-flat dismiss-btn"
@action={{this.dismiss}}

View File

@ -1,39 +1,34 @@
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
export default Component.extend({
tagName: "",
loading: false,
export default class ChatRetentionReminder extends Component {
@service currentUser;
@discourseComputed(
"chatChannel.chatable_type",
"currentUser.{needs_dm_retention_reminder,needs_channel_retention_reminder}"
)
show() {
get show() {
return (
!this.chatChannel.isDraft &&
((this.chatChannel.isDirectMessageChannel &&
this.currentUser.needs_dm_retention_reminder) ||
(this.chatChannel.isCategoryChannel &&
this.currentUser.needs_channel_retention_reminder))
!this.args.channel?.isDraft &&
((this.args.channel?.isDirectMessageChannel &&
this.currentUser?.get("needs_dm_retention_reminder")) ||
(this.args.channel?.isCategoryChannel &&
this.currentUser?.get("needs_channel_retention_reminder")))
);
},
}
@action
dismiss() {
return ajax("/chat/dismiss-retention-reminder", {
method: "POST",
data: { chatable_type: this.chatChannel.chatable_type },
data: { chatable_type: this.args.channel.chatableType },
})
.then(() => {
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);
},
});
}
}

View File

@ -0,0 +1,23 @@
<div class="scroll-stick-wrap">
<DButton
class={{concat-class
"btn-flat"
"chat-scroll-to-bottom"
(if
(or (not @isAlmostDocked) @hasNewMessages @channel.canLoadMoreFuture)
"visible"
)
}}
@action={{@scrollToBottom}}
>
<span class="chat-scroll-to-bottom__arrow">
{{d-icon "arrow-down"}}
{{#if @hasNewMessages}}
<span class="chat-scroll-to-bottom__text">
{{i18n "chat.scroll_to_new_messages"}}
</span>
{{/if}}
</span>
</DButton>
</div>

View File

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

View File

@ -1,13 +1,31 @@
<div class="chat-skeleton -animation">
{{#each this.placeholders as |rows|}}
<div
class="chat-skeleton -animation"
{{did-insert @onInsert}}
{{will-destroy @onDestroy}}
>
{{#each this.placeholders as |placeholder|}}
<div class="chat-skeleton__body">
<div class="chat-skeleton__message">
<div class="chat-skeleton__message-avatar"></div>
<div class="chat-skeleton__message-poster"></div>
<div class="chat-skeleton__message-content">
{{#each rows as |row|}}
<div class="chat-skeleton__message-msg" style={{row}}></div>
{{/each}}
{{#if placeholder.image}}
<div class="chat-skeleton__message-img"></div>
{{/if}}
<div class="chat-skeleton__message-text">
{{#each placeholder.rows as |row|}}
<div class="chat-skeleton__message-msg" style={{row}}></div>
{{/each}}
</div>
{{#if placeholder.reactions}}
<div class="chat-skeleton__message-reactions">
{{#each placeholder.reactions}}
<div class="chat-skeleton__message-reaction"></div>
{{/each}}
</div>
{{/if}}
</div>
</div>
</div>

View File

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

View File

@ -1,7 +1,6 @@
{{#if this.chat.activeChannel}}
<ChatLivePane
@chatChannel={{this.chat.activeChannel}}
@onBackClick={{action "navigateToIndex"}}
@channel={{this.chat.activeChannel}}
@targetMessageId={{readonly @targetMessageId}}
/>
{{/if}}

View File

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

View File

@ -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"];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import { tracked } from "@glimmer/tracking";
import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel";
import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-threads-manager";
import { getOwner } from "discourse-common/lib/get-owner";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
export const CHATABLE_TYPES = {
directMessageChannel: "DirectMessage",
@ -54,6 +55,16 @@ export default class ChatChannel extends RestModel {
@tracked chatableType;
@tracked status;
@tracked activeThread;
@tracked messages = new TrackedArray();
@tracked lastMessageSentAt;
@tracked canDeleteOthers;
@tracked canDeleteSelf;
@tracked canFlag;
@tracked canLoadMoreFuture;
@tracked canLoadMorePast;
@tracked canModerate;
@tracked userSilenced;
@tracked draft;
threadsManager = new ChatThreadsManager(getOwner(this));
@ -74,11 +85,11 @@ export default class ChatChannel extends RestModel {
}
get isDirectMessageChannel() {
return this.chatable_type === CHATABLE_TYPES.directMessageChannel;
return this.chatableType === CHATABLE_TYPES.directMessageChannel;
}
get isCategoryChannel() {
return this.chatable_type === CHATABLE_TYPES.categoryChannel;
return this.chatableType === CHATABLE_TYPES.categoryChannel;
}
get isOpen() {
@ -105,6 +116,56 @@ export default class ChatChannel extends RestModel {
return this.currentUserMembership.following;
}
get visibleMessages() {
return this.messages.filter((message) => message.visible);
}
set details(details) {
this.canDeleteOthers = details.can_delete_others ?? false;
this.canDeleteSelf = details.can_delete_self ?? false;
this.canFlag = details.can_flag ?? false;
this.canModerate = details.can_moderate ?? false;
if (details.can_load_more_future !== undefined) {
this.canLoadMoreFuture = details.can_load_more_future;
}
if (details.can_load_more_past !== undefined) {
this.canLoadMorePast = details.can_load_more_past;
}
this.userSilenced = details.user_silenced ?? false;
this.status = details.channel_status;
this.channelMessageBusLastId = details.channel_message_bus_last_id;
}
clearMessages() {
this.messages.clear();
this.canLoadMoreFuture = null;
this.canLoadMorePast = null;
}
addMessages(messages = []) {
this.messages = this.messages
.concat(messages)
.uniqBy("id")
.sortBy("createdAt");
}
findMessage(messageId) {
return this.messages.find(
(message) => message.id === parseInt(messageId, 10)
);
}
removeMessage(message) {
return this.messages.removeObject(message);
}
findStagedMessage(stagedMessageId) {
return this.messages.find(
(message) => message.staged && message.id === stagedMessageId
);
}
canModifyMessages(user) {
if (user.staff) {
return !STAFF_READONLY_STATUSES.includes(this.status);
@ -127,6 +188,10 @@ export default class ChatChannel extends RestModel {
return;
}
if (this.currentUserMembership.last_read_message_id >= messageId) {
return;
}
return ajax(`/chat/${this.id}/read/${messageId}.json`, {
method: "PUT",
}).then(() => {
@ -142,12 +207,17 @@ ChatChannel.reopenClass({
this._initUserModels(args);
this._initUserMembership(args);
args.chatableType = args.chatable_type;
args.membershipsCount = args.memberships_count;
this._remapKey(args, "chatable_type", "chatableType");
this._remapKey(args, "memberships_count", "membershipsCount");
this._remapKey(args, "last_message_sent_at", "lastMessageSentAt");
return this._super(args);
},
_remapKey(obj, oldKey, newKey) {
delete Object.assign(obj, { [newKey]: obj[oldKey] })[oldKey];
},
_initUserModels(args) {
if (args.chatable?.users?.length) {
for (let i = 0; i < args.chatable?.users?.length; i++) {

View File

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

View File

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

View File

@ -1,26 +1,191 @@
import RestModel from "discourse/models/rest";
import User from "discourse/models/user";
import EmberObject from "@ember/object";
import { cached, tracked } from "@glimmer/tracking";
import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
import Bookmark from "discourse/models/bookmark";
import I18n from "I18n";
import guid from "pretty-text/guid";
export default class ChatMessage extends RestModel {}
export default class ChatMessage {
static cookFunction = null;
ChatMessage.reopenClass({
create(args = {}) {
this._initReactions(args);
this._initUserModel(args);
static create(channel, args = {}) {
return new ChatMessage(channel, args);
}
return this._super(args);
},
static createStagedMessage(channel, args = {}) {
args.id = guid();
args.staged = true;
return new ChatMessage(channel, args);
}
_initReactions(args) {
args.reactions = EmberObject.create(args.reactions || {});
},
@tracked id;
@tracked error;
@tracked selected;
@tracked channel;
@tracked staged = false;
@tracked channelId;
@tracked createdAt;
@tracked deletedAt;
@tracked uploads;
@tracked excerpt;
@tracked message;
@tracked threadId;
@tracked reactions;
@tracked reviewableId;
@tracked user;
@tracked cooked;
@tracked inReplyTo;
@tracked expanded;
@tracked bookmark;
@tracked userFlagStatus;
@tracked hidden;
@tracked version = 0;
@tracked edited;
@tracked chatWebhookEvent = new TrackedObject();
@tracked mentionWarning;
@tracked availableFlags;
@tracked newest = false;
@tracked highlighted = false;
_initUserModel(args) {
if (!args.user || args.user instanceof User) {
return;
constructor(channel, args = {}) {
this.channel = channel;
this.id = args.id;
this.newest = args.newest;
this.staged = args.staged;
this.edited = args.edited;
this.availableFlags = args.available_flags;
this.hidden = args.hidden;
this.threadId = args.thread_id;
this.channelId = args.chat_channel_id;
this.chatWebhookEvent = args.chat_webhook_event;
this.createdAt = args.created_at;
this.deletedAt = args.deleted_at;
this.excerpt = args.excerpt;
this.reviewableId = args.reviewable_id;
this.userFlagStatus = args.user_flag_status;
this.inReplyTo = args.in_reply_to
? ChatMessage.create(channel, args.in_reply_to)
: null;
this.message = args.message;
this.cooked = args.cooked || ChatMessage.cookFunction(this.message);
this.reactions = this.#initChatMessageReactionModel(
args.id,
args.reactions
);
this.uploads = new TrackedArray(args.uploads || []);
this.user = this.#initUserModel(args.user);
this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null;
}
get read() {
return this.channel.currentUserMembership?.last_read_message_id >= this.id;
}
get firstMessageOfTheDayAt() {
if (!this.previousMessage) {
return this.#calendarDate(this.createdAt);
}
args.user = User.create(args.user);
},
});
if (
!this.#areDatesOnSameDay(
new Date(this.previousMessage.createdAt),
new Date(this.createdAt)
)
) {
return this.#calendarDate(this.createdAt);
}
}
#calendarDate(date) {
return moment(date).calendar(moment(), {
sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`,
lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`,
lastWeek: "LL",
sameElse: "LL",
});
}
@cached
get index() {
return this.channel.messages.indexOf(this);
}
@cached
get previousMessage() {
return this.channel?.messages?.objectAt?.(this.index - 1);
}
@cached
get nextMessage() {
return this.channel?.messages?.objectAt?.(this.index + 1);
}
react(emoji, action, actor, currentUserId) {
const selfReaction = actor.id === currentUserId;
const existingReaction = this.reactions.find(
(reaction) => reaction.emoji === emoji
);
if (existingReaction) {
if (action === "add") {
if (selfReaction && existingReaction.reacted) {
return false;
}
existingReaction.count = existingReaction.count + 1;
if (selfReaction) {
existingReaction.reacted = true;
}
existingReaction.users.pushObject(actor);
} else {
existingReaction.count = existingReaction.count - 1;
if (selfReaction) {
existingReaction.reacted = false;
}
if (existingReaction.count === 0) {
this.reactions.removeObject(existingReaction);
} else {
existingReaction.users.removeObject(
existingReaction.users.find((user) => user.id === actor.id)
);
}
}
} else {
if (action === "add") {
this.reactions.pushObject(
ChatMessageReaction.create({
count: 1,
emoji,
reacted: selfReaction,
users: [actor],
})
);
}
}
}
#initChatMessageReactionModel(messageId, reactions = []) {
return reactions.map((reaction) =>
ChatMessageReaction.create(Object.assign({ messageId }, reaction))
);
}
#initUserModel(user) {
if (!user || user instanceof User) {
return user;
}
return User.create(user);
}
#areDatesOnSameDay(a, b) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -54,7 +54,12 @@
/>
{{#if this.categoryPermissionsHint}}
<div class="create-channel-hint">
<div
class={{concat-class
"create-channel-hint"
(if this.loadingPermissionHint "loading-permissions")
}}
>
{{this.categoryPermissionsHint}}
</div>
{{/if}}

View File

@ -5,6 +5,7 @@
display: flex;
flex-direction: column;
align-items: center;
z-index: 3;
&.-no-description {
.chat-channel-title {

View File

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

View File

@ -6,10 +6,6 @@
.chat-message-actions {
.chat-message-reaction {
@include chat-reaction;
&:not(.show) {
display: none;
}
}
}

View File

@ -1,42 +1,96 @@
.chat-message-separator {
@include unselectable;
margin: 0.25rem 0 0.25rem 1rem;
display: flex;
font-size: var(--font-down-1);
position: relative;
transform: translateZ(0);
position: relative;
&.new-message {
color: var(--danger-medium);
&-new {
position: relative;
padding: 20px 0;
.divider {
background-color: var(--danger-medium);
.chat-message-separator__text-container {
text-align: center;
position: absolute;
height: 40px;
width: 100%;
box-sizing: border-box;
z-index: 1;
top: 0;
display: flex;
align-items: center;
justify-content: center;
.chat-message-separator__text {
color: var(--danger-medium);
background-color: var(--secondary);
padding: 0.25rem 0.5rem;
font-size: var(--font-down-1);
}
}
.chat-message-separator__line-container {
width: 100%;
.chat-message-separator__line {
border-top: 1px solid var(--danger-medium);
}
}
}
&.first-daily-message {
.text {
color: var(--secondary-low);
font-weight: 600;
}
.divider {
background-color: var(--secondary-high);
}
}
.text {
margin: 0 auto;
padding: 0 0.75rem;
z-index: 1;
background: var(--secondary);
}
.divider {
&-date {
position: absolute;
width: 100%;
height: 1px;
top: 50%;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: center;
pointer-events: none;
&.last-visit {
.chat-message-separator__text {
color: var(--danger-medium);
}
& + .chat-message-separator__line-container {
.chat-message-separator__line {
border-color: var(--danger-medium);
}
}
}
.chat-message-separator__text-container {
padding-top: 7px;
position: sticky;
top: -1px;
&.is-pinned {
.chat-message-separator__text {
border: 1px solid var(--primary-medium);
border-radius: 3px;
}
}
}
.chat-message-separator__text {
@include unselectable;
background-color: var(--secondary);
border: 1px solid transparent;
color: var(--secondary-low);
font-size: var(--font-down-1);
padding: 0.25rem 0.5rem;
box-sizing: border-box;
}
& + .chat-message-separator__line-container {
padding: 20px 0;
box-sizing: border-box;
.chat-message-separator__line {
border-top: 1px solid var(--secondary-high);
left: 0;
margin: 0 0 -1px;
position: relative;
right: 0;
top: -1px;
}
}
}
}

View File

@ -42,6 +42,10 @@
background: var(--primary-low);
border-color: var(--primary-low-mid);
}
&:focus {
background: none;
}
}
.emoji {
@ -60,10 +64,6 @@
.chat-message-reaction {
@include chat-reaction;
&:not(.show) {
display: none;
}
}
&.chat-action {
@ -82,21 +82,6 @@
background-color: var(--danger-hover);
}
&.transition-slow {
transition: 2s linear background-color;
}
&.user-info-hidden {
.chat-time {
color: var(--secondary-medium);
flex-shrink: 0;
font-size: var(--font-down-2);
margin-top: 0.4em;
display: none;
width: var(--message-left-width);
}
}
&.is-reply {
display: grid;
grid-template-columns: var(--message-left-width) 1fr;
@ -254,6 +239,14 @@
.chat-message.chat-message-bookmarked {
background: var(--highlight-bg);
&:hover {
background: var(--highlight-medium);
}
}
.chat-message.chat-message-staged {
opacity: 0.6;
}
.not-mobile-device & .chat-message-reaction-list .chat-message-react-btn {

View File

@ -1,4 +1,4 @@
$radius: 10px;
$radius: 3px;
.chat-skeleton {
height: auto;
@ -55,11 +55,35 @@ $radius: 10px;
&__message-content {
grid-area: content;
width: 100%;
padding: 10px 0;
}
&__message-msg {
height: 13px;
&__message-reactions {
display: flex;
padding: 5px 0 0 0;
}
&__message-reaction {
background-color: var(--primary-100);
width: 32px;
height: 18px;
border-radius: $radius;
margin: 5px 0;
& + & {
margin-left: 0.5rem;
}
}
&__message-text {
display: flex;
padding: 0;
flex-direction: column;
}
&__message-msg {
height: 10px;
border-radius: $radius;
margin: 2px 0;
.chat-skeleton__body:nth-of-type(odd) & {
background-color: var(--primary-100);
@ -69,6 +93,14 @@ $radius: 10px;
}
}
&__message-img {
height: 80px;
border-radius: $radius;
margin: 2px 0;
width: 200px;
background-color: var(--primary-100);
}
*[class^="chat-skeleton__message-"] {
position: relative;
overflow: hidden;
@ -78,7 +110,7 @@ $radius: 10px;
position: relative;
overflow: hidden;
*[class^="chat-skeleton__message-"]:not(.chat-skeleton__message-content):after {
*[class^="chat-skeleton__message-"]:not(.chat-skeleton__message-content):not(.chat-skeleton__message-text):not(.chat-skeleton__message-reactions):after {
position: absolute;
top: 0;
right: 0;

View File

@ -144,6 +144,7 @@ $float-height: 530px;
.chat-messages-container {
word-wrap: break-word;
white-space: normal;
position: relative;
.chat-message-container {
display: grid;
@ -283,6 +284,8 @@ $float-height: 530px;
display: flex;
flex-direction: column-reverse;
z-index: 1;
margin: 0 3px 0 0;
will-change: transform;
&::-webkit-scrollbar {
width: 15px;
@ -323,37 +326,68 @@ $float-height: 530px;
}
.chat-scroll-to-bottom {
background: var(--primary-medium);
bottom: 1em;
border-radius: 100%;
left: 50%;
opacity: 50%;
padding: 0.5em;
left: calc(50% - calc(32px / 2));
align-items: center;
justify-content: center;
position: absolute;
transform: translateX(-50%);
z-index: 2;
z-index: 1;
flex-direction: column;
bottom: -75px;
background: none;
opacity: 0;
transition: opacity 0.25s ease, transform 0.5s ease;
transform: scale(0.1);
padding: 0;
&:hover {
> * {
pointer-events: none;
}
&:hover,
&:active,
&:focus {
background: none !important;
}
&.visible {
transform: translateY(-75px) scale(1);
opacity: 0.8;
}
&__text {
color: var(--secondary);
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--primary-medium);
opacity: 100%;
border-radius: 3px;
text-align: center;
font-size: var(--font-down-1);
bottom: 40px;
position: absolute;
}
.d-icon {
color: var(--primary);
margin: 0;
}
&.unread-messages {
opacity: 85%;
border-radius: 0;
transition: border-radius 0.1s linear;
&:hover {
opacity: 100%;
}
&__arrow {
display: flex;
background: var(--primary-medium);
border-radius: 100%;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
position: relative;
.d-icon {
margin: 0 0 0 0.5em;
color: var(--secondary);
}
}
&:hover {
opacity: 1;
.chat-scroll-to-bottom__arrow {
.d-icon {
color: var(--secondary);
}
}
}
}

View File

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

View File

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

View File

@ -22,6 +22,8 @@
border-radius: 8px;
.selected-message-reply {
margin-left: 5px;
&:not(.is-expanded) {
@include ellipsis;
}

View File

@ -4,7 +4,3 @@
.replying-text {
@include unselectable;
}
.chat-message-container {
transform: translateZ(0);
}

View File

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

View File

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

View File

@ -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 doesnt exist" do
it "doesnt 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 "doesnt 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 doesnt exist" do
it "doesnt 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 "doesnt 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 "doesnt 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 "doesnt list inactive users" do
memberships = described_class.call(channel_1)
memberships = described_class.call(channel: channel_1)
expect(memberships).to be_blank
end
end

View File

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

View File

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

View File

@ -21,12 +21,14 @@ describe ChatMessageSerializer do
it "doesnt 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

View File

@ -37,7 +37,7 @@ RSpec.describe "Chat channel", type: :system, js: true do
chat.visit_channel(channel_1)
expect(channel).to have_no_loading_skeleton
channel.send_message("aaaaaaaaaaaaaaaaaaaa")
expect(page).to have_no_css("[data-staged-id]")
expect(page).to have_no_css(".chat-message-staged")
last_message = find(".chat-message-container:last-child")
last_message.hover
@ -183,7 +183,7 @@ RSpec.describe "Chat channel", type: :system, js: true do
it "shows a date separator" do
chat.visit_channel(channel_1)
expect(page).to have_selector(".first-daily-message", text: "Today")
expect(page).to have_selector(".chat-message-separator__text", text: "Today")
end
end

View File

@ -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(
"&lt;script&gt;e&lt;/script&gt;",

View File

@ -18,7 +18,7 @@ RSpec.describe "Deleted message", type: :system, js: true do
chat_page.visit_channel(channel_1)
expect(channel_page).to have_no_loading_skeleton
channel_page.send_message("aaaaaaaaaaaaaaaaaaaa")
expect(page).to have_no_css("[data-staged-id]")
expect(page).to have_no_css(".chat-message-staged")
last_message = find(".chat-message-container:last-child")
channel_page.delete_message(OpenStruct.new(id: last_message["data-id"]))

View File

@ -3,6 +3,7 @@
RSpec.describe "Drawer", type: :system, js: true do
fab!(:current_user) { Fabricate(:admin) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:drawer) { PageObjects::Pages::ChatDrawer.new }
before do
@ -52,4 +53,39 @@ RSpec.describe "Drawer", type: :system, js: true do
expect(page.find(".chat-drawer").native.style("height")).to eq("530px")
end
end
context "when going from drawer to full page" do
fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:channel_2) { Fabricate(:chat_channel) }
fab!(:user_1) { Fabricate(:user) }
before do
channel_1.add(current_user)
channel_2.add(current_user)
channel_1.add(user_1)
channel_2.add(user_1)
end
it "correctly resets subscriptions" do
visit("/")
chat_page.open_from_header
drawer.maximize
chat_page.minimize_full_page
drawer.maximize
using_session("user_1") do |session|
sign_in(user_1)
chat_page.visit_channel(channel_1)
channel_page.send_message("onlyonce")
session.quit
end
expect(page).to have_content("onlyonce", count: 1)
chat_page.visit_channel(channel_2)
expect(page).to have_content("onlyonce", count: 0)
end
end
end

View File

@ -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 "doesnt allow to flag a message" do
chat.visit_channel(dm_channel_1)

Some files were not shown because too many files have changed in this diff Show More