REFACTOR: <ChatMessage> component (#22172)

- Moves `<ChatMessageInfo />` to `<Chat::Message::Info />`
- Moves `<ChatMessageAvatar />` to `<Chat::Message::Avatar />`
- Moves `<ChatMessageLeftGutter />` to `<Chat::Message::LeftGutter />`, adds tests
- Creates `<Chat::Message::Error />`
- Creates `<Chat::Message::MentionWarning />`, adds tests and a styleguide
- Creates a model for ChatMessageMentionWarning, adds fabricator for it
- Keeps the enter/leave viewport logic inside the `<ChatMessage />` component instead of bubbling it to the channel and thread components
- Adds a scale animation when clicking a reaction
- Creates `chat/later-fn` modifier which accepts a function and a delay. It allows to call a function Xms after a component has been inserted, it's useful for animations.
- Moves css code out of chat-message into relevant files
- Deletes unused code

<!-- NOTE: All pull requests should have tests (rspec in Ruby, qunit in JavaScript). If your code does not include test coverage, please include an explanation of why it was omitted. -->
This commit is contained in:
Joffrey JAFFEUX 2023-06-19 09:50:54 +02:00 committed by GitHub
parent 626eda4c91
commit cbb9396353
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 801 additions and 484 deletions

View File

@ -42,8 +42,6 @@
<ChatMessage
@message={{message}}
@resendStagedMessage={{this.resendStagedMessage}}
@messageDidEnterViewport={{this.messageDidEnterViewport}}
@messageDidLeaveViewport={{this.messageDidLeaveViewport}}
@context="channel"
/>
{{/each}}

View File

@ -491,16 +491,6 @@ export default class ChatLivePane extends Component {
});
}
@action
messageDidEnterViewport(message) {
message.visible = true;
}
@action
messageDidLeaveViewport(message) {
message.visible = false;
}
@debounce(READ_INTERVAL_MS)
updateLastReadMessage() {
schedule("afterRender", () => {

View File

@ -1,61 +0,0 @@
<div
class="chat-message-info"
{{did-insert this.trackStatus}}
{{will-destroy this.stopTrackingStatus}}
>
{{#if @message.chatWebhookEvent}}
{{#if @message.chatWebhookEvent.username}}
<span
class={{concat-class
"chat-message-info__username"
this.usernameClasses
}}
>
{{@message.chatWebhookEvent.username}}
</span>
{{/if}}
<span class="chat-message-info__bot-indicator">
{{i18n "chat.bot"}}
</span>
{{else}}
<span
role="button"
class={{concat-class
"chat-message-info__username"
this.usernameClasses
"clickable"
}}
data-user-card={{@message.user.username}}
>
<span class="chat-message-info__username__name">{{this.name}}</span>
{{#if this.showStatus}}
<div class="chat-message-info__status">
<UserStatusMessage @status={{@message.user.status}} />
</div>
{{/if}}
</span>
{{/if}}
<span class="chat-message-info__date">
{{format-chat-date @message}}
</span>
{{#if @message.bookmark}}
<span class="chat-message-info__bookmark">
<BookmarkIcon @bookmark={{@message.bookmark}} />
</span>
{{/if}}
{{#if this.isFlagged}}
<span class="chat-message-info__flag">
{{#if @message.reviewableId}}
<LinkTo @route="review.show" @model={{@message.reviewableId}}>
{{d-icon "flag" title="chat.flagged"}}
</LinkTo>
{{else}}
{{d-icon "flag" title="chat.you_flagged"}}
{{/if}}
</span>
{{/if}}
</div>

View File

@ -37,10 +37,8 @@
this.onLongPressCancel
}}
{{chat/track-message
(hash
didEnterViewport=(fn @messageDidEnterViewport @message)
didLeaveViewport=(fn @messageDidLeaveViewport @message)
)
(fn (mut @message.visible) true)
(fn (mut @message.visible) false)
}}
>
{{#if this.show}}
@ -54,7 +52,7 @@
{{/if}}
{{#if this.deletedAndCollapsed}}
<div class="chat-message-text chat-message-deleted">
<div class="chat-message-text -deleted">
<DButton
@class="btn-flat chat-message-expand"
@action={{this.expand}}
@ -62,7 +60,7 @@
/>
</div>
{{else if this.hiddenAndCollapsed}}
<div class="chat-message-hidden">
<div class="chat-message-text -hidden">
<DButton
@class="btn-flat chat-message-expand"
@action={{this.expand}}
@ -76,15 +74,16 @@
{{/unless}}
{{#if this.hideUserInfo}}
<ChatMessageLeftGutter @message={{@message}} />
<Chat::Message::LeftGutter @message={{@message}} />
{{else}}
<ChatMessageAvatar @message={{@message}} />
<Chat::Message::Avatar @message={{@message}} />
{{/if}}
<div class="chat-message-content">
{{#unless this.hideUserInfo}}
<ChatMessageInfo @message={{@message}} />
{{/unless}}
<Chat::Message::Info
@message={{@message}}
@show={{not this.hideUserInfo}}
/>
<ChatMessageText
@cooked={{@message.cooked}}
@ -93,12 +92,6 @@
>
{{#if @message.reactions.length}}
<div class="chat-message-reaction-list">
{{#if this.reactionLabel}}
<div class="reaction-users-list">
{{replace-emoji this.reactionLabel}}
</div>
{{/if}}
{{#each @message.reactions as |reaction|}}
<ChatMessageReaction
@reaction={{reaction}}
@ -108,8 +101,7 @@
/>
{{/each}}
{{#if this.chat.userCanInteractWithChat}}
{{#unless this.site.mobileView}}
{{#if this.shouldRenderOpenEmojiPickerButton}}
<DButton
@class="chat-message-react-btn"
@action={{this.messageInteractor.openEmojiPicker}}
@ -117,83 +109,16 @@
@title="chat.react"
@forwardEvent={{true}}
/>
{{/unless}}
{{/if}}
</div>
{{/if}}
</ChatMessageText>
{{#if @message.error}}
<div class="chat-send-error">
{{#if (eq @message.error "network_error")}}
<DButton
class="retry-staged-message-btn"
@action={{fn @resendStagedMessage @message}}
@icon="exclamation-circle"
>
<span class="retry-staged-message-btn__title">
{{i18n "chat.retry_staged_message.title"}}
</span>
<span class="retry-staged-message-btn__action">
{{i18n "chat.retry_staged_message.action"}}
</span>
</DButton>
{{else}}
{{@message.error}}
{{/if}}
</div>
{{/if}}
{{#if this.mentionWarning}}
<div class="alert alert-info chat-message-mention-warning">
{{#if this.mentionWarning.invitation_sent}}
{{d-icon "check"}}
<span>
{{i18n
"chat.mention_warning.invitations_sent"
count=this.mentionWarning.without_membership.length
}}
</span>
{{else}}
<FlatButton
@class="dismiss-mention-warning"
@title="chat.mention_warning.dismiss"
@action={{this.dismissMentionWarning}}
@icon="times"
<Chat::Message::Error
@message={{@message}}
@onRetry={{@resendStagedMessage}}
/>
{{#if this.mentionWarning.cannot_see}}
<p class="warning-item cannot-see">
{{this.mentionedCannotSeeText}}
</p>
{{/if}}
{{#if this.mentionWarning.without_membership}}
<p class="warning-item without-membership">
<span>{{this.mentionedWithoutMembershipText}}</span>
<a
class="invite-link"
href
onclick={{this.inviteMentioned}}
>
{{i18n "chat.mention_warning.invite"}}
</a>
</p>
{{/if}}
{{#if this.mentionWarning.group_mentions_disabled}}
<p class="warning-item">
{{this.groupsWithDisabledMentions}}
</p>
{{/if}}
{{#if this.mentionWarning.groups_with_too_many_members}}
<p class="warning-item">
{{this.groupsWithTooManyMembers}}
</p>
{{/if}}
{{/if}}
</div>
{{/if}}
<Chat::Message::MentionWarning @message={{@message}} />
</div>
{{#if this.showThreadIndicator}}

View File

@ -2,7 +2,6 @@ import { action } from "@ember/object";
import Component from "@glimmer/component";
import I18n from "I18n";
import optionalService from "discourse/lib/optional-service";
import { ajax } from "discourse/lib/ajax";
import { cancel, schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import discourseLater from "discourse-common/lib/later";
@ -101,6 +100,10 @@ export default class ChatMessage extends Component {
);
}
get shouldRenderOpenEmojiPickerButton() {
return this.chat.userCanInteractWithChat && this.site.desktopView;
}
@action
expand() {
const recursiveExpand = (message) => {
@ -386,82 +389,6 @@ export default class ChatMessage extends Component {
);
}
get mentionWarning() {
return this.args.message.mentionWarning;
}
get mentionedCannotSeeText() {
return this._findTranslatedWarning(
"chat.mention_warning.cannot_see",
"chat.mention_warning.cannot_see_multiple",
{
username: this.mentionWarning?.cannot_see?.[0]?.username,
count: this.mentionWarning?.cannot_see?.length,
}
);
}
get mentionedWithoutMembershipText() {
return this._findTranslatedWarning(
"chat.mention_warning.without_membership",
"chat.mention_warning.without_membership_multiple",
{
username: this.mentionWarning?.without_membership?.[0]?.username,
count: this.mentionWarning?.without_membership?.length,
}
);
}
get groupsWithDisabledMentions() {
return this._findTranslatedWarning(
"chat.mention_warning.group_mentions_disabled",
"chat.mention_warning.group_mentions_disabled_multiple",
{
group_name: this.mentionWarning?.group_mentions_disabled?.[0],
count: this.mentionWarning?.group_mentions_disabled?.length,
}
);
}
get groupsWithTooManyMembers() {
return this._findTranslatedWarning(
"chat.mention_warning.too_many_members",
"chat.mention_warning.too_many_members_multiple",
{
group_name: this.mentionWarning.groups_with_too_many_members?.[0],
count: this.mentionWarning.groups_with_too_many_members?.length,
}
);
}
_findTranslatedWarning(oneKey, multipleKey, args) {
const translationKey = args.count === 1 ? oneKey : multipleKey;
args.count--;
return I18n.t(translationKey, args);
}
@action
inviteMentioned() {
const userIds = this.mentionWarning.without_membership.mapBy("id");
ajax(`/chat/${this.args.message.channel.id}/invite`, {
method: "PUT",
data: { user_ids: userIds, chat_message_id: this.args.message.id },
}).then(() => {
this.args.message.mentionWarning.set("invitationSent", true);
this._invitationSentTimer = discourseLater(() => {
this.dismissMentionWarning();
}, 3000);
});
return false;
}
@action
dismissMentionWarning() {
this.args.message.mentionWarning = null;
}
#teardownMentionedUsers() {
this.args.message.mentionedUsers.forEach((user) => {
user.stopTrackingStatus();

View File

@ -27,8 +27,6 @@
<ChatMessage
@message={{message}}
@resendStagedMessage={{this.resendStagedMessage}}
@messageDidEnterViewport={{this.messageDidEnterViewport}}
@messageDidLeaveViewport={{this.messageDidLeaveViewport}}
@context="thread"
/>
{{/each}}

View File

@ -348,16 +348,6 @@ export default class ChatThreadPanel extends Component {
@action
resendStagedMessage() {}
@action
messageDidEnterViewport(message) {
message.visible = true;
}
@action
messageDidLeaveViewport(message) {
message.visible = false;
}
#handleErrors(error) {
switch (error?.jqXHR?.status) {
case 429:

View File

@ -0,0 +1,20 @@
{{#if @message.error}}
<div class="chat-message-error">
{{#if (eq @message.error "network_error")}}
<DButton
class="chat-message-error__retry-btn"
@action={{fn @onRetry @message}}
@icon="exclamation-circle"
>
<span class="chat-message-error__retry-btn-title">
{{i18n "chat.retry_staged_message.title"}}
</span>
<span class="chat-message-error__retry-btn-action">
{{i18n "chat.retry_staged_message.action"}}
</span>
</DButton>
{{else}}
{{@message.error}}
{{/if}}
</div>
{{/if}}

View File

@ -0,0 +1,63 @@
{{#if @show}}
<div
class="chat-message-info"
{{did-insert this.trackStatus}}
{{will-destroy this.stopTrackingStatus}}
>
{{#if @message.chatWebhookEvent}}
{{#if @message.chatWebhookEvent.username}}
<span
class={{concat-class
"chat-message-info__username"
this.usernameClasses
}}
>
{{@message.chatWebhookEvent.username}}
</span>
{{/if}}
<span class="chat-message-info__bot-indicator">
{{i18n "chat.bot"}}
</span>
{{else}}
<span
role="button"
class={{concat-class
"chat-message-info__username"
this.usernameClasses
"clickable"
}}
data-user-card={{@message.user.username}}
>
<span class="chat-message-info__username__name">{{this.name}}</span>
{{#if this.showStatus}}
<div class="chat-message-info__status">
<UserStatusMessage @status={{@message.user.status}} />
</div>
{{/if}}
</span>
{{/if}}
<span class="chat-message-info__date">
{{format-chat-date @message}}
</span>
{{#if @message.bookmark}}
<span class="chat-message-info__bookmark">
<BookmarkIcon @bookmark={{@message.bookmark}} />
</span>
{{/if}}
{{#if this.isFlagged}}
<span class="chat-message-info__flag">
{{#if @message.reviewableId}}
<LinkTo @route="review.show" @model={{@message.reviewableId}}>
{{d-icon "flag" title="chat.flagged"}}
</LinkTo>
{{else}}
{{d-icon "flag" title="chat.you_flagged"}}
{{/if}}
</span>
{{/if}}
</div>
{{/if}}

View File

@ -0,0 +1,56 @@
{{#if this.shouldRender}}
<div class="chat-message-mention-warning alert alert-info">
{{#if this.mentionWarning.invitationSent}}
<span
class="chat-message-mention-warning__invitation-sent"
{{chat/later-fn this.onDismissInvitationSent 3000}}
>
{{d-icon "check"}}
<span>
{{i18n
"chat.mention_warning.invitations_sent"
count=this.mentionWarning.withoutMembership.length
}}
</span>
</span>
{{else}}
<DButton
class="chat-message-mention-warning__dismiss-btn btn-flat"
title={{i18n "chat.mention_warning.dismiss"}}
@action={{this.onDismissMentionWarning}}
@icon="times"
/>
{{#if this.mentionWarning.cannotSee}}
<p class="chat-message-mention-warning__text -cannot-see">
{{this.mentionedCannotSeeText}}
</p>
{{/if}}
{{#if this.mentionWarning.withoutMembership}}
<p class="chat-message-mention-warning__text -without-membership">
<span>{{this.mentionedWithoutMembershipText}}</span>
<a href {{on "click" this.onSendInvite bubbles=false}}>
{{i18n "chat.mention_warning.invite"}}
</a>
</p>
{{/if}}
{{#if this.mentionWarning.groupWithMentionsDisabled}}
<p
class="chat-message-mention-warning__text -groups-with-mentions-disabled"
>
{{this.groupsWithDisabledMentions}}
</p>
{{/if}}
{{#if this.mentionWarning.groupsWithTooManyMembers}}
<p
class="chat-message-mention-warning__text -groups-with-too-many-members"
>
{{this.groupsWithTooManyMembers}}
</p>
{{/if}}
{{/if}}
</div>
{{/if}}

View File

@ -0,0 +1,98 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
import I18n from "I18n";
export default class ChatMessageMentionWarning extends Component {
@service("chat-api") api;
@action
async onSendInvite() {
const userIds = this.mentionWarning.withoutMembership.mapBy("id");
try {
await this.api.invite(this.args.message.channel.id, userIds, {
messageId: this.args.message.id,
});
this.mentionWarning.invitationSent = true;
} catch (error) {
popupAjaxError(error);
}
}
@action
onDismissInvitationSent() {
this.mentionWarning.invitationSent = false;
}
@action
onDismissMentionWarning() {
this.args.message.mentionWarning = null;
}
get shouldRender() {
return (
this.mentionWarning &&
(this.mentionWarning.groupWithMentionsDisabled?.length ||
this.mentionWarning.cannotSee?.length ||
this.mentionWarning.withoutMembership?.length ||
this.mentionWarning.groupsWithTooManyMembers?.length)
);
}
get mentionWarning() {
return this.args.message.mentionWarning;
}
get mentionedCannotSeeText() {
return this.#findTranslatedWarning(
"chat.mention_warning.cannot_see",
"chat.mention_warning.cannot_see_multiple",
{
username: this.mentionWarning?.cannotSee?.[0]?.username,
count: this.mentionWarning?.cannotSee?.length,
}
);
}
get mentionedWithoutMembershipText() {
return this.#findTranslatedWarning(
"chat.mention_warning.without_membership",
"chat.mention_warning.without_membership_multiple",
{
username: this.mentionWarning?.withoutMembership?.[0]?.username,
count: this.mentionWarning?.withoutMembership?.length,
}
);
}
get groupsWithDisabledMentions() {
return this.#findTranslatedWarning(
"chat.mention_warning.group_mentions_disabled",
"chat.mention_warning.group_mentions_disabled_multiple",
{
group_name: this.mentionWarning?.groupWithMentionsDisabled?.[0],
count: this.mentionWarning?.groupWithMentionsDisabled?.length,
}
);
}
get groupsWithTooManyMembers() {
return this.#findTranslatedWarning(
"chat.mention_warning.too_many_members",
"chat.mention_warning.too_many_members_multiple",
{
group_name: this.mentionWarning.groupsWithTooManyMembers?.[0],
count: this.mentionWarning.groupsWithTooManyMembers?.length,
}
);
}
#findTranslatedWarning(oneKey, multipleKey, args) {
const translationKey = args.count === 1 ? oneKey : multipleKey;
args.count--;
return I18n.t(translationKey, args);
}
}

View File

@ -0,0 +1,35 @@
<StyleguideExample @title="<Chat::Message::MentionWarning>">
<Styleguide::Component>
<Chat::Message::MentionWarning @message={{this.message}} />
</Styleguide::Component>
<Styleguide::Controls::Row @name="Cannot see">
<DToggleSwitch
@state={{gt this.message.mentionWarning.cannotSee.length 0}}
{{on "click" this.toggleCannotSee}}
/>
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="Group with mentions disabled">
<DToggleSwitch
@state={{gt
this.message.mentionWarning.groupWithMentionsDisabled.length
0
}}
{{on "click" this.toggleGroupWithMentionsDisabled}}
/>
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="Group with too many members">
<DToggleSwitch
@state={{gt
this.message.mentionWarning.groupsWithTooManyMembers.length
0
}}
{{on "click" this.toggleGroupsWithTooManyMembers}}
/>
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="Without membership">
<DToggleSwitch
@state={{gt this.message.mentionWarning.withoutMembership.length 0}}
{{on "click" this.toggleWithoutMembership}}
/>
</Styleguide::Controls::Row>
</StyleguideExample>

View File

@ -0,0 +1,75 @@
import Component from "@glimmer/component";
import fabricators from "discourse/plugins/chat/discourse/lib/fabricators";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default class ChatMessageMentionWarning extends Component {
@service currentUser;
constructor() {
super(...arguments);
this.message = fabricators.message({ user: this.currentUser });
}
@action
toggleCannotSee() {
if (this.message.mentionWarning?.cannotSee) {
this.message.mentionWarning = null;
} else {
this.message.mentionWarning = fabricators.messageMentionWarning(
this.message,
{
cannot_see: [fabricators.user({ username: "bob" })].map((u) => {
return { username: u.username, id: u.id };
}),
}
);
}
}
@action
toggleGroupWithMentionsDisabled() {
if (this.message.mentionWarning?.groupWithMentionsDisabled) {
this.message.mentionWarning = null;
} else {
this.message.mentionWarning = fabricators.messageMentionWarning(
this.message,
{
group_mentions_disabled: [fabricators.group()].mapBy("name"),
}
);
}
}
@action
toggleGroupsWithTooManyMembers() {
if (this.message.mentionWarning?.groupsWithTooManyMembers) {
this.message.mentionWarning = null;
} else {
this.message.mentionWarning = fabricators.messageMentionWarning(
this.message,
{
groups_with_too_many_members: [
fabricators.group(),
fabricators.group({ name: "Moderators" }),
].mapBy("name"),
}
);
}
}
@action
toggleWithoutMembership() {
if (this.message.mentionWarning?.withoutMembership) {
this.message.mentionWarning = null;
} else {
this.message.mentionWarning = fabricators.messageMentionWarning(
this.message,
{
without_membership: [fabricators.user()].map((u) => {
return { username: u.username, id: u.id };
}),
}
);
}
}
}

View File

@ -1,11 +1,6 @@
<StyleguideExample @title="<ChatMessage>">
<Styleguide::Component>
<ChatMessage
@message={{this.message}}
@context="channel"
@messageDidEnterViewport={{(noop)}}
@messageDidLeaveViewport={{(noop)}}
/>
<ChatMessage @message={{this.message}} @context="channel" />
</Styleguide::Component>
<Styleguide::Controls>

View File

@ -3,3 +3,4 @@
<Styleguide::ChatThreadListItem />
<Styleguide::ChatComposerMessageDetails />
<Styleguide::ChatHeaderIcon />
<Styleguide::ChatMessageMentionWarning />

View File

@ -13,10 +13,12 @@ import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
import ChatThreadPreview from "discourse/plugins/chat/discourse/models/chat-thread-preview";
import ChatDirectMessage from "discourse/plugins/chat/discourse/models/chat-direct-message";
import ChatMessageMentionWarning from "discourse/plugins/chat/discourse/models/chat-message-mention-warning";
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
import User from "discourse/models/user";
import Bookmark from "discourse/models/bookmark";
import Category from "discourse/models/category";
import Group from "discourse/models/group";
let sequence = 0;
@ -145,6 +147,16 @@ function reactionFabricator(args = {}) {
});
}
function groupFabricator(args = {}) {
return Group.create({
name: args.name || "Engineers",
});
}
function messageMentionWarningFabricator(message, args = {}) {
return ChatMessageMentionWarning.create(message, args);
}
function uploadFabricator() {
return {
extension: "jpeg",
@ -175,4 +187,6 @@ export default {
upload: uploadFabricator,
category: categoryFabricator,
directMessage: directMessageFabricator,
messageMentionWarning: messageMentionWarningFabricator,
group: groupFabricator,
};

View File

@ -0,0 +1,21 @@
import { tracked } from "@glimmer/tracking";
export default class ChatMessageMentionWarning {
static create(message, args = {}) {
return new ChatMessageMentionWarning(message, args);
}
@tracked invitationSent = false;
@tracked cannotSee;
@tracked withoutMembership;
@tracked groupsWithTooManyMembers;
@tracked groupWithMentionsDisabled;
constructor(message, args = {}) {
this.message = args.message;
this.cannotSee = args.cannot_see;
this.withoutMembership = args.without_membership;
this.groupsWithTooManyMembers = args.groups_with_too_many_members;
this.groupWithMentionsDisabled = args.group_mentions_disabled;
}
}

View File

@ -0,0 +1,21 @@
import Modifier from "ember-modifier";
import { registerDestructor } from "@ember/destroyable";
import { cancel } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later";
export default class ChatLaterFn extends Modifier {
constructor(owner, args) {
super(owner, args);
registerDestructor(this, (instance) => instance.cleanup());
}
modify(element, [fn, delay]) {
this.handler = discourseLater(() => {
fn?.(element);
}, delay);
}
cleanup() {
cancel(this.handler);
}
}

View File

@ -11,9 +11,9 @@ export default class ChatTrackMessage extends Modifier {
registerDestructor(this, (instance) => instance.cleanup());
}
modify(element, [callbacks = {}]) {
this.didEnterViewport = callbacks.didEnterViewport;
this.didLeaveViewport = callbacks.didLeaveViewport;
modify(element, [didEnterViewport, didLeaveViewport]) {
this.didEnterViewport = didEnterViewport;
this.didLeaveViewport = didLeaveViewport;
this.intersectionObserver = new IntersectionObserver(
this._intersectionObserverCallback,

View File

@ -460,6 +460,20 @@ export default class ChatApi extends Service {
});
}
/**
* Invite users to a channel.
*
* @param {number} channelId - The ID of the channel.
* @param {Array<number>} userIds - The IDs of the users to invite.
* @param {Array<number>} [messageId] - The ID of a message to highlight when opening the notification.
*/
invite(channelId, userIds, options = {}) {
return ajax(`/chat/${channelId}/invite`, {
type: "put",
data: { user_ids: userIds, chat_message_id: options.messageId },
});
}
get #basePath() {
return "/chat/api";
}

View File

@ -1,6 +1,6 @@
import Service, { inject as service } from "@ember/service";
import EmberObject from "@ember/object";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatMessageMentionWarning from "discourse/plugins/chat/discourse/models/chat-message-mention-warning";
import { cloneJSON } from "discourse-common/lib/object";
import { bind } from "discourse-common/utils/decorators";
@ -200,7 +200,7 @@ export default class ChatPaneBaseSubscriptionsManager extends Service {
handleMentionWarning(data) {
const message = this.messagesManager.findMessage(data.chat_message_id);
if (message) {
message.mentionWarning = EmberObject.create(data);
message.mentionWarning = ChatMessageMentionWarning.create(message, data);
}
}

View File

@ -0,0 +1,40 @@
.chat-message-error {
color: var(--danger-medium);
&__retry-btn {
padding: 0.5em 0;
background: none;
&:hover,
&:focus,
.-active & {
background: none !important;
}
&:focus .retry-staged-message-btn__action {
text-decoration: underline;
}
.d-icon,
&-title,
&:hover .d-icon {
color: var(--danger) !important;
font-size: var(--font-down-1);
}
.d-icon {
margin-right: 0.25em !important;
}
&-action {
color: var(--tertiary);
font-size: var(--font-down-1);
margin-left: 0.25em;
&:hover {
color: var(--tertiary-high);
text-decoration: underline;
}
}
}
}

View File

@ -0,0 +1,19 @@
.chat-message-mention-warning {
position: relative;
margin-top: 0.25rem;
font-size: var(--font-down-1);
&__dismiss-btn {
position: absolute;
top: 7px;
right: 5px;
}
&__text {
margin: 0.25rem 0;
}
&__invite-sent {
color: var(--tertiary);
}
}

View File

@ -1,5 +1,5 @@
.chat-message-deleted,
.chat-message-hidden {
.chat-message-text.-deleted,
.chat-message-text.-hidden {
margin-left: calc(var(--message-left-width) + 0.75em);
padding: 0;
@ -18,6 +18,12 @@
}
}
.chat-message-reaction {
> * {
pointer-events: none;
}
}
.chat-message {
align-items: flex-start;
padding: 0.25em 0.5em 0.25em 0.75em;
@ -26,10 +32,11 @@
.chat-message-reaction {
@include chat-reaction;
}
will-change: scale;
.not-mobile-device &.deleted:hover {
background-color: var(--danger-hover);
&:active {
transform: scale(0.93);
}
}
.chat-message-content {
@ -87,23 +94,16 @@
display: flex;
flex-wrap: wrap;
.reaction-users-list {
position: absolute;
top: -2px;
transform: translateY(-100%);
border: 1px solid var(--primary-low);
border-radius: 6px;
padding: 0.5em;
background: var(--primary-very-low);
max-width: 300px;
z-index: 3;
}
.chat-message-react-btn {
vertical-align: top;
padding: 0em 0.25em;
background: none;
border: none;
will-change: scale;
&:active {
transform: scale(0.93);
}
> * {
pointer-events: none;
@ -121,32 +121,6 @@
}
}
.chat-send-error {
color: var(--danger-medium);
}
.chat-message-mention-warning {
position: relative;
margin-top: 0.25em;
font-size: var(--font-down-1);
.dismiss-mention-warning {
position: absolute;
top: 15px;
right: 5px;
cursor: pointer;
}
.warning-item {
margin: 0.25em 0;
}
.invite-link {
color: var(--tertiary);
cursor: pointer;
}
}
.chat-message-avatar .chat-user-avatar .chat-user-avatar-container .avatar,
.chat-emoji-avatar .chat-emoji-avatar-container {
width: 28px;
@ -256,94 +230,3 @@
display: none;
}
}
.has-full-page-chat .chat-message .onebox:not(img),
.chat-drawer-container .chat-message .onebox {
margin: 0.5em 0;
border-width: 2px;
header {
margin-bottom: 0.5em;
}
h3 a,
h4 a {
font-size: 14px;
}
pre {
display: flex;
max-height: 150px;
}
p {
overflow: hidden;
}
}
.chat-drawer-container .chat-message .onebox {
width: 85%;
border: 2px solid var(--primary-low);
header {
margin-bottom: 0.5em;
}
.onebox-body {
grid-template-rows: auto auto auto;
overflow: auto;
}
h3 {
@include line-clamp(2);
font-weight: 500;
font-size: var(--font-down-1);
}
p {
display: none;
}
}
.chat-message-reaction {
> * {
pointer-events: none;
}
}
.retry-staged-message-btn {
padding: 0.5em 0;
background: none;
&:hover,
&:focus,
.-active & {
background: none !important;
}
&:focus .retry-staged-message-btn__action {
text-decoration: underline;
}
.d-icon,
&__title,
&:hover .d-icon {
color: var(--danger) !important;
font-size: var(--font-down-1);
}
.d-icon {
margin-right: 0.25em !important;
}
&__action {
color: var(--tertiary);
font-size: var(--font-down-1);
margin-left: 0.25em;
&:hover {
color: var(--tertiary-high);
text-decoration: underline;
}
}
}

View File

@ -28,8 +28,50 @@
}
}
.chat-transcript {
.chat-transcript-user-avatar .avatar {
aspect-ratio: 20 / 20;
.chat-drawer-container .chat-message .onebox {
width: 85%;
border: 2px solid var(--primary-low);
header {
margin-bottom: 0.5em;
}
.onebox-body {
grid-template-rows: auto auto auto;
overflow: auto;
}
h3 {
@include line-clamp(2);
font-weight: 500;
font-size: var(--font-down-1);
}
p {
display: none;
}
}
.has-full-page-chat .chat-message .onebox:not(img),
.chat-drawer-container .chat-message .onebox {
margin: 0.5em 0;
border-width: 2px;
header {
margin-bottom: 0.5em;
}
h3 a,
h4 a {
font-size: 14px;
}
pre {
display: flex;
max-height: 150px;
}
p {
overflow: hidden;
}
}

View File

@ -53,6 +53,10 @@
}
}
.chat-transcript-user-avatar .avatar {
aspect-ratio: 20 / 20;
}
.chat-transcript-user {
display: flex;
flex-wrap: wrap-reverse;

View File

@ -58,3 +58,5 @@
@import "chat-thread-unread-indicator";
@import "chat-thread-participants";
@import "channel-summary-modal";
@import "chat-message-mention-warning";
@import "chat-message-error";

View File

@ -24,7 +24,7 @@ RSpec.describe "Deleted message", type: :system do
last_message = find(".chat-message-container:last-child")
channel_page.delete_message(OpenStruct.new(id: last_message["data-id"]))
expect(channel_page).to have_deleted_message(
expect(channel_page.messages).to have_deleted_message(
OpenStruct.new(id: last_message["data-id"]),
count: 1,
)
@ -40,12 +40,12 @@ RSpec.describe "Deleted message", type: :system do
.update!(last_read_message_id: message.id)
chat_page.visit_channel(channel_1)
channel_page.delete_message(message)
expect(channel_page).to have_deleted_message(message, count: 1)
expect(channel_page.messages).to have_deleted_message(message, count: 1)
sidebar_component.click_link(channel_2.name)
expect(channel_page).to have_no_loading_skeleton
sidebar_component.click_link(channel_1.name)
expect(channel_page).to have_deleted_message(message, count: 1)
expect(channel_page.messages).to have_deleted_message(message, count: 1)
end
context "when the current user is not admin" do
@ -72,7 +72,7 @@ RSpec.describe "Deleted message", type: :system do
end
sidebar_component.click_link(channel_1.name)
expect(channel_page).to have_no_message(id: message.id)
expect(channel_page.messages).to have_no_message(id: message.id)
end
end
end
@ -93,10 +93,9 @@ RSpec.describe "Deleted message", type: :system do
channel_page.delete_message(message_4)
channel_page.delete_message(message_6)
expect(channel_page).to have_deleted_message(message_1)
expect(channel_page).to have_deleted_message(message_4, count: 2)
expect(channel_page).to have_deleted_message(message_6)
expect(channel_page).to have_no_message(id: message_3.id)
expect(channel_page.messages).to have_deleted_messages(message_1, message_6)
expect(channel_page.messages).to have_deleted_message(message_4, count: 2)
expect(channel_page.messages).to have_no_message(id: message_3.id)
end
end
@ -129,20 +128,20 @@ RSpec.describe "Deleted message", type: :system do
channel_page.message_thread_indicator(thread.original_message).click
expect(side_panel).to have_open_thread(thread)
expect(channel_page).to have_message(id: message_1.id)
expect(channel_page).to have_message(id: message_2.id)
expect(open_thread).to have_message(thread_id: thread.id, id: message_4.id)
expect(open_thread).to have_message(thread_id: thread.id, id: message_5.id)
expect(channel_page.messages).to have_message(id: message_2.id)
expect(channel_page.messages).to have_message(id: message_1.id)
expect(open_thread.messages).to have_message(thread_id: thread.id, id: message_4.id)
expect(open_thread.messages).to have_message(thread_id: thread.id, id: message_5.id)
Chat::Publisher.publish_bulk_delete!(
channel_1,
[message_1.id, message_2.id, message_4.id, message_5.id].flatten,
)
expect(channel_page).to have_no_message(id: message_1.id)
expect(channel_page).to have_deleted_message(message_2, count: 2)
expect(open_thread).to have_no_message(thread_id: thread.id, id: message_4.id)
expect(open_thread).to have_deleted_message(message_5, count: 2)
expect(channel_page.messages).to have_no_message(id: message_1.id)
expect(channel_page.messages).to have_deleted_message(message_2, count: 2)
expect(open_thread.messages).to have_no_message(thread_id: thread.id, id: message_4.id)
expect(open_thread.messages).to have_deleted_message(message_5, count: 2)
end
end
end

View File

@ -84,11 +84,11 @@ RSpec.describe "Move message to channel", type: :system do
click_button(I18n.t("js.chat.move_to_channel.confirm_move"))
expect(page).to have_current_path(chat.channel_path(channel_2.slug, channel_2.id))
expect(channel_page).to have_message(text: message_1.message)
expect(channel_page.messages).to have_message(text: message_1.message)
chat_page.visit_channel(channel_1)
expect(channel_page).to have_deleted_message(message_1)
expect(channel_page.messages).to have_deleted_message(message_1)
end
end
end

View File

@ -188,13 +188,6 @@ module PageObjects
check_message_presence(exists: false, text: text, id: id)
end
def has_deleted_message?(message, count: 1)
has_css?(
".chat-channel .chat-message-container[data-id=\"#{message.id}\"] .chat-message-deleted",
text: I18n.t("js.chat.deleted", count: count),
)
end
def check_message_presence(exists: true, text: nil, id: nil)
css_method = exists ? :has_css? : :has_no_css?
if text

View File

@ -144,13 +144,6 @@ module PageObjects
".chat-thread .chat-messages-container .chat-message-container[data-id=\"#{id}\"]"
end
def has_deleted_message?(message, count: 1)
has_css?(
".chat-thread .chat-message-container[data-id=\"#{message.id}\"] .chat-message-deleted",
text: I18n.t("js.chat.deleted", count: count),
)
end
def open_edit_message(message)
hover_message(message)
click_more_button

View File

@ -44,6 +44,14 @@ module PageObjects
messages.all? { |message| has_message?(id: message.id, selected: true) }
end
def has_deleted_messages?(*messages)
messages.all? { |message| has_message?(id: message.id, deleted: 1) }
end
def has_deleted_message?(message, count: 1)
has_message?(id: message.id, deleted: count)
end
private
def message

View File

@ -226,7 +226,7 @@ acceptance("Chat | User status on mentions", function (needs) {
await visit(`/chat/c/-/${channelId}`);
await deleteMessage(".chat-message-content");
await restoreMessage(".chat-message-deleted");
await restoreMessage(".chat-message-text.-deleted");
assertStatusIsRendered(
assert,
@ -239,7 +239,7 @@ acceptance("Chat | User status on mentions", function (needs) {
await visit(`/chat/c/-/${channelId}`);
await deleteMessage(".chat-message-content");
await restoreMessage(".chat-message-deleted");
await restoreMessage(".chat-message-text.-deleted");
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser1.id]: newStatus,
@ -254,7 +254,7 @@ acceptance("Chat | User status on mentions", function (needs) {
await visit(`/chat/c/-/${channelId}`);
await deleteMessage(".chat-message-content");
await restoreMessage(".chat-message-deleted");
await restoreMessage(".chat-message-text.-deleted");
loggedInUser().appEvents.trigger("user-status:changed", {
[mentionedUser1.id]: null,

View File

@ -14,7 +14,7 @@ module("Discourse Chat | Component | chat-message-avatar", function (hooks) {
chat_webhook_event: { emoji: ":heart:" },
});
await render(hbs`<ChatMessageAvatar @message={{this.message}} />`);
await render(hbs`<Chat::Message::Avatar @message={{this.message}} />`);
assert.strictEqual(query(".chat-emoji-avatar .emoji").title, "heart");
});
@ -24,7 +24,7 @@ module("Discourse Chat | Component | chat-message-avatar", function (hooks) {
user: { username: "discobot" },
});
await render(hbs`<ChatMessageAvatar @message={{this.message}} />`);
await render(hbs`<Chat::Message::Avatar @message={{this.message}} />`);
assert.true(exists('.chat-user-avatar [data-user-card="discobot"]'));
});

View File

@ -11,12 +11,16 @@ import fabricators from "discourse/plugins/chat/discourse/lib/fabricators";
module("Discourse Chat | Component | chat-message-info", function (hooks) {
setupRenderingTest(hooks);
const template = hbs`
<Chat::Message::Info @message={{this.message}} @show={{true}} />
`;
test("chat_webhook_event", async function (assert) {
this.message = ChatMessage.create(fabricators.channel(), {
this.message = fabricators.message({
chat_webhook_event: { username: "discobot" },
});
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
await render(template);
assert.strictEqual(
query(".chat-message-info__username").innerText.trim(),
@ -29,11 +33,11 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) {
});
test("user", async function (assert) {
this.message = ChatMessage.create(fabricators.channel(), {
this.message = fabricators.message({
user: { username: "discobot" },
});
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
await render(template);
assert.strictEqual(
query(".chat-message-info__username").innerText.trim(),
@ -42,18 +46,18 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) {
});
test("date", async function (assert) {
this.message = ChatMessage.create(fabricators.channel(), {
this.message = fabricators.message({
user: { username: "discobot" },
created_at: moment(),
});
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
await render(template);
assert.true(exists(".chat-message-info__date"));
});
test("bookmark (with reminder)", async function (assert) {
this.message = ChatMessage.create(fabricators.channel(), {
this.message = fabricators.message({
user: { username: "discobot" },
bookmark: Bookmark.create({
reminder_at: moment(),
@ -61,7 +65,7 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) {
}),
});
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
await render(template);
assert.true(
exists(".chat-message-info__bookmark .d-icon-discourse-bookmark-clock")
@ -76,50 +80,48 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) {
}),
});
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
await render(template);
assert.true(exists(".chat-message-info__bookmark .d-icon-bookmark"));
});
test("user status", async function (assert) {
const status = { description: "off to dentist", emoji: "tooth" };
this.message = ChatMessage.create(fabricators.channel(), {
user: { status },
});
this.message = fabricators.message({ user: { status } });
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
await render(template);
assert.true(exists(".chat-message-info__status .user-status-message"));
});
test("reviewable", async function (assert) {
this.message = ChatMessage.create(fabricators.channel(), {
test("flag status", async function (assert) {
this.message = fabricators.message({
user: { username: "discobot" },
user_flag_status: 0,
});
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
await render(template);
assert.strictEqual(
query(".chat-message-info__flag > .svg-icon-title").title,
I18n.t("chat.you_flagged")
);
this.message = ChatMessage.create(fabricators.channel(), {
user: { username: "discobot" },
reviewable_id: 1,
assert
.dom(".chat-message-info__flag > .svg-icon-title")
.hasAttribute("title", I18n.t("chat.you_flagged"));
});
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
test("reviewable", async function (assert) {
this.message = fabricators.message({
user: { username: "discobot" },
user_flag_status: 0,
});
assert.strictEqual(
query(".chat-message-info__flag a .svg-icon-title").title,
I18n.t("chat.flagged")
);
await render(template);
assert
.dom(".chat-message-info__flag > .svg-icon-title")
.hasAttribute("title", I18n.t("chat.you_flagged"));
});
test("with username classes", async function (assert) {
this.message = ChatMessage.create(fabricators.channel(), {
this.message = fabricators.message({
user: {
username: "discobot",
admin: true,
@ -129,7 +131,7 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) {
},
});
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
await render(template);
assert.dom(".chat-message-info__username.is-staff").exists();
assert.dom(".chat-message-info__username.is-admin").exists();
@ -139,11 +141,11 @@ module("Discourse Chat | Component | chat-message-info", function (hooks) {
});
test("without username classes", async function (assert) {
this.message = ChatMessage.create(fabricators.channel(), {
this.message = fabricators.message({
user: { username: "discobot" },
});
await render(hbs`<ChatMessageInfo @message={{this.message}} />`);
await render(template);
assert.dom(".chat-message-info__username.is-staff").doesNotExist();
assert.dom(".chat-message-info__username.is-admin").doesNotExist();

View File

@ -0,0 +1,54 @@
import { render } from "@ember/test-helpers";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import fabricators from "discourse/plugins/chat/discourse/lib/fabricators";
import I18n from "I18n";
module(
"Discourse Chat | Component | Chat::Message::LeftGutter",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`
<Chat::Message::LeftGutter @message={{this.message}} />
`;
test("default", async function (assert) {
this.message = fabricators.message();
await render(template);
assert.dom(".chat-message-left-gutter__date").exists();
});
test("with reviewable", async function (assert) {
this.message = fabricators.message({ reviewable_id: 1 });
await render(template);
assert
.dom(".chat-message-left-gutter__flag .svg-icon-title")
.hasAttribute("title", I18n.t("chat.flagged"));
});
test("with flag status", async function (assert) {
this.message = fabricators.message({ user_flag_status: 0 });
await render(template);
assert
.dom(".chat-message-left-gutter__flag .svg-icon-title")
.hasAttribute("title", I18n.t("chat.you_flagged"));
});
test("bookmark", async function (assert) {
this.message = fabricators.message({ bookmark: fabricators.bookmark() });
await render(template);
assert.dom(".chat-message-left-gutter__date").exists();
assert.dom(".chat-message-left-gutter__bookmark").exists();
});
}
);

View File

@ -0,0 +1,102 @@
import { render } from "@ember/test-helpers";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import fabricators from "discourse/plugins/chat/discourse/lib/fabricators";
module(
"Discourse Chat | Component | Chat::Message::MentionWarning",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`
<Chat::Message::MentionWarning @message={{this.message}} />
`;
test("without memberships", async function (assert) {
this.message = fabricators.message();
this.message.mentionWarning = fabricators.messageMentionWarning(
this.message,
{
without_membership: [fabricators.user()].map((u) => {
return { username: u.username, id: u.id };
}),
}
);
await render(template);
assert
.dom(".chat-message-mention-warning__text.-without-membership")
.exists();
});
test("cannot see channel", async function (assert) {
this.message = fabricators.message();
this.message.mentionWarning = fabricators.messageMentionWarning(
this.message,
{
cannot_see: [fabricators.user()].map((u) => {
return { username: u.username, id: u.id };
}),
}
);
await render(template);
assert.dom(".chat-message-mention-warning__text.-cannot-see").exists();
});
test("cannot see channel", async function (assert) {
this.message = fabricators.message();
this.message.mentionWarning = fabricators.messageMentionWarning(
this.message,
{
cannot_see: [fabricators.user()].map((u) => {
return { username: u.username, id: u.id };
}),
}
);
await render(template);
assert.dom(".chat-message-mention-warning__text.-cannot-see").exists();
});
test("too many groups", async function (assert) {
this.message = fabricators.message();
this.message.mentionWarning = fabricators.messageMentionWarning(
this.message,
{
groups_with_too_many_members: [fabricators.group()].mapBy("name"),
}
);
await render(template);
assert
.dom(
".chat-message-mention-warning__text.-groups-with-too-many-members"
)
.exists();
});
test("groups with mentions disabled", async function (assert) {
this.message = fabricators.message();
this.message.mentionWarning = fabricators.messageMentionWarning(
this.message,
{
group_mentions_disabled: [fabricators.group()].mapBy("name"),
}
);
await render(template);
assert
.dom(
".chat-message-mention-warning__text.-groups-with-mentions-disabled"
)
.exists();
});
}
);

View File

@ -9,11 +9,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
setupRenderingTest(hooks);
const template = hbs`
<ChatMessage
@message={{this.message}}
@messageDidEnterViewport={{fn (noop)}}
@messageDidLeaveViewport={{fn (noop)}}
/>
<ChatMessage @message={{this.message}} />
`;
test("Message with edits", async function (assert) {
@ -31,7 +27,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
await render(template);
assert.true(
exists(".chat-message-deleted .chat-message-expand"),
exists(".chat-message-text.-deleted .chat-message-expand"),
"has the correct css class and expand button within"
);
});
@ -41,7 +37,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
await render(template);
assert.true(
exists(".chat-message-hidden .chat-message-expand"),
exists(".chat-message-text.-hidden .chat-message-expand"),
"has the correct css class and expand button within"
);
});