DEV: rework the chat-live-pane (#20519)
This PR is introducing glimmer usage in the chat-live-pane, for components but also for models. RestModel usage has been dropped in favor of native classes. Other changes/additions in this PR: sticky dates, scrolling will now keep the date separator of the current section at the top of the screen better unread management, marking a channel as unread will correctly mark the correct message and not mark the whole channel as read. Tracking state will also now correctly return unread count and unread mentions. adds an animation on bottom arrow better scrolling behavior, we should now always correctly keep the scroll position while loading more reactions are now more reactive, and will update their tooltip without needed to close/reopen it skeleton has been improved with placeholder images and reactions when making a reaction on the desktop message actions, the menu won't move anymore simplify logic and stop maintaining a list of unloaded messages
This commit is contained in:
parent
e08a0b509d
commit
6b0aeced7e
|
@ -9,7 +9,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont
|
|||
|
||||
memberships =
|
||||
ChatChannelMembershipsQuery.call(
|
||||
channel_from_params,
|
||||
channel: channel_from_params,
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
username: params[:username],
|
||||
|
|
|
@ -223,7 +223,7 @@ class ChatMessage < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def url
|
||||
"/chat/message/#{self.id}"
|
||||
"/chat/c/-/#{self.chat_channel_id}/#{self.id}"
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ChatChannelMembershipsQuery
|
||||
def self.call(channel, limit: 50, offset: 0, username: nil, count_only: false)
|
||||
def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false)
|
||||
query =
|
||||
UserChatChannelMembership
|
||||
.joins(:user)
|
||||
|
@ -42,6 +42,6 @@ class ChatChannelMembershipsQuery
|
|||
end
|
||||
|
||||
def self.count(channel)
|
||||
call(channel, count_only: true)
|
||||
call(channel: channel, count_only: true)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
{
|
||||
message_bus_last_ids: {
|
||||
channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.id}"),
|
||||
new_messages:
|
||||
@opts[:new_messages_message_bus_last_id] ||
|
||||
MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)),
|
||||
|
|
|
@ -35,23 +35,23 @@ class ChatMessageSerializer < ApplicationSerializer
|
|||
end
|
||||
|
||||
def reactions
|
||||
reactions_hash = {}
|
||||
object
|
||||
.reactions
|
||||
.group_by(&:emoji)
|
||||
.each do |emoji, reactions|
|
||||
users = reactions[0..5].map(&:user).filter { |user| user.id != scope&.user&.id }[0..4]
|
||||
|
||||
.map do |emoji, reactions|
|
||||
next unless Emoji.exists?(emoji)
|
||||
|
||||
reactions_hash[emoji] = {
|
||||
users = reactions.take(5).map(&:user)
|
||||
|
||||
{
|
||||
emoji: emoji,
|
||||
count: reactions.count,
|
||||
users:
|
||||
ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json,
|
||||
reacted: users_reactions.include?(emoji),
|
||||
}
|
||||
end
|
||||
reactions_hash
|
||||
.compact
|
||||
end
|
||||
|
||||
def include_reactions?
|
||||
|
|
|
@ -16,6 +16,7 @@ class ChatViewSerializer < ApplicationSerializer
|
|||
|
||||
def meta
|
||||
meta_hash = {
|
||||
channel_id: object.chat_channel.id,
|
||||
can_flag: scope.can_flag_in_chat_channel?(object.chat_channel),
|
||||
channel_status: object.chat_channel.status,
|
||||
user_silenced: !scope.can_create_chat_message?,
|
||||
|
|
|
@ -12,7 +12,7 @@ module ChatPublisher
|
|||
{ scope: anonymous_guardian, root: :chat_message },
|
||||
).as_json
|
||||
content[:type] = :sent
|
||||
content[:stagedId] = staged_id
|
||||
content[:staged_id] = staged_id
|
||||
permissions = permissions(chat_channel)
|
||||
|
||||
MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions)
|
||||
|
@ -133,9 +133,13 @@ module ChatPublisher
|
|||
end
|
||||
|
||||
def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id)
|
||||
data = { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id }.merge(
|
||||
ChatChannelUnreadsQuery.call(channel_id: chat_channel_id, user_id: user.id),
|
||||
)
|
||||
|
||||
MessageBus.publish(
|
||||
self.user_tracking_state_message_bus_channel(user.id),
|
||||
{ chat_channel_id: chat_channel_id, chat_message_id: chat_message_id.to_i }.as_json,
|
||||
data.as_json,
|
||||
user_ids: [user.id],
|
||||
)
|
||||
end
|
||||
|
|
|
@ -1,24 +1,30 @@
|
|||
import { bind } from "discourse-common/utils/decorators";
|
||||
import Component from "@ember/component";
|
||||
import { action, computed } from "@ember/object";
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { and, empty } from "@ember/object/computed";
|
||||
|
||||
export default class ChannelsList extends Component {
|
||||
@service chat;
|
||||
@service router;
|
||||
@service chatStateManager;
|
||||
@service chatChannelsManager;
|
||||
tagName = "";
|
||||
inSidebar = false;
|
||||
toggleSection = null;
|
||||
@empty("chatChannelsManager.publicMessageChannels")
|
||||
publicMessageChannelsEmpty;
|
||||
@and("site.mobileView", "showDirectMessageChannels")
|
||||
showMobileDirectMessageButton;
|
||||
@service site;
|
||||
@service session;
|
||||
@service currentUser;
|
||||
|
||||
get showMobileDirectMessageButton() {
|
||||
return this.site.mobileView && this.showDirectMessageChannels;
|
||||
}
|
||||
|
||||
get inSidebar() {
|
||||
return this.args.inSidebar ?? false;
|
||||
}
|
||||
|
||||
get publicMessageChannelsEmpty() {
|
||||
return this.chatChannelsManager.publicMessageChannels?.length === 0;
|
||||
}
|
||||
|
||||
@computed("canCreateDirectMessageChannel")
|
||||
get createDirectMessageChannelLabel() {
|
||||
if (!this.canCreateDirectMessageChannel) {
|
||||
return "chat.direct_messages.cannot_create";
|
||||
|
@ -27,10 +33,6 @@ export default class ChannelsList extends Component {
|
|||
return "chat.direct_messages.new";
|
||||
}
|
||||
|
||||
@computed(
|
||||
"canCreateDirectMessageChannel",
|
||||
"chatChannelsManager.directMessageChannels"
|
||||
)
|
||||
get showDirectMessageChannels() {
|
||||
return (
|
||||
this.canCreateDirectMessageChannel ||
|
||||
|
@ -42,17 +44,12 @@ export default class ChannelsList extends Component {
|
|||
return this.chat.userCanDirectMessage;
|
||||
}
|
||||
|
||||
@computed("inSidebar")
|
||||
get publicChannelClasses() {
|
||||
return `channels-list-container public-channels ${
|
||||
this.inSidebar ? "collapsible-sidebar-section" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
@computed(
|
||||
"publicMessageChannelsEmpty",
|
||||
"currentUser.{staff,has_joinable_public_channels}"
|
||||
)
|
||||
get displayPublicChannels() {
|
||||
if (this.publicMessageChannelsEmpty) {
|
||||
return (
|
||||
|
@ -64,7 +61,6 @@ export default class ChannelsList extends Component {
|
|||
return true;
|
||||
}
|
||||
|
||||
@computed("inSidebar")
|
||||
get directMessageChannelClasses() {
|
||||
return `channels-list-container direct-message-channels ${
|
||||
this.inSidebar ? "collapsible-sidebar-section" : ""
|
||||
|
@ -73,7 +69,7 @@ export default class ChannelsList extends Component {
|
|||
|
||||
@action
|
||||
toggleChannelSection(section) {
|
||||
this.toggleSection(section);
|
||||
this.args.toggleSection(section);
|
||||
}
|
||||
|
||||
didRender() {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
{{this.lastMessageFormatedDate}}
|
||||
</div>
|
||||
|
||||
{{#if @unreadIndicator}}
|
||||
{{#if this.unreadIndicator}}
|
||||
<ChatChannelUnreadIndicator @channel={{@channel}} />
|
||||
{{/if}}
|
||||
</div>
|
|
@ -1,18 +1,18 @@
|
|||
import Component from "@glimmer/component";
|
||||
|
||||
export default class ChatChannelMetadata extends Component {
|
||||
unreadIndicator = false;
|
||||
get unreadIndicator() {
|
||||
return this.args.unreadIndicator ?? false;
|
||||
}
|
||||
|
||||
get lastMessageFormatedDate() {
|
||||
return moment(this.args.channel.get("last_message_sent_at")).calendar(
|
||||
null,
|
||||
{
|
||||
sameDay: "LT",
|
||||
nextDay: "[Tomorrow]",
|
||||
nextWeek: "dddd",
|
||||
lastDay: "[Yesterday]",
|
||||
lastWeek: "dddd",
|
||||
sameElse: "l",
|
||||
}
|
||||
);
|
||||
return moment(this.args.channel.lastMessageSentAt).calendar(null, {
|
||||
sameDay: "LT",
|
||||
nextDay: "[Tomorrow]",
|
||||
nextWeek: "dddd",
|
||||
lastDay: "[Yesterday]",
|
||||
lastWeek: "dddd",
|
||||
sameElse: "l",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,15 +4,15 @@
|
|||
(unless this.hasDescription "-no-description")
|
||||
}}
|
||||
>
|
||||
<ChatChannelTitle @channel={{this.channel}} />
|
||||
<ChatChannelTitle @channel={{@channel}} />
|
||||
{{#if this.hasDescription}}
|
||||
<p class="chat-channel-preview-card__description">
|
||||
{{this.channel.description}}
|
||||
{{@channel.description}}
|
||||
</p>
|
||||
{{/if}}
|
||||
{{#if this.showJoinButton}}
|
||||
<ToggleChannelMembershipButton
|
||||
@channel={{this.channel}}
|
||||
@channel={{@channel}}
|
||||
@options={{hash joinClass="btn-primary"}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
|
|
@ -1,19 +1,15 @@
|
|||
import Component from "@ember/component";
|
||||
import Component from "@glimmer/component";
|
||||
import { isEmpty } from "@ember/utils";
|
||||
import { computed } from "@ember/object";
|
||||
import { readOnly } from "@ember/object/computed";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class ChatChannelPreviewCard extends Component {
|
||||
@service chat;
|
||||
tagName = "";
|
||||
|
||||
channel = null;
|
||||
get showJoinButton() {
|
||||
return this.args.channel?.isOpen;
|
||||
}
|
||||
|
||||
@readOnly("channel.isOpen") showJoinButton;
|
||||
|
||||
@computed("channel.description")
|
||||
get hasDescription() {
|
||||
return !isEmpty(this.channel.description);
|
||||
return !isEmpty(this.args.channel?.description);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -194,7 +194,7 @@ export default Component.extend({
|
|||
|
||||
getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) {
|
||||
let sortedChannels = this.chatChannelsManager.channels.sort((a, b) => {
|
||||
return new Date(a.last_message_sent_at) > new Date(b.last_message_sent_at)
|
||||
return new Date(a.lastMessageSentAt) > new Date(b.lastMessageSentAt)
|
||||
? -1
|
||||
: 1;
|
||||
});
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
{{#if this.buttons.length}}
|
||||
{{#if @buttons.length}}
|
||||
<DPopover
|
||||
@class="chat-composer-dropdown"
|
||||
@options={{hash arrow=null}}
|
||||
as |state|
|
||||
>
|
||||
<FlatButton
|
||||
@disabled={{this.isDisabled}}
|
||||
@disabled={{@isDisabled}}
|
||||
@class="chat-composer-dropdown__trigger-btn d-popover-trigger"
|
||||
@title="chat.composer.toggle_toolbar"
|
||||
@icon={{if state.isExpanded "times" "plus"}}
|
||||
/>
|
||||
<ul class="chat-composer-dropdown__list">
|
||||
{{#each this.buttons as |button|}}
|
||||
{{#each @buttons as |button|}}
|
||||
<li class="chat-composer-dropdown__item {{button.id}}">
|
||||
<DButton
|
||||
@class={{concat "chat-composer-dropdown__action-btn " button.id}}
|
||||
|
|
|
@ -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 discourseComputed, { bind } from "discourse-common/utils/decorators";
|
||||
import UppyUploadMixin from "discourse/mixins/uppy-upload";
|
||||
import { cloneJSON } from "discourse-common/lib/object";
|
||||
|
||||
export default Component.extend(UppyUploadMixin, {
|
||||
classNames: ["chat-composer-uploads"],
|
||||
|
@ -12,16 +13,25 @@ export default Component.extend(UppyUploadMixin, {
|
|||
chatStateManager: service(),
|
||||
id: "chat-composer-uploader",
|
||||
type: "chat-composer",
|
||||
existingUploads: null,
|
||||
uploads: null,
|
||||
useMultipartUploadsIfAvailable: true,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.setProperties({
|
||||
uploads: [],
|
||||
fileInputSelector: `#${this.fileUploadElementId}`,
|
||||
});
|
||||
this.appEvents.on("chat-composer:load-uploads", this, "_loadUploads");
|
||||
},
|
||||
|
||||
didReceiveAttrs() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.set(
|
||||
"uploads",
|
||||
this.existingUploads ? cloneJSON(this.existingUploads) : []
|
||||
);
|
||||
this._uppyInstance?.cancelAll();
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
|
@ -32,7 +42,7 @@ export default Component.extend(UppyUploadMixin, {
|
|||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
this.appEvents.off("chat-composer:load-uploads", this, "_loadUploads");
|
||||
|
||||
this.composerInputEl?.removeEventListener(
|
||||
"paste",
|
||||
this._pasteEventListener
|
||||
|
@ -81,11 +91,6 @@ export default Component.extend(UppyUploadMixin, {
|
|||
};
|
||||
},
|
||||
|
||||
_loadUploads(uploads) {
|
||||
this._uppyInstance?.cancelAll();
|
||||
this.set("uploads", uploads);
|
||||
},
|
||||
|
||||
_uppyReady() {
|
||||
if (this.siteSettings.composer_media_optimization_image_enabled) {
|
||||
this._useUploadPlugin(UppyMediaOptimization, {
|
||||
|
|
|
@ -79,7 +79,11 @@
|
|||
{{#if this.canAttachUploads}}
|
||||
<ChatComposerUploads
|
||||
@fileUploadElementId={{this.fileUploadElementId}}
|
||||
@onUploadChanged={{action "uploadsChanged"}}
|
||||
@onUploadChanged={{this.uploadsChanged}}
|
||||
@existingUploads={{or
|
||||
this.chatChannel.draft.uploads
|
||||
this.editingMessage.uploads
|
||||
}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
|
|
|
@ -29,11 +29,10 @@ const THROTTLE_MS = 150;
|
|||
|
||||
export default Component.extend(TextareaTextManipulation, {
|
||||
chatChannel: null,
|
||||
lastChatChannelId: null,
|
||||
chat: service(),
|
||||
classNames: ["chat-composer-container"],
|
||||
classNameBindings: ["emojiPickerVisible:with-emoji-picker"],
|
||||
userSilenced: readOnly("details.user_silenced"),
|
||||
userSilenced: readOnly("chatChannel.userSilenced"),
|
||||
chatEmojiReactionStore: service("chat-emoji-reaction-store"),
|
||||
chatEmojiPickerManager: service("chat-emoji-picker-manager"),
|
||||
chatStateManager: service("chat-state-manager"),
|
||||
|
@ -220,18 +219,18 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
|
||||
if (
|
||||
!this.editingMessage &&
|
||||
this.draft &&
|
||||
this.chatChannel?.draft &&
|
||||
this.chatChannel?.canModifyMessages(this.currentUser)
|
||||
) {
|
||||
// uses uploads from draft here...
|
||||
this.setProperties({
|
||||
value: this.draft.value,
|
||||
replyToMsg: this.draft.replyToMsg,
|
||||
value: this.chatChannel.draft.message,
|
||||
replyToMsg: this.chatChannel.draft.replyToMsg,
|
||||
});
|
||||
|
||||
this._captureMentions();
|
||||
this._syncUploads(this.draft.uploads);
|
||||
this.setInReplyToMsg(this.draft.replyToMsg);
|
||||
this._syncUploads(this.chatChannel.draft.uploads);
|
||||
this.setInReplyToMsg(this.chatChannel.draft.replyToMsg);
|
||||
}
|
||||
|
||||
if (this.editingMessage && !this.loading) {
|
||||
|
@ -244,7 +243,6 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false });
|
||||
}
|
||||
|
||||
this.set("lastChatChannelId", this.chatChannel.id);
|
||||
this.resizeTextarea();
|
||||
},
|
||||
|
||||
|
@ -271,7 +269,6 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
}
|
||||
|
||||
this.set("_uploads", cloneJSON(newUploads));
|
||||
this.appEvents.trigger("chat-composer:load-uploads", this._uploads);
|
||||
},
|
||||
|
||||
_inProgressUploadsChanged(inProgressUploads) {
|
||||
|
@ -286,7 +283,7 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
|
||||
_replyToMsgChanged(replyToMsg) {
|
||||
this.set("replyToMsg", replyToMsg);
|
||||
this.onValueChange?.(this.value, this._uploads, replyToMsg);
|
||||
this.onValueChange?.({ replyToMsg });
|
||||
},
|
||||
|
||||
@action
|
||||
|
@ -302,12 +299,14 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
|
||||
@bind
|
||||
_handleTextareaInput() {
|
||||
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
|
||||
this.onValueChange?.({ value: this.value });
|
||||
},
|
||||
|
||||
@bind
|
||||
_captureMentions() {
|
||||
this.chatComposerWarningsTracker.trackMentions(this.value);
|
||||
if (this.value) {
|
||||
this.chatComposerWarningsTracker.trackMentions(this.value);
|
||||
}
|
||||
},
|
||||
|
||||
@bind
|
||||
|
@ -699,7 +698,7 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
cancelReplyTo() {
|
||||
this.set("replyToMsg", null);
|
||||
this.setInReplyToMsg(null);
|
||||
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
|
||||
this.onValueChange?.({ replyToMsg: null });
|
||||
},
|
||||
|
||||
@action
|
||||
|
@ -722,7 +721,7 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
@action
|
||||
uploadsChanged(uploads) {
|
||||
this.set("_uploads", cloneJSON(uploads));
|
||||
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
|
||||
this.onValueChange?.({ uploads: this._uploads });
|
||||
},
|
||||
|
||||
@action
|
||||
|
|
|
@ -19,9 +19,6 @@
|
|||
/>
|
||||
|
||||
{{#if this.previewedChannel}}
|
||||
<ChatLivePane
|
||||
@chatChannel={{this.previewedChannel}}
|
||||
@includeHeader={{false}}
|
||||
/>
|
||||
<ChatLivePane @channel={{this.previewedChannel}} @includeHeader={{false}} />
|
||||
{{/if}}
|
||||
</div>
|
|
@ -18,7 +18,7 @@
|
|||
{{#if this.chat.activeChannel}}
|
||||
<ChatLivePane
|
||||
@targetMessageId={{readonly @params.messageId}}
|
||||
@chatChannel={{this.chat.activeChannel}}
|
||||
@channel={{this.chat.activeChannel}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
|
|
@ -65,6 +65,10 @@ export default class ChatEmojiPicker extends Component {
|
|||
}
|
||||
|
||||
get flatEmojis() {
|
||||
if (!this.chatEmojiPickerManager.emojis) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let { favorites, ...rest } = this.chatEmojiPickerManager.emojis;
|
||||
return Object.values(rest).flat();
|
||||
|
|
|
@ -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,118 @@
|
|||
{{#if (and this.chatStateManager.isFullPageActive this.includeHeader)}}
|
||||
<div
|
||||
class="chat-full-page-header
|
||||
{{unless this.chatChannel.isFollowing '-not-following'}}"
|
||||
>
|
||||
<div class="chat-channel-header-details">
|
||||
{{#if this.site.mobileView}}
|
||||
<div class="chat-full-page-header__left-actions">
|
||||
<DButton
|
||||
@class="chat-full-page-header__back-btn no-text btn-flat"
|
||||
@icon="chevron-left"
|
||||
@action={{this.onBackClick}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<LinkTo
|
||||
@route="chat.channel.info"
|
||||
@models={{this.chatChannel.routeModels}}
|
||||
class="chat-channel-title-wrapper"
|
||||
>
|
||||
<ChatChannelTitle @channel={{this.chatChannel}} />
|
||||
</LinkTo>
|
||||
|
||||
{{#if this.showCloseFullScreenBtn}}
|
||||
<div class="chat-full-page-header__right-actions">
|
||||
<DButton
|
||||
@icon="discourse-compress"
|
||||
@title="chat.close_full_page"
|
||||
class="open-drawer-btn btn-flat no-text"
|
||||
@action={{action this.onCloseFullScreen}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatChannelStatus @channel={{this.chatChannel}} />
|
||||
{{/if}}
|
||||
|
||||
<ChatRetentionReminder @chatChannel={{this.chatChannel}} />
|
||||
|
||||
<ChatMentionWarnings />
|
||||
|
||||
<div class="chat-message-actions-mobile-anchor"></div>
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-message-emoji-picker-anchor"
|
||||
(if
|
||||
(and
|
||||
this.chatEmojiPickerManager.opened
|
||||
(eq this.chatEmojiPickerManager.context "chat-message")
|
||||
)
|
||||
"-opened"
|
||||
)
|
||||
"chat-live-pane"
|
||||
(if this.loading "loading")
|
||||
(if this.sendingLoading "sending-loading")
|
||||
}}
|
||||
{{did-insert this.setupListeners}}
|
||||
{{will-destroy this.teardownListeners}}
|
||||
{{did-insert this.updateChannel}}
|
||||
{{did-update this.loadMessages @targetMessageId}}
|
||||
{{did-update this.updateChannel @channel.id}}
|
||||
{{did-insert this.addAutoFocusEventListener}}
|
||||
{{will-destroy this.removeAutoFocusEventListener}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages-scroll chat-messages-container">
|
||||
<div class="chat-message-actions-desktop-anchor"></div>
|
||||
<div class="chat-messages-container">
|
||||
{{#if (or this.loading this.loadingMorePast)}}
|
||||
<ChatSkeleton @tagName="" />
|
||||
{{/if}}
|
||||
|
||||
{{#each this.messages as |message|}}
|
||||
<ChatMessage
|
||||
@message={{message}}
|
||||
@canInteractWithChat={{this.canInteractWithChat}}
|
||||
@details={{this.details}}
|
||||
@chatChannel={{this.chatChannel}}
|
||||
@setReplyTo={{action "setReplyTo"}}
|
||||
@replyMessageClicked={{action "replyMessageClicked"}}
|
||||
@editButtonClicked={{action "editButtonClicked"}}
|
||||
@selectingMessages={{this.selectingMessages}}
|
||||
@onStartSelectingMessages={{this.onStartSelectingMessages}}
|
||||
@onSelectMessage={{this.onSelectMessage}}
|
||||
@bulkSelectMessages={{this.bulkSelectMessages}}
|
||||
@fullPage={{this.fullPage}}
|
||||
@afterReactionAdded={{action "reStickScrollIfNeeded"}}
|
||||
@isHovered={{eq message.id this.hoveredMessageId}}
|
||||
@onHoverMessage={{this.onHoverMessage}}
|
||||
@resendStagedMessage={{this.resendStagedMessage}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
{{#if this.loadingMoreFuture}}
|
||||
<ChatSkeleton @tagName="" />
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.allPastMessagesLoaded}}
|
||||
<div class="all-loaded-message">
|
||||
{{i18n "chat.all_loaded"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.showScrollToBottomBtn}}
|
||||
<div class="scroll-stick-wrap">
|
||||
<a
|
||||
href
|
||||
title={{i18n "chat.scroll_to_bottom"}}
|
||||
class={{concat-class
|
||||
"btn"
|
||||
"btn-flat"
|
||||
"chat-scroll-to-bottom"
|
||||
(if this.hasNewMessages "unread-messages")
|
||||
}}
|
||||
{{on "click" (action "restickScrolling")}}
|
||||
>
|
||||
{{#if this.hasNewMessages}}
|
||||
{{i18n "chat.scroll_to_new_messages"}}
|
||||
{{/if}}
|
||||
{{d-icon "arrow-down"}}
|
||||
</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.selectingMessages}}
|
||||
<ChatSelectionManager
|
||||
@selectedMessageIds={{this.selectedMessageIds}}
|
||||
@chatChannel={{this.chatChannel}}
|
||||
@canModerate={{this.details.can_moderate}}
|
||||
@cancelSelecting={{action "cancelSelecting"}}
|
||||
<ChatFullPageHeader
|
||||
@channel={{@channel}}
|
||||
@onCloseFullScreen={{this.onCloseFullScreen}}
|
||||
@displayed={{this.includeHeader}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if (or this.chatChannel.isDraft this.chatChannel.isFollowing)}}
|
||||
<ChatComposer
|
||||
@draft={{this.draft}}
|
||||
@details={{this.details}}
|
||||
@canInteractWithChat={{this.canInteractWithChat}}
|
||||
@sendMessage={{action "sendMessage"}}
|
||||
@editMessage={{action "editMessage"}}
|
||||
@setReplyTo={{action "setReplyTo"}}
|
||||
@loading={{this.sendingLoading}}
|
||||
@editingMessage={{readonly this.editingMessage}}
|
||||
@onCancelEditing={{this.cancelEditing}}
|
||||
@setInReplyToMsg={{this.setInReplyToMsg}}
|
||||
@onEditLastMessageRequested={{this.editLastMessageRequested}}
|
||||
@onValueChange={{action "composerValueChanged"}}
|
||||
@chatChannel={{this.chatChannel}}
|
||||
|
||||
<ChatRetentionReminder @channel={{@channel}} />
|
||||
|
||||
<ChatMentionWarnings />
|
||||
|
||||
<div class="chat-message-actions-mobile-anchor"></div>
|
||||
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-message-emoji-picker-anchor"
|
||||
(if
|
||||
(and
|
||||
this.chatEmojiPickerManager.opened
|
||||
(eq this.chatEmojiPickerManager.context "chat-message")
|
||||
)
|
||||
"-opened"
|
||||
)
|
||||
}}
|
||||
></div>
|
||||
|
||||
<div class="chat-messages-scroll chat-messages-container">
|
||||
<div class="chat-message-actions-desktop-anchor"></div>
|
||||
<div class="chat-messages-container">
|
||||
|
||||
{{#if this.loadingMorePast}}
|
||||
<ChatSkeleton
|
||||
@onInsert={{this.onDidInsertSkeleton}}
|
||||
@onDestroy={{this.onDestroySkeleton}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#each @channel.messages key="id" as |message|}}
|
||||
<ChatMessage
|
||||
@message={{message}}
|
||||
@canInteractWithChat={{this.canInteractWithChat}}
|
||||
@channel={{@channel}}
|
||||
@setReplyTo={{this.setReplyTo}}
|
||||
@replyMessageClicked={{this.replyMessageClicked}}
|
||||
@editButtonClicked={{this.editButtonClicked}}
|
||||
@selectingMessages={{this.selectingMessages}}
|
||||
@onStartSelectingMessages={{this.onStartSelectingMessages}}
|
||||
@onSelectMessage={{this.onSelectMessage}}
|
||||
@bulkSelectMessages={{this.bulkSelectMessages}}
|
||||
@isHovered={{eq message.id this.hoveredMessageId}}
|
||||
@onHoverMessage={{this.onHoverMessage}}
|
||||
@resendStagedMessage={{this.resendStagedMessage}}
|
||||
@didShowMessage={{this.didShowMessage}}
|
||||
@didHideMessage={{this.didHideMessage}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
{{#if (or this.loadingMoreFuture)}}
|
||||
<ChatSkeleton
|
||||
@onInsert={{this.onDidInsertSkeleton}}
|
||||
@onDestroy={{this.onDestroySkeleton}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if (and this.loadedOnce (not @channel.canLoadMorePast))}}
|
||||
<div class="all-loaded-message">
|
||||
{{i18n "chat.all_loaded"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<ChatScrollToBottomArrow
|
||||
@scrollToBottom={{this.scrollToBottom}}
|
||||
@hasNewMessages={{this.hasNewMessages}}
|
||||
@isAlmostDocked={{this.isAlmostDocked}}
|
||||
@channel={{@channel}}
|
||||
/>
|
||||
|
||||
{{#if this.selectingMessages}}
|
||||
<ChatSelectionManager
|
||||
@selectedMessageIds={{this.selectedMessageIds}}
|
||||
@chatChannel={{@channel}}
|
||||
@cancelSelecting={{this.cancelSelecting}}
|
||||
/>
|
||||
{{else}}
|
||||
<ChatChannelPreviewCard @channel={{this.chatChannel}} />
|
||||
{{#if (or @channel.isDraft @channel.isFollowing)}}
|
||||
<ChatComposer
|
||||
@canInteractWithChat={{this.canInteractWithChat}}
|
||||
@sendMessage={{this.sendMessage}}
|
||||
@editMessage={{this.editMessage}}
|
||||
@setReplyTo={{this.setReplyTo}}
|
||||
@loading={{this.sendingLoading}}
|
||||
@editingMessage={{readonly this.editingMessage}}
|
||||
@onCancelEditing={{this.cancelEditing}}
|
||||
@setInReplyToMsg={{this.setInReplyToMsg}}
|
||||
@onEditLastMessageRequested={{this.editLastMessageRequested}}
|
||||
@onValueChange={{this.composerValueChanged}}
|
||||
@chatChannel={{@channel}}
|
||||
/>
|
||||
{{else}}
|
||||
<ChatChannelPreviewCard @channel={{@channel}} />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
File diff suppressed because it is too large
Load Diff
|
@ -6,11 +6,11 @@
|
|||
>
|
||||
<div class="chat-message-actions">
|
||||
{{#if this.chatStateManager.isFullPageActive}}
|
||||
{{#each @emojiReactions as |reaction|}}
|
||||
{{#each @emojiReactions key="emoji" as |reaction|}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@react={{@messageActions.react}}
|
||||
@class="show"
|
||||
@showCount={{false}}
|
||||
/>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
|
|
@ -31,6 +31,7 @@ export default class ChatMessageActionsDesktop extends Component {
|
|||
),
|
||||
{
|
||||
placement: "top-end",
|
||||
strategy: "fixed",
|
||||
modifiers: [
|
||||
{ name: "hide", enabled: true },
|
||||
{ name: "eventListeners", options: { scroll: false } },
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@react={{@messageActions.react}}
|
||||
@class="show"
|
||||
@showCount={{false}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<div class="chat-message-avatar">
|
||||
{{#if @message.chat_webhook_event.emoji}}
|
||||
<ChatEmojiAvatar @emoji={{@message.chat_webhook_event.emoji}} />
|
||||
{{#if @message.chatWebhookEvent.emoji}}
|
||||
<ChatEmojiAvatar @emoji={{@message.chatWebhookEvent.emoji}} />
|
||||
{{else}}
|
||||
<ChatUserAvatar @user={{@message.user}} @avatarSize="medium" />
|
||||
{{/if}}
|
||||
|
|
|
@ -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">
|
||||
{{#if this.hasUploads}}
|
||||
{{html-safe this.cooked}}
|
||||
{{html-safe @cooked}}
|
||||
|
||||
<Collapser @header={{this.uploadsHeader}}>
|
||||
<div class="chat-uploads">
|
||||
{{#each this.uploads as |upload|}}
|
||||
{{#each @uploads as |upload|}}
|
||||
<ChatUpload @upload={{upload}} />
|
||||
{{/each}}
|
||||
</div>
|
||||
|
|
|
@ -1,28 +1,20 @@
|
|||
import Component from "@ember/component";
|
||||
import { computed } from "@ember/object";
|
||||
import Component from "@glimmer/component";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import domFromString from "discourse-common/lib/dom-from-string";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class ChatMessageCollapser extends Component {
|
||||
tagName = "";
|
||||
collapsed = false;
|
||||
uploads = null;
|
||||
cooked = null;
|
||||
|
||||
@computed("uploads")
|
||||
get hasUploads() {
|
||||
return hasUploads(this.uploads);
|
||||
return hasUploads(this.args.uploads);
|
||||
}
|
||||
|
||||
@computed("uploads")
|
||||
get uploadsHeader() {
|
||||
let name = "";
|
||||
if (this.uploads.length === 1) {
|
||||
name = this.uploads[0].original_filename;
|
||||
if (this.args.uploads.length === 1) {
|
||||
name = this.args.uploads[0].original_filename;
|
||||
} else {
|
||||
name = I18n.t("chat.uploaded_files", { count: this.uploads.length });
|
||||
name = I18n.t("chat.uploaded_files", { count: this.args.uploads.length });
|
||||
}
|
||||
return htmlSafe(
|
||||
`<span class="chat-message-collapser-link-small">${escapeExpression(
|
||||
|
@ -31,9 +23,10 @@ export default class ChatMessageCollapser extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
@computed("cooked")
|
||||
get cookedBodies() {
|
||||
const elements = Array.prototype.slice.call(domFromString(this.cooked));
|
||||
const elements = Array.prototype.slice.call(
|
||||
domFromString(this.args.cooked)
|
||||
);
|
||||
|
||||
if (hasYoutube(elements)) {
|
||||
return this.youtubeCooked(elements);
|
||||
|
|
|
@ -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}}
|
||||
{{will-destroy this.stopTrackingStatus}}
|
||||
>
|
||||
{{#if @message.chat_webhook_event}}
|
||||
{{#if @message.chat_webhook_event.username}}
|
||||
{{#if @message.chatWebhookEvent}}
|
||||
{{#if @message.chatWebhookEvent.username}}
|
||||
<span
|
||||
class={{concat-class
|
||||
"chat-message-info__username"
|
||||
this.usernameClasses
|
||||
}}
|
||||
>
|
||||
{{@message.chat_webhook_event.username}}
|
||||
{{@message.chatWebhookEvent.username}}
|
||||
</span>
|
||||
{{/if}}
|
||||
|
||||
|
@ -49,8 +49,8 @@
|
|||
|
||||
{{#if this.isFlagged}}
|
||||
<span class="chat-message-info__flag">
|
||||
{{#if @message.reviewable_id}}
|
||||
<LinkTo @route="review.show" @model={{@message.reviewable_id}}>
|
||||
{{#if @message.reviewableId}}
|
||||
<LinkTo @route="review.show" @model={{@message.reviewableId}}>
|
||||
{{d-icon "flag" title="chat.flagged"}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
|
|
|
@ -48,10 +48,7 @@ export default class ChatMessageInfo extends Component {
|
|||
}
|
||||
|
||||
get isFlagged() {
|
||||
return (
|
||||
this.#message?.get("reviewable_id") ||
|
||||
this.#message?.get("user_flag_status") === 0
|
||||
);
|
||||
return this.#message?.reviewableId || this.#message?.userFlagStatus === 0;
|
||||
}
|
||||
|
||||
get prioritizeName() {
|
||||
|
@ -66,7 +63,7 @@ export default class ChatMessageInfo extends Component {
|
|||
}
|
||||
|
||||
get #user() {
|
||||
return this.#message?.get("user");
|
||||
return this.#message?.user;
|
||||
}
|
||||
|
||||
get #message() {
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
<div class="chat-message-left-gutter">
|
||||
{{#if @message.reviewable_id}}
|
||||
{{#if @message.reviewableId}}
|
||||
<LinkTo
|
||||
@route="review.show"
|
||||
@model={{@message.reviewable_id}}
|
||||
@model={{@message.reviewableId}}
|
||||
class="chat-message-left-gutter__flag"
|
||||
>
|
||||
{{d-icon "flag" title="chat.flagged"}}
|
||||
</LinkTo>
|
||||
{{else if (eq @message.user_flag_status 0)}}
|
||||
{{else if (eq @message.userFlagStatus 0)}}
|
||||
<div class="chat-message-left-gutter__flag">
|
||||
{{d-icon "flag" title="chat.you_flagged"}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{else if this.site.desktopView}}
|
||||
<span class="chat-message-left-gutter__date">
|
||||
{{format-chat-date @message "tiny"}}
|
||||
</span>
|
||||
|
|
|
@ -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"
|
||||
@icon="sign-out-alt"
|
||||
@disabled={{this.disableMoveButton}}
|
||||
@action={{action "moveMessages"}}
|
||||
@action={{this.moveMessages}}
|
||||
@label="chat.move_to_channel.confirm_move"
|
||||
@id="chat-confirm-move-messages-to-channel"
|
||||
/>
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
{{#if (and this.reaction this.emojiUrl)}}
|
||||
{{#if (and @reaction this.emojiUrl)}}
|
||||
<button
|
||||
id={{this.componentId}}
|
||||
type="button"
|
||||
{{on "click" (action "handleClick")}}
|
||||
{{on "click" this.handleClick}}
|
||||
tabindex="0"
|
||||
class={{concat-class
|
||||
this.class
|
||||
"chat-message-reaction"
|
||||
(if this.reaction.reacted "reacted")
|
||||
(if this.reaction.count "show")
|
||||
(if @reaction.reacted "reacted")
|
||||
}}
|
||||
data-emoji-name={{this.reaction.emoji}}
|
||||
data-emoji-name={{@reaction.emoji}}
|
||||
data-tippy-content={{this.popoverContent}}
|
||||
title={{this.emojiString}}
|
||||
{{did-insert this.setupTooltip}}
|
||||
{{will-destroy this.teardownTooltip}}
|
||||
{{did-update this.refreshTooltip this.popoverContent}}
|
||||
>
|
||||
<img
|
||||
loading="lazy"
|
||||
|
@ -22,8 +23,8 @@
|
|||
src={{this.emojiUrl}}
|
||||
/>
|
||||
|
||||
{{#if this.reaction.count}}
|
||||
<span class="count">{{this.reaction.count}}</span>
|
||||
{{#if (and this.showCount @reaction.count)}}
|
||||
<span class="count">{{@reaction.count}}</span>
|
||||
{{/if}}
|
||||
</button>
|
||||
{{/if}}
|
|
@ -1,95 +1,73 @@
|
|||
import { guidFor } from "@ember/object/internals";
|
||||
import Component from "@ember/component";
|
||||
import { action, computed } from "@ember/object";
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { emojiUnescape, emojiUrlFor } from "discourse/lib/text";
|
||||
import setupPopover from "discourse/lib/d-popover";
|
||||
import I18n from "I18n";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import setupPopover from "discourse/lib/d-popover";
|
||||
|
||||
export default class ChatMessageReaction extends Component {
|
||||
reaction = null;
|
||||
showUsersList = false;
|
||||
tagName = "";
|
||||
react = null;
|
||||
class = null;
|
||||
@service currentUser;
|
||||
|
||||
didReceiveAttrs() {
|
||||
this._super(...arguments);
|
||||
get showCount() {
|
||||
return this.args.showCount ?? true;
|
||||
}
|
||||
|
||||
if (this.showUsersList) {
|
||||
@action
|
||||
setupTooltip(element) {
|
||||
if (this.args.showTooltip) {
|
||||
schedule("afterRender", () => {
|
||||
this._popover?.destroy();
|
||||
this._popover = this._setupPopover();
|
||||
this._tippyInstance?.destroy();
|
||||
this._tippyInstance = setupPopover(element, {
|
||||
interactive: false,
|
||||
allowHTML: true,
|
||||
delay: 250,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
this._popover?.destroy();
|
||||
@action
|
||||
teardownTooltip() {
|
||||
this._tippyInstance?.destroy();
|
||||
}
|
||||
|
||||
@computed
|
||||
get componentId() {
|
||||
return guidFor(this);
|
||||
@action
|
||||
refreshTooltip() {
|
||||
this._tippyInstance?.setContent(this.popoverContent);
|
||||
}
|
||||
|
||||
@computed("reaction.emoji")
|
||||
get emojiString() {
|
||||
return `:${this.reaction.emoji}:`;
|
||||
return `:${this.args.reaction.emoji}:`;
|
||||
}
|
||||
|
||||
@computed("reaction.emoji")
|
||||
get emojiUrl() {
|
||||
return emojiUrlFor(this.reaction.emoji);
|
||||
return emojiUrlFor(this.args.reaction.emoji);
|
||||
}
|
||||
|
||||
@action
|
||||
handleClick() {
|
||||
this?.react(this.reaction.emoji, this.reaction.reacted ? "remove" : "add");
|
||||
this.args.react?.(
|
||||
this.args.reaction.emoji,
|
||||
this.args.reaction.reacted ? "remove" : "add"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
_setupPopover() {
|
||||
const target = document.getElementById(this.componentId);
|
||||
|
||||
if (!target) {
|
||||
get popoverContent() {
|
||||
if (!this.args.reaction.count || !this.args.reaction.users?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const popover = setupPopover(target, {
|
||||
interactive: false,
|
||||
allowHTML: true,
|
||||
delay: 250,
|
||||
content: emojiUnescape(this.popoverContent),
|
||||
onClickOutside(instance) {
|
||||
instance.hide();
|
||||
},
|
||||
onTrigger(instance, event) {
|
||||
// ensures we close other reactions popovers when triggering one
|
||||
document
|
||||
.querySelectorAll(".chat-message-reaction")
|
||||
.forEach((chatMessageReaction) => {
|
||||
chatMessageReaction?._tippy?.hide();
|
||||
});
|
||||
|
||||
event.stopPropagation();
|
||||
},
|
||||
});
|
||||
|
||||
return popover?.id ? popover : null;
|
||||
return emojiUnescape(
|
||||
this.args.reaction.reacted
|
||||
? this.#reactionTextWithSelf
|
||||
: this.#reactionText
|
||||
);
|
||||
}
|
||||
|
||||
@computed("reaction")
|
||||
get popoverContent() {
|
||||
return this.reaction.reacted
|
||||
? this._reactionTextWithSelf()
|
||||
: this._reactionText();
|
||||
}
|
||||
|
||||
_reactionTextWithSelf() {
|
||||
const reactionCount = this.reaction.count;
|
||||
get #reactionTextWithSelf() {
|
||||
const reactionCount = this.args.reaction.count;
|
||||
|
||||
if (reactionCount === 0) {
|
||||
return;
|
||||
|
@ -97,55 +75,55 @@ export default class ChatMessageReaction extends Component {
|
|||
|
||||
if (reactionCount === 1) {
|
||||
return I18n.t("chat.reactions.only_you", {
|
||||
emoji: this.reaction.emoji,
|
||||
emoji: this.args.reaction.emoji,
|
||||
});
|
||||
}
|
||||
|
||||
const maxUsernames = 4;
|
||||
const usernames = this.reaction.users
|
||||
const maxUsernames = 5;
|
||||
const usernames = this.args.reaction.users
|
||||
.filter((user) => user.id !== this.currentUser?.id)
|
||||
.slice(0, maxUsernames)
|
||||
.mapBy("username");
|
||||
|
||||
if (reactionCount === 2) {
|
||||
return I18n.t("chat.reactions.you_and_single_user", {
|
||||
emoji: this.reaction.emoji,
|
||||
emoji: this.args.reaction.emoji,
|
||||
username: usernames.pop(),
|
||||
});
|
||||
}
|
||||
|
||||
// `-1` because the current user ("you") isn't included in `usernames`
|
||||
const unnamedUserCount = reactionCount - usernames.length - 1;
|
||||
|
||||
const unnamedUserCount = reactionCount - usernames.length;
|
||||
if (unnamedUserCount > 0) {
|
||||
return I18n.t("chat.reactions.you_multiple_users_and_more", {
|
||||
emoji: this.reaction.emoji,
|
||||
emoji: this.args.reaction.emoji,
|
||||
commaSeparatedUsernames: this._joinUsernames(usernames),
|
||||
count: unnamedUserCount,
|
||||
});
|
||||
}
|
||||
|
||||
return I18n.t("chat.reactions.you_and_multiple_users", {
|
||||
emoji: this.reaction.emoji,
|
||||
emoji: this.args.reaction.emoji,
|
||||
username: usernames.pop(),
|
||||
commaSeparatedUsernames: this._joinUsernames(usernames),
|
||||
});
|
||||
}
|
||||
|
||||
_reactionText() {
|
||||
const reactionCount = this.reaction.count;
|
||||
get #reactionText() {
|
||||
const reactionCount = this.args.reaction.count;
|
||||
|
||||
if (reactionCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxUsernames = 5;
|
||||
const usernames = this.reaction.users
|
||||
const usernames = this.args.reaction.users
|
||||
.filter((user) => user.id !== this.currentUser?.id)
|
||||
.slice(0, maxUsernames)
|
||||
.mapBy("username");
|
||||
|
||||
if (reactionCount === 1) {
|
||||
return I18n.t("chat.reactions.single_user", {
|
||||
emoji: this.reaction.emoji,
|
||||
emoji: this.args.reaction.emoji,
|
||||
username: usernames.pop(),
|
||||
});
|
||||
}
|
||||
|
@ -154,14 +132,14 @@ export default class ChatMessageReaction extends Component {
|
|||
|
||||
if (unnamedUserCount > 0) {
|
||||
return I18n.t("chat.reactions.multiple_users_and_more", {
|
||||
emoji: this.reaction.emoji,
|
||||
emoji: this.args.reaction.emoji,
|
||||
commaSeparatedUsernames: this._joinUsernames(usernames),
|
||||
count: unnamedUserCount,
|
||||
});
|
||||
}
|
||||
|
||||
return I18n.t("chat.reactions.multiple_users", {
|
||||
emoji: this.reaction.emoji,
|
||||
emoji: this.args.reaction.emoji,
|
||||
username: usernames.pop(),
|
||||
commaSeparatedUsernames: this._joinUsernames(usernames),
|
||||
});
|
||||
|
|
|
@ -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">
|
||||
{{#if this.isCollapsible}}
|
||||
<ChatMessageCollapser @cooked={{this.cooked}} @uploads={{this.uploads}} />
|
||||
<ChatMessageCollapser @cooked={{@cooked}} @uploads={{@uploads}} />
|
||||
{{else}}
|
||||
{{html-safe this.cooked}}
|
||||
{{html-safe @cooked}}
|
||||
{{/if}}
|
||||
|
||||
{{#if this.edited}}
|
||||
{{#if this.isEdited}}
|
||||
<span class="chat-message-edited">({{i18n "chat.edited"}})</span>
|
||||
{{/if}}
|
||||
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import Component from "@ember/component";
|
||||
import { computed } from "@ember/object";
|
||||
import Component from "@glimmer/component";
|
||||
import { isCollapsible } from "discourse/plugins/chat/discourse/components/chat-message-collapser";
|
||||
|
||||
export default class ChatMessageText extends Component {
|
||||
tagName = "";
|
||||
cooked = null;
|
||||
uploads = null;
|
||||
edited = false;
|
||||
get isEdited() {
|
||||
return this.args.edited ?? false;
|
||||
}
|
||||
|
||||
@computed("cooked", "uploads.[]")
|
||||
get isCollapsible() {
|
||||
return isCollapsible(this.cooked, this.uploads);
|
||||
return isCollapsible(this.args.cooked, this.args.uploads);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{{! template-lint-disable no-invalid-interactive }}
|
||||
|
||||
<ChatMessageSeparator @message={{@message}} />
|
||||
<ChatMessageSeparatorDate @message={{@message}} />
|
||||
<ChatMessageSeparatorNew @message={{@message}} />
|
||||
|
||||
{{#if
|
||||
(and
|
||||
|
@ -40,19 +41,23 @@
|
|||
{{did-insert this.setMessageActionsAnchors}}
|
||||
{{did-insert this.decorateCookedMessage}}
|
||||
{{did-update this.decorateCookedMessage @message.id}}
|
||||
{{did-update this.decorateCookedMessage @message.version}}
|
||||
{{on "touchmove" this.handleTouchMove passive=true}}
|
||||
{{on "touchstart" this.handleTouchStart passive=true}}
|
||||
{{on "touchend" this.handleTouchEnd passive=true}}
|
||||
{{on "mouseenter" (fn @onHoverMessage @message (hash desktopOnly=true))}}
|
||||
{{on "mouseleave" (fn @onHoverMessage null (hash desktopOnly=true))}}
|
||||
{{chat/track-message-visibility}}
|
||||
class={{concat-class
|
||||
"chat-message-container"
|
||||
(if @isHovered "is-hovered")
|
||||
(if @selectingMessages "selecting-messages")
|
||||
(if @message.highlighted "highlighted")
|
||||
}}
|
||||
data-id={{@message.id}}
|
||||
{{chat/track-message
|
||||
(fn @didShowMessage @message)
|
||||
(fn @didHideMessage @message)
|
||||
}}
|
||||
data-id={{or @message.id @message.stagedId}}
|
||||
data-staged-id={{if @message.staged @message.stagedId}}
|
||||
>
|
||||
{{#if this.show}}
|
||||
{{#if @selectingMessages}}
|
||||
|
@ -85,35 +90,17 @@
|
|||
class={{concat-class
|
||||
"chat-message"
|
||||
(if @message.staged "chat-message-staged")
|
||||
(if @message.deleted_at "deleted")
|
||||
(if @message.in_reply_to "is-reply")
|
||||
(if @message.deletedAt "deleted")
|
||||
(if (and @message.inReplyTo (not this.hideReplyToInfo)) "is-reply")
|
||||
(if this.hideUserInfo "user-info-hidden")
|
||||
(if @message.error "errored")
|
||||
(if @message.bookmark "chat-message-bookmarked")
|
||||
(if @isHovered "chat-message-selected")
|
||||
}}
|
||||
>
|
||||
{{#if @message.in_reply_to}}
|
||||
<div
|
||||
role="button"
|
||||
onclick={{action this.viewReplyOrThread}}
|
||||
class="chat-reply is-direct-reply"
|
||||
>
|
||||
{{d-icon "share" title="chat.in_reply_to"}}
|
||||
|
||||
{{#if @message.in_reply_to.chat_webhook_event.emoji}}
|
||||
<ChatEmojiAvatar
|
||||
@emoji={{@message.in_reply_to.chat_webhook_event.emoji}}
|
||||
/>
|
||||
{{else}}
|
||||
<ChatUserAvatar @user={{@message.in_reply_to.user}} />
|
||||
{{/if}}
|
||||
|
||||
<span class="chat-reply__excerpt">
|
||||
{{replace-emoji @message.in_reply_to.excerpt}}
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#unless this.hideReplyToInfo}}
|
||||
<ChatMessageInReplyToIndicator @message={{@message}} />
|
||||
{{/unless}}
|
||||
|
||||
{{#if this.hideUserInfo}}
|
||||
<ChatMessageLeftGutter @message={{@message}} />
|
||||
|
@ -131,7 +118,7 @@
|
|||
@uploads={{@message.uploads}}
|
||||
@edited={{@message.edited}}
|
||||
>
|
||||
{{#if this.hasReactions}}
|
||||
{{#if @message.reactions.length}}
|
||||
<div class="chat-message-reaction-list">
|
||||
{{#if this.reactionLabel}}
|
||||
<div class="reaction-users-list">
|
||||
|
@ -139,18 +126,13 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each-in @message.reactions as |emoji reactionAttrs|}}
|
||||
{{#each @message.reactions as |reaction|}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{hash
|
||||
emoji=emoji
|
||||
users=reactionAttrs.users
|
||||
count=reactionAttrs.count
|
||||
reacted=reactionAttrs.reacted
|
||||
}}
|
||||
@reaction={{reaction}}
|
||||
@react={{this.react}}
|
||||
@showUsersList={{true}}
|
||||
@showTooltip={{true}}
|
||||
/>
|
||||
{{/each-in}}
|
||||
{{/each}}
|
||||
|
||||
{{#if @canInteractWithChat}}
|
||||
{{#unless this.site.mobileView}}
|
||||
|
@ -189,7 +171,7 @@
|
|||
|
||||
{{#if this.mentionWarning}}
|
||||
<div class="alert alert-info chat-message-mention-warning">
|
||||
{{#if this.mentionWarning.invitationSent}}
|
||||
{{#if this.mentionWarning.invitation_sent}}
|
||||
{{d-icon "check"}}
|
||||
<span>
|
||||
{{i18n
|
||||
|
|
|
@ -5,8 +5,7 @@ import Component from "@glimmer/component";
|
|||
import I18n from "I18n";
|
||||
import getURL from "discourse-common/lib/get-url";
|
||||
import optionalService from "discourse/lib/optional-service";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import EmberObject, { action } from "@ember/object";
|
||||
import { action } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { cancel, schedule } from "@ember/runloop";
|
||||
import { clipboardCopy } from "discourse/lib/utilities";
|
||||
|
@ -18,6 +17,7 @@ import showModal from "discourse/lib/show-modal";
|
|||
import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
|
||||
|
||||
let _chatMessageDecorators = [];
|
||||
|
||||
|
@ -50,37 +50,24 @@ export default class ChatMessage extends Component {
|
|||
@optionalService adminTools;
|
||||
|
||||
cachedFavoritesReactions = null;
|
||||
|
||||
_hasSubscribedToAppEvents = false;
|
||||
_loadingReactions = [];
|
||||
reacting = false;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.args.message.id
|
||||
? this._subscribeToAppEvents()
|
||||
: this._waitForIdToBePopulated();
|
||||
|
||||
if (this.args.message.bookmark) {
|
||||
this.args.message.set(
|
||||
"bookmark",
|
||||
Bookmark.create(this.args.message.bookmark)
|
||||
);
|
||||
}
|
||||
|
||||
this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites;
|
||||
}
|
||||
|
||||
get deletedAndCollapsed() {
|
||||
return this.args.message?.get("deleted_at") && this.collapsed;
|
||||
return this.args.message?.deletedAt && this.collapsed;
|
||||
}
|
||||
|
||||
get hiddenAndCollapsed() {
|
||||
return this.args.message?.get("hidden") && this.collapsed;
|
||||
return this.args.message?.hidden && this.collapsed;
|
||||
}
|
||||
|
||||
get collapsed() {
|
||||
return !this.args.message?.get("expanded");
|
||||
return !this.args.message?.expanded;
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -97,32 +84,9 @@ export default class ChatMessage extends Component {
|
|||
|
||||
@action
|
||||
teardownChatMessage() {
|
||||
if (this.args.message?.stagedId) {
|
||||
this.appEvents.off(
|
||||
`chat-message-staged-${this.args.message.stagedId}:id-populated`,
|
||||
this,
|
||||
"_subscribeToAppEvents"
|
||||
);
|
||||
}
|
||||
|
||||
this.appEvents.off("chat:refresh-message", this, "_refreshedMessage");
|
||||
|
||||
this.appEvents.off(
|
||||
`chat-message-${this.args.message.id}:reaction`,
|
||||
this,
|
||||
"_handleReactionMessage"
|
||||
);
|
||||
|
||||
cancel(this._invitationSentTimer);
|
||||
}
|
||||
|
||||
@bind
|
||||
_refreshedMessage(message) {
|
||||
if (message.id === this.args.message.id) {
|
||||
this.decorateCookedMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
decorateCookedMessage() {
|
||||
schedule("afterRender", () => {
|
||||
|
@ -131,45 +95,22 @@ export default class ChatMessage extends Component {
|
|||
}
|
||||
|
||||
_chatMessageDecorators.forEach((decorator) => {
|
||||
decorator.call(this, this.messageContainer, this.args.chatChannel);
|
||||
decorator.call(this, this.messageContainer, this.args.channel);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get messageContainer() {
|
||||
const id = this.args.message?.id || this.args.message?.stagedId;
|
||||
return (
|
||||
id && document.querySelector(`.chat-message-container[data-id='${id}']`)
|
||||
);
|
||||
}
|
||||
|
||||
_subscribeToAppEvents() {
|
||||
if (!this.args.message.id || this._hasSubscribedToAppEvents) {
|
||||
return;
|
||||
const id = this.args.message?.id;
|
||||
if (id) {
|
||||
return document.querySelector(`.chat-message-container[data-id='${id}']`);
|
||||
}
|
||||
|
||||
this.appEvents.on("chat:refresh-message", this, "_refreshedMessage");
|
||||
|
||||
this.appEvents.on(
|
||||
`chat-message-${this.args.message.id}:reaction`,
|
||||
this,
|
||||
"_handleReactionMessage"
|
||||
);
|
||||
this._hasSubscribedToAppEvents = true;
|
||||
}
|
||||
|
||||
_waitForIdToBePopulated() {
|
||||
this.appEvents.on(
|
||||
`chat-message-staged-${this.args.message.stagedId}:id-populated`,
|
||||
this,
|
||||
"_subscribeToAppEvents"
|
||||
);
|
||||
}
|
||||
|
||||
get showActions() {
|
||||
return (
|
||||
this.args.canInteractWithChat &&
|
||||
!this.args.message?.get("staged") &&
|
||||
!this.args.message?.staged &&
|
||||
this.args.isHovered
|
||||
);
|
||||
}
|
||||
|
@ -270,17 +211,16 @@ export default class ChatMessage extends Component {
|
|||
|
||||
get hasThread() {
|
||||
return (
|
||||
this.args.chatChannel?.get("threading_enabled") &&
|
||||
this.args.message?.get("thread_id")
|
||||
this.args.channel?.get("threading_enabled") && this.args.message?.threadId
|
||||
);
|
||||
}
|
||||
|
||||
get show() {
|
||||
return (
|
||||
!this.args.message?.get("deleted_at") ||
|
||||
this.currentUser.id === this.args.message?.get("user.id") ||
|
||||
!this.args.message?.deletedAt ||
|
||||
this.currentUser.id === this.args.message?.user?.id ||
|
||||
this.currentUser.staff ||
|
||||
this.args.details?.can_moderate
|
||||
this.args.channel?.canModerate
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -331,83 +271,97 @@ export default class ChatMessage extends Component {
|
|||
|
||||
get hideUserInfo() {
|
||||
return (
|
||||
this.args.message?.get("hideUserInfo") &&
|
||||
!this.args.message?.get("chat_webhook_event")
|
||||
!this.args.message?.chatWebhookEvent &&
|
||||
!this.args.message?.inReplyTo &&
|
||||
!this.args.message?.previousMessage?.deletedAt &&
|
||||
Math.abs(
|
||||
new Date(this.args.message?.createdAt) -
|
||||
new Date(this.args.message?.createdAt)
|
||||
) < 300000 && // If the time between messages is over 5 minutes, break.
|
||||
this.args.message?.user?.id ===
|
||||
this.args.message?.previousMessage?.user?.id
|
||||
);
|
||||
}
|
||||
|
||||
get hideReplyToInfo() {
|
||||
return (
|
||||
this.args.message?.inReplyTo?.id ===
|
||||
this.args.message?.previousMessage?.id
|
||||
);
|
||||
}
|
||||
|
||||
get showEditButton() {
|
||||
return (
|
||||
!this.args.message?.get("deleted_at") &&
|
||||
this.currentUser?.id === this.args.message?.get("user.id") &&
|
||||
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
|
||||
!this.args.message?.deletedAt &&
|
||||
this.currentUser?.id === this.args.message?.user?.id &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get canFlagMessage() {
|
||||
return (
|
||||
this.currentUser?.id !== this.args.message?.get("user.id") &&
|
||||
this.args.message?.get("user_flag_status") === undefined &&
|
||||
this.args.details?.can_flag &&
|
||||
!this.args.message?.get("chat_webhook_event") &&
|
||||
!this.args.message?.get("deleted_at")
|
||||
this.currentUser?.id !== this.args.message?.user?.id &&
|
||||
!this.args.channel?.isDirectMessageChannel &&
|
||||
this.args.message?.userFlagStatus === undefined &&
|
||||
this.args.channel?.canFlag &&
|
||||
!this.args.message?.chatWebhookEvent &&
|
||||
!this.args.message?.deletedAt
|
||||
);
|
||||
}
|
||||
|
||||
get canManageDeletion() {
|
||||
return this.currentUser?.id === this.args.message.get("user.id")
|
||||
? this.args.details?.can_delete_self
|
||||
: this.args.details?.can_delete_others;
|
||||
return this.currentUser?.id === this.args.message.user.id
|
||||
? this.args.channel?.canDeleteSelf
|
||||
: this.args.channel?.canDeleteOthers;
|
||||
}
|
||||
|
||||
get canReply() {
|
||||
return (
|
||||
!this.args.message?.get("deleted_at") &&
|
||||
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
|
||||
!this.args.message?.deletedAt &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get canReact() {
|
||||
return (
|
||||
!this.args.message?.get("deleted_at") &&
|
||||
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
|
||||
!this.args.message?.deletedAt &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get showDeleteButton() {
|
||||
return (
|
||||
this.canManageDeletion &&
|
||||
!this.args.message?.get("deleted_at") &&
|
||||
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
|
||||
!this.args.message?.deletedAt &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get showRestoreButton() {
|
||||
return (
|
||||
this.canManageDeletion &&
|
||||
this.args.message?.get("deleted_at") &&
|
||||
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
|
||||
this.args.message?.deletedAt &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get showBookmarkButton() {
|
||||
return this.args.chatChannel?.canModifyMessages?.(this.currentUser);
|
||||
return this.args.channel?.canModifyMessages?.(this.currentUser);
|
||||
}
|
||||
|
||||
get showRebakeButton() {
|
||||
return (
|
||||
this.currentUser?.staff &&
|
||||
this.args.chatChannel?.canModifyMessages?.(this.currentUser)
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get hasReactions() {
|
||||
return Object.values(this.args.message.get("reactions")).some(
|
||||
(r) => r.count > 0
|
||||
);
|
||||
return Object.values(this.args.message.reactions).some((r) => r.count > 0);
|
||||
}
|
||||
|
||||
get mentionWarning() {
|
||||
return this.args.message.get("mentionWarning");
|
||||
return this.args.message.mentionWarning;
|
||||
}
|
||||
|
||||
get mentionedCannotSeeText() {
|
||||
|
@ -464,13 +418,13 @@ export default class ChatMessage extends Component {
|
|||
inviteMentioned() {
|
||||
const userIds = this.mentionWarning.without_membership.mapBy("id");
|
||||
|
||||
ajax(`/chat/${this.args.message.chat_channel_id}/invite`, {
|
||||
ajax(`/chat/${this.args.message.channelId}/invite`, {
|
||||
method: "PUT",
|
||||
data: { user_ids: userIds, chat_message_id: this.args.message.id },
|
||||
}).then(() => {
|
||||
this.args.message.set("mentionWarning.invitationSent", true);
|
||||
this.args.message.mentionWarning.set("invitationSent", true);
|
||||
this._invitationSentTimer = discourseLater(() => {
|
||||
this.args.message.set("mentionWarning", null);
|
||||
this.dismissMentionWarning();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
|
@ -479,7 +433,7 @@ export default class ChatMessage extends Component {
|
|||
|
||||
@action
|
||||
dismissMentionWarning() {
|
||||
this.args.message.set("mentionWarning", null);
|
||||
this.args.message.mentionWarning = null;
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -517,27 +471,17 @@ export default class ChatMessage extends Component {
|
|||
this.react(emoji, REACTIONS.add);
|
||||
}
|
||||
|
||||
@bind
|
||||
_handleReactionMessage(busData) {
|
||||
const loadingReactionIndex = this._loadingReactions.indexOf(busData.emoji);
|
||||
if (loadingReactionIndex > -1) {
|
||||
return this._loadingReactions.splice(loadingReactionIndex, 1);
|
||||
}
|
||||
|
||||
this._updateReactionsList(busData.emoji, busData.action, busData.user);
|
||||
this.args.afterReactionAdded();
|
||||
}
|
||||
|
||||
get capabilities() {
|
||||
return getOwner(this).lookup("capabilities:main");
|
||||
}
|
||||
|
||||
@action
|
||||
react(emoji, reactAction) {
|
||||
if (
|
||||
!this.args.canInteractWithChat ||
|
||||
this._loadingReactions.includes(emoji)
|
||||
) {
|
||||
if (!this.args.canInteractWithChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reacting) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -549,71 +493,21 @@ export default class ChatMessage extends Component {
|
|||
this.args.onHoverMessage(null);
|
||||
}
|
||||
|
||||
this._loadingReactions.push(emoji);
|
||||
this._updateReactionsList(emoji, reactAction, this.currentUser);
|
||||
|
||||
if (reactAction === REACTIONS.add) {
|
||||
this.chatEmojiReactionStore.track(`:${emoji}:`);
|
||||
}
|
||||
|
||||
return this._publishReaction(emoji, reactAction).then(() => {
|
||||
// creating reaction will create a membership if not present
|
||||
// so we will fully refresh if we were not members of the channel
|
||||
// already
|
||||
if (!this.args.chatChannel.isFollowing || this.args.chatChannel.isDraft) {
|
||||
return this.args.chatChannelsManager
|
||||
.getChannel(this.args.chatChannel.id)
|
||||
.then((reactedChannel) => {
|
||||
this.router.transitionTo("chat.channel", "-", reactedChannel.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
this.reacting = true;
|
||||
|
||||
_updateReactionsList(emoji, reactAction, user) {
|
||||
const selfReacted = this.currentUser.id === user.id;
|
||||
if (this.args.message.reactions[emoji]) {
|
||||
if (
|
||||
selfReacted &&
|
||||
reactAction === REACTIONS.add &&
|
||||
this.args.message.reactions[emoji].reacted
|
||||
) {
|
||||
// User is already has reaction added; do nothing
|
||||
return false;
|
||||
}
|
||||
this.args.message.react(
|
||||
emoji,
|
||||
reactAction,
|
||||
this.currentUser,
|
||||
this.currentUser.id
|
||||
);
|
||||
|
||||
let newCount =
|
||||
reactAction === REACTIONS.add
|
||||
? this.args.message.reactions[emoji].count + 1
|
||||
: this.args.message.reactions[emoji].count - 1;
|
||||
|
||||
this.args.message.reactions.set(`${emoji}.count`, newCount);
|
||||
if (selfReacted) {
|
||||
this.args.message.reactions.set(
|
||||
`${emoji}.reacted`,
|
||||
reactAction === REACTIONS.add
|
||||
);
|
||||
} else {
|
||||
this.args.message.reactions[emoji].users.pushObject(user);
|
||||
}
|
||||
|
||||
this.args.message.notifyPropertyChange("reactions");
|
||||
} else {
|
||||
if (reactAction === REACTIONS.add) {
|
||||
this.args.message.reactions.set(emoji, {
|
||||
count: 1,
|
||||
reacted: selfReacted,
|
||||
users: selfReacted ? [] : [user],
|
||||
});
|
||||
}
|
||||
|
||||
this.args.message.notifyPropertyChange("reactions");
|
||||
}
|
||||
}
|
||||
|
||||
_publishReaction(emoji, reactAction) {
|
||||
return ajax(
|
||||
`/chat/${this.args.message.chat_channel_id}/react/${this.args.message.id}`,
|
||||
`/chat/${this.args.message.channelId}/react/${this.args.message.id}`,
|
||||
{
|
||||
type: "PUT",
|
||||
data: {
|
||||
|
@ -621,10 +515,19 @@ export default class ChatMessage extends Component {
|
|||
emoji,
|
||||
},
|
||||
}
|
||||
).catch((errResult) => {
|
||||
popupAjaxError(errResult);
|
||||
this._updateReactionsList(emoji, REACTIONS.remove, this.currentUser);
|
||||
});
|
||||
)
|
||||
.catch((errResult) => {
|
||||
popupAjaxError(errResult);
|
||||
this.args.message.react(
|
||||
emoji,
|
||||
REACTIONS.remove,
|
||||
this.currentUser,
|
||||
this.currentUser.id
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.reacting = false;
|
||||
});
|
||||
}
|
||||
|
||||
// TODO(roman): For backwards-compatibility.
|
||||
|
@ -651,17 +554,6 @@ export default class ChatMessage extends Component {
|
|||
this.args.setReplyTo(this.args.message.id);
|
||||
}
|
||||
|
||||
viewReplyOrThread() {
|
||||
if (this.hasThread) {
|
||||
this.router.transitionTo(
|
||||
"chat.channel.thread",
|
||||
this.args.message.thread_id
|
||||
);
|
||||
} else {
|
||||
this.args.replyMessageClicked(this.args.message.in_reply_to);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
edit() {
|
||||
this.args.editButtonClicked(this.args.message.id);
|
||||
|
@ -673,12 +565,11 @@ export default class ChatMessage extends Component {
|
|||
requirejs.entries["discourse/lib/flag-targets/flag"];
|
||||
|
||||
if (targetFlagSupported) {
|
||||
const model = EmberObject.create(this.args.message);
|
||||
model.set("username", model.get("user.username"));
|
||||
model.set("user_id", model.get("user.id"));
|
||||
const model = this.args.message;
|
||||
model.username = model.user?.username;
|
||||
model.user_id = model.user?.id;
|
||||
let controller = showModal("flag", { model });
|
||||
|
||||
controller.setProperties({ flagTarget: new ChatMessageFlag() });
|
||||
controller.set("flagTarget", new ChatMessageFlag());
|
||||
} else {
|
||||
this._legacyFlag();
|
||||
}
|
||||
|
@ -686,13 +577,13 @@ export default class ChatMessage extends Component {
|
|||
|
||||
@action
|
||||
expand() {
|
||||
this.args.message.set("expanded", true);
|
||||
this.args.message.expanded = true;
|
||||
}
|
||||
|
||||
@action
|
||||
restore() {
|
||||
return ajax(
|
||||
`/chat/${this.args.message.chat_channel_id}/restore/${this.args.message.id}`,
|
||||
`/chat/${this.args.message.channelId}/restore/${this.args.message.id}`,
|
||||
{
|
||||
type: "PUT",
|
||||
}
|
||||
|
@ -701,10 +592,7 @@ export default class ChatMessage extends Component {
|
|||
|
||||
@action
|
||||
openThread() {
|
||||
this.router.transitionTo(
|
||||
"chat.channel.thread",
|
||||
this.args.message.thread_id
|
||||
);
|
||||
this.router.transitionTo("chat.channel.thread", this.args.message.threadId);
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -719,7 +607,7 @@ export default class ChatMessage extends Component {
|
|||
{
|
||||
onAfterSave: (savedData) => {
|
||||
const bookmark = Bookmark.create(savedData);
|
||||
this.args.message.set("bookmark", bookmark);
|
||||
this.args.message.bookmark = bookmark;
|
||||
this.appEvents.trigger(
|
||||
"bookmarks:changed",
|
||||
savedData,
|
||||
|
@ -727,7 +615,7 @@ export default class ChatMessage extends Component {
|
|||
);
|
||||
},
|
||||
onAfterDelete: () => {
|
||||
this.args.message.set("bookmark", null);
|
||||
this.args.message.bookmark = null;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -736,7 +624,7 @@ export default class ChatMessage extends Component {
|
|||
@action
|
||||
rebakeMessage() {
|
||||
return ajax(
|
||||
`/chat/${this.args.message.chat_channel_id}/${this.args.message.id}/rebake`,
|
||||
`/chat/${this.args.message.channelId}/${this.args.message.id}/rebake`,
|
||||
{
|
||||
type: "PUT",
|
||||
}
|
||||
|
@ -746,7 +634,7 @@ export default class ChatMessage extends Component {
|
|||
@action
|
||||
deleteMessage() {
|
||||
return ajax(
|
||||
`/chat/${this.args.message.chat_channel_id}/${this.args.message.id}`,
|
||||
`/chat/${this.args.message.channelId}/${this.args.message.id}`,
|
||||
{
|
||||
type: "DELETE",
|
||||
}
|
||||
|
@ -755,7 +643,7 @@ export default class ChatMessage extends Component {
|
|||
|
||||
@action
|
||||
selectMessage() {
|
||||
this.args.message.set("selected", true);
|
||||
this.args.message.selected = true;
|
||||
this.args.onStartSelectingMessages(this.args.message);
|
||||
}
|
||||
|
||||
|
@ -780,7 +668,7 @@ export default class ChatMessage extends Component {
|
|||
|
||||
const { protocol, host } = window.location;
|
||||
let url = getURL(
|
||||
`/chat/c/-/${this.args.message.chat_channel_id}/${this.args.message.id}`
|
||||
`/chat/c/-/${this.args.message.channelId}/${this.args.message.id}`
|
||||
);
|
||||
url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url;
|
||||
clipboardCopy(url);
|
||||
|
@ -793,25 +681,22 @@ export default class ChatMessage extends Component {
|
|||
}
|
||||
|
||||
get emojiReactions() {
|
||||
const favorites = this.cachedFavoritesReactions;
|
||||
let favorites = this.cachedFavoritesReactions;
|
||||
|
||||
// may be a {} if no defaults defined in some production builds
|
||||
if (!favorites || !favorites.slice) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const userReactions = Object.keys(this.args.message.reactions || {}).filter(
|
||||
(key) => {
|
||||
return this.args.message.reactions[key].reacted;
|
||||
}
|
||||
);
|
||||
|
||||
return favorites.slice(0, 3).map((emoji) => {
|
||||
if (userReactions.includes(emoji)) {
|
||||
return { emoji, reacted: true };
|
||||
} else {
|
||||
return { emoji, reacted: false };
|
||||
}
|
||||
return (
|
||||
this.args.message.reactions.find(
|
||||
(reaction) => reaction.emoji === emoji
|
||||
) ||
|
||||
ChatMessageReaction.create({
|
||||
emoji,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{#if this.show}}
|
||||
<div class="chat-retention-reminder">
|
||||
<ChatRetentionReminderText @channel={{this.chatChannel}} />
|
||||
<ChatRetentionReminderText @channel={{@channel}} />
|
||||
<DButton
|
||||
@class="btn-flat dismiss-btn"
|
||||
@action={{this.dismiss}}
|
||||
|
|
|
@ -1,39 +1,34 @@
|
|||
import Component from "@ember/component";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default Component.extend({
|
||||
tagName: "",
|
||||
loading: false,
|
||||
export default class ChatRetentionReminder extends Component {
|
||||
@service currentUser;
|
||||
|
||||
@discourseComputed(
|
||||
"chatChannel.chatable_type",
|
||||
"currentUser.{needs_dm_retention_reminder,needs_channel_retention_reminder}"
|
||||
)
|
||||
show() {
|
||||
get show() {
|
||||
return (
|
||||
!this.chatChannel.isDraft &&
|
||||
((this.chatChannel.isDirectMessageChannel &&
|
||||
this.currentUser.needs_dm_retention_reminder) ||
|
||||
(this.chatChannel.isCategoryChannel &&
|
||||
this.currentUser.needs_channel_retention_reminder))
|
||||
!this.args.channel?.isDraft &&
|
||||
((this.args.channel?.isDirectMessageChannel &&
|
||||
this.currentUser?.get("needs_dm_retention_reminder")) ||
|
||||
(this.args.channel?.isCategoryChannel &&
|
||||
this.currentUser?.get("needs_channel_retention_reminder")))
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
@action
|
||||
dismiss() {
|
||||
return ajax("/chat/dismiss-retention-reminder", {
|
||||
method: "POST",
|
||||
data: { chatable_type: this.chatChannel.chatable_type },
|
||||
data: { chatable_type: this.args.channel.chatableType },
|
||||
})
|
||||
.then(() => {
|
||||
const field = this.chatChannel.isDirectMessageChannel
|
||||
const field = this.args.channel.isDirectMessageChannel
|
||||
? "needs_dm_retention_reminder"
|
||||
: "needs_channel_retention_reminder";
|
||||
this.currentUser.set(field, false);
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
<div class="scroll-stick-wrap">
|
||||
<DButton
|
||||
class={{concat-class
|
||||
"btn-flat"
|
||||
"chat-scroll-to-bottom"
|
||||
(if
|
||||
(or (not @isAlmostDocked) @hasNewMessages @channel.canLoadMoreFuture)
|
||||
"visible"
|
||||
)
|
||||
}}
|
||||
@action={{@scrollToBottom}}
|
||||
>
|
||||
<span class="chat-scroll-to-bottom__arrow">
|
||||
{{d-icon "arrow-down"}}
|
||||
|
||||
{{#if @hasNewMessages}}
|
||||
<span class="chat-scroll-to-bottom__text">
|
||||
{{i18n "chat.scroll_to_new_messages"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
</DButton>
|
||||
</div>
|
|
@ -19,16 +19,17 @@ export default class AdminCustomizeColorsShowController extends Component {
|
|||
chatCopySuccess = false;
|
||||
showChatCopySuccess = false;
|
||||
cancelSelecting = null;
|
||||
canModerate = false;
|
||||
|
||||
@computed("selectedMessageIds.length")
|
||||
get anyMessagesSelected() {
|
||||
return this.selectedMessageIds.length > 0;
|
||||
}
|
||||
|
||||
@computed("chatChannel.isDirectMessageChannel", "canModerate")
|
||||
@computed("chatChannel.isDirectMessageChannel", "chatChannel.canModerate")
|
||||
get showMoveMessageButton() {
|
||||
return !this.chatChannel.isDirectMessageChannel && this.canModerate;
|
||||
return (
|
||||
!this.chatChannel.isDirectMessageChannel && this.chatChannel.canModerate
|
||||
);
|
||||
}
|
||||
|
||||
@bind
|
||||
|
|
|
@ -1,13 +1,31 @@
|
|||
<div class="chat-skeleton -animation">
|
||||
{{#each this.placeholders as |rows|}}
|
||||
<div
|
||||
class="chat-skeleton -animation"
|
||||
{{did-insert @onInsert}}
|
||||
{{will-destroy @onDestroy}}
|
||||
>
|
||||
{{#each this.placeholders as |placeholder|}}
|
||||
<div class="chat-skeleton__body">
|
||||
<div class="chat-skeleton__message">
|
||||
<div class="chat-skeleton__message-avatar"></div>
|
||||
<div class="chat-skeleton__message-poster"></div>
|
||||
<div class="chat-skeleton__message-content">
|
||||
{{#each rows as |row|}}
|
||||
<div class="chat-skeleton__message-msg" style={{row}}></div>
|
||||
{{/each}}
|
||||
{{#if placeholder.image}}
|
||||
<div class="chat-skeleton__message-img"></div>
|
||||
{{/if}}
|
||||
|
||||
<div class="chat-skeleton__message-text">
|
||||
{{#each placeholder.rows as |row|}}
|
||||
<div class="chat-skeleton__message-msg" style={{row}}></div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
{{#if placeholder.reactions}}
|
||||
<div class="chat-skeleton__message-reactions">
|
||||
{{#each placeholder.reactions}}
|
||||
<div class="chat-skeleton__message-reaction"></div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -4,9 +4,13 @@ import { htmlSafe } from "@ember/template";
|
|||
export default class ChatSkeleton extends Component {
|
||||
get placeholders() {
|
||||
return Array.from({ length: 15 }, () => {
|
||||
return Array.from({ length: this.#randomIntFromInterval(1, 5) }, () => {
|
||||
return htmlSafe(`width: ${this.#randomIntFromInterval(20, 95)}%`);
|
||||
});
|
||||
return {
|
||||
image: this.#randomIntFromInterval(1, 10) === 5,
|
||||
rows: Array.from({ length: this.#randomIntFromInterval(1, 5) }, () => {
|
||||
return htmlSafe(`width: ${this.#randomIntFromInterval(20, 95)}%`);
|
||||
}),
|
||||
reactions: Array.from({ length: this.#randomIntFromInterval(0, 3) }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
{{#if this.chat.activeChannel}}
|
||||
<ChatLivePane
|
||||
@chatChannel={{this.chat.activeChannel}}
|
||||
@onBackClick={{action "navigateToIndex"}}
|
||||
@channel={{this.chat.activeChannel}}
|
||||
@targetMessageId={{readonly @targetMessageId}}
|
||||
/>
|
||||
{{/if}}
|
|
@ -1,79 +1,6 @@
|
|||
import Component from "@ember/component";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import { action } from "@ember/object";
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default Component.extend({
|
||||
tagName: "",
|
||||
router: service(),
|
||||
chat: service(),
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
this._scrollSidebarToBottom();
|
||||
document.addEventListener("keydown", this._autoFocusChatComposer);
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
document.removeEventListener("keydown", this._autoFocusChatComposer);
|
||||
},
|
||||
|
||||
@bind
|
||||
_autoFocusChatComposer(event) {
|
||||
if (
|
||||
!event.key ||
|
||||
// Handles things like Enter, Tab, Shift
|
||||
event.key.length > 1 ||
|
||||
// Don't need to focus if the user is beginning a shortcut.
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
// Space's key comes through as ' ' so it's not covered by event.key
|
||||
event.code === "Space" ||
|
||||
// ? is used for the keyboard shortcut modal
|
||||
event.key === "?"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!event.target ||
|
||||
/^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const composer = document.querySelector(".chat-composer-input");
|
||||
if (composer && !this.chat.activeChannel.isDraft) {
|
||||
this.appEvents.trigger("chat:insert-text", event.key);
|
||||
composer.focus();
|
||||
}
|
||||
},
|
||||
|
||||
_scrollSidebarToBottom() {
|
||||
if (!this.teamsSidebarOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sidebarScroll = document.querySelector(
|
||||
".sidebar-container .scroll-wrapper"
|
||||
);
|
||||
if (sidebarScroll) {
|
||||
sidebarScroll.scrollTop = sidebarScroll.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
@action
|
||||
navigateToIndex() {
|
||||
this.router.transitionTo("chat.index");
|
||||
},
|
||||
});
|
||||
export default class FullPageChat extends Component {
|
||||
@service chat;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import Controller from "@ember/controller";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
|
||||
export default class ChatChannelController extends Controller {
|
||||
@service chat;
|
||||
|
||||
targetMessageId = null;
|
||||
@tracked targetMessageId = null;
|
||||
|
||||
// Backwards-compatibility
|
||||
queryParams = ["messageId"];
|
||||
|
|
|
@ -36,6 +36,7 @@ export default class CreateChannelController extends Controller.extend(
|
|||
categoryPermissionsHint = null;
|
||||
autoJoinUsers = null;
|
||||
autoJoinWarning = "";
|
||||
loadingPermissionHint = false;
|
||||
|
||||
@notEmpty("category") categorySelected;
|
||||
@gt("siteSettings.max_chat_auto_joined_users", 0) autoJoinAvailable;
|
||||
|
@ -153,6 +154,8 @@ export default class CreateChannelController extends Controller.extend(
|
|||
if (category) {
|
||||
const fullSlug = this._buildCategorySlug(category);
|
||||
|
||||
this.set("loadingPermissionHint", true);
|
||||
|
||||
return this.chatApi
|
||||
.categoryPermissions(category.id)
|
||||
.then((catPermissions) => {
|
||||
|
@ -194,6 +197,9 @@ export default class CreateChannelController extends Controller.extend(
|
|||
}
|
||||
|
||||
this.set("categoryPermissionsHint", htmlSafe(hint));
|
||||
})
|
||||
.finally(() => {
|
||||
this.set("loadingPermissionHint", false);
|
||||
});
|
||||
} else {
|
||||
this.set("categoryPermissionsHint", DEFAULT_HINT);
|
||||
|
|
|
@ -7,8 +7,8 @@ import User from "discourse/models/user";
|
|||
registerUnbound("format-chat-date", function (message, mode) {
|
||||
const currentUser = User.current();
|
||||
const tz = currentUser ? currentUser.user_option.timezone : moment.tz.guess();
|
||||
const date = moment(new Date(message.created_at), tz);
|
||||
const url = getURL(`/chat/c/-/${message.chat_channel_id}/${message.id}`);
|
||||
const date = moment(new Date(message.createdAt), tz);
|
||||
const url = getURL(`/chat/c/-/${message.channelId}/${message.id}`);
|
||||
const title = date.format(I18n.t("dates.long_with_year"));
|
||||
|
||||
const display =
|
||||
|
|
|
@ -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 {
|
||||
name: "chat-setup",
|
||||
|
||||
initialize(container) {
|
||||
this.chatService = container.lookup("service:chat");
|
||||
this.siteSettings = container.lookup("service:site-settings");
|
||||
|
@ -19,6 +20,7 @@ export default {
|
|||
if (!this.chatService.userCanChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
withPluginApi("0.12.1", (api) => {
|
||||
api.registerChatComposerButton({
|
||||
id: "chat-upload-btn",
|
||||
|
|
|
@ -38,7 +38,7 @@ export default class ChatMessageFlag {
|
|||
let flagsAvailable = site.flagTypes;
|
||||
|
||||
flagsAvailable = flagsAvailable.filter((flag) => {
|
||||
return model.available_flags.includes(flag.name_key);
|
||||
return model.availableFlags.includes(flag.name_key);
|
||||
});
|
||||
|
||||
// "message user" option should be at the top
|
||||
|
|
|
@ -7,6 +7,7 @@ import { tracked } from "@glimmer/tracking";
|
|||
import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel";
|
||||
import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-threads-manager";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||
|
||||
export const CHATABLE_TYPES = {
|
||||
directMessageChannel: "DirectMessage",
|
||||
|
@ -54,6 +55,16 @@ export default class ChatChannel extends RestModel {
|
|||
@tracked chatableType;
|
||||
@tracked status;
|
||||
@tracked activeThread;
|
||||
@tracked messages = new TrackedArray();
|
||||
@tracked lastMessageSentAt;
|
||||
@tracked canDeleteOthers;
|
||||
@tracked canDeleteSelf;
|
||||
@tracked canFlag;
|
||||
@tracked canLoadMoreFuture;
|
||||
@tracked canLoadMorePast;
|
||||
@tracked canModerate;
|
||||
@tracked userSilenced;
|
||||
@tracked draft;
|
||||
|
||||
threadsManager = new ChatThreadsManager(getOwner(this));
|
||||
|
||||
|
@ -74,11 +85,11 @@ export default class ChatChannel extends RestModel {
|
|||
}
|
||||
|
||||
get isDirectMessageChannel() {
|
||||
return this.chatable_type === CHATABLE_TYPES.directMessageChannel;
|
||||
return this.chatableType === CHATABLE_TYPES.directMessageChannel;
|
||||
}
|
||||
|
||||
get isCategoryChannel() {
|
||||
return this.chatable_type === CHATABLE_TYPES.categoryChannel;
|
||||
return this.chatableType === CHATABLE_TYPES.categoryChannel;
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
|
@ -105,6 +116,56 @@ export default class ChatChannel extends RestModel {
|
|||
return this.currentUserMembership.following;
|
||||
}
|
||||
|
||||
get visibleMessages() {
|
||||
return this.messages.filter((message) => message.visible);
|
||||
}
|
||||
|
||||
set details(details) {
|
||||
this.canDeleteOthers = details.can_delete_others ?? false;
|
||||
this.canDeleteSelf = details.can_delete_self ?? false;
|
||||
this.canFlag = details.can_flag ?? false;
|
||||
this.canModerate = details.can_moderate ?? false;
|
||||
if (details.can_load_more_future !== undefined) {
|
||||
this.canLoadMoreFuture = details.can_load_more_future;
|
||||
}
|
||||
if (details.can_load_more_past !== undefined) {
|
||||
this.canLoadMorePast = details.can_load_more_past;
|
||||
}
|
||||
this.userSilenced = details.user_silenced ?? false;
|
||||
this.status = details.channel_status;
|
||||
this.channelMessageBusLastId = details.channel_message_bus_last_id;
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
this.messages.clear();
|
||||
|
||||
this.canLoadMoreFuture = null;
|
||||
this.canLoadMorePast = null;
|
||||
}
|
||||
|
||||
addMessages(messages = []) {
|
||||
this.messages = this.messages
|
||||
.concat(messages)
|
||||
.uniqBy("id")
|
||||
.sortBy("createdAt");
|
||||
}
|
||||
|
||||
findMessage(messageId) {
|
||||
return this.messages.find(
|
||||
(message) => message.id === parseInt(messageId, 10)
|
||||
);
|
||||
}
|
||||
|
||||
removeMessage(message) {
|
||||
return this.messages.removeObject(message);
|
||||
}
|
||||
|
||||
findStagedMessage(stagedMessageId) {
|
||||
return this.messages.find(
|
||||
(message) => message.staged && message.id === stagedMessageId
|
||||
);
|
||||
}
|
||||
|
||||
canModifyMessages(user) {
|
||||
if (user.staff) {
|
||||
return !STAFF_READONLY_STATUSES.includes(this.status);
|
||||
|
@ -127,6 +188,10 @@ export default class ChatChannel extends RestModel {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.currentUserMembership.last_read_message_id >= messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return ajax(`/chat/${this.id}/read/${messageId}.json`, {
|
||||
method: "PUT",
|
||||
}).then(() => {
|
||||
|
@ -142,12 +207,17 @@ ChatChannel.reopenClass({
|
|||
this._initUserModels(args);
|
||||
this._initUserMembership(args);
|
||||
|
||||
args.chatableType = args.chatable_type;
|
||||
args.membershipsCount = args.memberships_count;
|
||||
this._remapKey(args, "chatable_type", "chatableType");
|
||||
this._remapKey(args, "memberships_count", "membershipsCount");
|
||||
this._remapKey(args, "last_message_sent_at", "lastMessageSentAt");
|
||||
|
||||
return this._super(args);
|
||||
},
|
||||
|
||||
_remapKey(obj, oldKey, newKey) {
|
||||
delete Object.assign(obj, { [newKey]: obj[oldKey] })[oldKey];
|
||||
},
|
||||
|
||||
_initUserModels(args) {
|
||||
if (args.chatable?.users?.length) {
|
||||
for (let i = 0; i < args.chatable?.users?.length; i++) {
|
||||
|
|
|
@ -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,191 @@
|
|||
import RestModel from "discourse/models/rest";
|
||||
import User from "discourse/models/user";
|
||||
import EmberObject from "@ember/object";
|
||||
import { cached, tracked } from "@glimmer/tracking";
|
||||
import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
|
||||
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
|
||||
import Bookmark from "discourse/models/bookmark";
|
||||
import I18n from "I18n";
|
||||
import guid from "pretty-text/guid";
|
||||
|
||||
export default class ChatMessage extends RestModel {}
|
||||
export default class ChatMessage {
|
||||
static cookFunction = null;
|
||||
|
||||
ChatMessage.reopenClass({
|
||||
create(args = {}) {
|
||||
this._initReactions(args);
|
||||
this._initUserModel(args);
|
||||
static create(channel, args = {}) {
|
||||
return new ChatMessage(channel, args);
|
||||
}
|
||||
|
||||
return this._super(args);
|
||||
},
|
||||
static createStagedMessage(channel, args = {}) {
|
||||
args.id = guid();
|
||||
args.staged = true;
|
||||
return new ChatMessage(channel, args);
|
||||
}
|
||||
|
||||
_initReactions(args) {
|
||||
args.reactions = EmberObject.create(args.reactions || {});
|
||||
},
|
||||
@tracked id;
|
||||
@tracked error;
|
||||
@tracked selected;
|
||||
@tracked channel;
|
||||
@tracked staged = false;
|
||||
@tracked channelId;
|
||||
@tracked createdAt;
|
||||
@tracked deletedAt;
|
||||
@tracked uploads;
|
||||
@tracked excerpt;
|
||||
@tracked message;
|
||||
@tracked threadId;
|
||||
@tracked reactions;
|
||||
@tracked reviewableId;
|
||||
@tracked user;
|
||||
@tracked cooked;
|
||||
@tracked inReplyTo;
|
||||
@tracked expanded;
|
||||
@tracked bookmark;
|
||||
@tracked userFlagStatus;
|
||||
@tracked hidden;
|
||||
@tracked version = 0;
|
||||
@tracked edited;
|
||||
@tracked chatWebhookEvent = new TrackedObject();
|
||||
@tracked mentionWarning;
|
||||
@tracked availableFlags;
|
||||
@tracked newest = false;
|
||||
@tracked highlighted = false;
|
||||
|
||||
_initUserModel(args) {
|
||||
if (!args.user || args.user instanceof User) {
|
||||
return;
|
||||
constructor(channel, args = {}) {
|
||||
this.channel = channel;
|
||||
this.id = args.id;
|
||||
this.newest = args.newest;
|
||||
this.staged = args.staged;
|
||||
this.edited = args.edited;
|
||||
this.availableFlags = args.available_flags;
|
||||
this.hidden = args.hidden;
|
||||
this.threadId = args.thread_id;
|
||||
this.channelId = args.chat_channel_id;
|
||||
this.chatWebhookEvent = args.chat_webhook_event;
|
||||
this.createdAt = args.created_at;
|
||||
this.deletedAt = args.deleted_at;
|
||||
this.excerpt = args.excerpt;
|
||||
this.reviewableId = args.reviewable_id;
|
||||
this.userFlagStatus = args.user_flag_status;
|
||||
this.inReplyTo = args.in_reply_to
|
||||
? ChatMessage.create(channel, args.in_reply_to)
|
||||
: null;
|
||||
this.message = args.message;
|
||||
this.cooked = args.cooked || ChatMessage.cookFunction(this.message);
|
||||
this.reactions = this.#initChatMessageReactionModel(
|
||||
args.id,
|
||||
args.reactions
|
||||
);
|
||||
this.uploads = new TrackedArray(args.uploads || []);
|
||||
this.user = this.#initUserModel(args.user);
|
||||
this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null;
|
||||
}
|
||||
|
||||
get read() {
|
||||
return this.channel.currentUserMembership?.last_read_message_id >= this.id;
|
||||
}
|
||||
|
||||
get firstMessageOfTheDayAt() {
|
||||
if (!this.previousMessage) {
|
||||
return this.#calendarDate(this.createdAt);
|
||||
}
|
||||
|
||||
args.user = User.create(args.user);
|
||||
},
|
||||
});
|
||||
if (
|
||||
!this.#areDatesOnSameDay(
|
||||
new Date(this.previousMessage.createdAt),
|
||||
new Date(this.createdAt)
|
||||
)
|
||||
) {
|
||||
return this.#calendarDate(this.createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
#calendarDate(date) {
|
||||
return moment(date).calendar(moment(), {
|
||||
sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`,
|
||||
lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`,
|
||||
lastWeek: "LL",
|
||||
sameElse: "LL",
|
||||
});
|
||||
}
|
||||
|
||||
@cached
|
||||
get index() {
|
||||
return this.channel.messages.indexOf(this);
|
||||
}
|
||||
|
||||
@cached
|
||||
get previousMessage() {
|
||||
return this.channel?.messages?.objectAt?.(this.index - 1);
|
||||
}
|
||||
|
||||
@cached
|
||||
get nextMessage() {
|
||||
return this.channel?.messages?.objectAt?.(this.index + 1);
|
||||
}
|
||||
|
||||
react(emoji, action, actor, currentUserId) {
|
||||
const selfReaction = actor.id === currentUserId;
|
||||
const existingReaction = this.reactions.find(
|
||||
(reaction) => reaction.emoji === emoji
|
||||
);
|
||||
|
||||
if (existingReaction) {
|
||||
if (action === "add") {
|
||||
if (selfReaction && existingReaction.reacted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
existingReaction.count = existingReaction.count + 1;
|
||||
if (selfReaction) {
|
||||
existingReaction.reacted = true;
|
||||
}
|
||||
existingReaction.users.pushObject(actor);
|
||||
} else {
|
||||
existingReaction.count = existingReaction.count - 1;
|
||||
|
||||
if (selfReaction) {
|
||||
existingReaction.reacted = false;
|
||||
}
|
||||
|
||||
if (existingReaction.count === 0) {
|
||||
this.reactions.removeObject(existingReaction);
|
||||
} else {
|
||||
existingReaction.users.removeObject(
|
||||
existingReaction.users.find((user) => user.id === actor.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (action === "add") {
|
||||
this.reactions.pushObject(
|
||||
ChatMessageReaction.create({
|
||||
count: 1,
|
||||
emoji,
|
||||
reacted: selfReaction,
|
||||
users: [actor],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#initChatMessageReactionModel(messageId, reactions = []) {
|
||||
return reactions.map((reaction) =>
|
||||
ChatMessageReaction.create(Object.assign({ messageId }, reaction))
|
||||
);
|
||||
}
|
||||
|
||||
#initUserModel(user) {
|
||||
if (!user || user instanceof User) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return User.create(user);
|
||||
}
|
||||
|
||||
#areDatesOnSameDay(a, b) {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
willTransition(transition) {
|
||||
// Technically we could keep messages to avoid re-fetching them, but
|
||||
// it's not worth the complexity for now
|
||||
this.chat.activeChannel?.clearMessages();
|
||||
|
||||
this.chat.activeChannel.activeThread = null;
|
||||
this.chatStateManager.closeSidePanel();
|
||||
|
||||
|
|
|
@ -233,6 +233,39 @@ export default class ChatApi extends Service {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns messages of a channel, from the last message or a specificed target.
|
||||
* @param {number} channelId - The ID of the channel.
|
||||
* @param {object} data - Params of the query.
|
||||
* @param {integer} data.targetMessageId - ID of the targeted message.
|
||||
* @param {integer} data.messageId - ID of the targeted message.
|
||||
* @param {integer} data.direction - Fetch past or future messages.
|
||||
* @param {integer} data.pageSize - Max number of messages to fetch.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async messages(channelId, data = {}) {
|
||||
let path;
|
||||
const args = {};
|
||||
|
||||
if (data.targetMessageId) {
|
||||
path = `/chat/lookup/${data.targetMessageId}`;
|
||||
args.chat_channel_id = channelId;
|
||||
} else {
|
||||
args.page_size = data.pageSize;
|
||||
path = `/chat/${channelId}/messages`;
|
||||
|
||||
if (data.messageId) {
|
||||
args.message_id = data.messageId;
|
||||
}
|
||||
|
||||
if (data.direction) {
|
||||
args.direction = data.direction;
|
||||
}
|
||||
}
|
||||
|
||||
return ajax(path, { data: args });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notifications settings of current user for a channel.
|
||||
* @param {number} channelId - The ID of the channel.
|
||||
|
|
|
@ -42,6 +42,14 @@ export default class ChatChannelsManager extends Service {
|
|||
this.#cache(model);
|
||||
}
|
||||
|
||||
if (
|
||||
channelObject.meta?.message_bus_last_ids?.channel_message_bus_last_id !==
|
||||
undefined
|
||||
) {
|
||||
model.channelMessageBusLastId =
|
||||
channelObject.meta.message_bus_last_ids.channel_message_bus_last_id;
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
|
@ -138,8 +146,7 @@ export default class ChatChannelsManager extends Service {
|
|||
const unreadCountA = a.currentUserMembership.unread_count || 0;
|
||||
const unreadCountB = b.currentUserMembership.unread_count || 0;
|
||||
if (unreadCountA === unreadCountB) {
|
||||
return new Date(a.get("last_message_sent_at")) >
|
||||
new Date(b.get("last_message_sent_at"))
|
||||
return new Date(a.lastMessageSentAt) > new Date(b.lastMessageSentAt)
|
||||
? -1
|
||||
: 1;
|
||||
} else {
|
||||
|
|
|
@ -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) {
|
||||
this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => {
|
||||
if (
|
||||
channel?.currentUserMembership?.last_read_message_id <=
|
||||
busData.chat_message_id
|
||||
!channel?.currentUserMembership?.last_read_message_id ||
|
||||
parseInt(channel?.currentUserMembership?.last_read_message_id, 10) <=
|
||||
busData.chat_message_id
|
||||
) {
|
||||
channel.currentUserMembership.last_read_message_id =
|
||||
busData.chat_message_id;
|
||||
channel.currentUserMembership.unread_count = 0;
|
||||
channel.currentUserMembership.unread_mentions = 0;
|
||||
channel.currentUserMembership.unread_count = busData.unread_count;
|
||||
channel.currentUserMembership.unread_mentions = busData.unread_mentions;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -3,29 +3,18 @@ import { tracked } from "@glimmer/tracking";
|
|||
import userSearch from "discourse/lib/user-search";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import Service, { inject as service } from "@ember/service";
|
||||
import Site from "discourse/models/site";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { generateCookFunction } from "discourse/lib/text";
|
||||
import { cancel, next } from "@ember/runloop";
|
||||
import { and } from "@ember/object/computed";
|
||||
import { computed } from "@ember/object";
|
||||
import { Promise } from "rsvp";
|
||||
import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import userPresent from "discourse/lib/user-presence";
|
||||
|
||||
export const LIST_VIEW = "list_view";
|
||||
export const CHAT_VIEW = "chat_view";
|
||||
export const DRAFT_CHANNEL_VIEW = "draft_channel_view";
|
||||
import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
|
||||
|
||||
const CHAT_ONLINE_OPTIONS = {
|
||||
userUnseenTime: 300000, // 5 minutes seconds with no interaction
|
||||
browserHiddenTime: 300000, // Or the browser has been in the background for 5 minutes
|
||||
};
|
||||
|
||||
const READ_INTERVAL = 1000;
|
||||
|
||||
export default class Chat extends Service {
|
||||
@service appEvents;
|
||||
@service chatNotificationManager;
|
||||
|
@ -64,13 +53,6 @@ export default class Chat extends Service {
|
|||
|
||||
if (this.userCanChat) {
|
||||
this.presenceChannel = this.presence.getChannel("/chat/online");
|
||||
this.draftStore = {};
|
||||
|
||||
if (this.currentUser.chat_drafts) {
|
||||
this.currentUser.chat_drafts.forEach((draft) => {
|
||||
this.draftStore[draft.channel_id] = JSON.parse(draft.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -103,6 +85,16 @@ export default class Chat extends Service {
|
|||
[...channels.public_channels, ...channels.direct_message_channels].forEach(
|
||||
(channelObject) => {
|
||||
const channel = this.chatChannelsManager.store(channelObject);
|
||||
|
||||
if (this.currentUser.chat_drafts) {
|
||||
const storedDraft = this.currentUser.chat_drafts.find(
|
||||
(draft) => draft.channel_id === channel.id
|
||||
);
|
||||
channel.draft = ChatMessageDraft.create(
|
||||
storedDraft ? JSON.parse(storedDraft.data) : null
|
||||
);
|
||||
}
|
||||
|
||||
return this.chatChannelsManager.follow(channel);
|
||||
}
|
||||
);
|
||||
|
@ -116,33 +108,6 @@ export default class Chat extends Service {
|
|||
}
|
||||
}
|
||||
|
||||
loadCookFunction(categories) {
|
||||
if (this.cook) {
|
||||
return Promise.resolve(this.cook);
|
||||
}
|
||||
|
||||
const markdownOptions = {
|
||||
featuresOverride: Site.currentProp(
|
||||
"markdown_additional_options.chat.limited_pretty_text_features"
|
||||
),
|
||||
markdownItRules: Site.currentProp(
|
||||
"markdown_additional_options.chat.limited_pretty_text_markdown_rules"
|
||||
),
|
||||
hashtagTypesInPriorityOrder:
|
||||
this.site.hashtag_configurations["chat-composer"],
|
||||
hashtagIcons: this.site.hashtag_icons,
|
||||
};
|
||||
|
||||
return generateCookFunction(markdownOptions).then((cookFunction) => {
|
||||
return this.set("cook", (raw) => {
|
||||
return simpleCategoryHashMentionTransform(
|
||||
cookFunction(raw),
|
||||
categories
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updatePresence() {
|
||||
next(() => {
|
||||
if (this.isDestroyed || this.isDestroying) {
|
||||
|
@ -277,10 +242,6 @@ export default class Chat extends Service {
|
|||
: this.router.transitionTo("chat.channel", ...channel.routeModels);
|
||||
}
|
||||
|
||||
_fireOpenMessageAppEvent(messageId) {
|
||||
this.appEvents.trigger("chat-live-pane:highlight-message", messageId);
|
||||
}
|
||||
|
||||
async followChannel(channel) {
|
||||
return this.chatChannelsManager.follow(channel);
|
||||
}
|
||||
|
@ -327,84 +288,6 @@ export default class Chat extends Service {
|
|||
});
|
||||
}
|
||||
|
||||
_saveDraft(channelId, draft) {
|
||||
const data = { chat_channel_id: channelId };
|
||||
if (draft) {
|
||||
data.data = JSON.stringify(draft);
|
||||
}
|
||||
|
||||
ajax("/chat/drafts.json", { type: "POST", data, ignoreUnsent: false })
|
||||
.then(() => {
|
||||
this.markNetworkAsReliable();
|
||||
})
|
||||
.catch((error) => {
|
||||
// we ignore a draft which can't be saved because it's too big
|
||||
// and only deal with network error for now
|
||||
if (!error.jqXHR?.responseJSON?.errors?.length) {
|
||||
this.markNetworkAsUnreliable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setDraftForChannel(channel, draft) {
|
||||
if (
|
||||
draft &&
|
||||
(draft.value || draft.uploads.length > 0 || draft.replyToMsg)
|
||||
) {
|
||||
this.draftStore[channel.id] = draft;
|
||||
} else {
|
||||
delete this.draftStore[channel.id];
|
||||
draft = null; // _saveDraft will destroy draft
|
||||
}
|
||||
|
||||
discourseDebounce(this, this._saveDraft, channel.id, draft, 2000);
|
||||
}
|
||||
|
||||
getDraftForChannel(channelId) {
|
||||
return (
|
||||
this.draftStore[channelId] || {
|
||||
value: "",
|
||||
uploads: [],
|
||||
replyToMsg: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateLastReadMessage() {
|
||||
discourseDebounce(this, this._queuedReadMessageUpdate, READ_INTERVAL);
|
||||
}
|
||||
|
||||
_queuedReadMessageUpdate() {
|
||||
const visibleMessages = document.querySelectorAll(
|
||||
".chat-message-container[data-visible=true]"
|
||||
);
|
||||
const channel = this.activeChannel;
|
||||
|
||||
if (
|
||||
!channel?.isFollowing ||
|
||||
visibleMessages?.length === 0 ||
|
||||
!userPresent()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestUnreadMsgId = parseInt(
|
||||
visibleMessages[visibleMessages.length - 1].dataset.id,
|
||||
10
|
||||
);
|
||||
|
||||
const membership = channel.currentUserMembership;
|
||||
const hasUnreadMessages =
|
||||
latestUnreadMsgId > membership.last_read_message_id;
|
||||
if (
|
||||
hasUnreadMessages ||
|
||||
membership.unread_count > 0 ||
|
||||
membership.unread_mentions > 0
|
||||
) {
|
||||
channel.updateLastReadMessage(latestUnreadMsgId);
|
||||
}
|
||||
}
|
||||
|
||||
addToolbarButton() {
|
||||
deprecated(
|
||||
"Use the new chat API `api.registerChatComposerButton` instead of `chat.addToolbarButton`"
|
||||
|
|
|
@ -54,7 +54,12 @@
|
|||
/>
|
||||
|
||||
{{#if this.categoryPermissionsHint}}
|
||||
<div class="create-channel-hint">
|
||||
<div
|
||||
class={{concat-class
|
||||
"create-channel-hint"
|
||||
(if this.loadingPermissionHint "loading-permissions")
|
||||
}}
|
||||
>
|
||||
{{this.categoryPermissionsHint}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 3;
|
||||
|
||||
&.-no-description {
|
||||
.chat-channel-title {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
.chat-composer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 3;
|
||||
background-color: var(--secondary);
|
||||
|
||||
#chat-full-page-uploader,
|
||||
#chat-widget-uploader {
|
||||
|
|
|
@ -6,10 +6,6 @@
|
|||
.chat-message-actions {
|
||||
.chat-message-reaction {
|
||||
@include chat-reaction;
|
||||
|
||||
&:not(.show) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,42 +1,96 @@
|
|||
.chat-message-separator {
|
||||
@include unselectable;
|
||||
margin: 0.25rem 0 0.25rem 1rem;
|
||||
display: flex;
|
||||
font-size: var(--font-down-1);
|
||||
position: relative;
|
||||
transform: translateZ(0);
|
||||
position: relative;
|
||||
|
||||
&.new-message {
|
||||
color: var(--danger-medium);
|
||||
&-new {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
|
||||
.divider {
|
||||
background-color: var(--danger-medium);
|
||||
.chat-message-separator__text-container {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.chat-message-separator__text {
|
||||
color: var(--danger-medium);
|
||||
background-color: var(--secondary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-separator__line-container {
|
||||
width: 100%;
|
||||
|
||||
.chat-message-separator__line {
|
||||
border-top: 1px solid var(--danger-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.first-daily-message {
|
||||
.text {
|
||||
color: var(--secondary-low);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: var(--secondary-high);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 auto;
|
||||
padding: 0 0.75rem;
|
||||
z-index: 1;
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
&-date {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
|
||||
&.last-visit {
|
||||
.chat-message-separator__text {
|
||||
color: var(--danger-medium);
|
||||
}
|
||||
|
||||
& + .chat-message-separator__line-container {
|
||||
.chat-message-separator__line {
|
||||
border-color: var(--danger-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-separator__text-container {
|
||||
padding-top: 7px;
|
||||
position: sticky;
|
||||
top: -1px;
|
||||
|
||||
&.is-pinned {
|
||||
.chat-message-separator__text {
|
||||
border: 1px solid var(--primary-medium);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-separator__text {
|
||||
@include unselectable;
|
||||
background-color: var(--secondary);
|
||||
border: 1px solid transparent;
|
||||
color: var(--secondary-low);
|
||||
font-size: var(--font-down-1);
|
||||
padding: 0.25rem 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
& + .chat-message-separator__line-container {
|
||||
padding: 20px 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.chat-message-separator__line {
|
||||
border-top: 1px solid var(--secondary-high);
|
||||
left: 0;
|
||||
margin: 0 0 -1px;
|
||||
position: relative;
|
||||
right: 0;
|
||||
top: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,10 @@
|
|||
background: var(--primary-low);
|
||||
border-color: var(--primary-low-mid);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
|
@ -60,10 +64,6 @@
|
|||
|
||||
.chat-message-reaction {
|
||||
@include chat-reaction;
|
||||
|
||||
&:not(.show) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.chat-action {
|
||||
|
@ -82,21 +82,6 @@
|
|||
background-color: var(--danger-hover);
|
||||
}
|
||||
|
||||
&.transition-slow {
|
||||
transition: 2s linear background-color;
|
||||
}
|
||||
|
||||
&.user-info-hidden {
|
||||
.chat-time {
|
||||
color: var(--secondary-medium);
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-down-2);
|
||||
margin-top: 0.4em;
|
||||
display: none;
|
||||
width: var(--message-left-width);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-reply {
|
||||
display: grid;
|
||||
grid-template-columns: var(--message-left-width) 1fr;
|
||||
|
@ -254,6 +239,14 @@
|
|||
|
||||
.chat-message.chat-message-bookmarked {
|
||||
background: var(--highlight-bg);
|
||||
|
||||
&:hover {
|
||||
background: var(--highlight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message.chat-message-staged {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.not-mobile-device & .chat-message-reaction-list .chat-message-react-btn {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
$radius: 10px;
|
||||
$radius: 3px;
|
||||
|
||||
.chat-skeleton {
|
||||
height: auto;
|
||||
|
@ -55,11 +55,35 @@ $radius: 10px;
|
|||
&__message-content {
|
||||
grid-area: content;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
&__message-msg {
|
||||
height: 13px;
|
||||
|
||||
&__message-reactions {
|
||||
display: flex;
|
||||
padding: 5px 0 0 0;
|
||||
}
|
||||
|
||||
&__message-reaction {
|
||||
background-color: var(--primary-100);
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
border-radius: $radius;
|
||||
margin: 5px 0;
|
||||
|
||||
& + & {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__message-text {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__message-msg {
|
||||
height: 10px;
|
||||
border-radius: $radius;
|
||||
margin: 2px 0;
|
||||
|
||||
.chat-skeleton__body:nth-of-type(odd) & {
|
||||
background-color: var(--primary-100);
|
||||
|
@ -69,6 +93,14 @@ $radius: 10px;
|
|||
}
|
||||
}
|
||||
|
||||
&__message-img {
|
||||
height: 80px;
|
||||
border-radius: $radius;
|
||||
margin: 2px 0;
|
||||
width: 200px;
|
||||
background-color: var(--primary-100);
|
||||
}
|
||||
|
||||
*[class^="chat-skeleton__message-"] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
@ -78,7 +110,7 @@ $radius: 10px;
|
|||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
*[class^="chat-skeleton__message-"]:not(.chat-skeleton__message-content):after {
|
||||
*[class^="chat-skeleton__message-"]:not(.chat-skeleton__message-content):not(.chat-skeleton__message-text):not(.chat-skeleton__message-reactions):after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
|
|
@ -144,6 +144,7 @@ $float-height: 530px;
|
|||
.chat-messages-container {
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
position: relative;
|
||||
|
||||
.chat-message-container {
|
||||
display: grid;
|
||||
|
@ -283,6 +284,8 @@ $float-height: 530px;
|
|||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
z-index: 1;
|
||||
margin: 0 3px 0 0;
|
||||
will-change: transform;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 15px;
|
||||
|
@ -323,37 +326,68 @@ $float-height: 530px;
|
|||
}
|
||||
|
||||
.chat-scroll-to-bottom {
|
||||
background: var(--primary-medium);
|
||||
bottom: 1em;
|
||||
border-radius: 100%;
|
||||
left: 50%;
|
||||
opacity: 50%;
|
||||
padding: 0.5em;
|
||||
left: calc(50% - calc(32px / 2));
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
z-index: 1;
|
||||
flex-direction: column;
|
||||
bottom: -75px;
|
||||
background: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease, transform 0.5s ease;
|
||||
transform: scale(0.1);
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
&.visible {
|
||||
transform: translateY(-75px) scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: var(--secondary);
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--primary-medium);
|
||||
opacity: 100%;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
font-size: var(--font-down-1);
|
||||
bottom: 40px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.unread-messages {
|
||||
opacity: 85%;
|
||||
border-radius: 0;
|
||||
transition: border-radius 0.1s linear;
|
||||
|
||||
&:hover {
|
||||
opacity: 100%;
|
||||
}
|
||||
&__arrow {
|
||||
display: flex;
|
||||
background: var(--primary-medium);
|
||||
border-radius: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
position: relative;
|
||||
|
||||
.d-icon {
|
||||
margin: 0 0 0 0.5em;
|
||||
color: var(--secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
.chat-scroll-to-bottom__arrow {
|
||||
.d-icon {
|
||||
color: var(--secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
.chat-composer-container {
|
||||
.chat-composer {
|
||||
margin: 0.25rem 10px 0 10px;
|
||||
margin: 0.25rem 5px 0 5px;
|
||||
}
|
||||
html.keyboard-visible .footer-nav-ipad & {
|
||||
margin: 0.25rem 10px 1rem 10px;
|
||||
|
|
|
@ -53,6 +53,25 @@
|
|||
|
||||
.chat-message.user-info-hidden {
|
||||
padding: 0.15em 1em;
|
||||
|
||||
.chat-time {
|
||||
color: var(--secondary-medium);
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-down-2);
|
||||
margin-top: 0.4em;
|
||||
display: none;
|
||||
width: var(--message-left-width);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.chat-message-left-gutter__bookmark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-time {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full Page Styling in Core
|
||||
|
|
|
@ -22,6 +22,8 @@
|
|||
border-radius: 8px;
|
||||
|
||||
.selected-message-reply {
|
||||
margin-left: 5px;
|
||||
|
||||
&:not(.is-expanded) {
|
||||
@include ellipsis;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,3 @@
|
|||
.replying-text {
|
||||
@include unselectable;
|
||||
}
|
||||
|
||||
.chat-message-container {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
|
|
@ -108,7 +108,7 @@ en:
|
|||
in_reply_to: "In reply to"
|
||||
heading: "Chat"
|
||||
join: "Join"
|
||||
new_messages: "new messages"
|
||||
last_visit: "last visit"
|
||||
mention_warning:
|
||||
dismiss: "dismiss"
|
||||
cannot_see: "%{username} can't access this channel and was not notified."
|
||||
|
|
|
@ -247,6 +247,7 @@ after_initialize do
|
|||
load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__)
|
||||
load File.expand_path("../app/controllers/api/chat_channel_threads_controller.rb", __FILE__)
|
||||
load File.expand_path("../app/controllers/api/chat_chatables_controller.rb", __FILE__)
|
||||
load File.expand_path("../app/queries/chat_channel_unreads_query.rb", __FILE__)
|
||||
load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__)
|
||||
|
||||
if Discourse.allow_dev_populate?
|
||||
|
|
|
@ -17,7 +17,7 @@ describe ChatChannelMembershipsQuery do
|
|||
|
||||
context "when no memberships exists" do
|
||||
it "returns an empty array" do
|
||||
expect(described_class.call(channel_1)).to eq([])
|
||||
expect(described_class.call(channel: channel_1)).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -28,7 +28,7 @@ describe ChatChannelMembershipsQuery do
|
|||
end
|
||||
|
||||
it "returns the memberships" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id)
|
||||
end
|
||||
|
@ -49,7 +49,7 @@ describe ChatChannelMembershipsQuery do
|
|||
end
|
||||
|
||||
it "lists the user" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships.pluck(:user_id)).to include(user_1.id)
|
||||
end
|
||||
|
@ -62,14 +62,16 @@ describe ChatChannelMembershipsQuery do
|
|||
permission_type: CategoryGroup.permission_types[:full],
|
||||
)
|
||||
|
||||
expect(described_class.call(channel_1).pluck(:user_id)).to contain_exactly(user_1.id)
|
||||
expect(described_class.call(channel: channel_1).pluck(:user_id)).to contain_exactly(
|
||||
user_1.id,
|
||||
)
|
||||
end
|
||||
|
||||
it "returns the membership if the user still has access through a staff group" do
|
||||
chatters_group.remove(user_1)
|
||||
Group.find_by(id: Group::AUTO_GROUPS[:staff]).add(user_1)
|
||||
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships.pluck(:user_id)).to include(user_1.id)
|
||||
end
|
||||
|
@ -77,7 +79,7 @@ describe ChatChannelMembershipsQuery do
|
|||
|
||||
context "when membership doesn’t exist" do
|
||||
it "doesn’t list the user" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships.pluck(:user_id)).to be_empty
|
||||
end
|
||||
|
@ -91,7 +93,7 @@ describe ChatChannelMembershipsQuery do
|
|||
end
|
||||
|
||||
it "doesn’t list the user" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships).to be_empty
|
||||
end
|
||||
|
@ -99,7 +101,7 @@ describe ChatChannelMembershipsQuery do
|
|||
|
||||
context "when membership doesn’t exist" do
|
||||
it "doesn’t list the user" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships).to be_empty
|
||||
end
|
||||
|
@ -114,7 +116,7 @@ describe ChatChannelMembershipsQuery do
|
|||
end
|
||||
|
||||
it "returns an empty array" do
|
||||
expect(described_class.call(channel_1)).to eq([])
|
||||
expect(described_class.call(channel: channel_1)).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -122,7 +124,7 @@ describe ChatChannelMembershipsQuery do
|
|||
fab!(:channel_1) { Fabricate(:direct_message_channel, users: [user_1, user_2]) }
|
||||
|
||||
it "returns the memberships" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id)
|
||||
end
|
||||
|
@ -139,7 +141,7 @@ describe ChatChannelMembershipsQuery do
|
|||
|
||||
describe "offset param" do
|
||||
it "offsets the results" do
|
||||
memberships = described_class.call(channel_1, offset: 1)
|
||||
memberships = described_class.call(channel: channel_1, offset: 1)
|
||||
|
||||
expect(memberships.length).to eq(1)
|
||||
end
|
||||
|
@ -147,7 +149,7 @@ describe ChatChannelMembershipsQuery do
|
|||
|
||||
describe "limit param" do
|
||||
it "limits the results" do
|
||||
memberships = described_class.call(channel_1, limit: 1)
|
||||
memberships = described_class.call(channel: channel_1, limit: 1)
|
||||
|
||||
expect(memberships.length).to eq(1)
|
||||
end
|
||||
|
@ -163,7 +165,7 @@ describe ChatChannelMembershipsQuery do
|
|||
end
|
||||
|
||||
it "filters the results" do
|
||||
memberships = described_class.call(channel_1, username: user_1.username)
|
||||
memberships = described_class.call(channel: channel_1, username: user_1.username)
|
||||
|
||||
expect(memberships.length).to eq(1)
|
||||
expect(memberships[0].user).to eq(user_1)
|
||||
|
@ -182,7 +184,7 @@ describe ChatChannelMembershipsQuery do
|
|||
before { SiteSetting.prioritize_username_in_ux = true }
|
||||
|
||||
it "is using ascending order on username" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships[0].user).to eq(user_1)
|
||||
expect(memberships[1].user).to eq(user_2)
|
||||
|
@ -193,7 +195,7 @@ describe ChatChannelMembershipsQuery do
|
|||
before { SiteSetting.prioritize_username_in_ux = false }
|
||||
|
||||
it "is using ascending order on name" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships[0].user).to eq(user_2)
|
||||
expect(memberships[1].user).to eq(user_1)
|
||||
|
@ -203,7 +205,7 @@ describe ChatChannelMembershipsQuery do
|
|||
before { SiteSetting.enable_names = false }
|
||||
|
||||
it "is using ascending order on username" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
|
||||
expect(memberships[0].user).to eq(user_1)
|
||||
expect(memberships[1].user).to eq(user_2)
|
||||
|
@ -222,7 +224,7 @@ describe ChatChannelMembershipsQuery do
|
|||
end
|
||||
|
||||
it "doesn’t list staged users" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
expect(memberships).to be_blank
|
||||
end
|
||||
end
|
||||
|
@ -242,7 +244,7 @@ describe ChatChannelMembershipsQuery do
|
|||
end
|
||||
|
||||
it "doesn’t list suspended users" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
expect(memberships).to be_blank
|
||||
end
|
||||
end
|
||||
|
@ -260,7 +262,7 @@ describe ChatChannelMembershipsQuery do
|
|||
end
|
||||
|
||||
it "doesn’t list inactive users" do
|
||||
memberships = described_class.call(channel_1)
|
||||
memberships = described_class.call(channel: channel_1)
|
||||
expect(memberships).to be_blank
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
heart_emoji = ":heart:"
|
||||
smile_emoji = ":smile"
|
||||
|
||||
last_message = chat_channel.chat_messages.last
|
||||
last_message.reactions.create(user: user, emoji: heart_emoji)
|
||||
last_message.reactions.create(user: admin, emoji: smile_emoji)
|
||||
|
||||
get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size }
|
||||
|
||||
reactions = response.parsed_body["chat_messages"].last["reactions"]
|
||||
expect(reactions[heart_emoji]["reacted"]).to be true
|
||||
expect(reactions[smile_emoji]["reacted"]).to be false
|
||||
heart_reaction = reactions.find { |r| r["emoji"] == heart_emoji }
|
||||
expect(heart_reaction["reacted"]).to be true
|
||||
smile_reaction = reactions.find { |r| r["emoji"] == smile_emoji }
|
||||
expect(smile_reaction["reacted"]).to be false
|
||||
end
|
||||
|
||||
it "sends the last message bus id for the channel" do
|
||||
|
|
|
@ -21,12 +21,14 @@ describe ChatMessageSerializer do
|
|||
it "doesn’t return the reaction" do
|
||||
Emoji.clear_cache
|
||||
|
||||
expect(subject.as_json[:reactions]["trout"]).to be_present
|
||||
trout_reaction = subject.as_json[:reactions].find { |r| r[:emoji] == "trout" }
|
||||
expect(trout_reaction).to be_present
|
||||
|
||||
custom_emoji.destroy!
|
||||
Emoji.clear_cache
|
||||
|
||||
expect(subject.as_json[:reactions]["trout"]).to_not be_present
|
||||
trout_reaction = subject.as_json[:reactions].find { |r| r[:emoji] == "trout" }
|
||||
expect(trout_reaction).to_not be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -37,7 +37,7 @@ RSpec.describe "Chat channel", type: :system, js: true do
|
|||
chat.visit_channel(channel_1)
|
||||
expect(channel).to have_no_loading_skeleton
|
||||
channel.send_message("aaaaaaaaaaaaaaaaaaaa")
|
||||
expect(page).to have_no_css("[data-staged-id]")
|
||||
expect(page).to have_no_css(".chat-message-staged")
|
||||
last_message = find(".chat-message-container:last-child")
|
||||
last_message.hover
|
||||
|
||||
|
@ -183,7 +183,7 @@ RSpec.describe "Chat channel", type: :system, js: true do
|
|||
it "shows a date separator" do
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
expect(page).to have_selector(".first-daily-message", text: "Today")
|
||||
expect(page).to have_selector(".chat-message-separator__text", text: "Today")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -81,6 +81,7 @@ RSpec.describe "Create channel", type: :system, js: true do
|
|||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
channel_modal.select_category(private_category_1)
|
||||
expect(page).to have_no_css(".loading-permissions")
|
||||
|
||||
expect(channel_modal.create_channel_hint["innerHTML"].strip).to include(
|
||||
"<script>e</script>",
|
||||
|
|
|
@ -18,7 +18,7 @@ RSpec.describe "Deleted message", type: :system, js: true do
|
|||
chat_page.visit_channel(channel_1)
|
||||
expect(channel_page).to have_no_loading_skeleton
|
||||
channel_page.send_message("aaaaaaaaaaaaaaaaaaaa")
|
||||
expect(page).to have_no_css("[data-staged-id]")
|
||||
expect(page).to have_no_css(".chat-message-staged")
|
||||
last_message = find(".chat-message-container:last-child")
|
||||
channel_page.delete_message(OpenStruct.new(id: last_message["data-id"]))
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
RSpec.describe "Drawer", type: :system, js: true do
|
||||
fab!(:current_user) { Fabricate(:admin) }
|
||||
let(:chat_page) { PageObjects::Pages::Chat.new }
|
||||
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
|
||||
let(:drawer) { PageObjects::Pages::ChatDrawer.new }
|
||||
|
||||
before do
|
||||
|
@ -52,4 +53,39 @@ RSpec.describe "Drawer", type: :system, js: true do
|
|||
expect(page.find(".chat-drawer").native.style("height")).to eq("530px")
|
||||
end
|
||||
end
|
||||
|
||||
context "when going from drawer to full page" do
|
||||
fab!(:channel_1) { Fabricate(:chat_channel) }
|
||||
fab!(:channel_2) { Fabricate(:chat_channel) }
|
||||
fab!(:user_1) { Fabricate(:user) }
|
||||
|
||||
before do
|
||||
channel_1.add(current_user)
|
||||
channel_2.add(current_user)
|
||||
channel_1.add(user_1)
|
||||
channel_2.add(user_1)
|
||||
end
|
||||
|
||||
it "correctly resets subscriptions" do
|
||||
visit("/")
|
||||
|
||||
chat_page.open_from_header
|
||||
drawer.maximize
|
||||
chat_page.minimize_full_page
|
||||
drawer.maximize
|
||||
|
||||
using_session("user_1") do |session|
|
||||
sign_in(user_1)
|
||||
chat_page.visit_channel(channel_1)
|
||||
channel_page.send_message("onlyonce")
|
||||
session.quit
|
||||
end
|
||||
|
||||
expect(page).to have_content("onlyonce", count: 1)
|
||||
|
||||
chat_page.visit_channel(channel_2)
|
||||
|
||||
expect(page).to have_content("onlyonce", count: 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -32,7 +32,7 @@ RSpec.describe "Flag message", type: :system, js: true do
|
|||
|
||||
context "when direct message channel" do
|
||||
fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [current_user]) }
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: dm_channel_1, user: current_user) }
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: dm_channel_1) }
|
||||
|
||||
it "doesn’t allow to flag a message" do
|
||||
chat.visit_channel(dm_channel_1)
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue