diff --git a/app/models/notification.rb b/app/models/notification.rb index a5ee6dd4eac..42768c594da 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -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 diff --git a/plugins/chat/app/jobs/regular/chat/notify_watching.rb b/plugins/chat/app/jobs/regular/chat/notify_watching.rb index 319f9af02ac..ac05cf1ccc2 100644 --- a/plugins/chat/app/jobs/regular/chat/notify_watching.rb +++ b/plugins/chat/app/jobs/regular/chat/notify_watching.rb @@ -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 diff --git a/plugins/chat/app/models/chat/thread.rb b/plugins/chat/app/models/chat/thread.rb index 0ced16998ca..1a104c4f36a 100644 --- a/plugins/chat/app/models/chat/thread.rb +++ b/plugins/chat/app/models/chat/thread.rb @@ -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, diff --git a/plugins/chat/app/models/chat/tracking_state_report.rb b/plugins/chat/app/models/chat/tracking_state_report.rb index b15f273f9c3..17a3c51488f 100644 --- a/plugins/chat/app/models/chat/tracking_state_report.rb +++ b/plugins/chat/app/models/chat/tracking_state_report.rb @@ -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 diff --git a/plugins/chat/app/queries/chat/channel_unreads_query.rb b/plugins/chat/app/queries/chat/channel_unreads_query.rb index 972ce7942fd..e64f451ea4c 100644 --- a/plugins/chat/app/queries/chat/channel_unreads_query.rb +++ b/plugins/chat/app/queries/chat/channel_unreads_query.rb @@ -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 diff --git a/plugins/chat/app/queries/chat/thread_unreads_query.rb b/plugins/chat/app/queries/chat/thread_unreads_query.rb index 73841f09721..f3d2f337bcc 100644 --- a/plugins/chat/app/queries/chat/thread_unreads_query.rb +++ b/plugins/chat/app/queries/chat/thread_unreads_query.rb @@ -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 diff --git a/plugins/chat/app/queries/chat/tracking_state_report_query.rb b/plugins/chat/app/queries/chat/tracking_state_report_query.rb index 21e1d21fa38..b042c04f6b9 100644 --- a/plugins/chat/app/queries/chat/tracking_state_report_query.rb +++ b/plugins/chat/app/queries/chat/tracking_state_report_query.rb @@ -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 diff --git a/plugins/chat/app/services/chat/lookup_user_threads.rb b/plugins/chat/app/services/chat/lookup_user_threads.rb index ff340f5810e..75d311e1553 100644 --- a/plugins/chat/app/services/chat/lookup_user_threads.rb +++ b/plugins/chat/app/services/chat/lookup_user_threads.rb @@ -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], ], ) diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.gjs index 7f28d434d89..21b6347e367 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.gjs @@ -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; } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs index 6b4e0f7b66c..be026a9a829 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs @@ -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() { diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread-list.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-thread-list.gjs index 55833ae1337..f6f782a5f1b 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread-list.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread-list.gjs @@ -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 ( diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/footer/unread-indicator.gjs b/plugins/chat/assets/javascripts/discourse/components/chat/footer/unread-indicator.gjs index a3c3971c4fc..dc57ad8d175 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/footer/unread-indicator.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat/footer/unread-indicator.gjs @@ -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; } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel.gjs b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel.gjs index b268e5e2906..eb78c9e9ffe 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat/message-creator/channel.gjs @@ -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) ); } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.gjs b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.gjs index 9782c87c569..0e5d9cf5a3c 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread-list/item.gjs @@ -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 diff --git a/plugins/chat/assets/javascripts/discourse/components/thread-unread-indicator/index.gjs b/plugins/chat/assets/javascripts/discourse/components/thread-unread-indicator/index.gjs index cacd5be8e29..bae75c0d459 100644 --- a/plugins/chat/assets/javascripts/discourse/components/thread-unread-indicator/index.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/thread-unread-indicator/index.gjs @@ -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" : ""; }