diff --git a/osu.Game/Online/Metadata/IMetadataClient.cs b/osu.Game/Online/Metadata/IMetadataClient.cs index ad1e7ebbaf..7102554ae9 100644 --- a/osu.Game/Online/Metadata/IMetadataClient.cs +++ b/osu.Game/Online/Metadata/IMetadataClient.cs @@ -2,11 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using System.Threading.Tasks; +using osu.Game.Users; namespace osu.Game.Online.Metadata { - public interface IMetadataClient + /// + /// Interface for metadata-related remote procedure calls to be executed on the client side. + /// + public interface IMetadataClient : IStatefulUserHubClient { + /// + /// Delivers the set of requested to the client. + /// Task BeatmapSetsUpdated(BeatmapUpdates updates); + + /// + /// Delivers an update of the of the user with the supplied . + /// + Task UserPresenceUpdated(int userId, UserPresence? status); } } diff --git a/osu.Game/Online/Metadata/IMetadataServer.cs b/osu.Game/Online/Metadata/IMetadataServer.cs index 994f60f877..9780045333 100644 --- a/osu.Game/Online/Metadata/IMetadataServer.cs +++ b/osu.Game/Online/Metadata/IMetadataServer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Threading.Tasks; +using osu.Game.Users; namespace osu.Game.Online.Metadata { @@ -17,5 +18,25 @@ namespace osu.Game.Online.Metadata /// The last processed queue ID. /// Task GetChangesSince(int queueId); + + /// + /// Signals to the server that the current user's has changed. + /// + Task UpdateActivity(UserActivity? activity); + + /// + /// Signals to the server that the current user's has changed. + /// + Task UpdateStatus(UserStatus? status); + + /// + /// Signals to the server that the current user would like to begin receiving updates on other users' online presence. + /// + Task BeginWatchingUserPresence(); + + /// + /// Signals to the server that the current user would like to stop receiving updates on other users' online presence. + /// + Task EndWatchingUserPresence(); } } diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs index d4e7540fe7..8e99a9b2cb 100644 --- a/osu.Game/Online/Metadata/MetadataClient.cs +++ b/osu.Game/Online/Metadata/MetadataClient.cs @@ -4,22 +4,71 @@ using System; using System.Linq; using System.Threading.Tasks; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Users; namespace osu.Game.Online.Metadata { public abstract partial class MetadataClient : Component, IMetadataClient, IMetadataServer { - public abstract Task BeatmapSetsUpdated(BeatmapUpdates updates); + public abstract IBindable IsConnected { get; } + + #region Beatmap metadata updates public abstract Task GetChangesSince(int queueId); - public Action? ChangedBeatmapSetsArrived; + public abstract Task BeatmapSetsUpdated(BeatmapUpdates updates); + + public event Action? ChangedBeatmapSetsArrived; protected Task ProcessChanges(int[] beatmapSetIDs) { ChangedBeatmapSetsArrived?.Invoke(beatmapSetIDs.Distinct().ToArray()); return Task.CompletedTask; } + + #endregion + + #region User presence updates + + /// + /// Whether the client is currently receiving user presence updates from the server. + /// + public abstract IBindable IsWatchingUserPresence { get; } + + /// + /// Dictionary keyed by user ID containing all of the information about currently online users received from the server. + /// + public abstract IBindableDictionary UserStates { get; } + + /// + public abstract Task UpdateActivity(UserActivity? activity); + + /// + public abstract Task UpdateStatus(UserStatus? status); + + /// + public abstract Task BeginWatchingUserPresence(); + + /// + public abstract Task EndWatchingUserPresence(); + + /// + public abstract Task UserPresenceUpdated(int userId, UserPresence? presence); + + #endregion + + #region Disconnection handling + + public event Action? Disconnecting; + + public virtual Task DisconnectRequested() + { + Schedule(() => Disconnecting?.Invoke()); + return Task.CompletedTask; + } + + #endregion } } diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index 57311419f7..27093d7961 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.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Allocation; @@ -10,17 +11,32 @@ using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; namespace osu.Game.Online.Metadata { public partial class OnlineMetadataClient : MetadataClient { + public override IBindable IsConnected { get; } = new Bindable(); + + public override IBindable IsWatchingUserPresence => isWatchingUserPresence; + private readonly BindableBool isWatchingUserPresence = new BindableBool(); + + // ReSharper disable once InconsistentlySynchronizedField + public override IBindableDictionary UserStates => userStates; + private readonly BindableDictionary userStates = new BindableDictionary(); + private readonly string endpoint; private IHubClientConnector? connector; private Bindable lastQueueId = null!; + private IBindable localUser = null!; + private IBindable userActivity = null!; + private IBindable? userStatus; + private HubConnection? connection => connector?.CurrentConnection; public OnlineMetadataClient(EndpointConfiguration endpoints) @@ -33,7 +49,7 @@ namespace osu.Game.Online.Metadata { // 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. - connector = api.GetHubConnector(nameof(OnlineMetadataClient), endpoint); + connector = api.GetHubConnector(nameof(OnlineMetadataClient), endpoint, false); if (connector != null) { @@ -42,12 +58,37 @@ namespace osu.Game.Online.Metadata // this is kind of SILLY // https://github.com/dotnet/aspnetcore/issues/15198 connection.On(nameof(IMetadataClient.BeatmapSetsUpdated), ((IMetadataClient)this).BeatmapSetsUpdated); + connection.On(nameof(IMetadataClient.UserPresenceUpdated), ((IMetadataClient)this).UserPresenceUpdated); }; - connector.IsConnected.BindValueChanged(isConnectedChanged, true); + IsConnected.BindTo(connector.IsConnected); + IsConnected.BindValueChanged(isConnectedChanged, true); } lastQueueId = config.GetBindable(OsuSetting.LastProcessedMetadataId); + + localUser = api.LocalUser.GetBoundCopy(); + userActivity = api.Activity.GetBoundCopy()!; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + localUser.BindValueChanged(_ => + { + if (localUser.Value is not GuestUser) + { + userStatus = localUser.Value.Status.GetBoundCopy(); + userStatus.BindValueChanged(status => UpdateStatus(status.NewValue), true); + } + else + userStatus = null; + }, true); + userActivity.BindValueChanged(activity => + { + if (localUser.Value is not GuestUser) + UpdateActivity(activity.NewValue); + }, true); } private bool catchingUp; @@ -55,7 +96,17 @@ namespace osu.Game.Online.Metadata private void isConnectedChanged(ValueChangedEvent connected) { if (!connected.NewValue) + { + isWatchingUserPresence.Value = false; + userStates.Clear(); return; + } + + if (localUser.Value is not GuestUser) + { + UpdateActivity(userActivity.Value); + UpdateStatus(userStatus?.Value); + } if (lastQueueId.Value >= 0) { @@ -116,6 +167,71 @@ namespace osu.Game.Online.Metadata return connection.InvokeAsync(nameof(IMetadataServer.GetChangesSince), queueId); } + public override Task UpdateActivity(UserActivity? activity) + { + if (connector?.IsConnected.Value != true) + return Task.FromCanceled(new CancellationToken(true)); + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMetadataServer.UpdateActivity), activity); + } + + public override Task UpdateStatus(UserStatus? status) + { + if (connector?.IsConnected.Value != true) + return Task.FromCanceled(new CancellationToken(true)); + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMetadataServer.UpdateStatus), status); + } + + public override Task UserPresenceUpdated(int userId, UserPresence? presence) + { + lock (userStates) + { + if (presence != null) + userStates[userId] = presence.Value; + else + userStates.Remove(userId); + } + + return Task.CompletedTask; + } + + public override async Task BeginWatchingUserPresence() + { + if (connector?.IsConnected.Value != true) + throw new OperationCanceledException(); + + Debug.Assert(connection != null); + await connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence)).ConfigureAwait(false); + isWatchingUserPresence.Value = true; + } + + public override async Task EndWatchingUserPresence() + { + try + { + if (connector?.IsConnected.Value != true) + throw new OperationCanceledException(); + + // must happen synchronously before any remote calls to avoid misordering. + userStates.Clear(); + Debug.Assert(connection != null); + await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false); + } + finally + { + isWatchingUserPresence.Value = false; + } + } + + public override async Task DisconnectRequested() + { + await base.DisconnectRequested().ConfigureAwait(false); + await EndWatchingUserPresence().ConfigureAwait(false); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index 0d846f7d27..c36e4ab894 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; using osu.Game.Online.API; +using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Overlays; @@ -30,6 +31,9 @@ namespace osu.Game.Online [Resolved] private SpectatorClient spectatorClient { get; set; } = null!; + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + [Resolved] private INotificationOverlay? notificationOverlay { get; set; } @@ -56,6 +60,7 @@ namespace osu.Game.Online multiplayerClient.Disconnecting += notifyAboutForcedDisconnection; spectatorClient.Disconnecting += notifyAboutForcedDisconnection; + metadataClient.Disconnecting += notifyAboutForcedDisconnection; } protected override void LoadComplete() @@ -131,6 +136,9 @@ namespace osu.Game.Online if (multiplayerClient.IsNotNull()) multiplayerClient.Disconnecting -= notifyAboutForcedDisconnection; + + if (metadataClient.IsNotNull()) + metadataClient.Disconnecting -= notifyAboutForcedDisconnection; } } } diff --git a/osu.Game/Users/UserPresence.cs b/osu.Game/Users/UserPresence.cs new file mode 100644 index 0000000000..dff40a9889 --- /dev/null +++ b/osu.Game/Users/UserPresence.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; + +namespace osu.Game.Users +{ + /// + /// Structure containing all relevant information about a user's online presence. + /// + [Serializable] + [MessagePackObject] + public struct UserPresence + { + /// + /// The user's current activity. + /// + [Key(0)] + public UserActivity? Activity { get; set; } + + /// + /// The user's current status. + /// + [Key(1)] + public UserStatus? Status { get; set; } + } +}