1
0
mirror of https://github.com/discourse/discourse.git synced 2025-03-09 14:34:35 +00:00

DEV: rework the chat-live-pane

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-02 16:34:25 +01:00 committed by GitHub
parent e206bd8907
commit 67c0498f64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
118 changed files with 2550 additions and 2289 deletions
plugins/chat
app
assets
javascripts/discourse
components
controllers
helpers
initializers
lib
models
modifiers/chat
routes
services
templates/modal
stylesheets
config/locales
plugin.rb
spec

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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) {
@ -307,7 +304,9 @@ export default Component.extend(TextareaTextManipulation, {
@bind @bind
_captureMentions() { _captureMentions() {
this.chatComposerWarningsTracker.trackMentions(this.value); if (this.value) {
this.chatComposerWarningsTracker.trackMentions(this.value);
}
}, },
@bind @bind

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

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

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

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

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

@ -1,148 +1,115 @@
{{#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 @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>

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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,22 @@
{{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 @selectingMessages "selecting-messages") (if @selectingMessages "selecting-messages")
}} }}
data-id={{or @message.id @message.stagedId}} data-id={{@message.id}}
data-staged-id={{if @message.staged @message.stagedId}} data-staged-id={{if @message.staged @message.stagedId}}
{{chat/track-message
(fn @didShowMessage @message)
(fn @didHideMessage @message)
}}
> >
{{#if this.show}} {{#if this.show}}
{{#if @selectingMessages}} {{#if @selectingMessages}}
@ -85,35 +89,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 +117,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 +125,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 +170,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

@ -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,29 @@ 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"); const stagedId = this.args.message?.stagedId;
if (stagedId) {
this.appEvents.on( return document.querySelector(
`chat-message-${this.args.message.id}:reaction`, `.chat-message-container[data-staged-id='${stagedId}']`
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 +218,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 +278,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 +425,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 +440,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 +478,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 +500,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 +522,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 +561,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 +572,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 +584,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 +599,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 +614,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 +622,7 @@ export default class ChatMessage extends Component {
); );
}, },
onAfterDelete: () => { onAfterDelete: () => {
this.args.message.set("bookmark", null); this.args.message.bookmark = null;
}, },
} }
); );
@ -736,7 +631,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 +641,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 +650,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 +675,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 +688,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,
})
);
}); });
} }
} }

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

@ -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?.needs_dm_retention_reminder) ||
(this.chatChannel.isCategoryChannel && (this.args.channel?.isCategoryChannel &&
this.currentUser.needs_channel_retention_reminder)) this.currentUser?.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);
}, }
}); }

@ -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}}
>
{{#if @hasNewMessages}}
<span class="chat-scroll-to-bottom__text">
{{i18n "chat.scroll_to_new_messages"}}
</span>
{{/if}}
<span class="chat-scroll-to-bottom__arrow">
{{d-icon "arrow-down"}}
</span>
</DButton>
</div>

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

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

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

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

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

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

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

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

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

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

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

@ -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,57 @@ 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;
}
appendMessages(messages) {
this.messages.pushObjects(messages);
}
prependMessages(messages) {
this.messages.unshiftObjects(messages);
}
findMessage(messageId) {
return this.messages.find(
(message) => message.id === parseInt(messageId, 10)
);
}
removeMessage(message) {
return this.messages.removeObject(message);
}
findStagedMessage(stagedMessageId) {
return this.messages.find(
(message) => message.stagedId === stagedMessageId
);
}
canModifyMessages(user) { canModifyMessages(user) {
if (user.staff) { if (user.staff) {
return !STAFF_READONLY_STATUSES.includes(this.status); return !STAFF_READONLY_STATUSES.includes(this.status);
@ -127,6 +189,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 +208,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++) {

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

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

@ -1,26 +1,193 @@
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.staged_id = guid();
return new ChatMessage(channel, args);
}
_initReactions(args) { @tracked id;
args.reactions = EmberObject.create(args.reactions || {}); @tracked error;
}, @tracked selected;
@tracked channel;
@tracked stagedId;
@tracked channelId;
@tracked createdAt;
@tracked deletedAt;
@tracked uploads;
@tracked excerpt;
@tracked message;
@tracked threadId;
@tracked reactions;
@tracked reviewableId;
@tracked user;
@tracked cooked;
@tracked inReplyTo;
@tracked expanded;
@tracked bookmark;
@tracked userFlagStatus;
@tracked hidden;
@tracked version = 0;
@tracked edited;
@tracked chatWebhookEvent = new TrackedObject();
@tracked mentionWarning;
@tracked availableFlags;
@tracked newest = false;
_initUserModel(args) { constructor(channel, args = {}) {
if (!args.user || args.user instanceof User) { this.channel = channel;
return; this.id = args.id;
this.newest = args.newest;
this.edited = args.edited;
this.availableFlags = args.available_flags;
this.hidden = args.hidden;
this.threadId = args.thread_id;
this.channelId = args.chat_channel_id;
this.chatWebhookEvent = args.chat_webhook_event;
this.createdAt = args.created_at;
this.deletedAt = args.deleted_at;
this.excerpt = args.excerpt;
this.reviewableId = args.reviewable_id;
this.userFlagStatus = args.user_flag_status;
this.inReplyTo = args.in_reply_to
? ChatMessage.create(channel, args.in_reply_to)
: null;
this.message = args.message;
this.cooked = args.cooked || ChatMessage.cookFunction(this.message);
this.reactions = this.#initChatMessageReactionModel(
args.id,
args.reactions
);
this.stagedId = args.staged_id;
this.uploads = new TrackedArray(args.uploads || []);
this.user = this.#initUserModel(args.user);
this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null;
}
get read() {
return this.channel.currentUserMembership?.last_read_message_id >= this.id;
}
get firstMessageOfTheDayAt() {
if (!this.previousMessage) {
return this.#calendarDate(this.createdAt);
} }
args.user = User.create(args.user); if (
}, !this.#areDatesOnSameDay(
}); new Date(this.previousMessage.createdAt),
new Date(this.createdAt)
)
) {
return this.#calendarDate(this.createdAt);
}
}
#calendarDate(date) {
return moment(date).calendar(moment(), {
sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`,
lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`,
lastWeek: "LL",
sameElse: "LL",
});
}
@cached
get index() {
return this.channel.messages.indexOf(this);
}
@cached
get previousMessage() {
return this.channel?.messages?.objectAt?.(this.index - 1);
}
@cached
get nextMessage() {
return this.channel?.messages?.objectAt?.(this.index + 1);
}
get staged() {
return this.stagedId?.length > 0;
}
react(emoji, action, actor, currentUserId) {
const selfReaction = actor.id === currentUserId;
const existingReaction = this.reactions.find(
(reaction) => reaction.emoji === emoji
);
if (existingReaction) {
if (action === "add") {
if (selfReaction && existingReaction.reacted) {
return false;
}
existingReaction.count = existingReaction.count + 1;
if (selfReaction) {
existingReaction.reacted = true;
}
existingReaction.users.pushObject(actor);
} else {
existingReaction.count = existingReaction.count - 1;
if (selfReaction) {
existingReaction.reacted = false;
}
if (existingReaction.count === 0) {
this.reactions.removeObject(existingReaction);
} else {
existingReaction.users.removeObject(
existingReaction.users.find((user) => user.id === actor.id)
);
}
}
} else {
if (action === "add") {
this.reactions.pushObject(
ChatMessageReaction.create({
count: 1,
emoji,
reacted: selfReaction,
users: [actor],
})
);
}
}
}
#initChatMessageReactionModel(messageId, reactions = []) {
return reactions.map((reaction) =>
ChatMessageReaction.create(Object.assign({ messageId }, reaction))
);
}
#initUserModel(user) {
if (!user || user instanceof User) {
return user;
}
return User.create(user);
}
#areDatesOnSameDay(a, b) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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 {
@ -57,13 +61,11 @@
background-color: var(--secondary); background-color: var(--secondary);
display: flex; display: flex;
min-width: 0; min-width: 0;
content-visibility: auto;
contain-intrinsic-size: auto 200px;
.chat-message-reaction { .chat-message-reaction {
@include chat-reaction; @include chat-reaction;
&:not(.show) {
display: none;
}
} }
&.chat-action { &.chat-action {
@ -86,17 +88,6 @@
transition: 2s linear background-color; 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 +245,10 @@
.chat-message.chat-message-bookmarked { .chat-message.chat-message-bookmarked {
background: var(--highlight-bg); background: var(--highlight-bg);
&:hover {
background: var(--highlight-medium);
}
} }
.not-mobile-device & .chat-message-reaction-list .chat-message-react-btn { .not-mobile-device & .chat-message-reaction-list .chat-message-react-btn {
@ -284,7 +279,6 @@
font-style: italic; font-style: italic;
} }
.chat-message-container.is-hovered,
.chat-message.chat-message-selected { .chat-message.chat-message-selected {
background: var(--primary-very-low); background: var(--primary-very-low);
} }

@ -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-reactions {
display: flex;
padding: 5px 0 0 0;
}
&__message-reaction {
background-color: var(--primary-100);
width: 32px;
height: 18px;
border-radius: $radius;
& + & {
margin-left: 0.5rem;
}
}
&__message-text {
display: flex;
padding: 5px 0;
flex-direction: column;
}
&__message-msg { &__message-msg {
height: 13px; height: 13px;
border-radius: $radius; border-radius: $radius;
margin: 5px 0; 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;

@ -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,65 @@ $float-height: 530px;
} }
.chat-scroll-to-bottom { .chat-scroll-to-bottom {
background: var(--primary-medium); left: calc(50% - calc(45px / 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: 5px;
&:hover { > * {
pointer-events: none;
}
&:hover,
&:active,
&:focus {
background: none !important;
}
&.visible {
transform: translateY(-75px) scale(1);
opacity: 0.8;
}
&__text {
color: var(--secondary);
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--primary-medium); background: var(--primary-medium);
opacity: 100%; border-radius: 3px;
text-align: center;
font-size: var(--font-down-1);
} }
.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: 35px;
border-radius: 0; width: 35px;
transition: border-radius 0.1s linear;
&: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);
}
} }
} }
} }

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,60 @@
# frozen_string_literal: true
RSpec.describe "Sticky date", type: :system, js: true do
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:category_channel) }
let(:chat_page) { PageObjects::Pages::Chat.new }
before do
chat_system_bootstrap
sign_in(current_user)
end
context "when previous message is from a different user" do
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
fab!(:message_2) { Fabricate(:chat_message, chat_channel: channel_1) }
it "shows user info on the message" do
chat_page.visit_channel(channel_1)
expect(page.find("[data-id='#{message_2.id}']")).to have_css(".chat-message-avatar")
end
end
context "when previous message is from the same user" do
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1, user: current_user) }
fab!(:message_2) { Fabricate(:chat_message, chat_channel: channel_1, user: current_user) }
it "doesnt show user info on the message" do
chat_page.visit_channel(channel_1)
expect(page.find("[data-id='#{message_2.id}']")).to have_no_css(".chat-message-avatar")
end
context "when previous message is old" do
fab!(:message_1) do
Fabricate(
:chat_message,
chat_channel: channel_1,
user: current_user,
created_at: DateTime.parse("2018-11-10 17:00"),
)
end
fab!(:message_2) do
Fabricate(
:chat_message,
chat_channel: channel_1,
user: current_user,
created_at: DateTime.parse("2018-11-10 17:30"),
)
end
it "shows user info on the message" do
chat_page.visit_channel(channel_1)
expect(page.find("[data-id='#{message_2.id}']")).to have_no_css(".chat-message-avatar")
end
end
end
end

@ -60,8 +60,9 @@ RSpec.describe "Navigating to message", type: :system, js: true do
it "highlights the correct message after using the bottom arrow" do it "highlights the correct message after using the bottom arrow" do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
click_link(link) click_link(link)
click_link(I18n.t("js.chat.scroll_to_bottom")) click_button(class: "chat-scroll-to-bottom")
click_link(link) click_link(link)
expect(page).to have_css( expect(page).to have_css(
@ -149,8 +150,9 @@ RSpec.describe "Navigating to message", type: :system, js: true do
visit("/") visit("/")
chat_page.open_from_header chat_page.open_from_header
chat_drawer_page.open_channel(channel_1) chat_drawer_page.open_channel(channel_1)
click_link(link) click_link(link)
click_link(I18n.t("js.chat.scroll_to_bottom")) click_button(class: "chat-scroll-to-bottom")
click_link(link) click_link(link)
expect(page).to have_css( expect(page).to have_css(

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