FEATURE: Show unread in sidebar for unread channel threads (#22342)

This commit makes it so that when the user has unread threads
for a channel we show a blue dot in the sidebar (or channel index
for mobile/drawer).

This blue dot is slightly different from the channel unread messages:

1. It will only show if the new thread messages were created since
   the user last viewed the channel
2. It will be cleared when the user views the channel, but the threads
   are still considered unread because we want the user to click into
   the thread list to view them

This necessitates a change to the current user serializer to also
include the unread thread overview, which is all unread threads
across all channels and their last reply date + time.
This commit is contained in:
Martin Brennan 2023-07-17 13:00:49 +10:00 committed by GitHub
parent edc837eaf5
commit 07c3782e51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 459 additions and 132 deletions

View File

@ -8,11 +8,12 @@ module Chat
attr_accessor :channel_tracking, :thread_tracking attr_accessor :channel_tracking, :thread_tracking
class TrackingStateInfo class TrackingStateInfo
attr_accessor :unread_count, :mention_count attr_accessor :unread_count, :mention_count, :last_reply_created_at
def initialize(info) def initialize(info)
@unread_count = info.present? ? info[:unread_count] : 0 @unread_count = info.present? ? info[:unread_count] : 0
@mention_count = info.present? ? info[:mention_count] : 0 @mention_count = info.present? ? info[:mention_count] : 0
@last_reply_created_at = info.present? ? info[:last_reply_created_at] : nil
end end
def to_hash def to_hash
@ -20,7 +21,11 @@ module Chat
end end
def to_h def to_h
{ unread_count: unread_count, mention_count: mention_count } {
unread_count: unread_count,
mention_count: mention_count,
last_reply_created_at: last_reply_created_at,
}
end end
end end
@ -38,10 +43,34 @@ module Chat
end end
def find_channel_threads(channel_id) def find_channel_threads(channel_id)
thread_tracking thread_tracking.inject({}) do |result, (thread_id, thread)|
.select { |_, thread| thread[:channel_id] == channel_id } if thread[:channel_id] == channel_id
.map { |thread_id, thread| [thread_id, TrackingStateInfo.new(thread)] } result.merge(thread_id => TrackingStateInfo.new(thread))
.to_h else
result
end
end
end
def find_channel_thread_overviews(channel_id)
thread_tracking.inject({}) do |result, (thread_id, thread)|
if thread[:channel_id] == channel_id
result.merge(thread_id => thread[:last_reply_created_at])
else
result
end
end
end
def thread_unread_overview_by_channel
thread_tracking.inject({}) do |acc, tt|
thread_id = tt.first
data = tt.second
acc[data[:channel_id]] = {} if !acc[data[:channel_id]]
acc[data[:channel_id]][thread_id] = data[:last_reply_created_at]
acc
end
end end
end end
end end

View File

@ -7,7 +7,7 @@ module Chat
:chat_messages, :chat_messages,
:can_load_more_past, :can_load_more_past,
:can_load_more_future, :can_load_more_future,
:unread_thread_ids, :unread_thread_overview,
:threads, :threads,
:tracking, :tracking,
:thread_memberships, :thread_memberships,
@ -19,7 +19,7 @@ module Chat
user:, user:,
can_load_more_past: nil, can_load_more_past: nil,
can_load_more_future: nil, can_load_more_future: nil,
unread_thread_ids: nil, unread_thread_overview: nil,
threads: nil, threads: nil,
tracking: nil, tracking: nil,
thread_memberships: nil, thread_memberships: nil,
@ -30,7 +30,7 @@ module Chat
@user = user @user = user
@can_load_more_past = can_load_more_past @can_load_more_past = can_load_more_past
@can_load_more_future = can_load_more_future @can_load_more_future = can_load_more_future
@unread_thread_ids = unread_thread_ids @unread_thread_overview = unread_thread_overview
@threads = threads @threads = threads
@tracking = tracking @tracking = tracking
@thread_memberships = thread_memberships @thread_memberships = thread_memberships

View File

@ -35,7 +35,8 @@ module Chat
thread_ids: nil, thread_ids: nil,
include_missing_memberships: false, include_missing_memberships: false,
include_threads: false, include_threads: false,
include_read: true include_read: true,
include_last_reply_details: false
) )
report = ::Chat::TrackingStateReport.new report = ::Chat::TrackingStateReport.new
@ -59,24 +60,40 @@ module Chat
if !include_threads || (thread_ids.blank? && channel_ids.blank?) if !include_threads || (thread_ids.blank? && channel_ids.blank?)
report.thread_tracking = {} report.thread_tracking = {}
else else
tracking =
::Chat::ThreadUnreadsQuery.call(
channel_ids: channel_ids,
thread_ids: thread_ids,
user_id: guardian.user.id,
include_missing_memberships: include_missing_memberships,
include_read: include_read,
)
last_reply_details =
DB.query(<<~SQL, tracking.map(&:thread_id)) if include_last_reply_details
SELECT chat_threads.id AS thread_id, last_message.created_at
FROM chat_threads
INNER JOIN chat_messages AS last_message ON last_message.id = chat_threads.last_message_id
WHERE chat_threads.id IN (?)
AND last_message.deleted_at IS NULL
SQL
report.thread_tracking = report.thread_tracking =
::Chat::ThreadUnreadsQuery tracking
.call(
channel_ids: channel_ids,
thread_ids: thread_ids,
user_id: guardian.user.id,
include_missing_memberships: include_missing_memberships,
include_read: include_read,
)
.map do |tt| .map do |tt|
[ data = {
tt.thread_id, channel_id: tt.channel_id,
{ mention_count: tt.mention_count,
channel_id: tt.channel_id, unread_count: tt.unread_count,
mention_count: tt.mention_count, }
unread_count: tt.unread_count,
}, if include_last_reply_details
] data[:last_reply_created_at] = last_reply_details
.find { |details| details.thread_id == tt.thread_id }
&.created_at
end
[tt.thread_id, data]
end end
.to_h .to_h
end end

View File

@ -2,12 +2,20 @@
module Chat module Chat
class StructuredChannelSerializer < ApplicationSerializer class StructuredChannelSerializer < ApplicationSerializer
attributes :public_channels, :direct_message_channels, :tracking, :meta attributes :public_channels, :direct_message_channels, :tracking, :meta, :unread_thread_overview
def tracking def tracking
object[:tracking] object[:tracking]
end end
def include_unread_thread_overview?
SiteSetting.enable_experimental_chat_threaded_discussions
end
def unread_thread_overview
object[:unread_thread_overview]
end
def public_channels def public_channels
object[:public_channels].map do |channel| object[:public_channels].map do |channel|
Chat::ChannelSerializer.new( Chat::ChannelSerializer.new(

View File

@ -2,7 +2,7 @@
module Chat module Chat
class ViewSerializer < ApplicationSerializer class ViewSerializer < ApplicationSerializer
attributes :meta, :chat_messages, :threads, :tracking, :unread_thread_ids, :channel attributes :meta, :chat_messages, :threads, :tracking, :unread_thread_overview, :channel
def threads def threads
return [] if !object.threads return [] if !object.threads
@ -23,15 +23,15 @@ module Chat
object.tracking || {} object.tracking || {}
end end
def unread_thread_ids def unread_thread_overview
object.unread_thread_ids || [] object.unread_thread_overview || {}
end end
def include_threads? def include_threads?
include_thread_data? include_thread_data?
end end
def include_unread_thread_ids? def include_unread_thread_overview?
include_thread_data? include_thread_data?
end end

View File

@ -34,7 +34,7 @@ module Chat
step :determine_threads_enabled step :determine_threads_enabled
step :determine_include_thread_messages step :determine_include_thread_messages
step :fetch_messages step :fetch_messages
step :fetch_unread_thread_ids step :fetch_unread_thread_overview
step :fetch_threads_for_messages step :fetch_threads_for_messages
step :fetch_tracking step :fetch_tracking
step :fetch_thread_memberships step :fetch_thread_memberships
@ -155,24 +155,25 @@ module Chat
end end
end end
# The thread tracking overview is a simple array of thread IDs # The thread tracking overview is a simple array of hashes consisting
# that have unread messages, only threads with unread messages # of thread IDs that have unread messages as well as the datetime of the
# will be included in this array. This is a low-cost way to know # last reply in the thread.
# how many threads the user has unread across the entire channel. #
def fetch_unread_thread_ids(guardian:, channel:, threads_enabled:, **) # Only threads with unread messages will be included in this array.
# This is a low-cost way to know how many threads the user has unread
# across the entire channel.
def fetch_unread_thread_overview(guardian:, channel:, threads_enabled:, **)
if !threads_enabled if !threads_enabled
context.unread_thread_ids = [] context.unread_thread_overview = {}
else else
context.unread_thread_ids = context.unread_thread_overview =
::Chat::TrackingStateReportQuery ::Chat::TrackingStateReportQuery.call(
.call( guardian: guardian,
guardian: guardian, channel_ids: [channel.id],
channel_ids: [channel.id], include_threads: true,
include_threads: true, include_read: false,
include_read: false, include_last_reply_details: true,
) ).find_channel_thread_overviews(channel.id)
.find_channel_threads(channel.id)
.keys
end end
end end
@ -238,7 +239,7 @@ module Chat
messages:, messages:,
threads:, threads:,
tracking:, tracking:,
unread_thread_ids:, unread_thread_overview:,
can_load_more_past:, can_load_more_past:,
can_load_more_future:, can_load_more_future:,
thread_memberships:, thread_memberships:,
@ -252,7 +253,7 @@ module Chat
user: guardian.user, user: guardian.user,
can_load_more_past: can_load_more_past, can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future, can_load_more_future: can_load_more_future,
unread_thread_ids: unread_thread_ids, unread_thread_overview: unread_thread_overview,
threads: threads, threads: threads,
tracking: tracking, tracking: tracking,
thread_memberships: thread_memberships, thread_memberships: thread_memberships,

View File

@ -297,15 +297,13 @@ module Chat
# and a message is sent in the thread. We also need to pass the actual # and a message is sent in the thread. We also need to pass the actual
# thread tracking state. # thread tracking state.
if channel.threading_enabled && message.thread_reply? if channel.threading_enabled && message.thread_reply?
data[:unread_thread_ids] = ::Chat::TrackingStateReportQuery data[:unread_thread_overview] = ::Chat::TrackingStateReportQuery.call(
.call( guardian: user.guardian,
guardian: user.guardian, channel_ids: [channel.id],
channel_ids: [channel.id], include_threads: true,
include_threads: true, include_read: false,
include_read: false, include_last_reply_details: true,
) ).find_channel_thread_overviews(channel.id)
.find_channel_threads(channel.id)
.keys
data[:thread_tracking] = ::Chat::TrackingStateReportQuery.call( data[:thread_tracking] = ::Chat::TrackingStateReportQuery.call(
guardian: user.guardian, guardian: user.guardian,

View File

@ -5,6 +5,7 @@
</div> </div>
{{/if}} {{/if}}
{{! TODO (martin) We need to have the thread unread count in the mobile channel row too }}
{{#if this.unreadIndicator}} {{#if this.unreadIndicator}}
<ChatChannelUnreadIndicator @channel={{@channel}} /> <ChatChannelUnreadIndicator @channel={{@channel}} />
{{/if}} {{/if}}

View File

@ -1,17 +1,12 @@
{{#if (gt @channel.tracking.unreadCount 0)}} {{#if this.showUnreadIndicator}}
<div <div
class={{concat-class class={{concat-class
"chat-channel-unread-indicator" "chat-channel-unread-indicator"
(if (if this.isUrgent "-urgent")
(or
@channel.isDirectMessageChannel (gt @channel.tracking.mentionCount 0)
)
"-urgent"
)
}} }}
> >
<div <div class="chat-channel-unread-indicator__number">{{#if
class="chat-channel-unread-indicator__number" this.showUnreadCount
>{{@channel.tracking.unreadCount}}</div> }}{{this.unreadCount}}{{else}}&nbsp;{{/if}}</div>
</div> </div>
{{/if}} {{/if}}

View File

@ -0,0 +1,32 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class ChatChannelUnreadIndicator extends Component {
@service chat;
@service site;
get showUnreadIndicator() {
return (
this.args.channel.tracking.unreadCount > 0 ||
// We want to do this so we don't show a blue dot if the user is inside
// the channel and a new unread thread comes in.
(this.chat.activeChannel?.id !== this.args.channel.id &&
this.args.channel.unreadThreadsCountSinceLastViewed > 0)
);
}
get unreadCount() {
return this.args.channel.tracking.unreadCount;
}
get isUrgent() {
return (
this.args.channel.isDirectMessageChannel ||
this.args.channel.tracking.mentionCount > 0
);
}
get showUnreadCount() {
return this.args.channel.isDirectMessageChannel;
}
}

View File

@ -214,6 +214,10 @@ export default class ChatLivePane extends Component {
this.args.channel.addMessages(messages); this.args.channel.addMessages(messages);
this.args.channel.details = meta; this.args.channel.details = meta;
// We update this value server-side when we load the Channel
// here, so this reflects reality for sidebar unread logic.
this.args.channel.updateLastViewedAt();
if (result.threads) { if (result.threads) {
result.threads.forEach((thread) => { result.threads.forEach((thread) => {
const storedThread = this.args.channel.threadsManager.add( const storedThread = this.args.channel.threadsManager.add(
@ -237,8 +241,9 @@ export default class ChatLivePane extends Component {
}); });
} }
if (result.unread_thread_ids) { if (result.unread_thread_overview) {
this.args.channel.unreadThreadIds = result.unread_thread_ids; this.args.channel.threadsManager.unreadThreadOverview =
result.unread_thread_overview;
} }
if (this.requestedTargetMessageId) { if (this.requestedTargetMessageId) {
@ -353,12 +358,9 @@ export default class ChatLivePane extends Component {
}); });
} }
if (result.thread_tracking_overview) { if (result.unread_thread_overview) {
result.thread_tracking_overview.forEach((threadId) => { this.args.channel.threadsManager.unreadThreadOverview =
if (!this.args.channel.threadTrackingOverview.includes(threadId)) { result.unread_thread_overview;
this.args.channel.threadTrackingOverview.push(threadId);
}
});
} }
this.args.channel.details = meta; this.args.channel.details = meta;

View File

@ -9,7 +9,7 @@ export default class ChatThreadHeaderUnreadIndicator extends Component {
} }
get unreadCount() { get unreadCount() {
return this.args.channel.unreadThreadCount; return this.args.channel.threadsManager.unreadThreadCount;
} }
get showUnreadIndicator() { get showUnreadIndicator() {

View File

@ -4,7 +4,7 @@
title={{i18n "chat.threads.list"}} title={{i18n "chat.threads.list"}}
class={{concat-class class={{concat-class
"chat-threads-list-button btn btn-flat" "chat-threads-list-button btn btn-flat"
(if @channel.unreadThreadCount "has-unreads") (if @channel.threadsManager.unreadThreadCount "has-unreads")
}} }}
> >
{{d-icon "discourse-threads"}} {{d-icon "discourse-threads"}}

View File

@ -93,7 +93,13 @@ export default {
} }
get suffixValue() { get suffixValue() {
return this.channel.tracking.unreadCount > 0 ? "circle" : ""; return this.channel.tracking.unreadCount > 0 ||
// We want to do this so we don't show a blue dot if the user is inside
// the channel and a new unread thread comes in.
(this.chatService.activeChannel?.id !== this.channel.id &&
this.channel.unreadThreadsCountSinceLastViewed > 0)
? "circle"
: "";
} }
get suffixCSSClass() { get suffixCSSClass() {

View File

@ -3,7 +3,7 @@ import { setOwner } from "@ember/application";
import Promise from "rsvp"; import Promise from "rsvp";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread"; import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
import { cached, tracked } from "@glimmer/tracking"; import { cached, tracked } from "@glimmer/tracking";
import { TrackedObject } from "@ember-compat/tracked-built-ins"; import { TrackedMap, TrackedObject } from "@ember-compat/tracked-built-ins";
/* /*
The ChatThreadsManager is responsible for managing the loaded chat threads The ChatThreadsManager is responsible for managing the loaded chat threads
@ -19,11 +19,37 @@ export default class ChatThreadsManager {
@service chatApi; @service chatApi;
@tracked _cached = new TrackedObject(); @tracked _cached = new TrackedObject();
@tracked _unreadThreadOverview = new TrackedMap();
constructor(owner) { constructor(owner) {
setOwner(this, owner); setOwner(this, owner);
} }
get unreadThreadCount() {
return this.unreadThreadOverview.size;
}
get unreadThreadOverview() {
return this._unreadThreadOverview;
}
set unreadThreadOverview(unreadThreadOverview) {
this._unreadThreadOverview.clear();
for (const [threadId, lastReplyCreatedAt] of Object.entries(
unreadThreadOverview
)) {
this.markThreadUnread(threadId, lastReplyCreatedAt);
}
}
markThreadUnread(threadId, lastReplyCreatedAt) {
this.unreadThreadOverview.set(
parseInt(threadId, 10),
new Date(lastReplyCreatedAt)
);
}
@cached @cached
get threads() { get threads() {
return Object.values(this._cached); return Object.values(this._cached);

View File

@ -1,6 +1,5 @@
import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership"; import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { TrackedSet } from "@ember-compat/tracked-built-ins";
import { escapeExpression } from "discourse/lib/utilities"; import { escapeExpression } from "discourse/lib/utilities";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel";
@ -80,7 +79,6 @@ export default class ChatChannel {
threadsManager = new ChatThreadsManager(getOwner(this)); threadsManager = new ChatThreadsManager(getOwner(this));
messagesManager = new ChatMessagesManager(getOwner(this)); messagesManager = new ChatMessagesManager(getOwner(this));
@tracked _unreadThreadIds = new TrackedSet();
@tracked _currentUserMembership; @tracked _currentUserMembership;
@tracked _lastMessage; @tracked _lastMessage;
@ -119,16 +117,15 @@ export default class ChatChannel {
this.lastMessage = args.last_message; this.lastMessage = args.last_message;
} }
get unreadThreadCount() { get unreadThreadsCountSinceLastViewed() {
return this.unreadThreadIds.size; return Array.from(this.threadsManager.unreadThreadOverview.values()).filter(
(lastReplyCreatedAt) =>
lastReplyCreatedAt >= this.currentUserMembership.lastViewedAt
).length;
} }
get unreadThreadIds() { updateLastViewedAt() {
return this._unreadThreadIds; this.currentUserMembership.lastViewedAt = new Date();
}
set unreadThreadIds(unreadThreadIds) {
this._unreadThreadIds = new TrackedSet(unreadThreadIds);
} }
findIndexOfMessage(id) { findIndexOfMessage(id) {

View File

@ -11,8 +11,8 @@ export default class UserChatChannelMembership {
@tracked desktopNotificationLevel = null; @tracked desktopNotificationLevel = null;
@tracked mobileNotificationLevel = null; @tracked mobileNotificationLevel = null;
@tracked lastReadMessageId = null; @tracked lastReadMessageId = null;
@tracked user = null;
@tracked lastViewedAt = null; @tracked lastViewedAt = null;
@tracked user = null;
constructor(args = {}) { constructor(args = {}) {
this.following = args.following; this.following = args.following;
@ -20,7 +20,7 @@ export default class UserChatChannelMembership {
this.desktopNotificationLevel = args.desktop_notification_level; this.desktopNotificationLevel = args.desktop_notification_level;
this.mobileNotificationLevel = args.mobile_notification_level; this.mobileNotificationLevel = args.mobile_notification_level;
this.lastReadMessageId = args.last_read_message_id; this.lastReadMessageId = args.last_read_message_id;
this.lastViewedAt = args.last_viewed_at; this.lastViewedAt = new Date(args.last_viewed_at);
this.user = this.#initUserModel(args.user); this.user = this.#initUserModel(args.user);
} }

View File

@ -6,6 +6,10 @@ export default class ChatIndexRoute extends DiscourseRoute {
@service chatChannelsManager; @service chatChannelsManager;
@service router; @service router;
activate() {
this.chat.activeChannel = null;
}
redirect() { redirect() {
// Always want the channel index on mobile. // Always want the channel index on mobile.
if (this.site.mobileView) { if (this.site.mobileView) {

View File

@ -211,7 +211,11 @@ export default class ChatSubscriptionsManager extends Service {
.find(channel.id, busData.thread_id) .find(channel.id, busData.thread_id)
.then((thread) => { .then((thread) => {
if (thread.currentUserMembership) { if (thread.currentUserMembership) {
channel.unreadThreadIds.add(busData.thread_id); channel.threadsManager.markThreadUnread(
busData.thread_id,
busData.message.created_at
);
this._updateActiveLastViewedAt(channel);
} }
}); });
} }
@ -228,7 +232,9 @@ export default class ChatSubscriptionsManager extends Service {
if (busData.message.user.id === this.currentUser.id) { if (busData.message.user.id === this.currentUser.id) {
// Thread should no longer be considered unread. // Thread should no longer be considered unread.
if (thread.currentUserMembership) { if (thread.currentUserMembership) {
channel.unreadThreadIds.delete(busData.thread_id); channel.threadsManager.unreadThreadOverview.delete(
parseInt(busData.thread_id, 10)
);
thread.currentUserMembership.lastReadMessageId = thread.currentUserMembership.lastReadMessageId =
busData.message.id; busData.message.id;
} }
@ -251,8 +257,12 @@ export default class ChatSubscriptionsManager extends Service {
(thread.currentUserMembership.lastReadMessageId || 0) && (thread.currentUserMembership.lastReadMessageId || 0) &&
!thread.currentUserMembership.isQuiet !thread.currentUserMembership.isQuiet
) { ) {
channel.unreadThreadIds.add(busData.thread_id); channel.threadsManager.markThreadUnread(
busData.thread_id,
busData.message.created_at
);
thread.tracking.unreadCount++; thread.tracking.unreadCount++;
this._updateActiveLastViewedAt(channel);
} }
} }
} }
@ -260,6 +270,14 @@ export default class ChatSubscriptionsManager extends Service {
}); });
} }
// If the user is currently looking at this channel via activeChannel, we don't want the unread
// indicator to show in the sidebar for unread threads (since that is based on the lastViewedAt).
_updateActiveLastViewedAt(channel) {
if (this.chat.activeChannel?.id === channel.id) {
channel.updateLastViewedAt();
}
}
_startUserTrackingStateSubscription(lastId) { _startUserTrackingStateSubscription(lastId) {
if (!this.currentUser) { if (!this.currentUser) {
return; return;
@ -316,8 +334,9 @@ export default class ChatSubscriptionsManager extends Service {
channel.tracking.unreadCount = busData.unread_count; channel.tracking.unreadCount = busData.unread_count;
channel.tracking.mentionCount = busData.mention_count; channel.tracking.mentionCount = busData.mention_count;
if (busData.hasOwnProperty("unread_thread_ids")) { if (busData.hasOwnProperty("unread_thread_overview")) {
channel.unreadThreadIds = busData.unread_thread_ids; channel.threadsManager.unreadThreadOverview =
busData.unread_thread_overview;
} }
if (busData.thread_id && busData.hasOwnProperty("thread_tracking")) { if (busData.thread_id && busData.hasOwnProperty("thread_tracking")) {

View File

@ -173,36 +173,44 @@ export default class Chat extends Service {
this.set("isNetworkUnreliable", false); this.set("isNetworkUnreliable", false);
} }
setupWithPreloadedChannels(channels) { setupWithPreloadedChannels(channelsView) {
this.chatSubscriptionsManager.startChannelsSubscriptions( this.chatSubscriptionsManager.startChannelsSubscriptions(
channels.meta.message_bus_last_ids channelsView.meta.message_bus_last_ids
); );
this.presenceChannel.subscribe(channels.global_presence_channel_state); this.presenceChannel.subscribe(channelsView.global_presence_channel_state);
[...channels.public_channels, ...channels.direct_message_channels].forEach( [
(channelObject) => { ...channelsView.public_channels,
const channel = this.chatChannelsManager.store(channelObject); ...channelsView.direct_message_channels,
const storedDraft = (this.currentUser?.chat_drafts || []).find( ].forEach((channelObject) => {
(draft) => draft.channel_id === channel.id const storedChannel = this.chatChannelsManager.store(channelObject);
); const storedDraft = (this.currentUser?.chat_drafts || []).find(
(draft) => draft.channel_id === storedChannel.id
);
if (storedDraft) { if (storedDraft) {
this.chatDraftsManager.add( this.chatDraftsManager.add(
ChatMessage.createDraftMessage( ChatMessage.createDraftMessage(
channel, storedChannel,
Object.assign( Object.assign(
{ user: this.currentUser }, { user: this.currentUser },
JSON.parse(storedDraft.data) JSON.parse(storedDraft.data)
)
) )
); )
} );
return this.chatChannelsManager.follow(channel);
} }
);
this.chatTrackingStateManager.setupWithPreloadedState(channels.tracking); if (channelsView.unread_thread_overview?.[storedChannel.id]) {
storedChannel.threadsManager.unreadThreadOverview =
channelsView.unread_thread_overview[storedChannel.id];
}
return this.chatChannelsManager.follow(storedChannel);
});
this.chatTrackingStateManager.setupWithPreloadedState(
channelsView.tracking
);
} }
willDestroy() { willDestroy() {

View File

@ -237,6 +237,17 @@ after_initialize do
add_to_serializer(:current_user, :chat_channels) do add_to_serializer(:current_user, :chat_channels) do
structured = Chat::ChannelFetcher.structured(self.scope) structured = Chat::ChannelFetcher.structured(self.scope)
if SiteSetting.enable_experimental_chat_threaded_discussions
structured[:unread_thread_overview] = ::Chat::TrackingStateReportQuery.call(
guardian: self.scope,
channel_ids: structured[:public_channels].map(&:id),
include_threads: true,
include_read: false,
include_last_reply_details: true,
).thread_unread_overview_by_channel
end
Chat::ChannelIndexSerializer.new(structured, scope: self.scope, root: false).as_json Chat::ChannelIndexSerializer.new(structured, scope: self.scope, root: false).as_json
end end

View File

@ -9,6 +9,7 @@ RSpec.describe Chat::TrackingStateReportQuery do
include_missing_memberships: include_missing_memberships, include_missing_memberships: include_missing_memberships,
include_threads: include_threads, include_threads: include_threads,
include_read: include_read, include_read: include_read,
include_last_reply_details: include_last_reply_details,
) )
end end
@ -20,6 +21,7 @@ RSpec.describe Chat::TrackingStateReportQuery do
let(:include_missing_memberships) { false } let(:include_missing_memberships) { false }
let(:include_threads) { false } let(:include_threads) { false }
let(:include_read) { true } let(:include_read) { true }
let(:include_last_reply_details) { false }
context "when channel_ids empty" do context "when channel_ids empty" do
it "returns empty object for channel_tracking" do it "returns empty object for channel_tracking" do
expect(query.channel_tracking).to eq({}) expect(query.channel_tracking).to eq({})
@ -129,6 +131,56 @@ RSpec.describe Chat::TrackingStateReportQuery do
) )
end end
context "when include_last_reply_details is true" do
let(:include_last_reply_details) { true }
before do
thread_1.add(current_user)
thread_2.add(current_user)
Fabricate(:chat_message, chat_channel: channel_1, thread: thread_1)
Fabricate(:chat_message, chat_channel: channel_2, thread: thread_2)
end
it "gets the last_reply_created_at for each thread based on the last_message" do
expect(query.thread_tracking).to eq(
{
thread_1.id => {
unread_count: 1,
mention_count: 0,
channel_id: channel_1.id,
last_reply_created_at: thread_1.reload.last_message.created_at,
},
thread_2.id => {
unread_count: 1,
mention_count: 0,
channel_id: channel_2.id,
last_reply_created_at: thread_2.reload.last_message.created_at,
},
},
)
end
it "does not get the last_reply_created_at for threads where the last_message is deleted" do
thread_1.reload.last_message.trash!
expect(query.thread_tracking).to eq(
{
thread_1.id => {
unread_count: 0,
mention_count: 0,
channel_id: channel_1.id,
last_reply_created_at: nil,
},
thread_2.id => {
unread_count: 1,
mention_count: 0,
channel_id: channel_2.id,
last_reply_created_at: thread_2.reload.last_message.created_at,
},
},
)
end
end
context "when thread_ids and channel_ids is empty" do context "when thread_ids and channel_ids is empty" do
let(:thread_ids) { [] } let(:thread_ids) { [] }
let(:channel_ids) { [] } let(:channel_ids) { [] }

View File

@ -173,6 +173,7 @@ RSpec.describe Chat::ChannelViewBuilder do
channel_ids: [channel.id], channel_ids: [channel.id],
include_threads: true, include_threads: true,
include_read: false, include_read: false,
include_last_reply_details: true,
) )
.returns(Chat::TrackingStateReport.new) .returns(Chat::TrackingStateReport.new)
.once .once
@ -188,7 +189,7 @@ RSpec.describe Chat::ChannelViewBuilder do
thread = Fabricate(:chat_thread, channel: channel) thread = Fabricate(:chat_thread, channel: channel)
thread.add(current_user) thread.add(current_user)
message_1 = Fabricate(:chat_message, chat_channel: channel, thread: thread) message_1 = Fabricate(:chat_message, chat_channel: channel, thread: thread)
expect(result.view.unread_thread_ids).to eq([message_1.thread.id]) expect(result.view.unread_thread_overview).to eq({ thread.id => message_1.created_at })
end end
it "fetches the tracking state of threads in the channel" do it "fetches the tracking state of threads in the channel" do

View File

@ -137,18 +137,21 @@ RSpec.describe Chat::MarkAllUserChannelsRead do
expect(message.data).to eq( expect(message.data).to eq(
channel_1.id.to_s => { channel_1.id.to_s => {
"last_read_message_id" => message_2.id, "last_read_message_id" => message_2.id,
"last_reply_created_at" => nil,
"membership_id" => membership_1.id, "membership_id" => membership_1.id,
"mention_count" => 0, "mention_count" => 0,
"unread_count" => 0, "unread_count" => 0,
}, },
channel_2.id.to_s => { channel_2.id.to_s => {
"last_read_message_id" => message_4.id, "last_read_message_id" => message_4.id,
"last_reply_created_at" => nil,
"membership_id" => membership_2.id, "membership_id" => membership_2.id,
"mention_count" => 0, "mention_count" => 0,
"unread_count" => 0, "unread_count" => 0,
}, },
channel_3.id.to_s => { channel_3.id.to_s => {
"last_read_message_id" => message_6.id, "last_read_message_id" => message_6.id,
"last_reply_created_at" => nil,
"membership_id" => membership_3.id, "membership_id" => membership_3.id,
"mention_count" => 0, "mention_count" => 0,
"unread_count" => 0, "unread_count" => 0,

View File

@ -96,8 +96,10 @@ describe Chat::Publisher do
context "when the channel has threading enabled and the message is a thread reply" do context "when the channel has threading enabled and the message is a thread reply" do
fab!(:thread) { Fabricate(:chat_thread, channel: channel) } fab!(:thread) { Fabricate(:chat_thread, channel: channel) }
before do before do
message_1.update!(thread: thread) message_1.update!(thread: thread)
thread.update_last_message_id!
channel.update!(threading_enabled: true) channel.update!(threading_enabled: true)
end end
@ -106,16 +108,22 @@ describe Chat::Publisher do
it "publishes the tracking state with correct counts" do it "publishes the tracking state with correct counts" do
expect(data["thread_id"]).to eq(thread.id) expect(data["thread_id"]).to eq(thread.id)
expect(data["unread_thread_ids"]).to eq([thread.id]) expect(data["unread_thread_overview"]).to eq(
expect(data["thread_tracking"]).to eq({ "unread_count" => 1, "mention_count" => 0 }) { thread.id.to_s => thread.reload.last_message.created_at.iso8601(3) },
)
expect(data["thread_tracking"]).to eq(
{ "unread_count" => 1, "mention_count" => 0, "last_reply_created_at" => nil },
)
end end
end end
context "when the user has no thread membership" do context "when the user has no thread membership" do
it "publishes the tracking state with zeroed out counts" do it "publishes the tracking state with zeroed out counts" do
expect(data["thread_id"]).to eq(thread.id) expect(data["thread_id"]).to eq(thread.id)
expect(data["unread_thread_ids"]).to eq([]) expect(data["unread_thread_overview"]).to eq({})
expect(data["thread_tracking"]).to eq({ "unread_count" => 0, "mention_count" => 0 }) expect(data["thread_tracking"]).to eq(
{ "unread_count" => 0, "mention_count" => 0, "last_reply_created_at" => nil },
)
end end
end end
end end

View File

@ -26,6 +26,7 @@
"avatar_template": { "type": "string" }, "avatar_template": { "type": "string" },
"username": { "type": "string" } "username": { "type": "string" }
} }
} },
"last_viewed_at": { "type": "datetime" }
} }
} }

View File

@ -109,7 +109,6 @@ RSpec.describe "Message notifications - mobile", type: :system, mobile: true do
expect(page).to have_css(".chat-header-icon .chat-channel-unread-indicator", text: "") expect(page).to have_css(".chat-header-icon .chat-channel-unread-indicator", text: "")
expect(page).to have_css( expect(page).to have_css(
".chat-channel-row[data-chat-channel-id=\"#{channel_1.id}\"] .chat-channel-unread-indicator", ".chat-channel-row[data-chat-channel-id=\"#{channel_1.id}\"] .chat-channel-unread-indicator",
text: 1,
) )
end end
end end
@ -130,7 +129,6 @@ RSpec.describe "Message notifications - mobile", type: :system, mobile: true do
expect(page).to have_css(".chat-header-icon .chat-channel-unread-indicator") expect(page).to have_css(".chat-header-icon .chat-channel-unread-indicator")
expect(page).to have_css( expect(page).to have_css(
".chat-channel-row[data-chat-channel-id=\"#{channel_1.id}\"] .chat-channel-unread-indicator", ".chat-channel-row[data-chat-channel-id=\"#{channel_1.id}\"] .chat-channel-unread-indicator",
text: 1,
) )
end end
end end

View File

@ -20,12 +20,22 @@ module PageObjects
end end
def open_channel(channel) def open_channel(channel)
find( find("#{VISIBLE_DRAWER} .channels-list #{channel_row_selector(channel)}").click
"#{VISIBLE_DRAWER} .channels-list .chat-channel-row[data-chat-channel-id='#{channel.id}']",
).click
has_no_css?(".chat-skeleton") has_no_css?(".chat-skeleton")
end end
def channel_row_selector(channel)
".chat-channel-row[data-chat-channel-id='#{channel.id}']"
end
def has_unread_channel?(channel)
has_css?("#{channel_row_selector(channel)} .chat-channel-unread-indicator")
end
def has_no_unread_channel?(channel)
has_no_css?("#{channel_row_selector(channel)} .chat-channel-unread-indicator")
end
def maximize def maximize
mouseout mouseout
find("#{VISIBLE_DRAWER} .chat-drawer-header__full-screen-btn").click find("#{VISIBLE_DRAWER} .chat-drawer-header__full-screen-btn").click

View File

@ -40,6 +40,16 @@ module PageObjects
find(".sidebar-section-link.channel-#{channel.id}") find(".sidebar-section-link.channel-#{channel.id}")
self self
end end
def has_unread_channel?(channel)
has_css?(".sidebar-section-link.channel-#{channel.id} .sidebar-section-link-suffix.unread")
end
def has_no_unread_channel?(channel)
has_no_css?(
".sidebar-section-link.channel-#{channel.id} .sidebar-section-link-suffix.unread",
)
end
end end
end end
end end

View File

@ -64,5 +64,53 @@ describe "Thread tracking state | drawer", type: :system do
expect(drawer_page).to have_unread_thread_indicator(count: 1) expect(drawer_page).to have_unread_thread_indicator(count: 1)
expect(thread_list_page).to have_unread_item(thread.id) expect(thread_list_page).to have_unread_item(thread.id)
end end
describe "channel index unread indicators" do
fab!(:other_channel) { Fabricate(:chat_channel) }
before { other_channel.add(current_user) }
it "shows an unread indicator for the channel with unread threads in the index" do
visit("/")
chat_page.open_from_header
expect(drawer_page).to have_unread_channel(channel)
end
it "does not show an unread indicator for the channel if the user has visited the channel since the unread thread message arrived" do
channel.membership_for(current_user).update!(last_viewed_at: Time.zone.now)
visit("/")
chat_page.open_from_header
expect(drawer_page).to have_no_unread_channel(channel)
end
it "clears the index unread indicator for the channel when opening it but keeps the thread list unread indicator" do
visit("/")
chat_page.open_from_header
drawer_page.open_channel(channel)
expect(channel_page).to have_unread_thread_indicator(count: 1)
drawer_page.back
expect(drawer_page).to have_no_unread_channel(channel)
end
it "does not show an unread indicator for the channel index if a new thread message arrives while the user is looking at the channel" do
visit("/")
chat_page.open_from_header
expect(drawer_page).to have_unread_channel(channel)
drawer_page.open_channel(channel)
Fabricate(:chat_message, thread: thread)
drawer_page.back
expect(drawer_page).to have_no_unread_channel(channel)
end
it "shows an unread indicator for the channel index if a new thread message arrives while the user is not looking at the channel" do
visit("/")
chat_page.open_from_header
drawer_page.open_channel(channel)
drawer_page.back
expect(drawer_page).to have_no_unread_channel(channel)
Fabricate(:chat_message, thread: thread)
expect(drawer_page).to have_unread_channel(channel)
end
end
end end
end end

View File

@ -10,6 +10,7 @@ describe "Thread tracking state | full page", type: :system do
let(:channel_page) { PageObjects::Pages::ChatChannel.new } let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new } let(:thread_page) { PageObjects::Pages::ChatThread.new }
let(:thread_list_page) { PageObjects::Components::Chat::ThreadList.new } let(:thread_list_page) { PageObjects::Components::Chat::ThreadList.new }
let(:sidebar_page) { PageObjects::Pages::Sidebar.new }
before do before do
SiteSetting.enable_experimental_chat_threaded_discussions = true SiteSetting.enable_experimental_chat_threaded_discussions = true
@ -86,6 +87,47 @@ describe "Thread tracking state | full page", type: :system do
expect(thread_list_page).to have_thread(new_thread) expect(thread_list_page).to have_thread(new_thread)
end end
describe "sidebar unread indicators" do
fab!(:other_channel) { Fabricate(:chat_channel) }
before do
other_channel.add(current_user)
SiteSetting.navigation_menu = "sidebar"
end
it "shows an unread indicator for the channel with unread threads in the sidebar" do
chat_page.visit_channel(other_channel)
expect(sidebar_page).to have_unread_channel(channel)
end
it "does not show an unread indicator for the channel if the user has visited the channel since the unread thread message arrived" do
channel.membership_for(current_user).update!(last_viewed_at: Time.zone.now)
chat_page.visit_channel(other_channel)
expect(sidebar_page).to have_no_unread_channel(channel)
end
it "clears the sidebar unread indicator for the channel when opening it but keeps the thread list unread indicator" do
chat_page.visit_channel(channel)
expect(sidebar_page).to have_no_unread_channel(channel)
expect(channel_page).to have_unread_thread_indicator(count: 1)
end
it "does not show an unread indicator for the channel sidebar if a new thread message arrives while the user is looking at the channel" do
chat_page.visit_channel(channel)
expect(sidebar_page).to have_no_unread_channel(channel)
Fabricate(:chat_message, thread: thread)
expect(sidebar_page).to have_no_unread_channel(channel)
end
it "shows an unread indicator for the channel sidebar if a new thread message arrives while the user is not looking at the channel" do
chat_page.visit_channel(channel)
expect(sidebar_page).to have_no_unread_channel(channel)
chat_page.visit_channel(other_channel)
Fabricate(:chat_message, thread: thread)
expect(sidebar_page).to have_unread_channel(channel)
end
end
context "when the user's notification level for the thread is set to normal" do context "when the user's notification level for the thread is set to normal" do
before { thread.membership_for(current_user).update!(notification_level: :normal) } before { thread.membership_for(current_user).update!(notification_level: :normal) }