FEATURE: Add ability to watch chat threads (#28639)

This change introduces a new thread notification level allowing users to get notified when someone replies to the thread.

Users who watch a thread will get a green notification on the chat icon and a user notification (blue). User notifications are consolidated based on thread id to prevent cluttering the original users notification area.

---------

Co-authored-by: Régis Hanol <regis@hanol.fr>
This commit is contained in:
David Battersby 2024-09-02 16:45:55 +04:00 committed by GitHub
parent cef1dcfc7d
commit 997fbc9757
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 929 additions and 78 deletions

View File

@ -164,6 +164,7 @@ class Notification < ActiveRecord::Base
new_features: 37,
admin_problems: 38,
linked_consolidated: 39,
chat_watched_thread: 40,
following: 800, # Used by https://github.com/discourse/discourse-follow
following_created_topic: 801, # Used by https://github.com/discourse/discourse-follow
following_replied: 802, # Used by https://github.com/discourse/discourse-follow

View File

@ -15,7 +15,6 @@ module Jobs
@is_direct_message_channel = @chat_channel.direct_message_channel?
always_notification_level = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
members =
::Chat::UserChatChannelMembership
.includes(user: :groups)
@ -25,9 +24,10 @@ module Jobs
.where(chat_channel_id: @chat_channel.id)
.where(following: true)
.where(
"desktop_notification_level = ? OR mobile_notification_level = ?",
always_notification_level,
always_notification_level,
"desktop_notification_level = :always OR mobile_notification_level = :always OR users.id IN (SELECT user_id FROM user_chat_thread_memberships WHERE thread_id = :thread_id AND notification_level = :watching)",
always: always_notification_level,
thread_id: @chat_message.thread_id,
watching: ::Chat::NotificationLevels.all[:watching],
)
.merge(User.not_suspended)
@ -83,6 +83,17 @@ module Jobs
channel_id: @chat_channel.id,
}
if @chat_message.in_thread? && !membership.muted?
thread_membership =
::Chat::UserChatThreadMembership.find_by(
user_id: user.id,
thread_id: @chat_message.thread_id,
notification_level: "watching",
)
thread_membership && create_watched_thread_notification(thread_membership)
end
if membership.desktop_notifications_always? && !membership.muted?
send_notification =
DiscoursePluginRegistry.push_notification_filters.all? do |filter|
@ -101,6 +112,27 @@ module Jobs
::PostAlerter.push_notification(user, payload)
end
end
def create_watched_thread_notification(thread_membership)
thread = @chat_message.thread
description = thread.title.presence || thread.original_message.message
data = {
username: @creator.username,
chat_message_id: @chat_message.id,
chat_channel_id: @chat_channel.id,
chat_thread_id: @chat_message.thread_id,
last_read_message_id: thread_membership&.last_read_message_id,
description: description,
user_ids: [@chat_message.user_id],
}
Notification.consolidate_or_create!(
notification_type: ::Notification.types[:chat_watched_thread],
user_id: thread_membership.user_id,
data: data.to_json,
)
end
end
end
end

View File

@ -43,7 +43,10 @@ module Chat
before_create { self.last_message_id = self.original_message_id }
def add(user, notification_level: Chat::NotificationLevels.all[:tracking])
Chat::UserChatThreadMembership.find_or_create_by!(
membership = Chat::UserChatThreadMembership.find_by(user: user, thread: self)
return membership if membership
Chat::UserChatThreadMembership.create!(
user: user,
thread: self,
notification_level: notification_level,

View File

@ -8,11 +8,15 @@ module Chat
attr_accessor :channel_tracking, :thread_tracking
class TrackingStateInfo
attr_accessor :unread_count, :mention_count, :last_reply_created_at
attr_accessor :unread_count,
:mention_count,
:watched_threads_unread_count,
:last_reply_created_at
def initialize(info)
@unread_count = info.present? ? info[:unread_count] : 0
@mention_count = info.present? ? info[:mention_count] : 0
@watched_threads_unread_count = info.present? ? info[:watched_threads_unread_count] : 0
@last_reply_created_at = info.present? ? info[:last_reply_created_at] : nil
end
@ -24,6 +28,7 @@ module Chat
{
unread_count: unread_count,
mention_count: mention_count,
watched_threads_unread_count: watched_threads_unread_count,
last_reply_created_at: last_reply_created_at,
}
end

View File

@ -43,11 +43,28 @@ module Chat
WHERE NOT read
AND user_chat_channel_memberships.chat_channel_id = memberships.chat_channel_id
AND notifications.user_id = :user_id
AND notifications.notification_type = :notification_type
AND notifications.notification_type = :notification_type_mention
AND (data::json->>'chat_message_id')::bigint > COALESCE(user_chat_channel_memberships.last_read_message_id, 0)
AND (data::json->>'chat_channel_id')::bigint = memberships.chat_channel_id
AND (chat_messages.thread_id IS NULL OR chat_messages.id = chat_threads.original_message_id)
) AS mention_count,
(
SELECT COUNT(*) AS watched_threads_unread_count
FROM chat_messages
INNER JOIN chat_channels ON chat_channels.id = chat_messages.chat_channel_id
INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id AND chat_threads.channel_id = chat_messages.chat_channel_id
INNER JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = chat_threads.id
WHERE chat_messages.chat_channel_id = memberships.chat_channel_id
AND chat_messages.thread_id = user_chat_thread_memberships.thread_id
AND chat_messages.user_id != :user_id
AND chat_messages.deleted_at IS NULL
AND chat_messages.thread_id IS NOT NULL
AND chat_messages.id != chat_threads.original_message_id
AND chat_messages.id > COALESCE(user_chat_thread_memberships.last_read_message_id, 0)
AND user_chat_thread_memberships.user_id = :user_id
AND user_chat_thread_memberships.notification_level = :watching_level
AND (chat_channels.threading_enabled OR chat_threads.force = true)
) AS watched_threads_unread_count,
memberships.chat_channel_id AS channel_id
FROM user_chat_channel_memberships AS memberships
WHERE memberships.user_id = :user_id AND memberships.chat_channel_id IN (:channel_ids)
@ -59,12 +76,12 @@ module Chat
SELECT * FROM (
#{sql}
) AS channel_tracking
WHERE (unread_count > 0 OR mention_count > 0)
WHERE (unread_count > 0 OR mention_count > 0 OR watched_threads_unread_count > 0)
SQL
sql += <<~SQL if include_missing_memberships && include_read
UNION ALL
SELECT 0 AS unread_count, 0 AS mention_count, chat_channels.id AS channel_id
SELECT 0 AS unread_count, 0 AS mention_count, 0 AS watched_threads_unread_count, chat_channels.id AS channel_id
FROM chat_channels
LEFT JOIN user_chat_channel_memberships ON user_chat_channel_memberships.chat_channel_id = chat_channels.id
AND user_chat_channel_memberships.user_id = :user_id
@ -77,7 +94,8 @@ module Chat
sql,
channel_ids: channel_ids,
user_id: user_id,
notification_type: Notification.types[:chat_mention],
notification_type_mention: ::Notification.types[:chat_mention],
watching_level: ::Chat::UserChatThreadMembership.notification_levels[:watching],
limit: MAX_CHANNELS,
)
end

View File

@ -54,12 +54,33 @@ module Chat
AND chat_messages.thread_id IS NOT NULL
AND chat_messages.id != chat_threads.original_message_id
AND (chat_channels.threading_enabled OR chat_threads.force = true)
AND user_chat_thread_memberships.notification_level NOT IN (:quiet_notification_levels)
AND user_chat_thread_memberships.notification_level = :tracking_level
AND original_message.deleted_at IS NULL
AND user_chat_channel_memberships.muted = false
AND user_chat_channel_memberships.user_id = :user_id
) AS unread_count,
0 AS mention_count,
0 as mention_count,
(
SELECT COUNT(*) AS watched_threads_unread_count
FROM chat_messages
INNER JOIN chat_channels ON chat_channels.id = chat_messages.chat_channel_id
INNER JOIN chat_threads ON chat_threads.id = chat_messages.thread_id AND chat_threads.channel_id = chat_messages.chat_channel_id
INNER JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = chat_threads.id
INNER JOIN user_chat_channel_memberships ON user_chat_channel_memberships.chat_channel_id = chat_messages.chat_channel_id
INNER JOIN chat_messages AS original_message ON original_message.id = chat_threads.original_message_id
WHERE chat_messages.thread_id = memberships.thread_id
AND chat_messages.user_id != :user_id
AND user_chat_thread_memberships.user_id = :user_id
AND chat_messages.id > COALESCE(user_chat_thread_memberships.last_read_message_id, 0)
AND chat_messages.deleted_at IS NULL
AND chat_messages.thread_id IS NOT NULL
AND chat_messages.id != chat_threads.original_message_id
AND (chat_channels.threading_enabled OR chat_threads.force = true)
AND user_chat_thread_memberships.notification_level = :watching_level
AND original_message.deleted_at IS NULL
AND user_chat_channel_memberships.user_id = :user_id
AND NOT user_chat_channel_memberships.muted
) AS watched_threads_unread_count,
chat_threads.channel_id,
memberships.thread_id
FROM user_chat_thread_memberships AS memberships
@ -75,12 +96,12 @@ module Chat
SELECT * FROM (
#{sql}
) AS thread_tracking
WHERE (unread_count > 0 OR mention_count > 0)
WHERE (unread_count > 0 OR mention_count > 0 OR watched_threads_unread_count > 0)
SQL
sql += <<~SQL if include_missing_memberships && include_read
UNION ALL
SELECT 0 AS unread_count, 0 AS mention_count, chat_threads.channel_id, chat_threads.id AS thread_id
SELECT 0 AS unread_count, 0 AS mention_count, 0 AS watched_threads_unread_count, chat_threads.channel_id, chat_threads.id AS thread_id
FROM chat_channels
INNER JOIN chat_threads ON chat_threads.channel_id = chat_channels.id
LEFT JOIN user_chat_thread_memberships ON user_chat_thread_memberships.thread_id = chat_threads.id
@ -99,10 +120,8 @@ module Chat
user_id: user_id,
notification_type: ::Notification.types[:chat_mention],
limit: MAX_THREADS,
quiet_notification_levels: [
::Chat::UserChatThreadMembership.notification_levels[:muted],
::Chat::UserChatThreadMembership.notification_levels[:normal],
],
tracking_level: ::Chat::UserChatThreadMembership.notification_levels[:tracking],
watching_level: ::Chat::UserChatThreadMembership.notification_levels[:watching],
)
end
end

View File

@ -52,7 +52,14 @@ module Chat
include_read: include_read,
)
.map do |ct|
[ct.channel_id, { mention_count: ct.mention_count, unread_count: ct.unread_count }]
[
ct.channel_id,
{
mention_count: ct.mention_count,
unread_count: ct.unread_count,
watched_threads_unread_count: ct.watched_threads_unread_count,
},
]
end
.to_h
end
@ -85,6 +92,7 @@ module Chat
channel_id: tt.channel_id,
mention_count: tt.mention_count,
unread_count: tt.unread_count,
watched_threads_unread_count: tt.watched_threads_unread_count,
}
if include_last_reply_details

View File

@ -103,6 +103,7 @@ module Chat
"user_chat_thread_memberships.notification_level IN (?)",
[
::Chat::UserChatThreadMembership.notification_levels[:normal],
::Chat::UserChatThreadMembership.notification_levels[:watching],
::Chat::UserChatThreadMembership.notification_levels[:tracking],
],
)

View File

@ -22,6 +22,9 @@ export default class ChatChannelUnreadIndicator extends Component {
if (this.#hasChannelMentions()) {
return this.args.channel.tracking.mentionCount;
}
if (this.#hasWatchedThreads()) {
return this.args.channel.tracking.watchedThreadsUnreadCount;
}
return this.args.channel.tracking.unreadCount;
}
@ -30,7 +33,9 @@ export default class ChatChannelUnreadIndicator extends Component {
return this.#hasChannelMentions();
}
return (
this.args.channel.isDirectMessageChannel || this.#hasChannelMentions()
this.args.channel.isDirectMessageChannel ||
this.#hasChannelMentions() ||
this.#hasWatchedThreads()
);
}
@ -38,6 +43,10 @@ export default class ChatChannelUnreadIndicator extends Component {
return this.args.channel.tracking.mentionCount > 0;
}
#hasWatchedThreads() {
return this.args.channel.tracking.watchedThreadsUnreadCount > 0;
}
#onlyMentions() {
return hasChatIndicator(this.currentUser).ONLY_MENTIONS;
}

View File

@ -680,6 +680,8 @@ export default class ChatChannel extends Component {
thread.tracking.unreadCount = threadTracking[thread.id].unread_count;
thread.tracking.mentionCount = threadTracking[thread.id].mention_count;
thread.tracking.watchedThreadsUnreadCount =
threadTracking[thread.id].watched_threads_unread_count;
}
#flushIgnoreNextScroll() {

View File

@ -84,6 +84,30 @@ export default class ChatThreadList extends Component {
thread.originalMessage?.id !== thread.lastMessageId
)
.sort((threadA, threadB) => {
// if both threads have watched unread count, then show latest first
if (
threadA.tracking.watchedThreadsUnreadCount &&
threadB.tracking.watchedThreadsUnreadCount
) {
if (
threadA.preview.lastReplyCreatedAt >
threadB.preview.lastReplyCreatedAt
) {
return -1;
} else {
return 1;
}
}
// sort threads by watched unread count
if (threadA.tracking.watchedThreadsUnreadCount) {
return -1;
}
if (threadB.tracking.watchedThreadsUnreadCount) {
return 1;
}
// If both are unread we just want to sort by last reply date + time descending.
if (threadA.tracking.unreadCount && threadB.tracking.unreadCount) {
if (

View File

@ -28,6 +28,8 @@ export default class FooterUnreadIndicator extends Component {
return this.chatTrackingStateManager.publicChannelMentionCount;
} else if (this.badgeType === DMS_TAB) {
return this.chatTrackingStateManager.directMessageUnreadCount;
} else if (this.badgeType === THREADS_TAB) {
return this.chatTrackingStateManager.watchedThreadsUnreadCount;
} else {
return 0;
}

View File

@ -11,7 +11,9 @@ export default class Channel extends Component {
return (
this.args.item.model.isDirectMessageChannel ||
(this.args.item.model.isCategoryChannel &&
this.args.item.model.tracking.mentionCount > 0)
this.args.item.model.tracking.mentionCount > 0) ||
(this.args.item.model.isCategoryChannel &&
this.args.item.model.tracking.watchedThreadsUnreadCount > 0)
);
}

View File

@ -25,6 +25,7 @@ export default class ChatThreadListItem extends Component {
class={{concatClass
"chat-thread-list-item"
(if (gt @thread.tracking.unreadCount 0) "-is-unread")
(if (gt @thread.tracking.watchedThreadsUnreadCount 0) "-is-urgent")
}}
data-thread-id={{@thread.id}}
...attributes

View File

@ -1,21 +1,32 @@
import Component from "@glimmer/component";
const MAX_UNREAD_COUNT = 99;
export default class ChatThreadUnreadIndicator extends Component {
get unreadCount() {
return this.args.thread.tracking.unreadCount;
}
get urgentCount() {
return this.args.thread.tracking.watchedThreadsUnreadCount;
}
get showUnreadIndicator() {
return this.unreadCount > 0;
return this.unreadCount > 0 || this.urgentCount > 0;
}
get unreadCountLabel() {
return this.unreadCount > 99 ? "99+" : this.unreadCount;
const count = this.urgentCount > 0 ? this.urgentCount : this.unreadCount;
return count > MAX_UNREAD_COUNT ? `${MAX_UNREAD_COUNT}+` : count;
}
get isUrgent() {
return this.urgentCount > 0 ? "-urgent" : "";
}
<template>
{{#if this.showUnreadIndicator}}
<span class="chat-thread-list-item-unread-indicator">
<span class="chat-thread-list-item-unread-indicator {{this.isUrgent}}">
<span class="chat-thread-list-item-unread-indicator__number">
{{this.unreadCountLabel}}
</span>

View File

@ -46,6 +46,8 @@ export default class UserThreads extends Component {
if (tracking) {
thread.tracking.mentionCount = tracking.mention_count;
thread.tracking.unreadCount = tracking.unread_count;
thread.tracking.watchedThreadsUnreadCount =
tracking.watched_threads_unread_count;
}
this.trackChannel(thread.channel);

View File

@ -103,6 +103,43 @@ export default {
};
}
);
api.registerNotificationTypeRenderer(
"chat_watched_thread",
(NotificationItemBase) => {
return class extends NotificationItemBase {
icon = "discourse-threads";
linkTitle = I18n.t("notifications.titles.chat_watched_thread");
description = this.notification.data.description;
get label() {
const data = this.notification.data;
if (data.user_ids.length > 2) {
return I18n.t("notifications.chat_watched_thread_label", {
username: formatUsername(data.username2),
count: data.user_ids.length - 1,
});
} else if (data.user_ids.length === 2) {
return I18n.t("notifications.chat_watched_thread_label", {
username: formatUsername(data.username2),
username2: formatUsername(data.username),
count: 1,
});
} else {
return formatUsername(data.username);
}
}
get linkHref() {
const data = this.notification.data;
return getURL(
`/chat/c/-/${data.chat_channel_id}/t/${data.chat_thread_id}/${data.chat_message_id}`
);
}
};
}
);
}
if (api.registerUserMenuTab) {
@ -123,7 +160,8 @@ export default {
get count() {
return (
this.getUnreadCountForType("chat_mention") +
this.getUnreadCountForType("chat_invitation")
this.getUnreadCountForType("chat_invitation") +
this.getUnreadCountForType("chat_watched_thread")
);
}
@ -133,6 +171,7 @@ export default {
"chat_mention",
"chat_message",
"chat_quoted",
"chat_watched_thread",
];
}
};

View File

@ -4,6 +4,7 @@ import {
} from "discourse/lib/notification-levels";
export const threadNotificationButtonLevels = [
NotificationLevels.WATCHING,
NotificationLevels.TRACKING,
NotificationLevels.REGULAR,
].map(buttonDetails);

View File

@ -114,6 +114,12 @@ export default class ChatChannel {
return Array.from(this.threadsManager.unreadThreadOverview.values()).length;
}
get watchedThreadsUnreadCount() {
return this.threadsManager.threads.reduce((unreadCount, thread) => {
return unreadCount + thread.tracking.watchedThreadsUnreadCount;
}, 0);
}
updateLastViewedAt() {
this.currentUserMembership.lastViewedAt = new Date();
}

View File

@ -7,16 +7,19 @@ export default class ChatTrackingState {
@tracked _unreadCount;
@tracked _mentionCount;
@tracked _watchedThreadsUnreadCount;
constructor(owner, params = {}) {
setOwner(this, owner);
this._unreadCount = params.unreadCount ?? 0;
this._mentionCount = params.mentionCount ?? 0;
this._watchedThreadsUnreadCount = params.watchedThreadsUnreadCount ?? 0;
}
reset() {
this._unreadCount = 0;
this._mentionCount = 0;
this._watchedThreadsUnreadCount = 0;
}
get unreadCount() {
@ -42,4 +45,16 @@ export default class ChatTrackingState {
this.chatTrackingStateManager.triggerNotificationsChanged();
}
}
get watchedThreadsUnreadCount() {
return this._watchedThreadsUnreadCount;
}
set watchedThreadsUnreadCount(value) {
const valueChanged = this._watchedThreadsUnreadCount !== value;
if (valueChanged) {
this._watchedThreadsUnreadCount = value;
this.chatTrackingStateManager.triggerNotificationsChanged();
}
}
}

View File

@ -1,4 +1,5 @@
import Service, { service } from "@ember/service";
import { NotificationLevels } from "discourse/lib/notification-levels";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel";
@ -267,7 +268,17 @@ export default class ChatSubscriptionsManager extends Service {
busData.thread_id,
busData.message.created_at
);
thread.tracking.unreadCount++;
if (
thread.currentUserMembership.notificationLevel ===
NotificationLevels.WATCHING
) {
thread.tracking.watchedThreadsUnreadCount++;
channel.tracking.watchedThreadsUnreadCount++;
} else {
thread.tracking.unreadCount++;
}
this._updateActiveLastViewedAt(channel);
}
}
@ -339,6 +350,8 @@ export default class ChatSubscriptionsManager extends Service {
channel.tracking.unreadCount = busData.unread_count;
channel.tracking.mentionCount = busData.mention_count;
channel.tracking.watchedThreadsUnreadCount =
busData.watched_threads_unread_count;
if (
busData.hasOwnProperty("unread_thread_overview") &&
@ -366,6 +379,8 @@ export default class ChatSubscriptionsManager extends Service {
busData.thread_tracking.unread_count;
thread.tracking.mentionCount =
busData.thread_tracking.mention_count;
thread.tracking.watchedThreadsUnreadCount =
busData.thread_tracking.watched_threads_unread_count;
}
});
}

View File

@ -70,7 +70,11 @@ export default class ChatTrackingStateManager extends Service {
}
get allChannelUrgentCount() {
return this.publicChannelMentionCount + this.directMessageUnreadCount;
return (
this.publicChannelMentionCount +
this.directMessageUnreadCount +
this.watchedThreadsUnreadCount
);
}
get hasUnreadThreads() {
@ -79,6 +83,12 @@ export default class ChatTrackingStateManager extends Service {
);
}
get watchedThreadsUnreadCount() {
return this.#publicChannels.reduce((unreadCount, channel) => {
return unreadCount + channel.tracking.watchedThreadsUnreadCount;
}, 0);
}
willDestroy() {
super.willDestroy(...arguments);
cancel(this._onTriggerNotificationDebounceHandler);
@ -108,6 +118,8 @@ export default class ChatTrackingStateManager extends Service {
}
model.tracking.unreadCount = state.unread_count;
model.tracking.mentionCount = state.mention_count;
model.tracking.watchedThreadsUnreadCount =
state.watched_threads_unread_count;
}
get #publicChannels() {

View File

@ -144,6 +144,8 @@ export default class Chat extends Service {
const state = channelsView.tracking.channel_tracking[channel.id];
channel.tracking.unreadCount = state.unread_count;
channel.tracking.mentionCount = state.mention_count;
channel.tracking.watchedThreadsUnreadCount =
state.watched_threads_unread_count;
channel.currentUserMembership =
channelObject.current_user_membership;

View File

@ -132,7 +132,7 @@ en:
header_indicator_preference:
title: "Show activity indicator in header"
all_new: "All New Messages"
dm_and_mentions: "Direct Messages and Mentions"
dm_and_mentions: "Direct Messages, Mentions and Watched Threads"
only_mentions: "Only Mentions"
never: "Never"
separate_sidebar_mode:
@ -634,10 +634,13 @@ en:
notifications:
regular:
title: "Normal"
description: "You will be notified if someone mentions your @name in this thread."
description: "Get notified when someone mentions you in this thread."
tracking:
title: "Tracking"
description: "A count of new replies for this thread will be shown in the thread list and the channel. You will be notified if someone mentions your @name in this thread."
description: "Get notified when someone mentions you in this thread and see a count of new replies in the thread list."
watching:
title: "Watching"
description: "Get notified about all replies in this thread and see a count of new replies in the thread list."
participants_other_count:
one: "+%{count}"
other: "+%{count}"
@ -664,6 +667,9 @@ en:
chat_invitation: "invited you to join a chat channel"
chat_invitation_html: "<span>%{username}</span> <span>invited you to join a chat channel</span>"
chat_quoted: "<span>%{username}</span> %{description}"
chat_watched_thread_label:
one: "%{username} and %{username2}"
other: "%{username} and %{count} others"
popup:
chat_mention:
@ -685,6 +691,7 @@ en:
chat_mention: "Chat mention"
chat_invitation: "Chat invitation"
chat_quoted: "Chat quoted"
chat_watched_thread: "Chat watched thread"
action_codes:
chat:
enabled: '%{who} enabled <button class="btn-link open-chat">chat</button> %{when}'

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
module Chat
module NotificationConsolidationExtension
CONSOLIDATION_THRESHOLD = 1
def self.watched_thread_message_plan
Notifications::ConsolidateNotifications.new(
from: Notification.types[:chat_watched_thread],
to: Notification.types[:chat_watched_thread],
threshold: CONSOLIDATION_THRESHOLD,
unconsolidated_query_blk:
Proc.new do |notifications, data|
notifications.where("data::json ->> 'consolidated' IS NULL").where(
"data::json ->> 'chat_thread_id' = ?",
data[:chat_thread_id].to_s,
)
end,
consolidated_query_blk:
Proc.new do |notifications, data|
notifications.where("(data::json ->> 'consolidated')::bool").where(
"data::json ->> 'chat_thread_id' = ?",
data[:chat_thread_id].to_s,
)
end,
).set_mutations(
set_data_blk:
lambda do |notification|
data = notification.data_hash
last_watched_thread_notification =
Notification
.where(user_id: notification.user_id)
.order("notifications.id DESC")
.where("data::json ->> 'chat_thread_id' = ?", data[:chat_thread_id].to_s)
.where(notification_type: Notification.types[:chat_watched_thread])
.first
return data if !last_watched_thread_notification
consolidated_data = last_watched_thread_notification.data_hash
if data[:last_read_message_id].to_i <= consolidated_data[:chat_message_id].to_i
data[:chat_message_id] = consolidated_data[:chat_message_id]
end
if !consolidated_data[:username2] && data[:username] != consolidated_data[:username]
data.merge(
username2: consolidated_data[:username],
user_ids: consolidated_data[:user_ids].concat(data[:user_ids]),
)
else
data.merge(
username: consolidated_data[:username],
username2: consolidated_data[:username2],
user_ids: (consolidated_data[:user_ids].concat(data[:user_ids])).uniq,
)
end
end,
)
end
end
end

View File

@ -68,6 +68,7 @@ after_initialize do
Guardian.prepend Chat::GuardianExtensions
UserNotifications.prepend Chat::UserNotificationsExtension
Notifications::ConsolidationPlan.prepend Chat::NotificationConsolidationExtension
UserOption.prepend Chat::UserOptionExtension
Category.prepend Chat::CategoryExtension
Reviewable.prepend Chat::ReviewableExtension
@ -533,6 +534,10 @@ after_initialize do
Proc.new { |user| Jobs.enqueue(Jobs::Chat::DeleteUserMessages, user_id: user.id) },
)
register_notification_consolidation_plan(
Chat::NotificationConsolidationExtension.watched_thread_message_plan,
)
register_bookmarkable(Chat::MessageBookmarkable)
end

View File

@ -12,16 +12,21 @@ RSpec.describe Jobs::Chat::NotifyWatching do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
end
def run_job
def run_job(message)
described_class.new.execute(chat_message_id: message.id, except_user_ids: except_user_ids)
end
def notification_messages_for(user)
def notification_messages_for(user, chat_message: message)
MessageBus
.track_publish { run_job }
.track_publish { run_job(chat_message) }
.filter { |m| m.channel == "/chat/notification-alert/#{user.id}" }
end
def track_core_notification(user:, message:, type: ::Notification.types[:chat_watched_thread])
described_class.new.execute(chat_message_id: message.id)
Notification.where(user: user, notification_type: type).last
end
context "for a category channel" do
fab!(:channel) { Fabricate(:category_channel) }
fab!(:membership1) do
@ -62,6 +67,77 @@ RSpec.describe Jobs::Chat::NotifyWatching do
)
end
context "with watched threads" do
fab!(:chat_message) { Fabricate(:chat_message, chat_channel: channel, user: user1) }
fab!(:thread) { Fabricate(:chat_thread, channel: channel, original_message: chat_message) }
fab!(:thread_message) do
Fabricate(:chat_message, chat_channel: channel, thread: thread, user: user2)
end
before { channel.update!(threading_enabled: true) }
context "with channel notification_level is always" do
before do
always = Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
membership1.update!(desktop_notification_level: always, mobile_notification_level: always)
end
it "creates a core notification when watching the thread" do
thread.membership_for(user1).update!(
notification_level: Chat::NotificationLevels.all[:watching],
)
notification = track_core_notification(user: user1, message: thread_message)
expect(notification).to be_present
expect(notification.notification_type).to eq(Notification.types[:chat_watched_thread])
end
it "does not create a core notification when not watching the thread" do
notification = track_core_notification(user: user1, message: thread_message)
expect(notification).to be_nil
end
it "does not create a core notification when the channel is muted" do
thread.membership_for(user1).update!(
notification_level: Chat::NotificationLevels.all[:watching],
)
membership1.update!(muted: true)
notification = track_core_notification(user: user1, message: thread_message)
expect(notification).to be_nil
end
end
context "without channel notifications" do
before do
thread.membership_for(user1).update!(
notification_level: Chat::NotificationLevels.all[:watching],
)
end
it "creates a core notification for watched threads" do
expect { run_job(thread_message) }.to change { Notification.count }
end
it "does not create a core notification if channel is muted" do
membership1.update!(muted: true)
expect { run_job(thread_message) }.not_to change { Notification.count }
end
it "does not create a desktop notification" do
messages = notification_messages_for(user1)
expect(messages).to be_empty
end
it "does not create a mobile notification" do
PostAlerter.expects(:push_notification).never
run_job(thread_message)
end
end
end
context "with chat_notification_translation_args plugin_modifier" do
let(:modifier_block) do
Proc.new do |args|

View File

@ -26,7 +26,14 @@ describe Chat::ChannelUnreadsQuery do
before { Fabricate(:chat_message, chat_channel: channel_1) }
it "returns a correct unread count" do
expect(query.first).to eq({ mention_count: 0, unread_count: 1, channel_id: channel_1.id })
expect(query.first).to eq(
{
mention_count: 0,
unread_count: 1,
channel_id: channel_1.id,
watched_threads_unread_count: 0,
},
)
end
context "when the membership has been muted" do
@ -38,7 +45,14 @@ describe Chat::ChannelUnreadsQuery do
end
it "returns a zeroed unread count" do
expect(query.first).to eq({ mention_count: 0, unread_count: 0, channel_id: channel_1.id })
expect(query.first).to eq(
{
mention_count: 0,
unread_count: 0,
channel_id: channel_1.id,
watched_threads_unread_count: 0,
},
)
end
end
@ -47,13 +61,27 @@ describe Chat::ChannelUnreadsQuery do
fab!(:thread) { Fabricate(:chat_thread, channel: channel_1, original_message: thread_om) }
it "does include the original message in the unread count" do
expect(query.first).to eq({ mention_count: 0, unread_count: 2, channel_id: channel_1.id })
expect(query.first).to eq(
{
mention_count: 0,
unread_count: 2,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
)
end
it "does not include other thread messages in the unread count" do
Fabricate(:chat_message, chat_channel: channel_1, thread: thread)
Fabricate(:chat_message, chat_channel: channel_1, thread: thread)
expect(query.first).to eq({ mention_count: 0, unread_count: 2, channel_id: channel_1.id })
expect(query.first).to eq(
{
mention_count: 0,
unread_count: 2,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
)
end
end
@ -70,8 +98,18 @@ describe Chat::ChannelUnreadsQuery do
it "returns accurate counts" do
expect(query).to match_array(
[
{ mention_count: 0, unread_count: 1, channel_id: channel_1.id },
{ mention_count: 0, unread_count: 2, channel_id: channel_2.id },
{
mention_count: 0,
unread_count: 1,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
{
mention_count: 0,
unread_count: 2,
watched_threads_unread_count: 0,
channel_id: channel_2.id,
},
],
)
end
@ -86,7 +124,14 @@ describe Chat::ChannelUnreadsQuery do
it "does not return counts for the channels" do
expect(query).to match_array(
[{ mention_count: 0, unread_count: 1, channel_id: channel_1.id }],
[
{
mention_count: 0,
unread_count: 1,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
],
)
end
@ -96,8 +141,18 @@ describe Chat::ChannelUnreadsQuery do
it "does return zeroed counts for the channels" do
expect(query).to match_array(
[
{ mention_count: 0, unread_count: 1, channel_id: channel_1.id },
{ mention_count: 0, unread_count: 0, channel_id: channel_2.id },
{
mention_count: 0,
unread_count: 1,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
{
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 0,
channel_id: channel_2.id,
},
],
)
end
@ -107,7 +162,14 @@ describe Chat::ChannelUnreadsQuery do
it "does not return counts for the channels" do
expect(query).to match_array(
[{ mention_count: 0, unread_count: 1, channel_id: channel_1.id }],
[
{
mention_count: 0,
unread_count: 1,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
],
)
end
end
@ -137,7 +199,14 @@ describe Chat::ChannelUnreadsQuery do
message = Fabricate(:chat_message, chat_channel: channel_1)
create_mention(message, channel_1)
expect(query.first).to eq({ mention_count: 1, unread_count: 1, channel_id: channel_1.id })
expect(query.first).to eq(
{
mention_count: 1,
unread_count: 1,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
)
end
context "for unread mentions in a thread" do
@ -146,7 +215,14 @@ describe Chat::ChannelUnreadsQuery do
it "does include the original message in the mention count" do
create_mention(thread_om, channel_1)
expect(query.first).to eq({ mention_count: 1, unread_count: 1, channel_id: channel_1.id })
expect(query.first).to eq(
{
mention_count: 1,
unread_count: 1,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
)
end
it "does not include other thread messages in the mention count" do
@ -154,7 +230,14 @@ describe Chat::ChannelUnreadsQuery do
thread_message_2 = Fabricate(:chat_message, chat_channel: channel_1, thread: thread)
create_mention(thread_message_1, channel_1)
create_mention(thread_message_2, channel_1)
expect(query.first).to eq({ mention_count: 0, unread_count: 1, channel_id: channel_1.id })
expect(query.first).to eq(
{
mention_count: 0,
unread_count: 1,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
)
end
end
@ -173,8 +256,93 @@ describe Chat::ChannelUnreadsQuery do
expect(query).to match_array(
[
{ mention_count: 1, unread_count: 1, channel_id: channel_1.id },
{ mention_count: 1, unread_count: 2, channel_id: channel_2.id },
{
mention_count: 1,
unread_count: 1,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
{
mention_count: 1,
unread_count: 2,
watched_threads_unread_count: 0,
channel_id: channel_2.id,
},
],
)
end
end
end
context "with watched threads" do
fab!(:message) { Fabricate(:chat_message, chat_channel: channel_1, user: current_user) }
fab!(:thread) { Fabricate(:chat_thread, channel: channel_1, original_message: message) }
fab!(:thread_reply) { Fabricate(:chat_message, chat_channel: channel_1, thread: thread) }
before do
channel_1.update(threading_enabled: true)
channel_1.membership_for(current_user).mark_read!(message.id)
thread.membership_for(current_user).update!(
notification_level: ::Chat::NotificationLevels.all[:watching],
)
end
it "returns correct watched thread unread count" do
expect(query.first).to eq(
{
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 1,
channel_id: channel_1.id,
},
)
end
it "returns unread and watched thread unread counts" do
Fabricate(:chat_message, chat_channel: channel_1)
expect(query.first).to eq(
{
mention_count: 0,
unread_count: 1,
watched_threads_unread_count: 1,
channel_id: channel_1.id,
},
)
end
context "for multiple channels" do
fab!(:channel_2) { Fabricate(:category_channel) }
fab!(:message_2) { Fabricate(:chat_message, chat_channel: channel_2, user: current_user) }
fab!(:thread_2) { Fabricate(:chat_thread, channel: channel_2, original_message: message_2) }
let(:channel_ids) { [channel_1.id, channel_2.id] }
before do
channel_2.add(current_user)
channel_2.update(threading_enabled: true)
channel_2.membership_for(current_user).mark_read!(message_2.id)
thread_2.membership_for(current_user).update!(
notification_level: ::Chat::NotificationLevels.all[:watching],
)
Fabricate(:chat_message, chat_channel: channel_2, thread: thread_2)
Fabricate(:chat_message, chat_channel: channel_2, thread: thread_2)
end
it "returns accurate counts" do
expect(query).to match_array(
[
{
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 1,
channel_id: channel_1.id,
},
{
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 2,
channel_id: channel_2.id,
},
],
)
end
@ -183,7 +351,14 @@ describe Chat::ChannelUnreadsQuery do
context "with nothing unread" do
it "returns a correct state" do
expect(query.first).to eq({ mention_count: 0, unread_count: 0, channel_id: channel_1.id })
expect(query.first).to eq(
{
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
)
end
context "when include_read is false" do

View File

@ -47,10 +47,34 @@ describe Chat::ThreadUnreadsQuery do
it "gets a count of all the thread unreads across the channels" do
expect(query.map(&:to_h)).to match_array(
[
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 1 },
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_2.id, unread_count: 0 },
{ channel_id: channel_2.id, mention_count: 0, thread_id: thread_3.id, unread_count: 1 },
{ channel_id: channel_2.id, mention_count: 0, thread_id: thread_4.id, unread_count: 1 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_2.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
{
channel_id: channel_2.id,
mention_count: 0,
thread_id: thread_3.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
{
channel_id: channel_2.id,
mention_count: 0,
thread_id: thread_4.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
],
)
end
@ -58,7 +82,13 @@ describe Chat::ThreadUnreadsQuery do
it "does not count deleted messages" do
message_1.trash!
expect(query.map(&:to_h).find { |tracking| tracking[:thread_id] == thread_1.id }).to eq(
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 0 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -66,17 +96,35 @@ describe Chat::ThreadUnreadsQuery do
channel_1.membership_for(current_user).update!(muted: true)
expect(query.map(&:to_h).find { |tracking| tracking[:thread_id] == thread_1.id }).to eq(
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 0 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
)
end
it "does not messages in threads where threading_enabled is false on the channel" do
channel_1.update!(threading_enabled: false)
expect(query.map(&:to_h).find { |tracking| tracking[:thread_id] == thread_1.id }).to eq(
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 0 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
)
expect(query.map(&:to_h).find { |tracking| tracking[:thread_id] == thread_2.id }).to eq(
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_2.id, unread_count: 0 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_2.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -86,7 +134,13 @@ describe Chat::ThreadUnreadsQuery do
.find_by(user: current_user)
.update!(last_read_message_id: message_1.id)
expect(query.map(&:to_h).find { |tracking| tracking[:thread_id] == thread_1.id }).to eq(
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 0 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -94,14 +148,26 @@ describe Chat::ThreadUnreadsQuery do
thread_1.original_message.destroy
thread_1.update!(original_message: message_1)
expect(query.map(&:to_h).find { |tracking| tracking[:thread_id] == thread_1.id }).to eq(
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 0 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
)
end
it "does not count the thread as unread if the original message is deleted" do
thread_1.original_message.destroy
expect(query.map(&:to_h).find { |tracking| tracking[:thread_id] == thread_1.id }).to eq(
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 0 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -116,6 +182,7 @@ describe Chat::ThreadUnreadsQuery do
mention_count: 0,
thread_id: thread_2.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
],
)
@ -129,8 +196,20 @@ describe Chat::ThreadUnreadsQuery do
it "gets a count of all the thread unreads for the specified threads" do
expect(query.map(&:to_h)).to match_array(
[
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 1 },
{ channel_id: channel_2.id, mention_count: 0, thread_id: thread_3.id, unread_count: 1 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
{
channel_id: channel_2.id,
mention_count: 0,
thread_id: thread_3.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
],
)
end
@ -145,7 +224,13 @@ describe Chat::ThreadUnreadsQuery do
it "gets a zeroed out count for the thread" do
expect(query.map(&:to_h)).to include(
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 0 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
)
end
end
@ -160,7 +245,13 @@ describe Chat::ThreadUnreadsQuery do
it "gets a zeroed out count for the thread" do
expect(query.map(&:to_h)).to include(
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 0 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
)
end
end
@ -176,6 +267,7 @@ describe Chat::ThreadUnreadsQuery do
mention_count: 0,
thread_id: thread_3.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
],
)
@ -192,12 +284,14 @@ describe Chat::ThreadUnreadsQuery do
mention_count: 0,
thread_id: thread_1.id,
unread_count: 0,
watched_threads_unread_count: 0,
},
{
channel_id: channel_2.id,
mention_count: 0,
thread_id: thread_3.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
],
)
@ -214,6 +308,7 @@ describe Chat::ThreadUnreadsQuery do
mention_count: 0,
thread_id: thread_3.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
],
)
@ -230,11 +325,113 @@ describe Chat::ThreadUnreadsQuery do
it "gets a count of all the thread unreads across the channels filtered by thread id" do
expect(query.map(&:to_h)).to match_array(
[
{ channel_id: channel_1.id, mention_count: 0, thread_id: thread_1.id, unread_count: 1 },
{ channel_id: channel_2.id, mention_count: 0, thread_id: thread_3.id, unread_count: 1 },
{
channel_id: channel_1.id,
mention_count: 0,
thread_id: thread_1.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
{
channel_id: channel_2.id,
mention_count: 0,
thread_id: thread_3.id,
unread_count: 1,
watched_threads_unread_count: 0,
},
],
)
end
end
end
context "with watched threads" do
let(:channel_ids) { [channel_1.id] }
before do
[thread_1, thread_3].each do |thread|
thread.membership_for(current_user).update!(
notification_level: Chat::NotificationLevels.all[:watching],
)
end
3.times { Fabricate(:chat_message, chat_channel: channel_1, thread: thread_1) }
2.times { Fabricate(:chat_message, chat_channel: channel_1, thread: thread_2) }
end
it "returns correct count for channel" do
expect(query.map(&:to_h)).to match_array(
[
{
channel_id: channel_1.id,
thread_id: thread_1.id,
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 3,
},
{
channel_id: channel_1.id,
thread_id: thread_2.id,
mention_count: 0,
unread_count: 2,
watched_threads_unread_count: 0,
},
],
)
end
it "returns correct count across multiple channels" do
channel_ids.push(channel_2.id)
Fabricate(:chat_message, chat_channel: channel_2, thread: thread_3)
expect(query.map(&:to_h)).to match_array(
[
{
channel_id: channel_1.id,
thread_id: thread_1.id,
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 3,
},
{
channel_id: channel_1.id,
thread_id: thread_2.id,
mention_count: 0,
unread_count: 2,
watched_threads_unread_count: 0,
},
{
channel_id: channel_2.id,
thread_id: thread_3.id,
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 1,
},
{
channel_id: channel_2.id,
thread_id: thread_4.id,
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 0,
},
],
)
end
context "when include_read is false" do
let(:include_read) { false }
it "does not get threads with no unread messages" do
expect(query.map(&:to_h)).to include(
{
channel_id: channel_1.id,
thread_id: thread_1.id,
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 3,
},
)
end
end
end
end

View File

@ -62,10 +62,12 @@ RSpec.describe Chat::TrackingStateReportQuery do
channel_1.id => {
unread_count: 1,
mention_count: 0,
watched_threads_unread_count: 0,
},
channel_2.id => {
unread_count: 1,
mention_count: 0,
watched_threads_unread_count: 0,
},
},
)
@ -113,10 +115,12 @@ RSpec.describe Chat::TrackingStateReportQuery do
channel_1.id => {
unread_count: 1,
mention_count: 0,
watched_threads_unread_count: 0,
},
channel_2.id => {
unread_count: 1,
mention_count: 0,
watched_threads_unread_count: 0,
},
},
)
@ -125,11 +129,13 @@ RSpec.describe Chat::TrackingStateReportQuery do
thread_1.id => {
unread_count: 1,
mention_count: 0,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
},
thread_2.id => {
unread_count: 1,
mention_count: 0,
watched_threads_unread_count: 0,
channel_id: channel_2.id,
},
},
@ -152,12 +158,14 @@ RSpec.describe Chat::TrackingStateReportQuery do
thread_1.id => {
unread_count: 1,
mention_count: 0,
watched_threads_unread_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,
watched_threads_unread_count: 0,
channel_id: channel_2.id,
last_reply_created_at: thread_2.reload.last_message.created_at,
},
@ -172,12 +180,14 @@ RSpec.describe Chat::TrackingStateReportQuery do
thread_1.id => {
unread_count: 0,
mention_count: 0,
watched_threads_unread_count: 0,
channel_id: channel_1.id,
last_reply_created_at: nil,
},
thread_2.id => {
unread_count: 1,
mention_count: 0,
watched_threads_unread_count: 0,
channel_id: channel_2.id,
last_reply_created_at: thread_2.reload.last_message.created_at,
},

View File

@ -180,7 +180,14 @@ RSpec.describe Chat::ListChannelMessages do
Fabricate(:chat_message, chat_channel: channel, thread: thread_1)
expect(result.tracking.thread_tracking).to eq(
{ thread_1.id => { channel_id: channel.id, mention_count: 0, unread_count: 0 } },
{
thread_1.id => {
channel_id: channel.id,
mention_count: 0,
unread_count: 0,
watched_threads_unread_count: 0,
},
},
)
end
@ -193,7 +200,14 @@ RSpec.describe Chat::ListChannelMessages do
Fabricate(:chat_message, chat_channel: channel, thread: thread_1)
expect(result.tracking.thread_tracking).to eq(
{ thread_1.id => { channel_id: channel.id, mention_count: 0, unread_count: 1 } },
{
thread_1.id => {
channel_id: channel.id,
mention_count: 0,
unread_count: 1,
watched_threads_unread_count: 0,
},
},
)
end
end
@ -214,7 +228,14 @@ RSpec.describe Chat::ListChannelMessages do
expect(result.tracking.channel_tracking).to eq({})
expect(result.tracking.thread_tracking).to eq(
{ thread_1.id => { channel_id: channel.id, mention_count: 0, unread_count: 1 } },
{
thread_1.id => {
channel_id: channel.id,
mention_count: 0,
unread_count: 1,
watched_threads_unread_count: 0,
},
},
)
end
end

View File

@ -20,7 +20,7 @@ RSpec.describe Chat::ListUserChannels do
expect(result.structured[:public_channels]).to eq([channel_1])
expect(result.structured[:direct_message_channels]).to eq([])
expect(result.structured[:tracking].channel_tracking[channel_1.id]).to eq(
{ mention_count: 0, unread_count: 0 },
{ mention_count: 0, unread_count: 0, watched_threads_unread_count: 0 },
)
end

View File

@ -143,6 +143,7 @@ RSpec.describe Chat::MarkAllUserChannelsRead do
"membership_id" => membership_1.id,
"mention_count" => 0,
"unread_count" => 0,
"watched_threads_unread_count" => 0,
},
channel_2.id.to_s => {
"last_read_message_id" => message_4.id,
@ -150,6 +151,7 @@ RSpec.describe Chat::MarkAllUserChannelsRead do
"membership_id" => membership_2.id,
"mention_count" => 0,
"unread_count" => 0,
"watched_threads_unread_count" => 0,
},
channel_3.id.to_s => {
"last_read_message_id" => message_6.id,
@ -157,6 +159,7 @@ RSpec.describe Chat::MarkAllUserChannelsRead do
"membership_id" => membership_3.id,
"mention_count" => 0,
"unread_count" => 0,
"watched_threads_unread_count" => 0,
},
)
end

View File

@ -109,7 +109,12 @@ describe Chat::Publisher do
{ 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 },
{
"unread_count" => 1,
"mention_count" => 0,
"watched_threads_unread_count" => 0,
"last_reply_created_at" => nil,
},
)
end
end
@ -119,7 +124,12 @@ describe Chat::Publisher do
expect(data["thread_id"]).to eq(thread.id)
expect(data["unread_thread_overview"]).to eq({})
expect(data["thread_tracking"]).to eq(
{ "unread_count" => 0, "mention_count" => 0, "last_reply_created_at" => nil },
{
"unread_count" => 0,
"mention_count" => 0,
"watched_threads_unread_count" => 0,
"last_reply_created_at" => nil,
},
)
end
end

View File

@ -44,6 +44,7 @@ RSpec.describe ::Chat::TrackingState do
channel_1.id => {
unread_count: 4, # 2 messages + 2 thread original messages
mention_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -55,11 +56,13 @@ RSpec.describe ::Chat::TrackingState do
channel_id: channel_1.id,
unread_count: 1,
mention_count: 0,
watched_threads_unread_count: 0,
},
thread_2.id => {
channel_id: channel_1.id,
unread_count: 2,
mention_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -74,6 +77,7 @@ RSpec.describe ::Chat::TrackingState do
channel_1.id => {
unread_count: 4, # 2 messages + 2 thread original messages
mention_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -89,6 +93,7 @@ RSpec.describe ::Chat::TrackingState do
channel_1.id => {
unread_count: 4, # 2 messages + 2 thread original messages
mention_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -100,6 +105,7 @@ RSpec.describe ::Chat::TrackingState do
channel_id: channel_1.id,
unread_count: 2,
mention_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -117,10 +123,12 @@ RSpec.describe ::Chat::TrackingState do
channel_1.id => {
unread_count: 4, # 2 messages + 2 thread original messages
mention_count: 0,
watched_threads_unread_count: 0,
},
channel_2.id => {
unread_count: 0,
mention_count: 0,
watched_threads_unread_count: 0,
},
)
end
@ -132,21 +140,25 @@ RSpec.describe ::Chat::TrackingState do
channel_id: channel_1.id,
unread_count: 1,
mention_count: 0,
watched_threads_unread_count: 0,
},
thread_2.id => {
channel_id: channel_1.id,
unread_count: 2,
mention_count: 0,
watched_threads_unread_count: 0,
},
thread_3.id => {
channel_id: channel_2.id,
unread_count: 0,
mention_count: 0,
watched_threads_unread_count: 0,
},
thread_4.id => {
channel_id: channel_2.id,
unread_count: 0,
mention_count: 0,
watched_threads_unread_count: 0,
},
)
end

View File

@ -153,6 +153,17 @@ RSpec.describe "Mobile Chat footer", type: :system, mobile: true do
expect(page).to have_no_css("#c-footer-threads .c-unread-indicator")
end
it "is urgent for watched thread messages" do
thread.membership_for(current_user).update!(
notification_level: ::Chat::NotificationLevels.all[:watching],
)
visit("/")
chat_page.open_from_header
expect(page).to have_css("#c-footer-threads .c-unread-indicator.-urgent")
end
end
end
end

View File

@ -45,20 +45,23 @@ module PageObjects
".chat-thread-list-item__last-reply-timestamp .relative-date[data-time='#{(last_reply.created_at.iso8601.to_time.to_f * 1000).to_i}']"
end
def has_unread_item?(id, count: nil)
def has_unread_item?(id, count: nil, urgent: false)
selector_class = urgent ? ".-is-urgent" : ".-is-unread"
if count.nil?
component.has_css?(item_by_id_selector(id) + ".-is-unread")
component.has_css?(item_by_id_selector(id) + selector_class)
else
component.has_css?(
item_by_id_selector(id) +
".-is-unread .chat-thread-list-item-unread-indicator__number",
item_by_id_selector(id) + selector_class +
" .chat-thread-list-item-unread-indicator__number",
text: count.to_s,
)
end
end
def has_no_unread_item?(id)
component.has_no_css?(item_by_id_selector(id) + ".-is-unread")
def has_no_unread_item?(id, urgent: false)
selector_class = urgent ? ".-is-urgent" : ".-is-unread"
component.has_no_css?(item_by_id_selector(id) + selector_class)
end
def item_by_id_selector(id)

View File

@ -43,6 +43,19 @@ describe "Thread tracking state | drawer", type: :system do
expect(thread_list_page).to have_unread_item(thread.id)
end
it "shows an urgent indicator on the watched thread in the list" do
thread.membership_for(current_user).update!(
notification_level: ::Chat::NotificationLevels.all[:watching],
)
visit("/")
chat_page.open_from_header
drawer_page.open_channel(channel)
drawer_page.open_thread_list
expect(drawer_page).to have_open_thread_list
expect(thread_list_page).to have_unread_item(thread.id, urgent: true)
end
it "marks the thread as read and removes both indicators when the user opens it" do
skip("Flaky on CI") if ENV["CI"]

View File

@ -74,6 +74,18 @@ describe "Thread tracking state | full page", type: :system do
expect(thread_list_page).to have_no_unread_item(thread.id)
end
it "shows and urgent for the header of the list when a new watched unread arrives" do
thread.membership_for(current_user).update!(last_read_message_id: message_2.id)
thread.membership_for(current_user).update!(notification_level: :watching)
chat_page.visit_channel(channel)
channel_page.open_thread_list
expect(thread_list_page).to have_no_unread_item(thread.id, urgent: true)
Fabricate(:chat_message, thread: thread, use_service: true)
expect(thread_list_page).to have_unread_item(thread.id, urgent: true)
end
it "allows the user to change their tracking level for an existing thread" do
chat_page.visit_thread(thread)
thread_page.notification_level = :normal

View File

@ -119,6 +119,9 @@
"chat_quoted": {
"type": "integer"
},
"chat_watched_thread": {
"type": "integer"
},
"assigned": {
"type": "integer"
},