From c570db6c40f52c4c417ff48426b85378eff20fad Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 7 Apr 2026 21:14:49 +0900 Subject: [PATCH] Add ability to search for users (#37225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was a private request for roundtable event usage. It’s also a common feature request, so I decided to spend a bit of time getting this working well-enough. https://github.com/user-attachments/assets/acceb57f-2979-43d0-9fc2-33e977bd2dd5 --- ### Delay loading spinner / loading layer initial load briefly to avoid flickering There's cases in this overlay where loading takes a few milliseconds. The loading spinner gets annoying. This also happens elsewhere, so this could be considered a global fix. Separate PR? probably... ### Ingest loading state of dashboard child content to show more correct loading layer Each display had their own loading layer implementation, but this is already too deep (inside the scroll content) and doesn't display great when for instance, results don't take up the full screen height. --------- Co-authored-by: Bartłomiej Dach --- .../Visual/Online/TestSceneFriendDisplay.cs | 7 +- .../Online/TestSceneUserSearchDisplay.cs | 24 +++ .../UserInterface/TestSceneUserListToolbar.cs | 2 +- .../Graphics/UserInterface/LoadingSpinner.cs | 7 +- .../Online/API/Requests/SearchUsersRequest.cs | 32 +++ .../API/Requests/SearchUsersResponse.cs | 28 +++ .../CurrentlyOnline/CurrentlyOnlineDisplay.cs | 11 +- .../Dashboard/DashboardOverlayHeader.cs | 5 +- .../Dashboard/Friends/FriendDisplay.cs | 11 +- .../Dashboard/Friends/UserListToolbar.cs | 8 +- .../Dashboard/UserSearch/UserPanelList.cs | 69 +++++++ .../Dashboard/UserSearch/UserSearchDisplay.cs | 187 ++++++++++++++++++ osu.Game/Overlays/DashboardOverlay.cs | 30 ++- 13 files changed, 400 insertions(+), 21 deletions(-) create mode 100644 osu.Game.Tests/Visual/Online/TestSceneUserSearchDisplay.cs create mode 100644 osu.Game/Online/API/Requests/SearchUsersRequest.cs create mode 100644 osu.Game/Online/API/Requests/SearchUsersResponse.cs create mode 100644 osu.Game/Overlays/Dashboard/UserSearch/UserPanelList.cs create mode 100644 osu.Game/Overlays/Dashboard/UserSearch/UserSearchDisplay.cs 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"); }