From 51b62a6d8e6877131542d2869f91158c000dcb50 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 7 Jan 2025 19:12:31 +0900 Subject: [PATCH 1/6] Display notification on friend presence changes --- .../TestSceneFriendPresenceNotifier.cs | 129 +++++++++++++++ osu.Game/Online/API/APIAccess.cs | 9 ++ osu.Game/Online/API/DummyAPIAccess.cs | 3 + osu.Game/Online/API/IAPIProvider.cs | 7 + osu.Game/Online/FriendPresenceNotifier.cs | 148 ++++++++++++++++++ osu.Game/OsuGame.cs | 1 + .../Visual/Metadata/TestMetadataClient.cs | 3 +- 7 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs create mode 100644 osu.Game/Online/FriendPresenceNotifier.cs diff --git a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs new file mode 100644 index 0000000000..851c1141db --- /dev/null +++ b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs @@ -0,0 +1,129 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Metadata; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Visual.Metadata; +using osu.Game.Users; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Components +{ + public partial class TestSceneFriendPresenceNotifier : OsuManualInputManagerTestScene + { + private ChannelManager channelManager = null!; + private NotificationOverlay notificationOverlay = null!; + private ChatOverlay chatOverlay = null!; + private TestMetadataClient metadataClient = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(ChannelManager), channelManager = new ChannelManager(API)), + (typeof(INotificationOverlay), notificationOverlay = new NotificationOverlay()), + (typeof(ChatOverlay), chatOverlay = new ChatOverlay()), + (typeof(MetadataClient), metadataClient = new TestMetadataClient()), + ], + Children = new Drawable[] + { + channelManager, + notificationOverlay, + chatOverlay, + metadataClient, + new FriendPresenceNotifier() + } + }; + + for (int i = 1; i <= 100; i++) + ((DummyAPIAccess)API).Friends.Add(new APIRelation { TargetID = i, TargetUser = new APIUser { Username = $"Friend {i}" } }); + }); + + [Test] + public void TestNotifications() + { + AddStep("bring friend 1 online", () => metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + AddStep("bring friend 1 offline", () => metadataClient.UserPresenceUpdated(1, null)); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); + } + + [Test] + public void TestSingleUserNotificationOpensChat() + { + AddStep("bring friend 1 online", () => metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("click notification", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("chat overlay opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddUntilStep("user channel selected", () => channelManager.CurrentChannel.Value.Name, () => Is.EqualTo(((DummyAPIAccess)API).Friends[0].TargetUser!.Username)); + } + + [Test] + public void TestMultipleUserNotificationDoesNotOpenChat() + { + AddStep("bring friends 1 & 2 online", () => + { + metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }); + metadataClient.UserPresenceUpdated(2, new UserPresence { Status = UserStatus.Online }); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("click notification", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("chat overlay not opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + } + + [Test] + public void TestNonFriendsDoNotNotify() + { + AddStep("bring non-friend 1000 online", () => metadataClient.UserPresenceUpdated(1000, new UserPresence { Status = UserStatus.Online })); + AddWaitStep("wait for possible notification", 10); + AddAssert("no notification", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero); + } + + [Test] + public void TestPostManyDebounced() + { + AddStep("bring friends 1-10 online", () => + { + for (int i = 1; i <= 10; i++) + metadataClient.UserPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("bring friends 1-10 offline", () => + { + for (int i = 1; i <= 10; i++) + metadataClient.UserPresenceUpdated(i, null); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); + } + } +} diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index ec48fa2436..39c09f2a5d 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -75,6 +75,7 @@ namespace osu.Game.Online.API protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); + private readonly Dictionary friendsMapping = new Dictionary(); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); private readonly Logger log; @@ -403,6 +404,8 @@ namespace osu.Game.Online.API public IChatClient GetChatClient() => new WebSocketChatClient(this); + public APIRelation GetFriend(int userId) => friendsMapping.GetValueOrDefault(userId); + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Debug.Assert(State.Value == APIState.Offline); @@ -594,6 +597,8 @@ namespace osu.Game.Online.API Schedule(() => { setLocalUser(createGuestUser()); + + friendsMapping.Clear(); friends.Clear(); }); @@ -610,7 +615,11 @@ namespace osu.Game.Online.API friendsReq.Failure += _ => state.Value = APIState.Failing; friendsReq.Success += res => { + friendsMapping.Clear(); friends.Clear(); + + foreach (var u in res) + friendsMapping[u.TargetID] = u; friends.AddRange(res); }; diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 5d63c04925..ca4edb3d8f 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Bindables; @@ -194,6 +195,8 @@ namespace osu.Game.Online.API public IChatClient GetChatClient() => new TestChatClientConnector(this); + public APIRelation? GetFriend(int userId) => Friends.FirstOrDefault(r => r.TargetID == userId); + public RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password) { Thread.Sleep(200); diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 1c4b2da742..4655b26f84 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -152,6 +152,13 @@ namespace osu.Game.Online.API /// IChatClient GetChatClient(); + /// + /// Retrieves a friend from a given user ID. + /// + /// The friend's user ID. + /// The object representing the friend, if any. + APIRelation? GetFriend(int userId); + /// /// Create a new user account. This is a blocking operation. /// diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs new file mode 100644 index 0000000000..8fcf1a9f69 --- /dev/null +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -0,0 +1,148 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Metadata; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Users; + +namespace osu.Game.Online +{ + public partial class FriendPresenceNotifier : Component + { + [Resolved] + private INotificationOverlay notifications { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + [Resolved] + private ChannelManager channelManager { get; set; } = null!; + + [Resolved] + private ChatOverlay chatOverlay { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private readonly IBindableDictionary userStates = new BindableDictionary(); + private readonly HashSet onlineAlertQueue = new HashSet(); + private readonly HashSet offlineAlertQueue = new HashSet(); + + private double? lastOnlineAlertTime; + private double? lastOfflineAlertTime; + + protected override void LoadComplete() + { + base.LoadComplete(); + + userStates.BindTo(metadataClient.UserStates); + userStates.BindCollectionChanged((_, args) => + { + switch (args.Action) + { + case NotifyDictionaryChangedAction.Add: + foreach ((int userId, var _) in args.NewItems!) + { + if (api.GetFriend(userId)?.TargetUser is APIUser user) + { + if (!offlineAlertQueue.Remove(user)) + { + onlineAlertQueue.Add(user); + lastOnlineAlertTime ??= Time.Current; + } + } + } + + break; + + case NotifyDictionaryChangedAction.Remove: + foreach ((int userId, var _) in args.OldItems!) + { + if (api.GetFriend(userId)?.TargetUser is APIUser user) + { + if (!onlineAlertQueue.Remove(user)) + { + offlineAlertQueue.Add(user); + lastOfflineAlertTime ??= Time.Current; + } + } + } + + break; + } + }); + } + + protected override void Update() + { + base.Update(); + + alertOnlineUsers(); + alertOfflineUsers(); + } + + private void alertOnlineUsers() + { + if (onlineAlertQueue.Count == 0) + return; + + if (lastOnlineAlertTime == null || Time.Current - lastOnlineAlertTime < 1000) + return; + + APIUser? singleUser = onlineAlertQueue.Count == 1 ? onlineAlertQueue.Single() : null; + + notifications.Post(new SimpleNotification + { + Icon = FontAwesome.Solid.UserPlus, + Text = $"Online: {string.Join(@", ", onlineAlertQueue.Select(u => u.Username))}", + IconColour = colours.Green, + Activated = () => + { + if (singleUser != null) + { + channelManager.OpenPrivateChannel(singleUser); + chatOverlay.Show(); + } + + return true; + } + }); + + onlineAlertQueue.Clear(); + lastOnlineAlertTime = null; + } + + private void alertOfflineUsers() + { + if (offlineAlertQueue.Count == 0) + return; + + if (lastOfflineAlertTime == null || Time.Current - lastOfflineAlertTime < 1000) + return; + + notifications.Post(new SimpleNotification + { + Icon = FontAwesome.Solid.UserMinus, + Text = $"Offline: {string.Join(@", ", offlineAlertQueue.Select(u => u.Username))}", + IconColour = colours.Red + }); + + offlineAlertQueue.Clear(); + lastOfflineAlertTime = null; + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index c20536a1ec..329ac89a6c 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1151,6 +1151,7 @@ namespace osu.Game Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); Add(new OnlineStatusNotifier(() => ScreenStack.CurrentScreen)); + Add(new FriendPresenceNotifier()); // side overlays which cancel each other. var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications, FirstRunOverlay }; diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index 4a862750bc..6dd6392b3a 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -66,7 +67,7 @@ namespace osu.Game.Tests.Visual.Metadata public override Task UserPresenceUpdated(int userId, UserPresence? presence) { - if (isWatchingUserPresence.Value) + if (isWatchingUserPresence.Value || api.Friends.Any(f => f.TargetID == userId)) { if (presence.HasValue) userStates[userId] = presence.Value; From 45e0adcd253f1dfa922723c502dab365b76f51cd Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 7 Jan 2025 19:32:30 +0900 Subject: [PATCH 2/6] Add config option --- osu.Game/Configuration/OsuConfigManager.cs | 2 ++ .../Localisation/OnlineSettingsStrings.cs | 12 +++++++++++- osu.Game/Online/FriendPresenceNotifier.cs | 19 +++++++++++++++++++ .../Online/AlertsAndPrivacySettings.cs | 6 ++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index dd3abb6f81..3c463f6f0c 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -96,6 +96,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.NotifyOnUsernameMentioned, true); SetDefault(OsuSetting.NotifyOnPrivateMessage, true); + SetDefault(OsuSetting.NotifyOnFriendPresenceChange, true); // Audio SetDefault(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); @@ -417,6 +418,7 @@ namespace osu.Game.Configuration IntroSequence, NotifyOnUsernameMentioned, NotifyOnPrivateMessage, + NotifyOnFriendPresenceChange, UIHoldActivationDelay, HitLighting, StarFountains, diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs index 8e8c81cf59..98364a3f5a 100644 --- a/osu.Game/Localisation/OnlineSettingsStrings.cs +++ b/osu.Game/Localisation/OnlineSettingsStrings.cs @@ -29,6 +29,16 @@ namespace osu.Game.Localisation /// public static LocalisableString NotifyOnPrivateMessage => new TranslatableString(getKey(@"notify_on_private_message"), @"Show a notification when you receive a private message"); + /// + /// "Show notification popups when friends change status" + /// + public static LocalisableString NotifyOnFriendPresenceChange => new TranslatableString(getKey(@"notify_on_friend_presence_change"), @"Show notification popups when friends change status"); + + /// + /// "Notifications will be shown when friends go online/offline." + /// + public static LocalisableString NotifyOnFriendPresenceChangeTooltip => new TranslatableString(getKey(@"notify_on_friend_presence_change_tooltip"), @"Notifications will be shown when friends go online/offline."); + /// /// "Integrations" /// @@ -84,6 +94,6 @@ namespace osu.Game.Localisation /// public static LocalisableString HideCountryFlags => new TranslatableString(getKey(@"hide_country_flags"), @"Hide country flags"); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index 8fcf1a9f69..655a004d3e 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -38,6 +39,10 @@ namespace osu.Game.Online [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private readonly Bindable notifyOnFriendPresenceChange = new BindableBool(); private readonly IBindableDictionary userStates = new BindableDictionary(); private readonly HashSet onlineAlertQueue = new HashSet(); private readonly HashSet offlineAlertQueue = new HashSet(); @@ -49,6 +54,8 @@ namespace osu.Game.Online { base.LoadComplete(); + config.BindWith(OsuSetting.NotifyOnFriendPresenceChange, notifyOnFriendPresenceChange); + userStates.BindTo(metadataClient.UserStates); userStates.BindCollectionChanged((_, args) => { @@ -103,6 +110,12 @@ namespace osu.Game.Online if (lastOnlineAlertTime == null || Time.Current - lastOnlineAlertTime < 1000) return; + if (!notifyOnFriendPresenceChange.Value) + { + lastOnlineAlertTime = null; + return; + } + APIUser? singleUser = onlineAlertQueue.Count == 1 ? onlineAlertQueue.Single() : null; notifications.Post(new SimpleNotification @@ -134,6 +147,12 @@ namespace osu.Game.Online if (lastOfflineAlertTime == null || Time.Current - lastOfflineAlertTime < 1000) return; + if (!notifyOnFriendPresenceChange.Value) + { + lastOfflineAlertTime = null; + return; + } + notifications.Post(new SimpleNotification { Icon = FontAwesome.Solid.UserMinus, diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index 7bd0829add..608c6ef1b2 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -29,6 +29,12 @@ namespace osu.Game.Overlays.Settings.Sections.Online Current = config.GetBindable(OsuSetting.NotifyOnPrivateMessage) }, new SettingsCheckbox + { + LabelText = OnlineSettingsStrings.NotifyOnFriendPresenceChange, + TooltipText = OnlineSettingsStrings.NotifyOnFriendPresenceChangeTooltip, + Current = config.GetBindable(OsuSetting.NotifyOnFriendPresenceChange), + }, + new SettingsCheckbox { LabelText = OnlineSettingsStrings.HideCountryFlags, Current = config.GetBindable(OsuSetting.HideCountryFlags) From f4d83fe6851272375f2382ffc2dd0c0d89721f93 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 9 Jan 2025 13:23:16 +0900 Subject: [PATCH 3/6] Keep friend states when stopping watching global activity --- .../Online/Metadata/OnlineMetadataClient.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index a3041c6753..ef748f0b49 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; @@ -31,10 +32,11 @@ namespace osu.Game.Online.Metadata private readonly string endpoint; + [Resolved] + private IAPIProvider api { get; set; } = null!; + private IHubClientConnector? connector; - private Bindable lastQueueId = null!; - private IBindable localUser = null!; private IBindable userActivity = null!; private IBindable? userStatus; @@ -47,7 +49,7 @@ namespace osu.Game.Online.Metadata } [BackgroundDependencyLoader] - private void load(IAPIProvider api, OsuConfigManager config) + private void load(OsuConfigManager config) { // Importantly, we are intentionally not using MessagePack here to correctly support derived class serialization. // More information on the limitations / reasoning can be found in osu-server-spectator's initialisation code. @@ -226,7 +228,15 @@ namespace osu.Game.Online.Metadata throw new OperationCanceledException(); // must be scheduled before any remote calls to avoid mis-ordering. - Schedule(() => userStates.Clear()); + Schedule(() => + { + foreach (int userId in userStates.Keys.ToArray()) + { + if (api.GetFriend(userId) == null) + userStates.Remove(userId); + } + }); + Debug.Assert(connection != null); await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false); Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network); From 7268b2e077ab95347a12d5374cbdf505ff8538d1 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 9 Jan 2025 17:31:01 +0900 Subject: [PATCH 4/6] Add separate path for friend presence notifications It proved to be too difficult to deal with the flow that clears user states on stopping the watching of global presence updates. It's not helped in the least that friends are updated via the API, so there's a third flow to consider (and the timings therein - both server-spectator and friends are updated concurrently). Simplest is to separate the friends flow, though this does mean some logic and state duplication. --- .../TestSceneFriendPresenceNotifier.cs | 14 +- osu.Game/Online/API/APIAccess.cs | 19 ++- osu.Game/Online/API/DummyAPIAccess.cs | 3 - osu.Game/Online/API/IAPIProvider.cs | 7 - osu.Game/Online/FriendPresenceNotifier.cs | 121 ++++++++++++------ osu.Game/Online/Metadata/IMetadataClient.cs | 5 + osu.Game/Online/Metadata/MetadataClient.cs | 8 ++ .../Online/Metadata/OnlineMetadataClient.cs | 34 +++-- .../Visual/Metadata/TestMetadataClient.cs | 16 ++- 9 files changed, 148 insertions(+), 79 deletions(-) diff --git a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs index 851c1141db..2fe2326508 100644 --- a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs +++ b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs @@ -56,16 +56,16 @@ namespace osu.Game.Tests.Visual.Components [Test] public void TestNotifications() { - AddStep("bring friend 1 online", () => metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); - AddStep("bring friend 1 offline", () => metadataClient.UserPresenceUpdated(1, null)); + AddStep("bring friend 1 offline", () => metadataClient.FriendPresenceUpdated(1, null)); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); } [Test] public void TestSingleUserNotificationOpensChat() { - AddStep("bring friend 1 online", () => metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); AddStep("click notification", () => @@ -83,8 +83,8 @@ namespace osu.Game.Tests.Visual.Components { AddStep("bring friends 1 & 2 online", () => { - metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }); - metadataClient.UserPresenceUpdated(2, new UserPresence { Status = UserStatus.Online }); + metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }); + metadataClient.FriendPresenceUpdated(2, new UserPresence { Status = UserStatus.Online }); }); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); @@ -112,7 +112,7 @@ namespace osu.Game.Tests.Visual.Components AddStep("bring friends 1-10 online", () => { for (int i = 1; i <= 10; i++) - metadataClient.UserPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); + metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); }); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); @@ -120,7 +120,7 @@ namespace osu.Game.Tests.Visual.Components AddStep("bring friends 1-10 offline", () => { for (int i = 1; i <= 10; i++) - metadataClient.UserPresenceUpdated(i, null); + metadataClient.FriendPresenceUpdated(i, null); }); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 39c09f2a5d..46476ab7f0 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; @@ -18,6 +19,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -75,7 +77,6 @@ namespace osu.Game.Online.API protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); - private readonly Dictionary friendsMapping = new Dictionary(); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); private readonly Logger log; @@ -404,8 +405,6 @@ namespace osu.Game.Online.API public IChatClient GetChatClient() => new WebSocketChatClient(this); - public APIRelation GetFriend(int userId) => friendsMapping.GetValueOrDefault(userId); - public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Debug.Assert(State.Value == APIState.Offline); @@ -597,8 +596,6 @@ namespace osu.Game.Online.API Schedule(() => { setLocalUser(createGuestUser()); - - friendsMapping.Clear(); friends.Clear(); }); @@ -615,12 +612,14 @@ namespace osu.Game.Online.API friendsReq.Failure += _ => state.Value = APIState.Failing; friendsReq.Success += res => { - friendsMapping.Clear(); - friends.Clear(); + // Add new friends into local list. + HashSet friendsSet = friends.Select(f => f.TargetID).ToHashSet(); + friends.AddRange(res.Where(f => !friendsSet.Contains(f.TargetID))); - foreach (var u in res) - friendsMapping[u.TargetID] = u; - friends.AddRange(res); + // Remove non-friends from local lists. + friendsSet.Clear(); + friendsSet.AddRange(res.Select(f => f.TargetID)); + friends.RemoveAll(f => !friendsSet.Contains(f.TargetID)); }; Queue(friendsReq); diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index ca4edb3d8f..5d63c04925 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Bindables; @@ -195,8 +194,6 @@ namespace osu.Game.Online.API public IChatClient GetChatClient() => new TestChatClientConnector(this); - public APIRelation? GetFriend(int userId) => Friends.FirstOrDefault(r => r.TargetID == userId); - public RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password) { Thread.Sleep(200); diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 4655b26f84..1c4b2da742 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -152,13 +152,6 @@ namespace osu.Game.Online.API /// IChatClient GetChatClient(); - /// - /// Retrieves a friend from a given user ID. - /// - /// The friend's user ID. - /// The object representing the friend, if any. - APIRelation? GetFriend(int userId); - /// /// Create a new user account. This is a blocking operation. /// diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index 655a004d3e..330e0a908f 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -43,7 +44,10 @@ namespace osu.Game.Online private OsuConfigManager config { get; set; } = null!; private readonly Bindable notifyOnFriendPresenceChange = new BindableBool(); - private readonly IBindableDictionary userStates = new BindableDictionary(); + + private readonly IBindableList friends = new BindableList(); + private readonly IBindableDictionary friendStates = new BindableDictionary(); + private readonly HashSet onlineAlertQueue = new HashSet(); private readonly HashSet offlineAlertQueue = new HashSet(); @@ -56,42 +60,11 @@ namespace osu.Game.Online config.BindWith(OsuSetting.NotifyOnFriendPresenceChange, notifyOnFriendPresenceChange); - userStates.BindTo(metadataClient.UserStates); - userStates.BindCollectionChanged((_, args) => - { - switch (args.Action) - { - case NotifyDictionaryChangedAction.Add: - foreach ((int userId, var _) in args.NewItems!) - { - if (api.GetFriend(userId)?.TargetUser is APIUser user) - { - if (!offlineAlertQueue.Remove(user)) - { - onlineAlertQueue.Add(user); - lastOnlineAlertTime ??= Time.Current; - } - } - } + friends.BindTo(api.Friends); + friends.BindCollectionChanged(onFriendsChanged, true); - break; - - case NotifyDictionaryChangedAction.Remove: - foreach ((int userId, var _) in args.OldItems!) - { - if (api.GetFriend(userId)?.TargetUser is APIUser user) - { - if (!onlineAlertQueue.Remove(user)) - { - offlineAlertQueue.Add(user); - lastOfflineAlertTime ??= Time.Current; - } - } - } - - break; - } - }); + friendStates.BindTo(metadataClient.FriendStates); + friendStates.BindCollectionChanged(onFriendStatesChanged, true); } protected override void Update() @@ -102,6 +75,82 @@ namespace osu.Game.Online alertOfflineUsers(); } + private void onFriendsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (APIRelation friend in e.NewItems!.Cast()) + { + if (friend.TargetUser is not APIUser user) + continue; + + if (friendStates.TryGetValue(friend.TargetID, out _)) + markUserOnline(user); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (APIRelation friend in e.OldItems!.Cast()) + { + if (friend.TargetUser is not APIUser user) + continue; + + onlineAlertQueue.Remove(user); + offlineAlertQueue.Remove(user); + } + + break; + } + } + + private void onFriendStatesChanged(object? sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + foreach ((int friendId, _) in e.NewItems!) + { + APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId); + + if (friend?.TargetUser is APIUser user) + markUserOnline(user); + } + + break; + + case NotifyDictionaryChangedAction.Remove: + foreach ((int friendId, _) in e.OldItems!) + { + APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId); + + if (friend?.TargetUser is APIUser user) + markUserOffline(user); + } + + break; + } + } + + private void markUserOnline(APIUser user) + { + if (!offlineAlertQueue.Remove(user)) + { + onlineAlertQueue.Add(user); + lastOnlineAlertTime ??= Time.Current; + } + } + + private void markUserOffline(APIUser user) + { + if (!onlineAlertQueue.Remove(user)) + { + offlineAlertQueue.Add(user); + lastOfflineAlertTime ??= Time.Current; + } + } + private void alertOnlineUsers() { if (onlineAlertQueue.Count == 0) diff --git a/osu.Game/Online/Metadata/IMetadataClient.cs b/osu.Game/Online/Metadata/IMetadataClient.cs index 97c1bbde5f..a4251fae80 100644 --- a/osu.Game/Online/Metadata/IMetadataClient.cs +++ b/osu.Game/Online/Metadata/IMetadataClient.cs @@ -21,6 +21,11 @@ namespace osu.Game.Online.Metadata /// Task UserPresenceUpdated(int userId, UserPresence? status); + /// + /// Delivers and update of the of a friend with the supplied . + /// + Task FriendPresenceUpdated(int userId, UserPresence? presence); + /// /// Delivers an update of the current "daily challenge" status. /// Null value means there is no "daily challenge" currently active. diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs index 8a5fe1733e..6578f70f74 100644 --- a/osu.Game/Online/Metadata/MetadataClient.cs +++ b/osu.Game/Online/Metadata/MetadataClient.cs @@ -42,6 +42,11 @@ namespace osu.Game.Online.Metadata /// public abstract IBindableDictionary UserStates { get; } + /// + /// Dictionary keyed by user ID containing all of the information about currently online friends received from the server. + /// + public abstract IBindableDictionary FriendStates { get; } + /// public abstract Task UpdateActivity(UserActivity? activity); @@ -57,6 +62,9 @@ namespace osu.Game.Online.Metadata /// public abstract Task UserPresenceUpdated(int userId, UserPresence? presence); + /// + public abstract Task FriendPresenceUpdated(int userId, UserPresence? presence); + #endregion #region Daily Challenge diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index ef748f0b49..a8a14b1c78 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; @@ -27,14 +26,14 @@ namespace osu.Game.Online.Metadata public override IBindableDictionary UserStates => userStates; private readonly BindableDictionary userStates = new BindableDictionary(); + public override IBindableDictionary FriendStates => friendStates; + private readonly BindableDictionary friendStates = new BindableDictionary(); + public override IBindable DailyChallengeInfo => dailyChallengeInfo; private readonly Bindable dailyChallengeInfo = new Bindable(); private readonly string endpoint; - [Resolved] - private IAPIProvider api { get; set; } = null!; - private IHubClientConnector? connector; private Bindable lastQueueId = null!; private IBindable localUser = null!; @@ -49,7 +48,7 @@ namespace osu.Game.Online.Metadata } [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(IAPIProvider api, OsuConfigManager config) { // Importantly, we are intentionally not using MessagePack here to correctly support derived class serialization. // More information on the limitations / reasoning can be found in osu-server-spectator's initialisation code. @@ -63,6 +62,7 @@ namespace osu.Game.Online.Metadata // https://github.com/dotnet/aspnetcore/issues/15198 connection.On(nameof(IMetadataClient.BeatmapSetsUpdated), ((IMetadataClient)this).BeatmapSetsUpdated); connection.On(nameof(IMetadataClient.UserPresenceUpdated), ((IMetadataClient)this).UserPresenceUpdated); + connection.On(nameof(IMetadataClient.FriendPresenceUpdated), ((IMetadataClient)this).FriendPresenceUpdated); connection.On(nameof(IMetadataClient.DailyChallengeUpdated), ((IMetadataClient)this).DailyChallengeUpdated); connection.On(nameof(IMetadataClient.MultiplayerRoomScoreSet), ((IMetadataClient)this).MultiplayerRoomScoreSet); connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMetadataClient)this).DisconnectRequested); @@ -108,6 +108,7 @@ namespace osu.Game.Online.Metadata { isWatchingUserPresence.Value = false; userStates.Clear(); + friendStates.Clear(); dailyChallengeInfo.Value = null; }); return; @@ -209,6 +210,19 @@ namespace osu.Game.Online.Metadata return Task.CompletedTask; } + public override Task FriendPresenceUpdated(int userId, UserPresence? presence) + { + Schedule(() => + { + if (presence?.Status != null) + friendStates[userId] = presence.Value; + else + friendStates.Remove(userId); + }); + + return Task.CompletedTask; + } + public override async Task BeginWatchingUserPresence() { if (connector?.IsConnected.Value != true) @@ -228,15 +242,7 @@ namespace osu.Game.Online.Metadata throw new OperationCanceledException(); // must be scheduled before any remote calls to avoid mis-ordering. - Schedule(() => - { - foreach (int userId in userStates.Keys.ToArray()) - { - if (api.GetFriend(userId) == null) - userStates.Remove(userId); - } - }); - + Schedule(() => userStates.Clear()); Debug.Assert(connection != null); await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false); Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network); diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index 6dd6392b3a..36f79a5adc 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -23,6 +22,9 @@ namespace osu.Game.Tests.Visual.Metadata public override IBindableDictionary UserStates => userStates; private readonly BindableDictionary userStates = new BindableDictionary(); + public override IBindableDictionary FriendStates => friendStates; + private readonly BindableDictionary friendStates = new BindableDictionary(); + public override Bindable DailyChallengeInfo => dailyChallengeInfo; private readonly Bindable dailyChallengeInfo = new Bindable(); @@ -67,7 +69,7 @@ namespace osu.Game.Tests.Visual.Metadata public override Task UserPresenceUpdated(int userId, UserPresence? presence) { - if (isWatchingUserPresence.Value || api.Friends.Any(f => f.TargetID == userId)) + if (isWatchingUserPresence.Value) { if (presence.HasValue) userStates[userId] = presence.Value; @@ -78,6 +80,16 @@ namespace osu.Game.Tests.Visual.Metadata return Task.CompletedTask; } + public override Task FriendPresenceUpdated(int userId, UserPresence? presence) + { + if (presence.HasValue) + friendStates[userId] = presence.Value; + else + friendStates.Remove(userId); + + return Task.CompletedTask; + } + public override Task GetChangesSince(int queueId) => Task.FromResult(new BeatmapUpdates(Array.Empty(), queueId)); From 51c7c218bfc83c8b45c7b1853485877c6a7504dd Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 14 Jan 2025 17:51:04 +0900 Subject: [PATCH 5/6] Simplify operations on local list --- osu.Game/Online/API/APIAccess.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 46476ab7f0..9d0ef06ebf 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -612,14 +612,14 @@ namespace osu.Game.Online.API friendsReq.Failure += _ => state.Value = APIState.Failing; friendsReq.Success += res => { - // Add new friends into local list. - HashSet friendsSet = friends.Select(f => f.TargetID).ToHashSet(); - friends.AddRange(res.Where(f => !friendsSet.Contains(f.TargetID))); + var existingFriends = friends.Select(f => f.TargetID).ToHashSet(); + var updatedFriends = res.Select(f => f.TargetID).ToHashSet(); - // Remove non-friends from local lists. - friendsSet.Clear(); - friendsSet.AddRange(res.Select(f => f.TargetID)); - friends.RemoveAll(f => !friendsSet.Contains(f.TargetID)); + // Add new friends into local list. + friends.AddRange(res.Where(r => !existingFriends.Contains(r.TargetID))); + + // Remove non-friends from local list. + friends.RemoveAll(f => !updatedFriends.Contains(f.TargetID)); }; Queue(friendsReq); From 156207d3472541422fe3b57fec0f05435b684e7f Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 14 Jan 2025 17:54:40 +0900 Subject: [PATCH 6/6] Remove unused using --- osu.Game/Online/API/APIAccess.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 9d0ef06ebf..d44ca90fa1 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -19,7 +19,6 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Configuration; -using osu.Game.Extensions; using osu.Game.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses;