diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index b9c1478fed..48e51c5b71 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -12,7 +12,6 @@ using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; @@ -219,13 +218,13 @@ namespace osu.Game.Tests.Visual.Online } private void waitForLoad() - => AddUntilStep("wait for panels to load", () => this.ChildrenOfType().First().State.Value, () => Is.EqualTo(Visibility.Hidden)); + => AddUntilStep("wait for panels to load", () => this.ChildrenOfType().Any()); private void assertVisiblePanelCount(int expectedVisible) where T : UserPanel { - AddAssert($"{typeof(T).ReadableName()}s in list", () => this.ChildrenOfType().Last().ChildrenOfType().All(p => p is T)); - AddAssert($"{expectedVisible} panels visible", () => this.ChildrenOfType().Last().ChildrenOfType().Count(p => p.IsPresent), + AddUntilStep($"{typeof(T).ReadableName()}s in list", () => this.ChildrenOfType().Last().ChildrenOfType().All(p => p is T)); + AddUntilStep($"{expectedVisible} panels visible", () => this.ChildrenOfType().Last().ChildrenOfType().Count(p => p.IsPresent), () => Is.EqualTo(expectedVisible)); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserSearchDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserSearchDisplay.cs new file mode 100644 index 0000000000..10189774f6 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneUserSearchDisplay.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Overlays; +using osu.Game.Overlays.Dashboard.UserSearch; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneUserSearchDisplay : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + protected override bool UseOnlineAPI => true; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = new UserSearchDisplay(); + }); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs index a54844099d..a373fbbc51 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs @@ -22,7 +22,7 @@ namespace osu.Game.Tests.Visual.UserInterface OsuSpriteText sort; OsuSpriteText displayStyle; - Add(toolbar = new UserListToolbar(true) + Add(toolbar = new UserListToolbar { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs index a2247006d7..58789ec680 100644 --- a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs +++ b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs @@ -171,7 +171,12 @@ namespace osu.Game.Graphics.UserInterface rotate(); MainContents.ScaleTo(1, TRANSITION_DURATION, Easing.OutQuint); - this.FadeIn(TRANSITION_DURATION, Easing.OutQuint); + + // Very slight delay to avoid spinner flickering briefly during minimal loads. + // Note that we still use fade in here because it is important for input blocking cases (see `LoadingLayer`). + this.FadeTo(0.01f, 50) + .Then() + .FadeIn(TRANSITION_DURATION, Easing.OutQuint); } protected override void PopOut() diff --git a/osu.Game/Online/API/Requests/SearchUsersRequest.cs b/osu.Game/Online/API/Requests/SearchUsersRequest.cs new file mode 100644 index 0000000000..9ed787b1bd --- /dev/null +++ b/osu.Game/Online/API/Requests/SearchUsersRequest.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + /// + /// Lookup up users with the given . + /// + public class SearchUsersRequest : APIRequest + { + public readonly string Query; + + public SearchUsersRequest(string query) + { + Query = query; + } + + protected override string Target => "search"; + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.AddParameter("mode", "user"); + req.AddParameter("query", Query); + + return req; + } + } +} diff --git a/osu.Game/Online/API/Requests/SearchUsersResponse.cs b/osu.Game/Online/API/Requests/SearchUsersResponse.cs new file mode 100644 index 0000000000..3dc84917c5 --- /dev/null +++ b/osu.Game/Online/API/Requests/SearchUsersResponse.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 System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class SearchUsersResponse + { + [JsonProperty("total")] + public int Total; + + public List Users => data.Users; + + [JsonProperty("user")] + private UserData data = null!; + + [Serializable] + private class UserData + { + [JsonProperty("data")] + public List Users = new List(); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnline/CurrentlyOnlineDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnline/CurrentlyOnlineDisplay.cs index c5c073ea4e..13c825efa7 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyOnline/CurrentlyOnlineDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnline/CurrentlyOnlineDisplay.cs @@ -18,6 +18,9 @@ namespace osu.Game.Overlays.Dashboard.CurrentlyOnline { public partial class CurrentlyOnlineDisplay : CompositeDrawable { + public IBindable Loading => loading; + private readonly BindableBool loading = new BindableBool(); + /// /// The current state of the . /// Presence is only updated when this value is . @@ -32,7 +35,6 @@ namespace osu.Game.Overlays.Dashboard.CurrentlyOnline private Box background = null!; private UserListToolbar userListToolbar = null!; private Container listContainer = null!; - private LoadingLayer loading = null!; private BasicSearchTextBox searchTextBox = null!; private CancellationTokenSource? listLoadCancellation; @@ -95,7 +97,7 @@ namespace osu.Game.Overlays.Dashboard.CurrentlyOnline PlaceholderText = HomeStrings.SearchPlaceholder, }, Empty(), - userListToolbar = new UserListToolbar(false) + userListToolbar = new UserListToolbar(supportsBrickMode: false) { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -115,7 +117,6 @@ namespace osu.Game.Overlays.Dashboard.CurrentlyOnline AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING } }, - loading = new LoadingLayer(true) } } } @@ -149,12 +150,12 @@ namespace osu.Game.Overlays.Dashboard.CurrentlyOnline SearchText = { BindTarget = searchTextBox.Current } }; - loading.Show(); + loading.Value = true; LoadComponentAsync(newList, finishLoad, cancellationSource.Token); void finishLoad(RealtimeUserList list) { - loading.Hide(); + loading.Value = false; if (currentList != null) { diff --git a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs index 8fd8f6b332..889f48bc19 100644 --- a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs +++ b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs @@ -30,6 +30,9 @@ namespace osu.Game.Overlays.Dashboard Friends, [Description("Currently online")] - CurrentlyPlaying + CurrentlyPlaying, + + [Description("User search")] + UserSearch } } diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index 66e90e51eb..c030d1d9cb 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -17,6 +17,9 @@ namespace osu.Game.Overlays.Dashboard.Friends { public partial class FriendDisplay : CompositeDrawable { + public IBindable Loading => loading; + private readonly BindableBool loading = new BindableBool(); + private readonly IBindableList apiFriends = new BindableList(); [Resolved] @@ -27,7 +30,6 @@ namespace osu.Game.Overlays.Dashboard.Friends private Box controlBackground = null!; private UserListToolbar userListToolbar = null!; private Container listContainer = null!; - private LoadingLayer loading = null!; private BasicSearchTextBox searchTextBox = null!; private CancellationTokenSource? listLoadCancellation; @@ -124,7 +126,7 @@ namespace osu.Game.Overlays.Dashboard.Friends PlaceholderText = HomeStrings.SearchPlaceholder, }, Empty(), - userListToolbar = new UserListToolbar(true) + userListToolbar = new UserListToolbar { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -144,7 +146,6 @@ namespace osu.Game.Overlays.Dashboard.Friends AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING } }, - loading = new LoadingLayer(true) } } } @@ -183,12 +184,12 @@ namespace osu.Game.Overlays.Dashboard.Friends SearchText = { BindTarget = searchTextBox.Current } }; - loading.Show(); + loading.Value = true; LoadComponentAsync(newList, finishLoad, cancellationSource.Token); void finishLoad(FriendsList list) { - loading.Hide(); + loading.Value = false; if (currentList != null) { diff --git a/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs index 62ba89495c..9098c5ab66 100644 --- a/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs +++ b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs @@ -2,11 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osuTK; -using osu.Framework.Bindables; using osu.Game.Configuration; +using osuTK; namespace osu.Game.Overlays.Dashboard.Friends { @@ -19,10 +19,11 @@ namespace osu.Game.Overlays.Dashboard.Friends private readonly Bindable configDisplayStyle = new Bindable(); private readonly bool supportsBrickMode; + private readonly UserSortTabControl sortControl; private readonly OverlayPanelDisplayStyleControl styleControl; - public UserListToolbar(bool supportsBrickMode) + public UserListToolbar(bool supportsBrickMode = true, bool supportsSort = true) { this.supportsBrickMode = supportsBrickMode; @@ -37,6 +38,7 @@ namespace osu.Game.Overlays.Dashboard.Friends { sortControl = new UserSortTabControl { + Alpha = supportsSort ? 1 : 0, Anchor = Anchor.Centre, Origin = Anchor.Centre, }, diff --git a/osu.Game/Overlays/Dashboard/UserSearch/UserPanelList.cs b/osu.Game/Overlays/Dashboard/UserSearch/UserPanelList.cs new file mode 100644 index 0000000000..caa60ece0c --- /dev/null +++ b/osu.Game/Overlays/Dashboard/UserSearch/UserPanelList.cs @@ -0,0 +1,69 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.UserSearch +{ + /// + /// This is copy pasted to hell, but it's where we're at. Based off FriendList but without the friend overheads. + /// + public partial class UserPanelList : CompositeDrawable + { + private readonly OverlayPanelDisplayStyle style; + private readonly APIUser[] users; + + public UserPanelList(OverlayPanelDisplayStyle style, APIUser[] users) + { + this.style = style; + this.users = users; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2), + ChildrenEnumerable = users.Select(createUserPanel) + }; + } + + private UserPanel createUserPanel(APIUser user) + { + UserPanel panel; + + switch (style) + { + default: + case OverlayPanelDisplayStyle.Card: + panel = new UserGridPanel(user); + panel.Anchor = Anchor.TopCentre; + panel.Origin = Anchor.TopCentre; + panel.Width = 290; + break; + + case OverlayPanelDisplayStyle.List: + panel = new UserListPanel(user); + break; + + case OverlayPanelDisplayStyle.Brick: + panel = new UserBrickPanel(user); + break; + } + + return panel; + } + } +} diff --git a/osu.Game/Overlays/Dashboard/UserSearch/UserSearchDisplay.cs b/osu.Game/Overlays/Dashboard/UserSearch/UserSearchDisplay.cs new file mode 100644 index 0000000000..dc2bfd39a3 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/UserSearch/UserSearchDisplay.cs @@ -0,0 +1,187 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Dashboard.UserSearch +{ + public partial class UserSearchDisplay : CompositeDrawable + { + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public IBindable Loading => loading; + private readonly BindableBool loading = new BindableBool(); + + private Box background = null!; + private UserListToolbar userListToolbar = null!; + private Container listContainer = null!; + private BasicSearchTextBox searchTextBox = null!; + + public UserSearchDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + Name = "User List", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Margin = new MarginPadding { Bottom = 20 }, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 40, + Vertical = 20 + }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 50), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + searchTextBox = new BasicSearchTextBox + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 40, + ReleaseFocusOnCommit = false, + HoldFocus = true, + PlaceholderText = HomeStrings.SearchPlaceholder, + }, + Empty(), + userListToolbar = new UserListToolbar(supportsBrickMode: true, supportsSort: false) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + listContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING } + }, + } + } + } + }, + } + } + } + }; + + background.Colour = colourProvider.Background4; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + searchTextBox.Current.BindValueChanged(_ => queueUpdateSearch()); + userListToolbar.DisplayStyle.BindValueChanged(_ => performSearch()); + } + + private ScheduledDelegate? queryChangedDebounce; + + private void queueUpdateSearch() + { + queryChangedDebounce?.Cancel(); + + if (!api.IsLoggedIn) + { + clearPreviousResults(); + return; + } + + if (string.IsNullOrEmpty(searchTextBox.Current.Value)) + { + loading.Value = false; + return; + } + + queryChangedDebounce = Scheduler.AddDelayed(performSearch, 500); + } + + private void performSearch() + { + loading.Value = true; + var getUsersRequest = new SearchUsersRequest(searchTextBox.Current.Value); + getUsersRequest.Success += showResults; + api.Queue(getUsersRequest); + } + + private void showResults(SearchUsersResponse response) + { + clearPreviousResults(); + var friendsList = new UserPanelList(userListToolbar.DisplayStyle.Value, response.Users.ToArray()); + listContainer.Add(friendsList); + + friendsList.FadeInFromZero(500, Easing.OutQuint); + } + + private void clearPreviousResults() + { + foreach (var child in listContainer.Children) + child.FadeOut(200).Expire(); + listContainer.Clear(); + + loading.Value = false; + } + } +} diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs index d0b1e96200..ce3dc2e457 100644 --- a/osu.Game/Overlays/DashboardOverlay.cs +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -2,19 +2,36 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Game.Overlays.Dashboard; using osu.Game.Overlays.Dashboard.CurrentlyOnline; using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Overlays.Dashboard.UserSearch; namespace osu.Game.Overlays { public partial class DashboardOverlay : TabbableOnlineOverlay { + private readonly BindableBool loading = new BindableBool(); + public DashboardOverlay() : base(OverlayColourScheme.Purple) { } + protected override void LoadComplete() + { + base.LoadComplete(); + + loading.BindValueChanged(loading => + { + if (loading.NewValue) + Loading.Show(); + else + Loading.Hide(); + }, true); + } + protected override DashboardOverlayHeader CreateHeader() => new DashboardOverlayHeader(); public override bool AcceptsFocus => false; @@ -24,16 +41,27 @@ namespace osu.Game.Overlays switch (tab) { case DashboardOverlayTabs.Friends: - LoadDisplay(new FriendDisplay()); + LoadDisplay(new FriendDisplay + { + Loading = { BindTarget = loading }, + }); break; case DashboardOverlayTabs.CurrentlyPlaying: LoadDisplay(new CurrentlyOnlineDisplay { + Loading = { BindTarget = loading }, OverlayState = { BindTarget = State } }); break; + case DashboardOverlayTabs.UserSearch: + LoadDisplay(new UserSearchDisplay + { + Loading = { BindTarget = loading }, + }); + break; + default: throw new NotImplementedException($"Display for {tab} tab is not implemented"); }