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 = memberships =
ChatChannelMembershipsQuery.call( ChatChannelMembershipsQuery.call(
channel_from_params, channel: channel_from_params,
offset: offset, offset: offset,
limit: limit, limit: limit,
username: params[:username], username: params[:username],

View File

@ -223,7 +223,7 @@ class ChatMessage < ActiveRecord::Base
end end
def url def url
"/chat/message/#{self.id}" "/chat/c/-/#{self.chat_channel_id}/#{self.id}"
end end
private private

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ChatChannelMembershipsQuery 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 = query =
UserChatChannelMembership UserChatChannelMembership
.joins(:user) .joins(:user)
@ -42,6 +42,6 @@ class ChatChannelMembershipsQuery
end end
def self.count(channel) def self.count(channel)
call(channel, count_only: true) call(channel: channel, count_only: true)
end end
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 def meta
{ {
message_bus_last_ids: { message_bus_last_ids: {
channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.id}"),
new_messages: new_messages:
@opts[:new_messages_message_bus_last_id] || @opts[:new_messages_message_bus_last_id] ||
MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)), MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)),

View File

@ -35,23 +35,23 @@ class ChatMessageSerializer < ApplicationSerializer
end end
def reactions def reactions
reactions_hash = {}
object object
.reactions .reactions
.group_by(&:emoji) .group_by(&:emoji)
.each do |emoji, reactions| .map do |emoji, reactions|
users = reactions[0..5].map(&:user).filter { |user| user.id != scope&.user&.id }[0..4]
next unless Emoji.exists?(emoji) next unless Emoji.exists?(emoji)
reactions_hash[emoji] = { users = reactions.take(5).map(&:user)
{
emoji: emoji,
count: reactions.count, count: reactions.count,
users: users:
ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json,
reacted: users_reactions.include?(emoji), reacted: users_reactions.include?(emoji),
} }
end end
reactions_hash .compact
end end
def include_reactions? def include_reactions?

View File

@ -16,6 +16,7 @@ class ChatViewSerializer < ApplicationSerializer
def meta def meta
meta_hash = { meta_hash = {
channel_id: object.chat_channel.id,
can_flag: scope.can_flag_in_chat_channel?(object.chat_channel), can_flag: scope.can_flag_in_chat_channel?(object.chat_channel),
channel_status: object.chat_channel.status, channel_status: object.chat_channel.status,
user_silenced: !scope.can_create_chat_message?, user_silenced: !scope.can_create_chat_message?,

View File

@ -12,7 +12,7 @@ module ChatPublisher
{ scope: anonymous_guardian, root: :chat_message }, { scope: anonymous_guardian, root: :chat_message },
).as_json ).as_json
content[:type] = :sent content[:type] = :sent
content[:stagedId] = staged_id content[:staged_id] = staged_id
permissions = permissions(chat_channel) permissions = permissions(chat_channel)
MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions)
@ -133,9 +133,13 @@ module ChatPublisher
end end
def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) 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( MessageBus.publish(
self.user_tracking_state_message_bus_channel(user.id), 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], user_ids: [user.id],
) )
end end

View File

@ -1,24 +1,30 @@
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import Component from "@ember/component"; import Component from "@glimmer/component";
import { action, computed } from "@ember/object"; import { action } from "@ember/object";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { and, empty } from "@ember/object/computed";
export default class ChannelsList extends Component { export default class ChannelsList extends Component {
@service chat; @service chat;
@service router; @service router;
@service chatStateManager; @service chatStateManager;
@service chatChannelsManager; @service chatChannelsManager;
tagName = ""; @service site;
inSidebar = false; @service session;
toggleSection = null; @service currentUser;
@empty("chatChannelsManager.publicMessageChannels")
publicMessageChannelsEmpty; get showMobileDirectMessageButton() {
@and("site.mobileView", "showDirectMessageChannels") return this.site.mobileView && this.showDirectMessageChannels;
showMobileDirectMessageButton; }
get inSidebar() {
return this.args.inSidebar ?? false;
}
get publicMessageChannelsEmpty() {
return this.chatChannelsManager.publicMessageChannels?.length === 0;
}
@computed("canCreateDirectMessageChannel")
get createDirectMessageChannelLabel() { get createDirectMessageChannelLabel() {
if (!this.canCreateDirectMessageChannel) { if (!this.canCreateDirectMessageChannel) {
return "chat.direct_messages.cannot_create"; return "chat.direct_messages.cannot_create";
@ -27,10 +33,6 @@ export default class ChannelsList extends Component {
return "chat.direct_messages.new"; return "chat.direct_messages.new";
} }
@computed(
"canCreateDirectMessageChannel",
"chatChannelsManager.directMessageChannels"
)
get showDirectMessageChannels() { get showDirectMessageChannels() {
return ( return (
this.canCreateDirectMessageChannel || this.canCreateDirectMessageChannel ||
@ -42,17 +44,12 @@ export default class ChannelsList extends Component {
return this.chat.userCanDirectMessage; return this.chat.userCanDirectMessage;
} }
@computed("inSidebar")
get publicChannelClasses() { get publicChannelClasses() {
return `channels-list-container public-channels ${ return `channels-list-container public-channels ${
this.inSidebar ? "collapsible-sidebar-section" : "" this.inSidebar ? "collapsible-sidebar-section" : ""
}`; }`;
} }
@computed(
"publicMessageChannelsEmpty",
"currentUser.{staff,has_joinable_public_channels}"
)
get displayPublicChannels() { get displayPublicChannels() {
if (this.publicMessageChannelsEmpty) { if (this.publicMessageChannelsEmpty) {
return ( return (
@ -64,7 +61,6 @@ export default class ChannelsList extends Component {
return true; return true;
} }
@computed("inSidebar")
get directMessageChannelClasses() { get directMessageChannelClasses() {
return `channels-list-container direct-message-channels ${ return `channels-list-container direct-message-channels ${
this.inSidebar ? "collapsible-sidebar-section" : "" this.inSidebar ? "collapsible-sidebar-section" : ""
@ -73,7 +69,7 @@ export default class ChannelsList extends Component {
@action @action
toggleChannelSection(section) { toggleChannelSection(section) {
this.toggleSection(section); this.args.toggleSection(section);
} }
didRender() { didRender() {

View File

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

View File

@ -1,18 +1,18 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
export default class ChatChannelMetadata extends Component { export default class ChatChannelMetadata extends Component {
unreadIndicator = false; get unreadIndicator() {
return this.args.unreadIndicator ?? false;
}
get lastMessageFormatedDate() { get lastMessageFormatedDate() {
return moment(this.args.channel.get("last_message_sent_at")).calendar( return moment(this.args.channel.lastMessageSentAt).calendar(null, {
null, sameDay: "LT",
{ nextDay: "[Tomorrow]",
sameDay: "LT", nextWeek: "dddd",
nextDay: "[Tomorrow]", lastDay: "[Yesterday]",
nextWeek: "dddd", lastWeek: "dddd",
lastDay: "[Yesterday]", sameElse: "l",
lastWeek: "dddd", });
sameElse: "l",
}
);
} }
} }

View File

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

View File

@ -1,19 +1,15 @@
import Component from "@ember/component"; import Component from "@glimmer/component";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { computed } from "@ember/object";
import { readOnly } from "@ember/object/computed";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
export default class ChatChannelPreviewCard extends Component { export default class ChatChannelPreviewCard extends Component {
@service chat; @service chat;
tagName = "";
channel = null; get showJoinButton() {
return this.args.channel?.isOpen;
}
@readOnly("channel.isOpen") showJoinButton;
@computed("channel.description")
get hasDescription() { 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 }) { getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) {
let sortedChannels = this.chatChannelsManager.channels.sort((a, b) => { 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
: 1; : 1;
}); });

View File

@ -1,17 +1,17 @@
{{#if this.buttons.length}} {{#if @buttons.length}}
<DPopover <DPopover
@class="chat-composer-dropdown" @class="chat-composer-dropdown"
@options={{hash arrow=null}} @options={{hash arrow=null}}
as |state| as |state|
> >
<FlatButton <FlatButton
@disabled={{this.isDisabled}} @disabled={{@isDisabled}}
@class="chat-composer-dropdown__trigger-btn d-popover-trigger" @class="chat-composer-dropdown__trigger-btn d-popover-trigger"
@title="chat.composer.toggle_toolbar" @title="chat.composer.toggle_toolbar"
@icon={{if state.isExpanded "times" "plus"}} @icon={{if state.isExpanded "times" "plus"}}
/> />
<ul class="chat-composer-dropdown__list"> <ul class="chat-composer-dropdown__list">
{{#each this.buttons as |button|}} {{#each @buttons as |button|}}
<li class="chat-composer-dropdown__item {{button.id}}"> <li class="chat-composer-dropdown__item {{button.id}}">
<DButton <DButton
@class={{concat "chat-composer-dropdown__action-btn " button.id}} @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 UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin";
import discourseComputed, { bind } from "discourse-common/utils/decorators"; import discourseComputed, { bind } from "discourse-common/utils/decorators";
import UppyUploadMixin from "discourse/mixins/uppy-upload"; import UppyUploadMixin from "discourse/mixins/uppy-upload";
import { cloneJSON } from "discourse-common/lib/object";
export default Component.extend(UppyUploadMixin, { export default Component.extend(UppyUploadMixin, {
classNames: ["chat-composer-uploads"], classNames: ["chat-composer-uploads"],
@ -12,16 +13,25 @@ export default Component.extend(UppyUploadMixin, {
chatStateManager: service(), chatStateManager: service(),
id: "chat-composer-uploader", id: "chat-composer-uploader",
type: "chat-composer", type: "chat-composer",
existingUploads: null,
uploads: null, uploads: null,
useMultipartUploadsIfAvailable: true, useMultipartUploadsIfAvailable: true,
init() { init() {
this._super(...arguments); this._super(...arguments);
this.setProperties({ this.setProperties({
uploads: [],
fileInputSelector: `#${this.fileUploadElementId}`, 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() { didInsertElement() {
@ -32,7 +42,7 @@ export default Component.extend(UppyUploadMixin, {
willDestroyElement() { willDestroyElement() {
this._super(...arguments); this._super(...arguments);
this.appEvents.off("chat-composer:load-uploads", this, "_loadUploads");
this.composerInputEl?.removeEventListener( this.composerInputEl?.removeEventListener(
"paste", "paste",
this._pasteEventListener this._pasteEventListener
@ -81,11 +91,6 @@ export default Component.extend(UppyUploadMixin, {
}; };
}, },
_loadUploads(uploads) {
this._uppyInstance?.cancelAll();
this.set("uploads", uploads);
},
_uppyReady() { _uppyReady() {
if (this.siteSettings.composer_media_optimization_image_enabled) { if (this.siteSettings.composer_media_optimization_image_enabled) {
this._useUploadPlugin(UppyMediaOptimization, { this._useUploadPlugin(UppyMediaOptimization, {

View File

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

View File

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

View File

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

View File

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

View File

@ -65,6 +65,10 @@ export default class ChatEmojiPicker extends Component {
} }
get flatEmojis() { get flatEmojis() {
if (!this.chatEmojiPickerManager.emojis) {
return [];
}
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
let { favorites, ...rest } = this.chatEmojiPickerManager.emojis; let { favorites, ...rest } = this.chatEmojiPickerManager.emojis;
return Object.values(rest).flat(); 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 <div
class={{concat-class class={{concat-class
"chat-message-emoji-picker-anchor" "chat-live-pane"
(if (if this.loading "loading")
(and (if this.sendingLoading "sending-loading")
this.chatEmojiPickerManager.opened
(eq this.chatEmojiPickerManager.context "chat-message")
)
"-opened"
)
}} }}
{{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> <ChatFullPageHeader
@channel={{@channel}}
<div class="chat-messages-scroll chat-messages-container"> @onCloseFullScreen={{this.onCloseFullScreen}}
<div class="chat-message-actions-desktop-anchor"></div> @displayed={{this.includeHeader}}
<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"}}
/> />
{{else}}
{{#if (or this.chatChannel.isDraft this.chatChannel.isFollowing)}} <ChatRetentionReminder @channel={{@channel}} />
<ChatComposer
@draft={{this.draft}} <ChatMentionWarnings />
@details={{this.details}}
@canInteractWithChat={{this.canInteractWithChat}} <div class="chat-message-actions-mobile-anchor"></div>
@sendMessage={{action "sendMessage"}}
@editMessage={{action "editMessage"}} <div
@setReplyTo={{action "setReplyTo"}} class={{concat-class
@loading={{this.sendingLoading}} "chat-message-emoji-picker-anchor"
@editingMessage={{readonly this.editingMessage}} (if
@onCancelEditing={{this.cancelEditing}} (and
@setInReplyToMsg={{this.setInReplyToMsg}} this.chatEmojiPickerManager.opened
@onEditLastMessageRequested={{this.editLastMessageRequested}} (eq this.chatEmojiPickerManager.context "chat-message")
@onValueChange={{action "composerValueChanged"}} )
@chatChannel={{this.chatChannel}} "-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}} {{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}}
{{/if}} </div>

View File

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

View File

@ -31,6 +31,7 @@ export default class ChatMessageActionsDesktop extends Component {
), ),
{ {
placement: "top-end", placement: "top-end",
strategy: "fixed",
modifiers: [ modifiers: [
{ name: "hide", enabled: true }, { name: "hide", enabled: true },
{ name: "eventListeners", options: { scroll: false } }, { name: "eventListeners", options: { scroll: false } },

View File

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

View File

@ -1,6 +1,6 @@
<div class="chat-message-avatar"> <div class="chat-message-avatar">
{{#if @message.chat_webhook_event.emoji}} {{#if @message.chatWebhookEvent.emoji}}
<ChatEmojiAvatar @emoji={{@message.chat_webhook_event.emoji}} /> <ChatEmojiAvatar @emoji={{@message.chatWebhookEvent.emoji}} />
{{else}} {{else}}
<ChatUserAvatar @user={{@message.user}} @avatarSize="medium" /> <ChatUserAvatar @user={{@message.user}} @avatarSize="medium" />
{{/if}} {{/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"> <div class="chat-message-collapser">
{{#if this.hasUploads}} {{#if this.hasUploads}}
{{html-safe this.cooked}} {{html-safe @cooked}}
<Collapser @header={{this.uploadsHeader}}> <Collapser @header={{this.uploadsHeader}}>
<div class="chat-uploads"> <div class="chat-uploads">
{{#each this.uploads as |upload|}} {{#each @uploads as |upload|}}
<ChatUpload @upload={{upload}} /> <ChatUpload @upload={{upload}} />
{{/each}} {{/each}}
</div> </div>

View File

@ -1,28 +1,20 @@
import Component from "@ember/component"; import Component from "@glimmer/component";
import { computed } from "@ember/object";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import { escapeExpression } from "discourse/lib/utilities"; import { escapeExpression } from "discourse/lib/utilities";
import domFromString from "discourse-common/lib/dom-from-string"; import domFromString from "discourse-common/lib/dom-from-string";
import I18n from "I18n"; import I18n from "I18n";
export default class ChatMessageCollapser extends Component { export default class ChatMessageCollapser extends Component {
tagName = "";
collapsed = false;
uploads = null;
cooked = null;
@computed("uploads")
get hasUploads() { get hasUploads() {
return hasUploads(this.uploads); return hasUploads(this.args.uploads);
} }
@computed("uploads")
get uploadsHeader() { get uploadsHeader() {
let name = ""; let name = "";
if (this.uploads.length === 1) { if (this.args.uploads.length === 1) {
name = this.uploads[0].original_filename; name = this.args.uploads[0].original_filename;
} else { } else {
name = I18n.t("chat.uploaded_files", { count: this.uploads.length }); name = I18n.t("chat.uploaded_files", { count: this.args.uploads.length });
} }
return htmlSafe( return htmlSafe(
`<span class="chat-message-collapser-link-small">${escapeExpression( `<span class="chat-message-collapser-link-small">${escapeExpression(
@ -31,9 +23,10 @@ export default class ChatMessageCollapser extends Component {
); );
} }
@computed("cooked")
get cookedBodies() { get cookedBodies() {
const elements = Array.prototype.slice.call(domFromString(this.cooked)); const elements = Array.prototype.slice.call(
domFromString(this.args.cooked)
);
if (hasYoutube(elements)) { if (hasYoutube(elements)) {
return this.youtubeCooked(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}} {{did-insert this.trackStatus}}
{{will-destroy this.stopTrackingStatus}} {{will-destroy this.stopTrackingStatus}}
> >
{{#if @message.chat_webhook_event}} {{#if @message.chatWebhookEvent}}
{{#if @message.chat_webhook_event.username}} {{#if @message.chatWebhookEvent.username}}
<span <span
class={{concat-class class={{concat-class
"chat-message-info__username" "chat-message-info__username"
this.usernameClasses this.usernameClasses
}} }}
> >
{{@message.chat_webhook_event.username}} {{@message.chatWebhookEvent.username}}
</span> </span>
{{/if}} {{/if}}
@ -49,8 +49,8 @@
{{#if this.isFlagged}} {{#if this.isFlagged}}
<span class="chat-message-info__flag"> <span class="chat-message-info__flag">
{{#if @message.reviewable_id}} {{#if @message.reviewableId}}
<LinkTo @route="review.show" @model={{@message.reviewable_id}}> <LinkTo @route="review.show" @model={{@message.reviewableId}}>
{{d-icon "flag" title="chat.flagged"}} {{d-icon "flag" title="chat.flagged"}}
</LinkTo> </LinkTo>
{{else}} {{else}}

View File

@ -48,10 +48,7 @@ export default class ChatMessageInfo extends Component {
} }
get isFlagged() { get isFlagged() {
return ( return this.#message?.reviewableId || this.#message?.userFlagStatus === 0;
this.#message?.get("reviewable_id") ||
this.#message?.get("user_flag_status") === 0
);
} }
get prioritizeName() { get prioritizeName() {
@ -66,7 +63,7 @@ export default class ChatMessageInfo extends Component {
} }
get #user() { get #user() {
return this.#message?.get("user"); return this.#message?.user;
} }
get #message() { get #message() {

View File

@ -1,17 +1,17 @@
<div class="chat-message-left-gutter"> <div class="chat-message-left-gutter">
{{#if @message.reviewable_id}} {{#if @message.reviewableId}}
<LinkTo <LinkTo
@route="review.show" @route="review.show"
@model={{@message.reviewable_id}} @model={{@message.reviewableId}}
class="chat-message-left-gutter__flag" class="chat-message-left-gutter__flag"
> >
{{d-icon "flag" title="chat.flagged"}} {{d-icon "flag" title="chat.flagged"}}
</LinkTo> </LinkTo>
{{else if (eq @message.user_flag_status 0)}} {{else if (eq @message.userFlagStatus 0)}}
<div class="chat-message-left-gutter__flag"> <div class="chat-message-left-gutter__flag">
{{d-icon "flag" title="chat.you_flagged"}} {{d-icon "flag" title="chat.you_flagged"}}
</div> </div>
{{else}} {{else if this.site.desktopView}}
<span class="chat-message-left-gutter__date"> <span class="chat-message-left-gutter__date">
{{format-chat-date @message "tiny"}} {{format-chat-date @message "tiny"}}
</span> </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" @class="btn-primary"
@icon="sign-out-alt" @icon="sign-out-alt"
@disabled={{this.disableMoveButton}} @disabled={{this.disableMoveButton}}
@action={{action "moveMessages"}} @action={{this.moveMessages}}
@label="chat.move_to_channel.confirm_move" @label="chat.move_to_channel.confirm_move"
@id="chat-confirm-move-messages-to-channel" @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 <button
id={{this.componentId}}
type="button" type="button"
{{on "click" (action "handleClick")}} {{on "click" this.handleClick}}
tabindex="0" tabindex="0"
class={{concat-class class={{concat-class
this.class
"chat-message-reaction" "chat-message-reaction"
(if this.reaction.reacted "reacted") (if @reaction.reacted "reacted")
(if this.reaction.count "show")
}} }}
data-emoji-name={{this.reaction.emoji}} data-emoji-name={{@reaction.emoji}}
data-tippy-content={{this.popoverContent}}
title={{this.emojiString}} title={{this.emojiString}}
{{did-insert this.setupTooltip}}
{{will-destroy this.teardownTooltip}}
{{did-update this.refreshTooltip this.popoverContent}}
> >
<img <img
loading="lazy" loading="lazy"
@ -22,8 +23,8 @@
src={{this.emojiUrl}} src={{this.emojiUrl}}
/> />
{{#if this.reaction.count}} {{#if (and this.showCount @reaction.count)}}
<span class="count">{{this.reaction.count}}</span> <span class="count">{{@reaction.count}}</span>
{{/if}} {{/if}}
</button> </button>
{{/if}} {{/if}}

View File

@ -1,95 +1,73 @@
import { guidFor } from "@ember/object/internals"; import Component from "@glimmer/component";
import Component from "@ember/component"; import { action } from "@ember/object";
import { action, computed } from "@ember/object";
import { emojiUnescape, emojiUrlFor } from "discourse/lib/text"; import { emojiUnescape, emojiUrlFor } from "discourse/lib/text";
import setupPopover from "discourse/lib/d-popover";
import I18n from "I18n"; import I18n from "I18n";
import { schedule } from "@ember/runloop"; 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 { export default class ChatMessageReaction extends Component {
reaction = null; @service currentUser;
showUsersList = false;
tagName = "";
react = null;
class = null;
didReceiveAttrs() { get showCount() {
this._super(...arguments); return this.args.showCount ?? true;
}
if (this.showUsersList) { @action
setupTooltip(element) {
if (this.args.showTooltip) {
schedule("afterRender", () => { schedule("afterRender", () => {
this._popover?.destroy(); this._tippyInstance?.destroy();
this._popover = this._setupPopover(); this._tippyInstance = setupPopover(element, {
interactive: false,
allowHTML: true,
delay: 250,
});
}); });
} }
} }
willDestroyElement() { @action
this._super(...arguments); teardownTooltip() {
this._tippyInstance?.destroy();
this._popover?.destroy();
} }
@computed @action
get componentId() { refreshTooltip() {
return guidFor(this); this._tippyInstance?.setContent(this.popoverContent);
} }
@computed("reaction.emoji")
get emojiString() { get emojiString() {
return `:${this.reaction.emoji}:`; return `:${this.args.reaction.emoji}:`;
} }
@computed("reaction.emoji")
get emojiUrl() { get emojiUrl() {
return emojiUrlFor(this.reaction.emoji); return emojiUrlFor(this.args.reaction.emoji);
} }
@action @action
handleClick() { 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; return false;
} }
_setupPopover() { get popoverContent() {
const target = document.getElementById(this.componentId); if (!this.args.reaction.count || !this.args.reaction.users?.length) {
if (!target) {
return; return;
} }
const popover = setupPopover(target, { return emojiUnescape(
interactive: false, this.args.reaction.reacted
allowHTML: true, ? this.#reactionTextWithSelf
delay: 250, : this.#reactionText
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;
} }
@computed("reaction") get #reactionTextWithSelf() {
get popoverContent() { const reactionCount = this.args.reaction.count;
return this.reaction.reacted
? this._reactionTextWithSelf()
: this._reactionText();
}
_reactionTextWithSelf() {
const reactionCount = this.reaction.count;
if (reactionCount === 0) { if (reactionCount === 0) {
return; return;
@ -97,55 +75,55 @@ export default class ChatMessageReaction extends Component {
if (reactionCount === 1) { if (reactionCount === 1) {
return I18n.t("chat.reactions.only_you", { return I18n.t("chat.reactions.only_you", {
emoji: this.reaction.emoji, emoji: this.args.reaction.emoji,
}); });
} }
const maxUsernames = 4; const maxUsernames = 5;
const usernames = this.reaction.users const usernames = this.args.reaction.users
.filter((user) => user.id !== this.currentUser?.id)
.slice(0, maxUsernames) .slice(0, maxUsernames)
.mapBy("username"); .mapBy("username");
if (reactionCount === 2) { if (reactionCount === 2) {
return I18n.t("chat.reactions.you_and_single_user", { return I18n.t("chat.reactions.you_and_single_user", {
emoji: this.reaction.emoji, emoji: this.args.reaction.emoji,
username: usernames.pop(), username: usernames.pop(),
}); });
} }
// `-1` because the current user ("you") isn't included in `usernames` const unnamedUserCount = reactionCount - usernames.length;
const unnamedUserCount = reactionCount - usernames.length - 1;
if (unnamedUserCount > 0) { if (unnamedUserCount > 0) {
return I18n.t("chat.reactions.you_multiple_users_and_more", { return I18n.t("chat.reactions.you_multiple_users_and_more", {
emoji: this.reaction.emoji, emoji: this.args.reaction.emoji,
commaSeparatedUsernames: this._joinUsernames(usernames), commaSeparatedUsernames: this._joinUsernames(usernames),
count: unnamedUserCount, count: unnamedUserCount,
}); });
} }
return I18n.t("chat.reactions.you_and_multiple_users", { return I18n.t("chat.reactions.you_and_multiple_users", {
emoji: this.reaction.emoji, emoji: this.args.reaction.emoji,
username: usernames.pop(), username: usernames.pop(),
commaSeparatedUsernames: this._joinUsernames(usernames), commaSeparatedUsernames: this._joinUsernames(usernames),
}); });
} }
_reactionText() { get #reactionText() {
const reactionCount = this.reaction.count; const reactionCount = this.args.reaction.count;
if (reactionCount === 0) { if (reactionCount === 0) {
return; return;
} }
const maxUsernames = 5; const maxUsernames = 5;
const usernames = this.reaction.users const usernames = this.args.reaction.users
.filter((user) => user.id !== this.currentUser?.id)
.slice(0, maxUsernames) .slice(0, maxUsernames)
.mapBy("username"); .mapBy("username");
if (reactionCount === 1) { if (reactionCount === 1) {
return I18n.t("chat.reactions.single_user", { return I18n.t("chat.reactions.single_user", {
emoji: this.reaction.emoji, emoji: this.args.reaction.emoji,
username: usernames.pop(), username: usernames.pop(),
}); });
} }
@ -154,14 +132,14 @@ export default class ChatMessageReaction extends Component {
if (unnamedUserCount > 0) { if (unnamedUserCount > 0) {
return I18n.t("chat.reactions.multiple_users_and_more", { return I18n.t("chat.reactions.multiple_users_and_more", {
emoji: this.reaction.emoji, emoji: this.args.reaction.emoji,
commaSeparatedUsernames: this._joinUsernames(usernames), commaSeparatedUsernames: this._joinUsernames(usernames),
count: unnamedUserCount, count: unnamedUserCount,
}); });
} }
return I18n.t("chat.reactions.multiple_users", { return I18n.t("chat.reactions.multiple_users", {
emoji: this.reaction.emoji, emoji: this.args.reaction.emoji,
username: usernames.pop(), username: usernames.pop(),
commaSeparatedUsernames: this._joinUsernames(usernames), 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"> <div class="chat-message-text">
{{#if this.isCollapsible}} {{#if this.isCollapsible}}
<ChatMessageCollapser @cooked={{this.cooked}} @uploads={{this.uploads}} /> <ChatMessageCollapser @cooked={{@cooked}} @uploads={{@uploads}} />
{{else}} {{else}}
{{html-safe this.cooked}} {{html-safe @cooked}}
{{/if}} {{/if}}
{{#if this.edited}} {{#if this.isEdited}}
<span class="chat-message-edited">({{i18n "chat.edited"}})</span> <span class="chat-message-edited">({{i18n "chat.edited"}})</span>
{{/if}} {{/if}}

View File

@ -1,15 +1,12 @@
import Component from "@ember/component"; import Component from "@glimmer/component";
import { computed } from "@ember/object";
import { isCollapsible } from "discourse/plugins/chat/discourse/components/chat-message-collapser"; import { isCollapsible } from "discourse/plugins/chat/discourse/components/chat-message-collapser";
export default class ChatMessageText extends Component { export default class ChatMessageText extends Component {
tagName = ""; get isEdited() {
cooked = null; return this.args.edited ?? false;
uploads = null; }
edited = false;
@computed("cooked", "uploads.[]")
get isCollapsible() { 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 }} {{! template-lint-disable no-invalid-interactive }}
<ChatMessageSeparator @message={{@message}} /> <ChatMessageSeparatorDate @message={{@message}} />
<ChatMessageSeparatorNew @message={{@message}} />
{{#if {{#if
(and (and
@ -40,19 +41,23 @@
{{did-insert this.setMessageActionsAnchors}} {{did-insert this.setMessageActionsAnchors}}
{{did-insert this.decorateCookedMessage}} {{did-insert this.decorateCookedMessage}}
{{did-update this.decorateCookedMessage @message.id}} {{did-update this.decorateCookedMessage @message.id}}
{{did-update this.decorateCookedMessage @message.version}}
{{on "touchmove" this.handleTouchMove passive=true}} {{on "touchmove" this.handleTouchMove passive=true}}
{{on "touchstart" this.handleTouchStart passive=true}} {{on "touchstart" this.handleTouchStart passive=true}}
{{on "touchend" this.handleTouchEnd passive=true}} {{on "touchend" this.handleTouchEnd passive=true}}
{{on "mouseenter" (fn @onHoverMessage @message (hash desktopOnly=true))}} {{on "mouseenter" (fn @onHoverMessage @message (hash desktopOnly=true))}}
{{on "mouseleave" (fn @onHoverMessage null (hash desktopOnly=true))}} {{on "mouseleave" (fn @onHoverMessage null (hash desktopOnly=true))}}
{{chat/track-message-visibility}}
class={{concat-class class={{concat-class
"chat-message-container" "chat-message-container"
(if @isHovered "is-hovered") (if @isHovered "is-hovered")
(if @selectingMessages "selecting-messages") (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 this.show}}
{{#if @selectingMessages}} {{#if @selectingMessages}}
@ -85,35 +90,17 @@
class={{concat-class class={{concat-class
"chat-message" "chat-message"
(if @message.staged "chat-message-staged") (if @message.staged "chat-message-staged")
(if @message.deleted_at "deleted") (if @message.deletedAt "deleted")
(if @message.in_reply_to "is-reply") (if (and @message.inReplyTo (not this.hideReplyToInfo)) "is-reply")
(if this.hideUserInfo "user-info-hidden") (if this.hideUserInfo "user-info-hidden")
(if @message.error "errored") (if @message.error "errored")
(if @message.bookmark "chat-message-bookmarked") (if @message.bookmark "chat-message-bookmarked")
(if @isHovered "chat-message-selected") (if @isHovered "chat-message-selected")
}} }}
> >
{{#if @message.in_reply_to}} {{#unless this.hideReplyToInfo}}
<div <ChatMessageInReplyToIndicator @message={{@message}} />
role="button" {{/unless}}
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}}
{{#if this.hideUserInfo}} {{#if this.hideUserInfo}}
<ChatMessageLeftGutter @message={{@message}} /> <ChatMessageLeftGutter @message={{@message}} />
@ -131,7 +118,7 @@
@uploads={{@message.uploads}} @uploads={{@message.uploads}}
@edited={{@message.edited}} @edited={{@message.edited}}
> >
{{#if this.hasReactions}} {{#if @message.reactions.length}}
<div class="chat-message-reaction-list"> <div class="chat-message-reaction-list">
{{#if this.reactionLabel}} {{#if this.reactionLabel}}
<div class="reaction-users-list"> <div class="reaction-users-list">
@ -139,18 +126,13 @@
</div> </div>
{{/if}} {{/if}}
{{#each-in @message.reactions as |emoji reactionAttrs|}} {{#each @message.reactions as |reaction|}}
<ChatMessageReaction <ChatMessageReaction
@reaction={{hash @reaction={{reaction}}
emoji=emoji
users=reactionAttrs.users
count=reactionAttrs.count
reacted=reactionAttrs.reacted
}}
@react={{this.react}} @react={{this.react}}
@showUsersList={{true}} @showTooltip={{true}}
/> />
{{/each-in}} {{/each}}
{{#if @canInteractWithChat}} {{#if @canInteractWithChat}}
{{#unless this.site.mobileView}} {{#unless this.site.mobileView}}
@ -189,7 +171,7 @@
{{#if this.mentionWarning}} {{#if this.mentionWarning}}
<div class="alert alert-info chat-message-mention-warning"> <div class="alert alert-info chat-message-mention-warning">
{{#if this.mentionWarning.invitationSent}} {{#if this.mentionWarning.invitation_sent}}
{{d-icon "check"}} {{d-icon "check"}}
<span> <span>
{{i18n {{i18n

View File

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

View File

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

View File

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

View File

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

View File

@ -4,9 +4,13 @@ import { htmlSafe } from "@ember/template";
export default class ChatSkeleton extends Component { export default class ChatSkeleton extends Component {
get placeholders() { get placeholders() {
return Array.from({ length: 15 }, () => { return Array.from({ length: 15 }, () => {
return Array.from({ length: this.#randomIntFromInterval(1, 5) }, () => { return {
return htmlSafe(`width: ${this.#randomIntFromInterval(20, 95)}%`); 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}} {{#if this.chat.activeChannel}}
<ChatLivePane <ChatLivePane
@chatChannel={{this.chat.activeChannel}} @channel={{this.chat.activeChannel}}
@onBackClick={{action "navigateToIndex"}}
@targetMessageId={{readonly @targetMessageId}} @targetMessageId={{readonly @targetMessageId}}
/> />
{{/if}} {{/if}}

View File

@ -1,79 +1,6 @@
import Component from "@ember/component"; import Component from "@glimmer/component";
import { bind } from "discourse-common/utils/decorators";
import { action } from "@ember/object";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
export default Component.extend({ export default class FullPageChat extends Component {
tagName: "", @service chat;
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");
},
});

View File

@ -1,10 +1,11 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking";
export default class ChatChannelController extends Controller { export default class ChatChannelController extends Controller {
@service chat; @service chat;
targetMessageId = null; @tracked targetMessageId = null;
// Backwards-compatibility // Backwards-compatibility
queryParams = ["messageId"]; queryParams = ["messageId"];

View File

@ -36,6 +36,7 @@ export default class CreateChannelController extends Controller.extend(
categoryPermissionsHint = null; categoryPermissionsHint = null;
autoJoinUsers = null; autoJoinUsers = null;
autoJoinWarning = ""; autoJoinWarning = "";
loadingPermissionHint = false;
@notEmpty("category") categorySelected; @notEmpty("category") categorySelected;
@gt("siteSettings.max_chat_auto_joined_users", 0) autoJoinAvailable; @gt("siteSettings.max_chat_auto_joined_users", 0) autoJoinAvailable;
@ -153,6 +154,8 @@ export default class CreateChannelController extends Controller.extend(
if (category) { if (category) {
const fullSlug = this._buildCategorySlug(category); const fullSlug = this._buildCategorySlug(category);
this.set("loadingPermissionHint", true);
return this.chatApi return this.chatApi
.categoryPermissions(category.id) .categoryPermissions(category.id)
.then((catPermissions) => { .then((catPermissions) => {
@ -194,6 +197,9 @@ export default class CreateChannelController extends Controller.extend(
} }
this.set("categoryPermissionsHint", htmlSafe(hint)); this.set("categoryPermissionsHint", htmlSafe(hint));
})
.finally(() => {
this.set("loadingPermissionHint", false);
}); });
} else { } else {
this.set("categoryPermissionsHint", DEFAULT_HINT); this.set("categoryPermissionsHint", DEFAULT_HINT);

View File

@ -7,8 +7,8 @@ import User from "discourse/models/user";
registerUnbound("format-chat-date", function (message, mode) { registerUnbound("format-chat-date", function (message, mode) {
const currentUser = User.current(); const currentUser = User.current();
const tz = currentUser ? currentUser.user_option.timezone : moment.tz.guess(); const tz = currentUser ? currentUser.user_option.timezone : moment.tz.guess();
const date = moment(new Date(message.created_at), tz); const date = moment(new Date(message.createdAt), tz);
const url = getURL(`/chat/c/-/${message.chat_channel_id}/${message.id}`); const url = getURL(`/chat/c/-/${message.channelId}/${message.id}`);
const title = date.format(I18n.t("dates.long_with_year")); const title = date.format(I18n.t("dates.long_with_year"));
const display = 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 { export default {
name: "chat-setup", name: "chat-setup",
initialize(container) { initialize(container) {
this.chatService = container.lookup("service:chat"); this.chatService = container.lookup("service:chat");
this.siteSettings = container.lookup("service:site-settings"); this.siteSettings = container.lookup("service:site-settings");
@ -19,6 +20,7 @@ export default {
if (!this.chatService.userCanChat) { if (!this.chatService.userCanChat) {
return; return;
} }
withPluginApi("0.12.1", (api) => { withPluginApi("0.12.1", (api) => {
api.registerChatComposerButton({ api.registerChatComposerButton({
id: "chat-upload-btn", id: "chat-upload-btn",

View File

@ -38,7 +38,7 @@ export default class ChatMessageFlag {
let flagsAvailable = site.flagTypes; let flagsAvailable = site.flagTypes;
flagsAvailable = flagsAvailable.filter((flag) => { 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 // "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 slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel";
import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-threads-manager"; import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-threads-manager";
import { getOwner } from "discourse-common/lib/get-owner"; import { getOwner } from "discourse-common/lib/get-owner";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
export const CHATABLE_TYPES = { export const CHATABLE_TYPES = {
directMessageChannel: "DirectMessage", directMessageChannel: "DirectMessage",
@ -54,6 +55,16 @@ export default class ChatChannel extends RestModel {
@tracked chatableType; @tracked chatableType;
@tracked status; @tracked status;
@tracked activeThread; @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)); threadsManager = new ChatThreadsManager(getOwner(this));
@ -74,11 +85,11 @@ export default class ChatChannel extends RestModel {
} }
get isDirectMessageChannel() { get isDirectMessageChannel() {
return this.chatable_type === CHATABLE_TYPES.directMessageChannel; return this.chatableType === CHATABLE_TYPES.directMessageChannel;
} }
get isCategoryChannel() { get isCategoryChannel() {
return this.chatable_type === CHATABLE_TYPES.categoryChannel; return this.chatableType === CHATABLE_TYPES.categoryChannel;
} }
get isOpen() { get isOpen() {
@ -105,6 +116,56 @@ export default class ChatChannel extends RestModel {
return this.currentUserMembership.following; 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) { canModifyMessages(user) {
if (user.staff) { if (user.staff) {
return !STAFF_READONLY_STATUSES.includes(this.status); return !STAFF_READONLY_STATUSES.includes(this.status);
@ -127,6 +188,10 @@ export default class ChatChannel extends RestModel {
return; return;
} }
if (this.currentUserMembership.last_read_message_id >= messageId) {
return;
}
return ajax(`/chat/${this.id}/read/${messageId}.json`, { return ajax(`/chat/${this.id}/read/${messageId}.json`, {
method: "PUT", method: "PUT",
}).then(() => { }).then(() => {
@ -142,12 +207,17 @@ ChatChannel.reopenClass({
this._initUserModels(args); this._initUserModels(args);
this._initUserMembership(args); this._initUserMembership(args);
args.chatableType = args.chatable_type; this._remapKey(args, "chatable_type", "chatableType");
args.membershipsCount = args.memberships_count; this._remapKey(args, "memberships_count", "membershipsCount");
this._remapKey(args, "last_message_sent_at", "lastMessageSentAt");
return this._super(args); return this._super(args);
}, },
_remapKey(obj, oldKey, newKey) {
delete Object.assign(obj, { [newKey]: obj[oldKey] })[oldKey];
},
_initUserModels(args) { _initUserModels(args) {
if (args.chatable?.users?.length) { if (args.chatable?.users?.length) {
for (let i = 0; i < args.chatable?.users?.length; i++) { 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 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({ static create(channel, args = {}) {
create(args = {}) { return new ChatMessage(channel, args);
this._initReactions(args); }
this._initUserModel(args);
return this._super(args); static createStagedMessage(channel, args = {}) {
}, args.id = guid();
args.staged = true;
return new ChatMessage(channel, args);
}
_initReactions(args) { @tracked id;
args.reactions = EmberObject.create(args.reactions || {}); @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) { constructor(channel, args = {}) {
if (!args.user || args.user instanceof User) { this.channel = channel;
return; 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 @action
willTransition(transition) { 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.chat.activeChannel.activeThread = null;
this.chatStateManager.closeSidePanel(); 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. * Update notifications settings of current user for a channel.
* @param {number} channelId - The ID of the channel. * @param {number} channelId - The ID of the channel.

View File

@ -42,6 +42,14 @@ export default class ChatChannelsManager extends Service {
this.#cache(model); 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; return model;
} }
@ -138,8 +146,7 @@ export default class ChatChannelsManager extends Service {
const unreadCountA = a.currentUserMembership.unread_count || 0; const unreadCountA = a.currentUserMembership.unread_count || 0;
const unreadCountB = b.currentUserMembership.unread_count || 0; const unreadCountB = b.currentUserMembership.unread_count || 0;
if (unreadCountA === unreadCountB) { if (unreadCountA === unreadCountB) {
return new Date(a.get("last_message_sent_at")) > return new Date(a.lastMessageSentAt) > new Date(b.lastMessageSentAt)
new Date(b.get("last_message_sent_at"))
? -1 ? -1
: 1; : 1;
} else { } 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) { _onUserTrackingStateUpdate(busData) {
this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => { this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => {
if ( if (
channel?.currentUserMembership?.last_read_message_id <= !channel?.currentUserMembership?.last_read_message_id ||
busData.chat_message_id parseInt(channel?.currentUserMembership?.last_read_message_id, 10) <=
busData.chat_message_id
) { ) {
channel.currentUserMembership.last_read_message_id = channel.currentUserMembership.last_read_message_id =
busData.chat_message_id; busData.chat_message_id;
channel.currentUserMembership.unread_count = 0; channel.currentUserMembership.unread_count = busData.unread_count;
channel.currentUserMembership.unread_mentions = 0; 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 userSearch from "discourse/lib/user-search";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import Service, { inject as service } from "@ember/service"; import Service, { inject as service } from "@ember/service";
import Site from "discourse/models/site";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { generateCookFunction } from "discourse/lib/text";
import { cancel, next } from "@ember/runloop"; import { cancel, next } from "@ember/runloop";
import { and } from "@ember/object/computed"; import { and } from "@ember/object/computed";
import { computed } from "@ember/object"; 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 discourseLater from "discourse-common/lib/later";
import userPresent from "discourse/lib/user-presence"; import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
export const LIST_VIEW = "list_view";
export const CHAT_VIEW = "chat_view";
export const DRAFT_CHANNEL_VIEW = "draft_channel_view";
const CHAT_ONLINE_OPTIONS = { const CHAT_ONLINE_OPTIONS = {
userUnseenTime: 300000, // 5 minutes seconds with no interaction userUnseenTime: 300000, // 5 minutes seconds with no interaction
browserHiddenTime: 300000, // Or the browser has been in the background for 5 minutes browserHiddenTime: 300000, // Or the browser has been in the background for 5 minutes
}; };
const READ_INTERVAL = 1000;
export default class Chat extends Service { export default class Chat extends Service {
@service appEvents; @service appEvents;
@service chatNotificationManager; @service chatNotificationManager;
@ -64,13 +53,6 @@ export default class Chat extends Service {
if (this.userCanChat) { if (this.userCanChat) {
this.presenceChannel = this.presence.getChannel("/chat/online"); 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( [...channels.public_channels, ...channels.direct_message_channels].forEach(
(channelObject) => { (channelObject) => {
const channel = this.chatChannelsManager.store(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); 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() { updatePresence() {
next(() => { next(() => {
if (this.isDestroyed || this.isDestroying) { if (this.isDestroyed || this.isDestroying) {
@ -277,10 +242,6 @@ export default class Chat extends Service {
: this.router.transitionTo("chat.channel", ...channel.routeModels); : this.router.transitionTo("chat.channel", ...channel.routeModels);
} }
_fireOpenMessageAppEvent(messageId) {
this.appEvents.trigger("chat-live-pane:highlight-message", messageId);
}
async followChannel(channel) { async followChannel(channel) {
return this.chatChannelsManager.follow(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() { addToolbarButton() {
deprecated( deprecated(
"Use the new chat API `api.registerChatComposerButton` instead of `chat.addToolbarButton`" "Use the new chat API `api.registerChatComposerButton` instead of `chat.addToolbarButton`"

View File

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

View File

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

View File

@ -1,6 +1,8 @@
.chat-composer-container { .chat-composer-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
z-index: 3;
background-color: var(--secondary);
#chat-full-page-uploader, #chat-full-page-uploader,
#chat-widget-uploader { #chat-widget-uploader {

View File

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

View File

@ -1,42 +1,96 @@
.chat-message-separator { .chat-message-separator {
@include unselectable; @include unselectable;
margin: 0.25rem 0 0.25rem 1rem;
display: flex; display: flex;
font-size: var(--font-down-1);
position: relative;
transform: translateZ(0);
position: relative;
&.new-message { &-new {
color: var(--danger-medium); position: relative;
padding: 20px 0;
.divider { .chat-message-separator__text-container {
background-color: var(--danger-medium); 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 { &-date {
.text {
color: var(--secondary-low);
font-weight: 600;
}
.divider {
background-color: var(--secondary-high);
}
}
.text {
margin: 0 auto;
padding: 0 0.75rem;
z-index: 1;
background: var(--secondary);
}
.divider {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 1px; z-index: 2;
top: 50%; 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); background: var(--primary-low);
border-color: var(--primary-low-mid); border-color: var(--primary-low-mid);
} }
&:focus {
background: none;
}
} }
.emoji { .emoji {
@ -60,10 +64,6 @@
.chat-message-reaction { .chat-message-reaction {
@include chat-reaction; @include chat-reaction;
&:not(.show) {
display: none;
}
} }
&.chat-action { &.chat-action {
@ -82,21 +82,6 @@
background-color: var(--danger-hover); 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 { &.is-reply {
display: grid; display: grid;
grid-template-columns: var(--message-left-width) 1fr; grid-template-columns: var(--message-left-width) 1fr;
@ -254,6 +239,14 @@
.chat-message.chat-message-bookmarked { .chat-message.chat-message-bookmarked {
background: var(--highlight-bg); 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 { .not-mobile-device & .chat-message-reaction-list .chat-message-react-btn {

View File

@ -1,4 +1,4 @@
$radius: 10px; $radius: 3px;
.chat-skeleton { .chat-skeleton {
height: auto; height: auto;
@ -55,11 +55,35 @@ $radius: 10px;
&__message-content { &__message-content {
grid-area: content; grid-area: content;
width: 100%; 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; 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) & { .chat-skeleton__body:nth-of-type(odd) & {
background-color: var(--primary-100); 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-"] { *[class^="chat-skeleton__message-"] {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -78,7 +110,7 @@ $radius: 10px;
position: relative; position: relative;
overflow: hidden; 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; position: absolute;
top: 0; top: 0;
right: 0; right: 0;

View File

@ -144,6 +144,7 @@ $float-height: 530px;
.chat-messages-container { .chat-messages-container {
word-wrap: break-word; word-wrap: break-word;
white-space: normal; white-space: normal;
position: relative;
.chat-message-container { .chat-message-container {
display: grid; display: grid;
@ -283,6 +284,8 @@ $float-height: 530px;
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
z-index: 1; z-index: 1;
margin: 0 3px 0 0;
will-change: transform;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 15px; width: 15px;
@ -323,37 +326,68 @@ $float-height: 530px;
} }
.chat-scroll-to-bottom { .chat-scroll-to-bottom {
background: var(--primary-medium); left: calc(50% - calc(32px / 2));
bottom: 1em; align-items: center;
border-radius: 100%; justify-content: center;
left: 50%;
opacity: 50%;
padding: 0.5em;
position: absolute; position: absolute;
transform: translateX(-50%); z-index: 1;
z-index: 2; 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); background: var(--primary-medium);
opacity: 100%; border-radius: 3px;
text-align: center;
font-size: var(--font-down-1);
bottom: 40px;
position: absolute;
} }
.d-icon { &__arrow {
color: var(--primary); display: flex;
margin: 0; background: var(--primary-medium);
} border-radius: 100%;
align-items: center;
&.unread-messages { justify-content: center;
opacity: 85%; height: 32px;
border-radius: 0; width: 32px;
transition: border-radius 0.1s linear; position: relative;
&:hover {
opacity: 100%;
}
.d-icon { .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-container {
.chat-composer { .chat-composer {
margin: 0.25rem 10px 0 10px; margin: 0.25rem 5px 0 5px;
} }
html.keyboard-visible .footer-nav-ipad & { html.keyboard-visible .footer-nav-ipad & {
margin: 0.25rem 10px 1rem 10px; margin: 0.25rem 10px 1rem 10px;

View File

@ -53,6 +53,25 @@
.chat-message.user-info-hidden { .chat-message.user-info-hidden {
padding: 0.15em 1em; 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 // Full Page Styling in Core

View File

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

View File

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

View File

@ -108,7 +108,7 @@ en:
in_reply_to: "In reply to" in_reply_to: "In reply to"
heading: "Chat" heading: "Chat"
join: "Join" join: "Join"
new_messages: "new messages" last_visit: "last visit"
mention_warning: mention_warning:
dismiss: "dismiss" dismiss: "dismiss"
cannot_see: "%{username} can't access this channel and was not notified." 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/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_channel_threads_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_chatables_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__) load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__)
if Discourse.allow_dev_populate? if Discourse.allow_dev_populate?

View File

@ -17,7 +17,7 @@ describe ChatChannelMembershipsQuery do
context "when no memberships exists" do context "when no memberships exists" do
it "returns an empty array" 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
end end
@ -28,7 +28,7 @@ describe ChatChannelMembershipsQuery do
end end
it "returns the memberships" do 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) expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id)
end end
@ -49,7 +49,7 @@ describe ChatChannelMembershipsQuery do
end end
it "lists the user" do 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) expect(memberships.pluck(:user_id)).to include(user_1.id)
end end
@ -62,14 +62,16 @@ describe ChatChannelMembershipsQuery do
permission_type: CategoryGroup.permission_types[:full], 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 end
it "returns the membership if the user still has access through a staff group" do it "returns the membership if the user still has access through a staff group" do
chatters_group.remove(user_1) chatters_group.remove(user_1)
Group.find_by(id: Group::AUTO_GROUPS[:staff]).add(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) expect(memberships.pluck(:user_id)).to include(user_1.id)
end end
@ -77,7 +79,7 @@ describe ChatChannelMembershipsQuery do
context "when membership doesnt exist" do context "when membership doesnt exist" do
it "doesnt list the user" 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 expect(memberships.pluck(:user_id)).to be_empty
end end
@ -91,7 +93,7 @@ describe ChatChannelMembershipsQuery do
end end
it "doesnt list the user" 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 expect(memberships).to be_empty
end end
@ -99,7 +101,7 @@ describe ChatChannelMembershipsQuery do
context "when membership doesnt exist" do context "when membership doesnt exist" do
it "doesnt list the user" 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 expect(memberships).to be_empty
end end
@ -114,7 +116,7 @@ describe ChatChannelMembershipsQuery do
end end
it "returns an empty array" 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
end end
@ -122,7 +124,7 @@ describe ChatChannelMembershipsQuery do
fab!(:channel_1) { Fabricate(:direct_message_channel, users: [user_1, user_2]) } fab!(:channel_1) { Fabricate(:direct_message_channel, users: [user_1, user_2]) }
it "returns the memberships" do 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) expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id)
end end
@ -139,7 +141,7 @@ describe ChatChannelMembershipsQuery do
describe "offset param" do describe "offset param" do
it "offsets the results" 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) expect(memberships.length).to eq(1)
end end
@ -147,7 +149,7 @@ describe ChatChannelMembershipsQuery do
describe "limit param" do describe "limit param" do
it "limits the results" 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) expect(memberships.length).to eq(1)
end end
@ -163,7 +165,7 @@ describe ChatChannelMembershipsQuery do
end end
it "filters the results" do 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.length).to eq(1)
expect(memberships[0].user).to eq(user_1) expect(memberships[0].user).to eq(user_1)
@ -182,7 +184,7 @@ describe ChatChannelMembershipsQuery do
before { SiteSetting.prioritize_username_in_ux = true } before { SiteSetting.prioritize_username_in_ux = true }
it "is using ascending order on username" do 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[0].user).to eq(user_1)
expect(memberships[1].user).to eq(user_2) expect(memberships[1].user).to eq(user_2)
@ -193,7 +195,7 @@ describe ChatChannelMembershipsQuery do
before { SiteSetting.prioritize_username_in_ux = false } before { SiteSetting.prioritize_username_in_ux = false }
it "is using ascending order on name" do 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[0].user).to eq(user_2)
expect(memberships[1].user).to eq(user_1) expect(memberships[1].user).to eq(user_1)
@ -203,7 +205,7 @@ describe ChatChannelMembershipsQuery do
before { SiteSetting.enable_names = false } before { SiteSetting.enable_names = false }
it "is using ascending order on username" do 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[0].user).to eq(user_1)
expect(memberships[1].user).to eq(user_2) expect(memberships[1].user).to eq(user_2)
@ -222,7 +224,7 @@ describe ChatChannelMembershipsQuery do
end end
it "doesnt list staged users" do it "doesnt list staged users" do
memberships = described_class.call(channel_1) memberships = described_class.call(channel: channel_1)
expect(memberships).to be_blank expect(memberships).to be_blank
end end
end end
@ -242,7 +244,7 @@ describe ChatChannelMembershipsQuery do
end end
it "doesnt list suspended users" do it "doesnt list suspended users" do
memberships = described_class.call(channel_1) memberships = described_class.call(channel: channel_1)
expect(memberships).to be_blank expect(memberships).to be_blank
end end
end end
@ -260,7 +262,7 @@ describe ChatChannelMembershipsQuery do
end end
it "doesnt list inactive users" do it "doesnt list inactive users" do
memberships = described_class.call(channel_1) memberships = described_class.call(channel: channel_1)
expect(memberships).to be_blank expect(memberships).to be_blank
end end
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 it "correctly marks reactions as 'reacted' for the current_user" do
heart_emoji = ":heart:" heart_emoji = ":heart:"
smile_emoji = ":smile" smile_emoji = ":smile"
last_message = chat_channel.chat_messages.last last_message = chat_channel.chat_messages.last
last_message.reactions.create(user: user, emoji: heart_emoji) last_message.reactions.create(user: user, emoji: heart_emoji)
last_message.reactions.create(user: admin, emoji: smile_emoji) last_message.reactions.create(user: admin, emoji: smile_emoji)
get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size }
reactions = response.parsed_body["chat_messages"].last["reactions"] reactions = response.parsed_body["chat_messages"].last["reactions"]
expect(reactions[heart_emoji]["reacted"]).to be true heart_reaction = reactions.find { |r| r["emoji"] == heart_emoji }
expect(reactions[smile_emoji]["reacted"]).to be false expect(heart_reaction["reacted"]).to be true
smile_reaction = reactions.find { |r| r["emoji"] == smile_emoji }
expect(smile_reaction["reacted"]).to be false
end end
it "sends the last message bus id for the channel" do 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 it "doesnt return the reaction" do
Emoji.clear_cache 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! custom_emoji.destroy!
Emoji.clear_cache 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 end
end end

View File

@ -37,7 +37,7 @@ RSpec.describe "Chat channel", type: :system, js: true do
chat.visit_channel(channel_1) chat.visit_channel(channel_1)
expect(channel).to have_no_loading_skeleton expect(channel).to have_no_loading_skeleton
channel.send_message("aaaaaaaaaaaaaaaaaaaa") 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 = find(".chat-message-container:last-child")
last_message.hover last_message.hover
@ -183,7 +183,7 @@ RSpec.describe "Chat channel", type: :system, js: true do
it "shows a date separator" do it "shows a date separator" do
chat.visit_channel(channel_1) 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
end end

View File

@ -81,6 +81,7 @@ RSpec.describe "Create channel", type: :system, js: true do
chat_page.visit_browse chat_page.visit_browse
chat_page.new_channel_button.click chat_page.new_channel_button.click
channel_modal.select_category(private_category_1) 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( expect(channel_modal.create_channel_hint["innerHTML"].strip).to include(
"&lt;script&gt;e&lt;/script&gt;", "&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) chat_page.visit_channel(channel_1)
expect(channel_page).to have_no_loading_skeleton expect(channel_page).to have_no_loading_skeleton
channel_page.send_message("aaaaaaaaaaaaaaaaaaaa") 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") last_message = find(".chat-message-container:last-child")
channel_page.delete_message(OpenStruct.new(id: last_message["data-id"])) 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 RSpec.describe "Drawer", type: :system, js: true do
fab!(:current_user) { Fabricate(:admin) } fab!(:current_user) { Fabricate(:admin) }
let(:chat_page) { PageObjects::Pages::Chat.new } let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:drawer) { PageObjects::Pages::ChatDrawer.new } let(:drawer) { PageObjects::Pages::ChatDrawer.new }
before do 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") expect(page.find(".chat-drawer").native.style("height")).to eq("530px")
end end
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 end

View File

@ -32,7 +32,7 @@ RSpec.describe "Flag message", type: :system, js: true do
context "when direct message channel" do context "when direct message channel" do
fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [current_user]) } 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 it "doesnt allow to flag a message" do
chat.visit_channel(dm_channel_1) chat.visit_channel(dm_channel_1)

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