FEATURE: Thread indicator improvements and participants (#21909)

This commit adds the initial part of thread indicator improvements:

* Show the reply count, last reply date and excerpt,
and the participants of the thread's avatars and
count of additional participants
* Add a participants component for the thread that
can be reused for the list
* Add a query class to get the thread participants
* Live update the thread indicator more consistently
with the last reply and participant details
image image

In subsequent PRs we will cache the participants since
they do not change often, and improve the thread list
further with participants.

This commit also adds a showPresence boolean (default
true) to ChatUserAvatar, since we don't want to show the
online indicator for thread participants.

---------

Co-authored-by: chapoi <charlie@discourse.org>
This commit is contained in:
Martin Brennan 2023-06-15 10:49:27 +10:00 committed by GitHub
parent 897b6d86c7
commit f75ac9da30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 753 additions and 150 deletions

View File

@ -32,6 +32,8 @@ class Chat::Api::ChannelThreadsController < Chat::ApiController
::Chat::ThreadSerializer,
root: "thread",
membership: result.membership,
include_preview: true,
participants: result.participants,
)
end
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }

View File

@ -16,8 +16,6 @@ module Jobs
Time.zone.now.to_i,
)
thread.set_replies_count_cache(thread.replies.count, update_db: true)
::Chat::Publisher.publish_thread_original_message_metadata!(thread)
end
end
end

View File

@ -42,7 +42,7 @@ module Chat
end
def replies
self.chat_messages.where.not(id: self.original_message_id)
self.chat_messages.where.not(id: self.original_message_id).order("created_at ASC, id ASC")
end
def url

View File

@ -10,7 +10,8 @@ module Chat
:unread_thread_ids,
:threads,
:tracking,
:thread_memberships
:thread_memberships,
:thread_participants
def initialize(
chat_channel:,
@ -21,7 +22,8 @@ module Chat
unread_thread_ids: nil,
threads: nil,
tracking: nil,
thread_memberships: nil
thread_memberships: nil,
thread_participants: nil
)
@chat_channel = chat_channel
@chat_messages = chat_messages
@ -32,6 +34,7 @@ module Chat
@threads = threads
@tracking = tracking
@thread_memberships = thread_memberships
@thread_participants = thread_participants
end
def reviewable_ids

View File

@ -76,7 +76,6 @@ module Chat
def thread_reply_count_cache_changed
Jobs.enqueue_in(5.seconds, Jobs::Chat::UpdateThreadReplyCount, thread_id: self.id)
::Chat::Publisher.publish_thread_original_message_metadata!(self)
end
end
end

View File

@ -0,0 +1,95 @@
# frozen_string_literal: true
module Chat
# Builds a query to find the total count of participants for one
# or more threads (on a per-thread basis), as well as up to 3
# participants in the thread. The participants will be made up
# of:
#
# - Participant 1 & 2 - The most frequent participants in the thread.
# - Participant 3 - The most recent participant in the thread.
#
# This result should be cached to avoid unnecessary queries,
# since the participants will not often change for a thread,
# and if there is a delay in updating them based on message
# count it is not a big deal.
class ThreadParticipantQuery
# @param thread_ids [Array<Integer>] The IDs of the threads to query.
# @return [Hash<Integer, Hash>] A hash of thread IDs to participant data.
def self.call(thread_ids:)
return {} if thread_ids.blank?
# We only want enough data for BasicUserSerializer, since the participants
# are just showing username & avatar.
thread_participant_stats = DB.query(<<~SQL, thread_ids: thread_ids)
SELECT thread_participant_stats.*, users.username, users.name, users.uploaded_avatar_id FROM (
SELECT chat_messages.thread_id, chat_messages.user_id, COUNT(*) AS message_count,
ROW_NUMBER() OVER (PARTITION BY chat_messages.thread_id ORDER BY COUNT(*) DESC) AS row_number
FROM chat_messages
INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id
INNER JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = chat_threads.id
AND user_chat_thread_memberships.user_id = chat_messages.user_id
WHERE chat_messages.thread_id IN (:thread_ids)
AND chat_messages.deleted_at IS NULL
GROUP BY chat_messages.thread_id, chat_messages.user_id
) AS thread_participant_stats
INNER JOIN users ON users.id = thread_participant_stats.user_id
ORDER BY thread_participant_stats.thread_id ASC, thread_participant_stats.message_count DESC, thread_participant_stats.user_id ASC
SQL
most_recent_participants = DB.query(<<~SQL, thread_ids: thread_ids)
SELECT DISTINCT ON (thread_id) chat_messages.thread_id, chat_messages.user_id,
users.username, users.name, users.uploaded_avatar_id
FROM chat_messages
INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id
INNER JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = chat_threads.id
AND user_chat_thread_memberships.user_id = chat_messages.user_id
INNER JOIN users ON users.id = chat_messages.user_id
WHERE chat_messages.thread_id IN (:thread_ids)
AND chat_messages.deleted_at IS NULL
ORDER BY chat_messages.thread_id ASC, chat_messages.created_at DESC
SQL
most_recent_participants =
most_recent_participants.reduce({}) do |hash, mrm|
hash[mrm.thread_id] = {
id: mrm.user_id,
username: mrm.username,
name: mrm.name,
uploaded_avatar_id: mrm.uploaded_avatar_id,
}
hash
end
thread_participants = {}
thread_participant_stats.each do |thread_participant_stat|
thread_id = thread_participant_stat.thread_id
thread_participants[thread_id] ||= {}
thread_participants[thread_id][:users] ||= []
thread_participants[thread_id][:total_count] ||= 0
# If we want to return more of the top N users in the thread we
# can just increase the number here.
if thread_participants[thread_id][:users].length < 2 &&
thread_participant_stat.user_id != most_recent_participants[thread_id][:id]
thread_participants[thread_id][:users].push(
{
id: thread_participant_stat.user_id,
username: thread_participant_stat.username,
name: thread_participant_stat.name,
uploaded_avatar_id: thread_participant_stat.uploaded_avatar_id,
},
)
end
thread_participants[thread_id][:total_count] += 1
end
# Always put the most recent participant at the end of the array.
most_recent_participants.each do |thread_id, user|
thread_participants[thread_id][:users].push(user)
end
thread_participants
end
end
end

View File

@ -16,8 +16,6 @@ module Chat
:bookmark,
:available_flags,
:thread_id,
:thread_reply_count,
:thread_title,
:chat_channel_id,
:mentioned_users
@ -172,17 +170,5 @@ module Chat
def include_thread_id?
include_threading_data?
end
def include_thread_reply_count?
include_threading_data? && object.thread_id.present?
end
def thread_reply_count
object.thread&.replies_count_cache || 0
end
def thread_title
object.thread&.title
end
end
end

View File

@ -2,6 +2,8 @@
module Chat
class ThreadOriginalMessageSerializer < Chat::MessageSerializer
has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects
def excerpt
object.censored_excerpt(rich: true, max_length: Chat::Thread::EXCERPT_LENGTH)
end

View File

@ -2,7 +2,22 @@
module Chat
class ThreadPreviewSerializer < ApplicationSerializer
attributes :last_reply_created_at, :last_reply_excerpt, :last_reply_id
attributes :last_reply_created_at,
:last_reply_excerpt,
:last_reply_id,
:participant_count,
:reply_count
has_many :participant_users, serializer: BasicUserSerializer, embed: :objects
has_one :last_reply_user, serializer: BasicUserSerializer, embed: :objects
def initialize(object, opts)
super(object, opts)
@participants = opts[:participants]
end
def reply_count
object.replies_count_cache || 0
end
def last_reply_created_at
object.last_reply.created_at
@ -13,7 +28,31 @@ module Chat
end
def last_reply_excerpt
object.last_reply.censored_excerpt
object.last_reply.censored_excerpt(rich: true, max_length: Chat::Thread::EXCERPT_LENGTH)
end
def last_reply_user
object.last_reply.user
end
def include_participant_data?
@participants.present?
end
def include_participant_users?
include_participant_data?
end
def include_participant_count?
include_participant_data?
end
def participant_users
@participant_users ||= @participants[:users].map { |user| User.new(user) }
end
def participant_count
@participants[:total_count]
end
end
end

View File

@ -2,7 +2,6 @@
module Chat
class ThreadSerializer < ApplicationSerializer
has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects
has_one :original_message, serializer: Chat::ThreadOriginalMessageSerializer, embed: :objects
attributes :id,
@ -36,7 +35,12 @@ module Chat
end
def preview
Chat::ThreadPreviewSerializer.new(object, scope: scope, root: false).as_json
Chat::ThreadPreviewSerializer.new(
object,
scope: scope,
root: false,
participants: @opts[:participants],
).as_json
end
def include_current_user_membership?

View File

@ -12,6 +12,8 @@ module Chat
thread,
scope: scope,
membership: object.thread_memberships.find { |m| m.thread_id == thread.id },
participants: object.thread_participants[thread.id],
include_preview: true,
root: nil,
)
end

View File

@ -38,6 +38,7 @@ module Chat
step :fetch_threads_for_messages
step :fetch_tracking
step :fetch_thread_memberships
step :fetch_thread_participants
step :build_view
class Contract
@ -218,6 +219,11 @@ module Chat
end
end
def fetch_thread_participants(threads:, **)
context.thread_participants =
::Chat::ThreadParticipantQuery.call(thread_ids: threads.map(&:id))
end
def build_view(
guardian:,
channel:,
@ -228,6 +234,7 @@ module Chat
can_load_more_past:,
can_load_more_future:,
thread_memberships:,
thread_participants:,
**
)
context.view =
@ -241,6 +248,7 @@ module Chat
threads: threads,
tracking: tracking,
thread_memberships: thread_memberships,
thread_participants: thread_participants,
)
end
end

View File

@ -55,7 +55,7 @@ module Chat
.strict_loading
.includes(
:channel,
last_reply: [:uploads],
last_reply: %i[user uploads],
original_message_user: :user_status,
original_message: [
:chat_webhook_event,

View File

@ -25,6 +25,7 @@ module Chat
policy :invalid_access
policy :threading_enabled_for_channel
step :fetch_membership
step :fetch_participants
# @!visibility private
class Contract
@ -43,6 +44,7 @@ module Chat
def fetch_thread(contract:, **)
Chat::Thread.includes(
:channel,
last_reply: :user,
original_message_user: :user_status,
original_message: :chat_webhook_event,
).find_by(id: contract.thread_id, channel_id: contract.channel_id)
@ -59,5 +61,9 @@ module Chat
def fetch_membership(thread:, guardian:, **)
context.membership = thread.membership_for(guardian.user)
end
def fetch_participants(thread:, **)
context.participants = ::Chat::ThreadParticipantQuery.call(thread_ids: [thread.id])[thread.id]
end
end
end

View File

@ -72,23 +72,27 @@ module Chat
user_id: chat_message.user.id,
username: chat_message.user.username,
thread_id: chat_message.thread_id,
created_at: chat_message.created_at,
excerpt:
chat_message.censored_excerpt(rich: true, max_length: Chat::Thread::EXCERPT_LENGTH),
},
permissions(chat_channel),
)
publish_thread_original_message_metadata!(chat_message.thread)
end
end
def self.publish_thread_original_message_metadata!(thread)
preview =
::Chat::ThreadPreviewSerializer.new(
thread,
participants: ::Chat::ThreadParticipantQuery.call(thread_ids: [thread.id])[thread.id],
root: false,
).as_json
publish_to_channel!(
thread.channel,
{
type: :update_thread_original_message,
original_message_id: thread.original_message_id,
replies_count: thread.replies_count_cache,
title: thread.title,
preview: preview.as_json,
},
)
end

View File

@ -58,6 +58,10 @@ module Chat
def publish_events(guardian:, message:, **)
DiscourseEvent.trigger(:chat_message_restored, message, message.chat_channel, guardian.user)
Chat::Publisher.publish_restore!(message.chat_channel, message)
if message.thread.present?
Chat::Publisher.publish_thread_original_message_metadata!(message.thread)
end
end
end
end

View File

@ -73,6 +73,10 @@ module Chat
def publish_events(guardian:, message:, **)
DiscourseEvent.trigger(:chat_message_trashed, message, message.chat_channel, guardian.user)
Chat::Publisher.publish_delete!(message.chat_channel, message)
if message.thread.present?
Chat::Publisher.publish_thread_original_message_metadata!(message.thread)
end
end
end
end

View File

@ -1,6 +1,5 @@
import { capitalize } from "@ember/string";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
import Component from "@glimmer/component";
import { bind, debounce } from "discourse-common/utils/decorators";
import { action } from "@ember/object";
@ -207,7 +206,18 @@ export default class ChatLivePane extends Component {
if (result.threads) {
result.threads.forEach((thread) => {
this.args.channel.threadsManager.store(this.args.channel, thread);
const storedThread = this.args.channel.threadsManager.store(
this.args.channel,
thread,
{ replace: true }
);
const originalMessage = messages.findBy(
"id",
storedThread.originalMessage.id
);
if (originalMessage) {
originalMessage.thread = storedThread;
}
});
}
@ -297,7 +307,18 @@ export default class ChatLivePane extends Component {
if (result.threads) {
result.threads.forEach((thread) => {
this.args.channel.threadsManager.store(this.args.channel, thread);
const storedThread = this.args.channel.threadsManager.store(
this.args.channel,
thread,
{ replace: true }
);
const originalMessage = messages.findBy(
"id",
storedThread.originalMessage.id
);
if (originalMessage) {
originalMessage.thread = storedThread;
}
});
}
@ -403,13 +424,6 @@ export default class ChatLivePane extends Component {
}
const message = ChatMessage.create(channel, messageData);
if (messageData.thread_id) {
message.thread = ChatThread.create(channel, {
id: messageData.thread_id,
});
}
messages.push(message);
});

View File

@ -3,11 +3,50 @@
@models={{@message.thread.routeModels}}
class="chat-message-thread-indicator"
>
<span class="chat-message-thread-indicator__replies-count">
{{i18n "chat.thread.replies" count=@message.threadReplyCount}}
</span>
<span class="chat-message-thread-indicator__view-thread overflow-ellipsis">
{{i18n "chat.thread.view_thread"}}{{#if this.threadTitle}}:
{{replace-emoji this.threadTitle}}{{/if}}
</span>
{{#unless this.chatStateManager.isDrawerActive}}
<div class="chat-message-thread-indicator__last-reply-avatar">
<ChatUserAvatar
@user={{@message.thread.preview.lastReplyUser}}
@avatarSize="small"
/>
</div>
{{/unless}}
<div class="chat-message-thread-indicator__body">
<div class="chat-message-thread-indicator__last-reply-metadata">
<div class="chat-message-thread-indicator__last-reply-user">
{{#if (or this.site.mobileView this.chatStateManager.isDrawerActive)}}
<span
class="chat-message-thread-indicator__last-reply-avatar -mobile"
>
<ChatUserAvatar
@user={{@message.thread.preview.lastReplyUser}}
@avatarSize="tiny"
/>
</span>
{{/if}}
<span class="chat-message-thread-indicator__last-reply-username">
{{@message.thread.preview.lastReplyUser.username}}
</span>
</div>
<span><span class="chat-message-thread-indicator__last-reply-label">{{i18n
"chat.thread.last_reply"
}}</span>{{format-date
@message.thread.preview.lastReplyCreatedAt
leaveAgo="true"
}}</span>
|
<span class="chat-message-thread-indicator__replies-count">
{{i18n "chat.thread.replies" count=@message.thread.preview.replyCount}}
</span>
</div>
<div class="chat-message-thread-indicator__last-reply-excerpt">
{{replace-emoji (html-safe @message.thread.preview.lastReplyExcerpt)}}
</div>
</div>
<div class="chat-message-thread-indicator__participants">
<Chat::Thread::Participants @thread={{@message.thread}} />
</div>
</LinkTo>

View File

@ -1,7 +1,11 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { escapeExpression } from "discourse/lib/utilities";
export default class ChatMessageThreadIndicator extends Component {
@service site;
@service chatStateManager;
get threadTitle() {
return escapeExpression(this.args.message.threadTitle);
}

View File

@ -356,7 +356,7 @@ export default class ChatMessage extends Component {
this.args.context !== MESSAGE_CONTEXT_THREAD &&
this.threadingEnabled &&
this.args.message?.thread &&
this.args.message?.threadReplyCount > 0
this.args.message?.thread.preview.replyCount > 0
);
}

View File

@ -1,4 +1,7 @@
<div class="chat-user-avatar {{if this.isOnline 'is-online'}}">
<div
class="chat-user-avatar
{{if (and this.isOnline this.showPresence) 'is-online'}}"
>
<div
role="button"
class="chat-user-avatar-container clickable"

View File

@ -9,6 +9,7 @@ export default class ChatUserAvatar extends Component {
user = null;
avatarSize = "tiny";
showPresence = true;
@computed("chat.presenceChannel.users.[]", "user.{id,username}")
get isOnline() {

View File

@ -0,0 +1,21 @@
{{#if (gt @thread.preview.participantUsers.length 1)}}
<div class="chat-thread-participants">
<div class="chat-thread-participants__avatar-group">
{{#each @thread.preview.participantUsers as |user|}}
<ChatUserAvatar
@user={{user}}
@avatarSize="tiny"
@showPresence={{false}}
/>
{{/each}}
</div>
{{#if @thread.preview.otherParticipantCount}}
<div class="chat-thread-participants__other-count">
{{i18n
"chat.thread.participants_other_count"
count=@thread.preview.otherParticipantCount
}}
</div>
{{/if}}
</div>
{{/if}}

View File

@ -54,12 +54,11 @@ export default class ChatStyleguideChatMessage extends Component {
if (this.message.thread) {
this.message.channel.threadingEnabled = false;
this.message.thread = null;
this.message.threadReplyCount = 0;
} else {
this.message.thread = fabricators.thread({
channel: this.message.channel,
});
this.message.threadReplyCount = 1;
this.message.thread.preview.replyCount = 1;
this.message.channel.threadingEnabled = true;
}
}

View File

@ -51,7 +51,6 @@ export default class ChatMessage {
@tracked firstOfResults;
@tracked message;
@tracked thread;
@tracked threadReplyCount;
@tracked manager;
@tracked threadTitle;
@ -68,8 +67,6 @@ export default class ChatMessage {
this.editing = args.editing || false;
this.availableFlags = args.availableFlags || args.available_flags;
this.hidden = args.hidden || false;
this.threadReplyCount = args.threadReplyCount || args.thread_reply_count;
this.threadTitle = args.threadTitle || args.thread_title;
this.chatWebhookEvent = args.chatWebhookEvent || args.chat_webhook_event;
this.createdAt = args.createdAt || args.created_at;
this.deletedAt = args.deletedAt || args.deleted_at;
@ -104,7 +101,6 @@ export default class ChatMessage {
edited: this.edited,
availableFlags: this.availableFlags,
hidden: this.hidden,
threadReplyCount: this.threadReplyCount,
chatWebhookEvent: this.chatWebhookEvent,
createdAt: this.createdAt,
deletedAt: this.deletedAt,

View File

@ -1,18 +1,38 @@
import { tracked } from "@glimmer/tracking";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
export default class ChatThreadPreview {
static create(args = {}) {
return new ChatThreadPreview(args);
}
@tracked replyCount;
@tracked lastReplyId;
@tracked lastReplyCreatedAt;
@tracked lastReplyExcerpt;
@tracked lastReplyUser;
@tracked participantCount;
@tracked participantUsers;
constructor(args = {}) {
if (!args) {
args = {};
}
this.replyCount = args.reply_count || args.replyCount || 0;
this.lastReplyId = args.last_reply_id || args.lastReplyId;
this.lastReplyCreatedAt =
args.last_reply_created_at || args.lastReplyCreatedAt;
this.lastReplyExcerpt = args.last_reply_excerpt || args.lastReplyExcerpt;
this.lastReplyUser = args.last_reply_user || args.lastReplyUser;
this.participantCount =
args.participant_count || args.participantCount || 0;
this.participantUsers = new TrackedArray(
args.participant_users || args.participantUsers || []
);
}
get otherParticipantCount() {
return this.participantCount - this.participantUsers.length;
}
}

View File

@ -58,9 +58,7 @@ export default class ChatThread {
}
this.tracking = new ChatTrackingState(getOwner(this));
if (args.preview) {
this.preview = ChatThreadPreview.create(args.preview);
}
this.preview = ChatThreadPreview.create(args.preview);
}
async stageMessage(message) {

View File

@ -1,5 +1,6 @@
import { inject as service } from "@ember/service";
import ChatPaneBaseSubscriptionsManager from "./chat-pane-base-subscriptions-manager";
import ChatThreadPreview from "../models/chat-thread-preview";
export default class ChatChannelPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager {
@service chat;
@ -23,10 +24,7 @@ export default class ChatChannelPaneSubscriptionsManager extends ChatPaneBaseSub
handleThreadOriginalMessageUpdate(data) {
const message = this.messagesManager.findMessage(data.original_message_id);
if (message) {
if (data.replies_count) {
message.threadReplyCount = data.replies_count;
}
message.threadTitle = data.title;
message.thread.preview = ChatThreadPreview.create(data.preview);
}
}

View File

@ -230,7 +230,7 @@ export default class ChatPaneBaseSubscriptionsManager extends Service {
stagedThread.staged = false;
stagedThread.id = data.thread_id;
stagedThread.originalMessage.thread = stagedThread;
stagedThread.originalMessage.threadReplyCount ??= 1;
stagedThread.originalMessage.thread.preview.replyCount ??= 1;
} else if (data.thread_id) {
this.model.threadsManager
.find(this.model.id, data.thread_id, { fetchIfNotFound: true })

View File

@ -3,7 +3,6 @@ import I18n from "I18n";
import { bind } from "discourse-common/utils/decorators";
import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel";
import ChatChannelArchive from "../models/chat-channel-archive";
import ChatThreadPreview from "../models/chat-thread-preview";
export default class ChatSubscriptionsManager extends Service {
@service store;
@ -224,12 +223,6 @@ export default class ChatSubscriptionsManager extends Service {
channel.threadsManager
.find(busData.channel_id, busData.thread_id)
.then((thread) => {
thread.preview = ChatThreadPreview.create({
lastReplyId: busData.message_id,
lastReplyExcerpt: busData.excerpt,
lastReplyCreatedAt: busData.created_at,
});
if (busData.user_id === this.currentUser.id) {
// Thread should no longer be considered unread.
if (thread.currentUserMembership) {

View File

@ -1,36 +1,67 @@
.chat-message-thread-indicator {
align-items: center;
display: grid;
grid: 1fr / auto-flow;
display: flex;
align-items: center;
gap: 1rem;
cursor: pointer;
grid-area: threadindicator;
max-width: 1000px;
background-color: var(--primary-very-low);
margin: 4px 0 -2px calc(var(--message-left-width) - 0.25rem);
padding-block: 0.25rem;
padding-block: 0.5rem;
padding-inline: 0.5rem;
max-width: 500px;
border-radius: 4px;
border-radius: 8px;
color: var(--primary);
&__replies-count {
&:visited,
&:hover {
color: var(--primary);
}
&:hover {
box-shadow: var(--shadow-dropdown-lite);
}
&__participants {
flex-shrink: 0;
align-self: flex-start;
}
&__last-reply-avatar {
align-self: flex-start;
.chat-user-avatar {
width: auto !important;
}
}
&__last-reply-metadata {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--primary-medium);
font-size: var(--font-down-1);
}
&__view-thread {
font-size: var(--font-down-1);
flex: 1;
.chat-message-thread-indicator:hover & {
text-decoration: underline;
}
&__last-reply-label {
margin-right: 0.25rem;
}
&__replies-count + &__view-thread {
padding-left: 0.25rem;
&__last-reply-username {
font-weight: bold;
color: var(--secondary-low);
font-size: var(--font-up-1);
}
&__separator {
margin: 0 0.5em;
&__last-reply-excerpt {
@include ellipsis;
}
&__body {
overflow: hidden;
white-space: nowrap;
flex-shrink: 1;
}
&__replies-count {
color: var(--tertiary);
}
}

View File

@ -64,11 +64,11 @@
&.is-threaded {
display: grid;
grid-template-columns: var(--message-left-width) 1fr;
grid-template-rows: auto 32px;
grid-template-rows: auto;
grid-template-areas:
"avatar message"
"threadindicator threadindicator";
padding: 0.65rem 1rem !important;
.chat-user-avatar {
grid-area: avatar;
}

View File

@ -0,0 +1,22 @@
.chat-thread-participants {
&__other-count {
font-size: var(--font-down-2);
text-align: right;
color: var(--primary-medium);
}
&__avatar-group {
display: flex;
justify-content: flex-end;
.chat-user-avatar {
width: auto !important;
.avatar {
width: 24px;
height: 24px;
padding: 0;
}
}
}
}

View File

@ -56,4 +56,5 @@
@import "chat-thread-header";
@import "chat-thread-list-header";
@import "chat-thread-unread-indicator";
@import "chat-thread-participants";
@import "channel-summary-modal";

View File

@ -0,0 +1,61 @@
.chat-message-thread-indicator {
width: min-content;
min-width: 400px;
max-width: 600px;
.chat-drawer & {
align-items: stretch;
flex-wrap: wrap;
width: auto;
max-width: 100%;
min-width: auto;
gap: 0.25rem;
}
&__last-reply-avatar {
.chat-drawer & {
display: inline-block;
}
}
&__last-reply-user {
margin-right: 0.25rem;
.chat-drawer & {
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: auto;
}
}
&__last-reply-metadata {
.chat-drawer & {
flex-wrap: wrap;
}
}
&__last-reply-excerpt {
.chat-drawer & {
white-space: wrap;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
margin-left: calc(26px + 0.5rem);
}
}
&__body {
.chat-drawer & {
flex-grow: 1;
}
}
&__participants {
margin-left: auto;
.chat-drawer & {
align-self: flex-end;
flex-basis: 100%;
}
}
}

View File

@ -5,4 +5,5 @@
@import "chat-index-full-page";
@import "chat-message-actions";
@import "chat-message";
@import "chat-message-thread-indicator";
@import "sidebar-extensions";

View File

@ -0,0 +1,39 @@
.chat-message-thread-indicator {
&__participants,
&__last-reply-avatar:not(.-mobile) {
display: none;
}
&__last-reply-metadata {
display: flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
}
&__last-reply-user {
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: auto;
}
&__last-reply-avatar {
align-self: center;
.avatar {
width: 18px;
height: 18px;
}
}
&__last-reply-username {
margin-right: auto;
}
&__last-reply-excerpt {
white-space: wrap;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
}

View File

@ -12,3 +12,4 @@
@import "chat-thread";
@import "chat-threads-list";
@import "chat-thread-settings-modal";
@import "chat-message-thread-indicator";

View File

@ -565,6 +565,9 @@ en:
started_by: "Started by"
settings: "Settings"
last_reply: "last reply"
participants_other_count:
one: "+%{count} other"
other: "+%{count} others"
threads:
open: "Open Thread"
list: "Ongoing discussions"

View File

@ -62,13 +62,13 @@ module Chat
create_thread
@chat_message.attach_uploads(uploads)
Chat::Draft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all
post_process_resolved_thread
Chat::Publisher.publish_new!(
@chat_channel,
@chat_message,
@staged_id,
staged_thread_id: @staged_thread_id,
)
post_process_resolved_thread
Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id })
Chat::Notifier.notify_new(chat_message: @chat_message, timestamp: @chat_message.created_at)
@chat_channel.touch(:last_message_sent_at)

View File

@ -41,6 +41,10 @@ module Chat
Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id })
Chat::Notifier.notify_edit(chat_message: @chat_message, timestamp: revision.created_at)
DiscourseEvent.trigger(:chat_message_edited, @chat_message, @chat_channel, @user)
if @chat_message.thread.present?
Chat::Publisher.publish_thread_original_message_metadata!(@chat_message.thread)
end
rescue => error
@error = error
end

View File

@ -769,11 +769,6 @@ describe Chat::MessageCreator do
it "does not create a thread membership if one exists" do
Fabricate(:user_chat_thread_membership, user: user1, thread: existing_thread)
Fabricate(
:user_chat_thread_membership,
user: existing_thread.original_message_user,
thread: existing_thread,
)
expect {
described_class.create(
chat_channel: public_chat_channel,

View File

@ -610,6 +610,29 @@ describe Chat::MessageUpdater do
end
end
context "when the message is in a thread" do
fab!(:message) do
Fabricate(
:chat_message,
user: user1,
chat_channel: public_chat_channel,
thread: Fabricate(:chat_thread, channel: public_chat_channel),
)
end
it "publishes a MessageBus event to update the original message metadata" do
messages =
MessageBus.track_publish("/chat/#{public_chat_channel.id}") do
Chat::MessageUpdater.update(
guardian: guardian,
chat_message: message,
new_content: "some new updated content",
)
end
expect(messages.find { |m| m.data["type"] == "update_thread_original_message" }).to be_present
end
end
describe "watched words" do
fab!(:watched_word) { Fabricate(:watched_word) }

View File

@ -168,7 +168,10 @@ Fabricator(:chat_thread, class_name: "Chat::Thread") do
)
end
after_create { |thread| thread.original_message.update!(thread_id: thread.id) }
after_create do |thread|
thread.original_message.update!(thread_id: thread.id)
thread.add(thread.original_message_user)
end
end
Fabricator(:user_chat_thread_membership, class_name: "Chat::UserChatThreadMembership") do

View File

@ -37,20 +37,4 @@ RSpec.describe Jobs::Chat::UpdateThreadReplyCount do
Time.at(Time.zone.now.to_i, in: Time.zone),
)
end
it "publishes the thread original message metadata" do
messages =
MessageBus.track_publish("/chat/#{thread.channel_id}") do
described_class.new.execute(thread_id: thread.id)
end
expect(messages.first.data).to eq(
{
"original_message_id" => thread.original_message_id,
"replies_count" => 2,
"type" => "update_thread_original_message",
"title" => thread.title,
},
)
end
end

View File

@ -0,0 +1,108 @@
# frozen_string_literal: true
RSpec.describe Chat::ThreadParticipantQuery do
fab!(:thread_1) { Fabricate(:chat_thread) }
fab!(:thread_2) { Fabricate(:chat_thread) }
context "when users have messaged in the thread" do
fab!(:user_1) { Fabricate(:user) }
fab!(:user_2) { Fabricate(:user) }
fab!(:user_3) { Fabricate(:user) }
before do
Fabricate(:chat_message, thread: thread_1, user: user_1)
Fabricate(:chat_message, thread: thread_1, user: user_1)
Fabricate(:chat_message, thread: thread_1, user: user_1)
Fabricate(:chat_message, thread: thread_1, user: user_2)
Fabricate(:chat_message, thread: thread_1, user: user_2)
Fabricate(:chat_message, thread: thread_1, user: user_3)
thread_1.add(user_1)
thread_1.add(user_2)
thread_1.add(user_3)
end
it "has all the user details needed for BasicUserSerializer" do
result = described_class.call(thread_ids: [thread_1.id, thread_2.id])
expect(result[thread_1.id][:users].first).to eq(
{
id: user_1.id,
username: user_1.username,
name: user_1.name,
uploaded_avatar_id: user_1.uploaded_avatar_id,
},
)
end
it "does not return more than 3 thread participants" do
other_user = Fabricate(:user)
thread_1.add(other_user)
Fabricate(:chat_message, thread: thread_1, user: other_user)
result = described_class.call(thread_ids: [thread_1.id])
expect(result[thread_1.id][:users].length).to eq(3)
end
it "calculates the top messagers in a thread as well as the last messager" do
result = described_class.call(thread_ids: [thread_1.id, thread_2.id])
expect(result[thread_1.id][:users].map { |u| u[:id] }).to eq(
[user_1.id, user_2.id, user_3.id],
)
end
it "does not count deleted messages for last messager" do
thread_1.replies.where(user: user_3).each(&:trash!)
result = described_class.call(thread_ids: [thread_1.id, thread_2.id])
expect(result[thread_1.id][:users].map { |u| u[:id] }).to eq(
[user_1.id, thread_1.original_message_user_id, user_2.id],
)
end
it "does not count deleted messages for participation" do
thread_1.replies.where(user: user_1).each(&:trash!)
result = described_class.call(thread_ids: [thread_1.id, thread_2.id])
expect(result[thread_1.id][:users].map { |u| u[:id] }).to eq(
[user_2.id, thread_1.original_message_user_id, user_3.id],
)
end
it "does not count users who are not members of the thread any longer for participation" do
thread_1.remove(user_1)
result = described_class.call(thread_ids: [thread_1.id, thread_2.id])
expect(result[thread_1.id][:users].map { |u| u[:id] }).to eq(
[user_2.id, thread_1.original_message_user_id, user_3.id],
)
end
it "calculates the total number of thread participants" do
result = described_class.call(thread_ids: [thread_1.id, thread_2.id])
expect(result[thread_1.id][:total_count]).to eq(4)
end
it "gets results for both threads" do
thread_2.add(user_2)
Fabricate(:chat_message, thread: thread_2, user: user_2)
Fabricate(:chat_message, thread: thread_2, user: user_2)
result = described_class.call(thread_ids: [thread_1.id, thread_2.id])
expect(result[thread_1.id][:users].map { |u| u[:id] }).to eq(
[user_1.id, user_2.id, user_3.id],
)
expect(result[thread_2.id][:users].map { |u| u[:id] }).to eq(
[thread_2.original_message_user_id, user_2.id],
)
end
end
context "when no one has messaged in either thread but the original message user" do
it "only returns that user as a participant" do
result = described_class.call(thread_ids: [thread_1.id, thread_2.id])
expect(result[thread_1.id][:users].map { |u| u[:id] }).to eq(
[thread_1.original_message.user_id],
)
expect(result[thread_1.id][:total_count]).to eq(1)
expect(result[thread_2.id][:users].map { |u| u[:id] }).to eq(
[thread_2.original_message.user_id],
)
expect(result[thread_2.id][:total_count]).to eq(1)
end
end
end

View File

@ -238,7 +238,6 @@ describe Chat::MessageSerializer do
it "does not include thread data" do
serialized = described_class.new(message_1, scope: guardian, root: nil).as_json
expect(serialized).not_to have_key(:thread_id)
expect(serialized).not_to have_key(:thread_reply_count)
end
end
@ -251,7 +250,6 @@ describe Chat::MessageSerializer do
it "does not include thread data" do
serialized = described_class.new(message_1, scope: guardian, root: nil).as_json
expect(serialized).not_to have_key(:thread_id)
expect(serialized).not_to have_key(:thread_reply_count)
end
end
@ -264,7 +262,6 @@ describe Chat::MessageSerializer do
it "does include thread data" do
serialized = described_class.new(message_1, scope: guardian, root: nil).as_json
expect(serialized).to have_key(:thread_id)
expect(serialized).to have_key(:thread_reply_count)
end
end
end

View File

@ -343,9 +343,6 @@ describe Chat::Publisher do
message_id: message_1.id,
user_id: message_1.user_id,
username: message_1.user.username,
excerpt:
message_1.censored_excerpt(rich: true, max_length: Chat::Thread::EXCERPT_LENGTH),
created_at: message_1.created_at,
thread_id: thread.id,
},
)

View File

@ -47,7 +47,6 @@ RSpec.describe Chat::UpdateThread do
.first
expect(message.data["type"]).to eq("update_thread_original_message")
expect(message.data["title"]).to eq(title)
end
end

View File

@ -66,13 +66,11 @@ describe "Thread indicator for chat messages", type: :system do
it "shows the correct reply counts" do
chat_page.visit_channel(channel)
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_css(
".chat-message-thread-indicator__replies-count",
text: I18n.t("js.chat.thread.replies", count: 3),
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_reply_count(
3,
)
expect(channel_page.message_thread_indicator(thread_2.original_message)).to have_css(
".chat-message-thread-indicator__replies-count",
text: I18n.t("js.chat.thread.replies", count: 1),
expect(channel_page.message_thread_indicator(thread_2.original_message)).to have_reply_count(
1,
)
end
@ -103,9 +101,8 @@ describe "Thread indicator for chat messages", type: :system do
it "increments the indicator when a new reply is sent in the thread" do
chat_page.visit_channel(channel)
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_css(
".chat-message-thread-indicator__replies-count",
text: I18n.t("js.chat.thread.replies", count: 3),
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_reply_count(
3,
)
channel_page.message_thread_indicator(thread_1.original_message).click
@ -114,9 +111,54 @@ describe "Thread indicator for chat messages", type: :system do
open_thread.send_message
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_css(
".chat-message-thread-indicator__replies-count",
text: I18n.t("js.chat.thread.replies", count: 4),
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_reply_count(
4,
)
end
it "shows participants of the thread" do
chat_page.visit_channel(channel)
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_participant(
current_user,
)
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_participant(
other_user,
)
end
it "shows an excerpt of the last reply in the thread" do
chat_page.visit_channel(channel)
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_excerpt(
thread_1.replies.last,
)
end
it "updates the last reply excerpt and participants when a new message is added to the thread" do
new_user = Fabricate(:user)
chat_system_user_bootstrap(user: new_user, channel: channel)
original_last_reply = thread_1.replies.last
chat_page.visit_channel(channel)
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_excerpt(
original_last_reply,
)
using_session(:new_user) do |session|
sign_in(new_user)
chat_page.visit_channel(channel)
channel_page.message_thread_indicator(thread_1.original_message).click
expect(side_panel).to have_open_thread(thread_1)
open_thread.send_message("wow i am happy to join this thread!")
end
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_participant(
new_user,
)
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_excerpt(
thread_1.replies.where(user: new_user).first,
)
end
end

View File

@ -209,16 +209,16 @@ module PageObjects
end
end
def has_thread_indicator?(message, text: nil)
has_css?(message_thread_indicator_selector(message), text: text)
def has_thread_indicator?(message)
message_thread_indicator(message).exists?
end
def has_no_thread_indicator?(message, text: nil)
has_no_css?(message_thread_indicator_selector(message), text: text)
def has_no_thread_indicator?(message)
message_thread_indicator(message).does_not_exist?
end
def message_thread_indicator(message)
find(message_thread_indicator_selector(message))
PageObjects::Components::Chat::ThreadIndicator.new(message_by_id_selector(message.id))
end
def open_thread_list

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
module PageObjects
module Components
module Chat
class ThreadIndicator < PageObjects::Components::Base
attr_reader :context
SELECTOR = ".chat-message-thread-indicator"
def initialize(context)
@context = context
end
def click
find(@context).find(SELECTOR).click
end
def exists?(**args)
find(@context).has_css?(SELECTOR)
end
def does_not_exist?(**args)
find(@context).has_no_css?(SELECTOR)
end
def has_reply_count?(count)
find(@context).has_css?(
"#{SELECTOR}__replies-count",
text: I18n.t("js.chat.thread.replies", count: count),
)
end
def has_participant?(user)
find(@context).has_css?(
".chat-thread-participants__avatar-group .chat-user-avatar .chat-user-avatar-container[data-user-card=\"#{user.username}\"] img",
)
end
def has_excerpt?(message)
excerpt_text =
message.censored_excerpt(rich: true, max_length: ::Chat::Thread::EXCERPT_LENGTH).gsub(
"&hellip;",
"",
)
find(@context).has_css?("#{SELECTOR}__last-reply-excerpt", text: excerpt_text)
end
end
end
end
end

View File

@ -57,7 +57,7 @@ RSpec.describe "Reply to message - channel - drawer", type: :system do
chat_page.open_from_header
drawer_page.open_channel(channel_1)
expect(channel_page).to have_thread_indicator(original_message, text: "1")
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(1)
channel_page.reply_to(original_message)
@ -70,7 +70,7 @@ RSpec.describe "Reply to message - channel - drawer", type: :system do
drawer_page.back
expect(channel_page).to have_thread_indicator(original_message, text: "2")
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(2)
expect(channel_page.messages).to have_no_message(text: "reply to message")
end
end

View File

@ -67,7 +67,7 @@ RSpec.describe "Reply to message - channel - full page", type: :system do
it "replies to the existing thread" do
chat_page.visit_channel(channel_1)
expect(channel_page).to have_thread_indicator(original_message, text: "1")
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(1)
channel_page.reply_to(original_message)
@ -78,7 +78,7 @@ RSpec.describe "Reply to message - channel - full page", type: :system do
expect(thread_page).to have_message(text: message_1.message)
expect(thread_page).to have_message(text: "reply to message")
expect(channel_page).to have_thread_indicator(original_message, text: "2")
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(2)
expect(channel_page).to have_no_message(text: "reply to message")
end
end

View File

@ -59,7 +59,7 @@ RSpec.describe "Reply to message - channel - mobile", type: :system, mobile: tru
it "replies to the existing thread" do
chat_page.visit_channel(channel_1)
expect(channel_page).to have_thread_indicator(original_message, text: "1")
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(1)
channel_page.reply_to(original_message)
thread_page.send_message("reply to message")
@ -69,7 +69,7 @@ RSpec.describe "Reply to message - channel - mobile", type: :system, mobile: tru
thread_page.close
expect(channel_page).to have_thread_indicator(original_message, text: "2")
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(2)
expect(channel_page.messages).to have_no_message(text: "reply to message")
end
end

View File

@ -36,39 +36,39 @@ RSpec.describe "Reply to message - smoke", type: :system do
thread_page.fill_composer("user1reply")
thread_page.click_send_message
expect(channel_page).to have_thread_indicator(original_message, text: 1)
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(1)
expect(thread_page).to have_message(text: "user1reply")
end
using_session(:user_2) do |session|
expect(thread_page).to have_message(text: "user1reply")
expect(channel_page).to have_thread_indicator(original_message, text: 1)
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(1)
thread_page.fill_composer("user2reply")
thread_page.click_send_message
expect(thread_page).to have_message(text: "user2reply")
expect(channel_page).to have_thread_indicator(original_message, text: 2)
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(2)
refresh
expect(thread_page).to have_message(text: "user1reply")
expect(thread_page).to have_message(text: "user2reply")
expect(channel_page).to have_thread_indicator(original_message, text: 2)
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(2)
session.quit
end
using_session(:user_1) do |session|
expect(thread_page).to have_message(text: "user2reply")
expect(channel_page).to have_thread_indicator(original_message, text: 2)
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(2)
refresh
expect(thread_page).to have_message(text: "user1reply")
expect(thread_page).to have_message(text: "user2reply")
expect(channel_page).to have_thread_indicator(original_message, text: 2)
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(2)
session.quit
end

View File

@ -182,7 +182,6 @@ module(
created_at: "2023-05-18T16:07:59.588Z",
excerpt: `Hey @${mentionedUser2.username}`,
available_flags: [],
thread_title: null,
chat_channel_id: 7,
mentioned_users: [mentionedUser2],
user: actingUser,