From ed6ec8b4176b204c656b373f70287bbbab846c4b Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 19 Mar 2026 03:41:58 +0900 Subject: [PATCH] Debounce user offline notifications (#37028) --- .../TestSceneFriendPresenceNotifier.cs | 29 +++++++- osu.Game/Online/FriendPresenceNotifier.cs | 67 +++++++++++++++++-- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs index dd44c92c09..d97fa3e546 100644 --- a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs +++ b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs @@ -25,6 +25,7 @@ namespace osu.Game.Tests.Visual.Components private NotificationOverlay notificationOverlay = null!; private ChatOverlay chatOverlay = null!; private TestMetadataClient metadataClient = null!; + private FriendPresenceNotifier notifier = null!; [SetUp] public void Setup() => Schedule(() => @@ -45,7 +46,11 @@ namespace osu.Game.Tests.Visual.Components notificationOverlay, chatOverlay, metadataClient, - new FriendPresenceNotifier() + notifier = new FriendPresenceNotifier + { + // Speeds up tests that don't rely on this debounce a little bit. + OfflineDebounceTime = 0 + } } }; @@ -127,5 +132,27 @@ namespace osu.Game.Tests.Visual.Components AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); } + + [Test] + public void TestOfflineDebounce() + { + AddStep("set debounce time", () => + { + notifier.NotificationDebounceTime = 0; + notifier.OfflineDebounceTime = 5000; + }); + + AddStep("bring friend online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddUntilStep("online notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + for (int i = 0; i < 3; i++) + { + AddStep("bring friend online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddStep("bring friend offline", () => metadataClient.FriendPresenceUpdated(1, null)); + } + + AddUntilStep("online notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + AddUntilStep("offline notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); + } } } diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index 77d0421354..6a55b4a2a2 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -25,6 +25,16 @@ namespace osu.Game.Online { public partial class FriendPresenceNotifier : Component { + /// + /// Minimum time between subsequent online/offline notifications. + /// + public double NotificationDebounceTime { get; set; } = 1000; + + /// + /// Minimum time after a user has gone offline, before they're added to the offline alert queue. + /// + public double OfflineDebounceTime { get; set; } = 15000; + [Resolved] private INotificationOverlay notifications { get; set; } = null!; @@ -42,13 +52,31 @@ namespace osu.Game.Online private readonly IBindableList friends = new BindableList(); private readonly IBindableDictionary friendPresences = new BindableDictionary(); + /// + /// List of users that will be notified as having come online with the next notification. + /// private readonly HashSet onlineAlertQueue = new HashSet(); + + /// + /// List of users that will be notified as having gone offline with the next notification. + /// private readonly HashSet offlineAlertQueue = new HashSet(); - private double? nextOnlineAlertTime; - private double? nextOfflineAlertTime; + /// + /// List of users that have gone offline, but we're waiting for them to potentially come online again before queueing them for notification. + /// For example, if a user is quickly toggling between the "Online" and "Appear Offline" states. + /// + private readonly HashSet pendingOfflineUsers = new HashSet(); - private const double debounce_time_before_notification = 1000; + /// + /// The post time for the next online notification. + /// + private double? nextOnlineAlertTime; + + /// + /// The post time for the next offline notification. + /// + private double? nextOfflineAlertTime; protected override void LoadComplete() { @@ -133,28 +161,55 @@ namespace osu.Game.Online APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId); if (friend?.TargetUser is APIUser user) - markUserOffline(user); + markUserOfflineDebounced(user); } break; } } + /// + /// Immediately registers a user for the next online notification alert. + /// private void markUserOnline(APIUser user) { + if (pendingOfflineUsers.Remove(user)) + return; + if (!offlineAlertQueue.Remove(user)) { onlineAlertQueue.Add(user); - nextOnlineAlertTime ??= Time.Current + debounce_time_before_notification; + nextOnlineAlertTime ??= Time.Current + NotificationDebounceTime; } } + /// + /// Waits before adding a user to the next offline notification alert. + /// + /// + private void markUserOfflineDebounced(APIUser user) + { + pendingOfflineUsers.Add(user); + + Scheduler.AddDelayed(() => + { + // Check if the friend has come back online. + if (!pendingOfflineUsers.Remove(user)) + return; + + markUserOffline(user); + }, OfflineDebounceTime); + } + + /// + /// Immediately registers a user for the next offline notification alert. + /// private void markUserOffline(APIUser user) { if (!onlineAlertQueue.Remove(user)) { offlineAlertQueue.Add(user); - nextOfflineAlertTime ??= Time.Current + debounce_time_before_notification; + nextOfflineAlertTime ??= Time.Current + NotificationDebounceTime; } }