1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-03 15:02:55 +08:00

Merge pull request #30467 from cdwcgt/friend-add

Add the ability to add/remove friends in `UserProfileHeader`
This commit is contained in:
Bartłomiej Dach 2024-11-05 10:25:27 +01:00 committed by GitHub
commit 2bd12e14db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 488 additions and 39 deletions

View File

@ -155,7 +155,13 @@ namespace osu.Game.Tests.Visual.Gameplay
var api = (DummyAPIAccess)API;
api.Friends.Clear();
api.Friends.Add(friend);
api.Friends.Add(new APIRelation
{
Mutual = true,
RelationType = RelationType.Friend,
TargetID = friend.OnlineID,
TargetUser = friend
});
});
int playerNumber = 1;

View File

@ -30,14 +30,20 @@ namespace osu.Game.Tests.Visual.Online
if (supportLevel > 3)
supportLevel = 0;
((DummyAPIAccess)API).Friends.Add(new APIUser
((DummyAPIAccess)API).Friends.Add(new APIRelation
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
IsSupporter = supportLevel > 0,
SupportLevel = supportLevel
TargetID = 2,
RelationType = RelationType.Friend,
Mutual = true,
TargetUser = new APIUser
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
IsSupporter = supportLevel > 0,
SupportLevel = supportLevel
}
});
}
}

View File

@ -3,15 +3,20 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Profile;
using osu.Game.Overlays.Profile.Header.Components;
using osu.Game.Rulesets.Osu;
using osu.Game.Users;
@ -22,6 +27,10 @@ namespace osu.Game.Tests.Visual.Online
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
private readonly ManualResetEventSlim requestLock = new ManualResetEventSlim();
[Resolved]
private OsuConfigManager configManager { get; set; } = null!;
@ -400,5 +409,97 @@ namespace osu.Game.Tests.Visual.Online
}
}, new OsuRuleset().RulesetInfo));
}
private APIUser nonFriend => new APIUser
{
Id = 727,
Username = "Whatever",
};
[Test]
public void TestAddFriend()
{
AddStep("Setup request", () =>
{
requestLock.Reset();
dummyAPI.HandleRequest = request =>
{
if (request is not AddFriendRequest req)
return false;
if (req.TargetId != nonFriend.OnlineID)
return false;
var apiRelation = new APIRelation
{
TargetID = nonFriend.OnlineID,
Mutual = true,
RelationType = RelationType.Friend,
TargetUser = nonFriend
};
Task.Run(() =>
{
requestLock.Wait(3000);
dummyAPI.Friends.Add(apiRelation);
req.TriggerSuccess(new AddFriendResponse
{
UserRelation = apiRelation
});
});
return true;
};
});
AddStep("clear friend list", () => dummyAPI.Friends.Clear());
AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo));
AddStep("Click followers button", () => this.ChildrenOfType<FollowersButton>().First().TriggerClick());
AddStep("Complete request", () => requestLock.Set());
AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID));
}
[Test]
public void TestAddFriendNonMutual()
{
AddStep("Setup request", () =>
{
requestLock.Reset();
dummyAPI.HandleRequest = request =>
{
if (request is not AddFriendRequest req)
return false;
if (req.TargetId != nonFriend.OnlineID)
return false;
var apiRelation = new APIRelation
{
TargetID = nonFriend.OnlineID,
Mutual = false,
RelationType = RelationType.Friend,
TargetUser = nonFriend
};
Task.Run(() =>
{
requestLock.Wait(3000);
dummyAPI.Friends.Add(apiRelation);
req.TriggerSuccess(new AddFriendResponse
{
UserRelation = apiRelation
});
});
return true;
};
});
AddStep("clear friend list", () => dummyAPI.Friends.Clear());
AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo));
AddStep("Click followers button", () => this.ChildrenOfType<FollowersButton>().First().TriggerClick());
AddStep("Complete request", () => requestLock.Set());
AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID));
}
}
}

View File

@ -57,7 +57,7 @@ namespace osu.Game.Online.API
private string password;
public IBindable<APIUser> LocalUser => localUser;
public IBindableList<APIUser> Friends => friends;
public IBindableList<APIRelation> Friends => friends;
public IBindable<UserActivity> Activity => activity;
public IBindable<UserStatistics> Statistics => statistics;
@ -67,7 +67,7 @@ namespace osu.Game.Online.API
private Bindable<APIUser> localUser { get; } = new Bindable<APIUser>(createGuestUser());
private BindableList<APIUser> friends { get; } = new BindableList<APIUser>();
private BindableList<APIRelation> friends { get; } = new BindableList<APIRelation>();
private Bindable<UserActivity> activity { get; } = new Bindable<UserActivity>();
@ -360,19 +360,7 @@ namespace osu.Game.Online.API
}
}
var friendsReq = new GetFriendsRequest();
friendsReq.Failure += _ => state.Value = APIState.Failing;
friendsReq.Success += res =>
{
friends.Clear();
friends.AddRange(res);
};
if (!handleRequest(friendsReq))
{
state.Value = APIState.Failing;
return;
}
UpdateLocalFriends();
// The Success callback event is fired on the main thread, so we should wait for that to run before proceeding.
// Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests
@ -624,6 +612,22 @@ namespace osu.Game.Online.API
localUser.Value.Statistics = newStatistics;
}
public void UpdateLocalFriends()
{
if (!IsLoggedIn)
return;
var friendsReq = new GetFriendsRequest();
friendsReq.Failure += _ => state.Value = APIState.Failing;
friendsReq.Success += res =>
{
friends.Clear();
friends.AddRange(res);
};
Queue(friendsReq);
}
private static APIUser createGuestUser() => new GuestUser();
private void setLocalUser(APIUser user) => Scheduler.Add(() =>

View File

@ -26,7 +26,7 @@ namespace osu.Game.Online.API
Id = DUMMY_USER_ID,
});
public BindableList<APIUser> Friends { get; } = new BindableList<APIUser>();
public BindableList<APIRelation> Friends { get; } = new BindableList<APIRelation>();
public Bindable<UserActivity> Activity { get; } = new Bindable<UserActivity>();
@ -201,6 +201,10 @@ namespace osu.Game.Online.API
LocalUser.Value.Statistics = newStatistics;
}
public void UpdateLocalFriends()
{
}
public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null;
public IChatClient GetChatClient() => new TestChatClientConnector(this);
@ -214,7 +218,7 @@ namespace osu.Game.Online.API
public void SetState(APIState newState) => state.Value = newState;
IBindable<APIUser> IAPIProvider.LocalUser => LocalUser;
IBindableList<APIUser> IAPIProvider.Friends => Friends;
IBindableList<APIRelation> IAPIProvider.Friends => Friends;
IBindable<UserActivity> IAPIProvider.Activity => Activity;
IBindable<UserStatistics?> IAPIProvider.Statistics => Statistics;

View File

@ -22,7 +22,7 @@ namespace osu.Game.Online.API
/// <summary>
/// The user's friends.
/// </summary>
IBindableList<APIUser> Friends { get; }
IBindableList<APIRelation> Friends { get; }
/// <summary>
/// The current user's activity.
@ -134,6 +134,11 @@ namespace osu.Game.Online.API
/// </summary>
void UpdateStatistics(UserStatistics newStatistics);
/// <summary>
/// Update the friends status of the current user.
/// </summary>
void UpdateLocalFriends();
/// <summary>
/// Schedule a callback to run on the update thread.
/// </summary>

View File

@ -0,0 +1,30 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class AddFriendRequest : APIRequest<AddFriendResponse>
{
public readonly int TargetId;
public AddFriendRequest(int targetId)
{
TargetId = targetId;
}
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
req.AddParameter("target", TargetId.ToString(), RequestParameterType.Query);
return req;
}
protected override string Target => @"friends";
}
}

View File

@ -0,0 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests
{
public class AddFriendResponse
{
[JsonProperty("user_relation")]
public APIRelation UserRelation = null!;
}
}

View File

@ -0,0 +1,27 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class DeleteFriendRequest : APIRequest
{
public readonly int TargetId;
public DeleteFriendRequest(int targetId)
{
TargetId = targetId;
}
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Delete;
return req;
}
protected override string Target => $@"friends/{TargetId}";
}
}

View File

@ -6,7 +6,7 @@ using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests
{
public class GetFriendsRequest : APIRequest<List<APIUser>>
public class GetFriendsRequest : APIRequest<List<APIRelation>>
{
protected override string Target => @"friends";
}

View File

@ -0,0 +1,30 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace osu.Game.Online.API.Requests.Responses
{
public class APIRelation
{
[JsonProperty("target_id")]
public int TargetID { get; set; }
[JsonProperty("relation_type")]
public RelationType RelationType { get; set; }
[JsonProperty("mutual")]
public bool Mutual { get; set; }
[JsonProperty("target")]
public APIUser? TargetUser { get; set; }
}
[JsonConverter(typeof(StringEnumConverter))]
public enum RelationType
{
Friend,
Block,
}
}

View File

@ -49,7 +49,7 @@ namespace osu.Game.Overlays.Dashboard.Friends
private LoadingLayer loading;
private BasicSearchTextBox searchTextBox;
private readonly IBindableList<APIUser> apiFriends = new BindableList<APIUser>();
private readonly IBindableList<APIRelation> apiFriends = new BindableList<APIRelation>();
public FriendDisplay()
{
@ -177,7 +177,7 @@ namespace osu.Game.Overlays.Dashboard.Friends
controlBackground.Colour = colourProvider.Background5;
apiFriends.BindTo(api.Friends);
apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.ToList()), true);
apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.Select(f => f.TargetUser).ToList()), true);
}
protected override void LoadComplete()

View File

@ -1,11 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.Notifications;
using osu.Game.Resources.Localisation.Web;
using SharpCompress;
namespace osu.Game.Overlays.Profile.Header.Components
{
@ -13,15 +23,201 @@ namespace osu.Game.Overlays.Profile.Header.Components
{
public readonly Bindable<UserProfileData?> User = new Bindable<UserProfileData?>();
public override LocalisableString TooltipText => FriendsStrings.ButtonsDisabled;
// Because it is impossible to update the number of friends after the operation,
// the number of friends obtained is stored and modified locally.
private int followerCount;
public override LocalisableString TooltipText
{
get
{
switch (status.Value)
{
case FriendStatus.Self:
return FriendsStrings.ButtonsDisabled;
case FriendStatus.None:
return FriendsStrings.ButtonsAdd;
case FriendStatus.NotMutual:
case FriendStatus.Mutual:
return FriendsStrings.ButtonsRemove;
}
return FriendsStrings.TitleCompact;
}
}
protected override IconUsage Icon => FontAwesome.Solid.User;
private readonly IBindableList<APIRelation> apiFriends = new BindableList<APIRelation>();
private readonly IBindable<APIUser> localUser = new Bindable<APIUser>();
private readonly Bindable<FriendStatus> status = new Bindable<FriendStatus>();
[Resolved]
private OsuColour colour { get; set; } = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
private void load(IAPIProvider api, INotificationOverlay? notifications)
{
// todo: when friending/unfriending is implemented, the APIAccess.Friends list should be updated accordingly.
User.BindValueChanged(user => SetValue(user.NewValue?.User.FollowerCount ?? 0), true);
localUser.BindTo(api.LocalUser);
status.BindValueChanged(_ =>
{
updateIcon();
updateColor();
});
User.BindValueChanged(u =>
{
followerCount = u.NewValue?.User.FollowerCount ?? 0;
updateStatus();
}, true);
apiFriends.BindTo(api.Friends);
apiFriends.BindCollectionChanged((_, _) => Schedule(updateStatus));
Action += () =>
{
if (User.Value == null)
return;
if (status.Value == FriendStatus.Self)
return;
ShowLoadingLayer();
APIRequest req = status.Value == FriendStatus.None ? new AddFriendRequest(User.Value.User.OnlineID) : new DeleteFriendRequest(User.Value.User.OnlineID);
req.Success += () =>
{
if (req is AddFriendRequest addedRequest)
{
SetValue(++followerCount);
status.Value = addedRequest.Response?.UserRelation.Mutual == true ? FriendStatus.Mutual : FriendStatus.NotMutual;
}
else
{
SetValue(--followerCount);
status.Value = FriendStatus.None;
}
api.UpdateLocalFriends();
HideLoadingLayer();
};
req.Failure += e =>
{
notifications?.Post(new SimpleNotification
{
Text = e.Message,
Icon = FontAwesome.Solid.Times,
});
HideLoadingLayer();
};
api.Queue(req);
};
}
protected override bool OnHover(HoverEvent e)
{
if (status.Value > FriendStatus.None)
{
SetIcon(FontAwesome.Solid.UserTimes);
}
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
base.OnHoverLost(e);
updateIcon();
}
private void updateStatus()
{
SetValue(followerCount);
if (localUser.Value.OnlineID == User.Value?.User.OnlineID)
{
status.Value = FriendStatus.Self;
return;
}
var friend = apiFriends.FirstOrDefault(u => User.Value?.User.OnlineID == u.TargetID);
if (friend != null)
{
status.Value = friend.Mutual ? FriendStatus.Mutual : FriendStatus.NotMutual;
}
else
{
status.Value = FriendStatus.None;
}
}
private void updateIcon()
{
switch (status.Value)
{
case FriendStatus.Self:
SetIcon(FontAwesome.Solid.User);
break;
case FriendStatus.None:
SetIcon(FontAwesome.Solid.UserPlus);
break;
case FriendStatus.NotMutual:
SetIcon(FontAwesome.Solid.User);
break;
case FriendStatus.Mutual:
SetIcon(FontAwesome.Solid.UserFriends);
break;
}
}
private void updateColor()
{
// https://github.com/ppy/osu-web/blob/0a5367a4a68a6cdf450eb483251b3cf03b3ac7d2/resources/css/bem/user-action-button.less
switch (status.Value)
{
case FriendStatus.Self:
case FriendStatus.None:
IdleColour = colourProvider.Background6;
HoverColour = colourProvider.Background5;
break;
case FriendStatus.NotMutual:
IdleColour = colour.Green.Opacity(0.7f);
HoverColour = IdleColour.Lighten(0.1f);
break;
case FriendStatus.Mutual:
IdleColour = colour.Pink.Opacity(0.7f);
HoverColour = IdleColour.Lighten(0.1f);
break;
}
EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour : IdleColour, FADE_DURATION, Easing.OutQuint));
}
private enum FriendStatus
{
Self,
None,
NotMutual,
Mutual,
}
}
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Profile.Header.Components
{
@ -14,6 +15,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{
private readonly Box background;
private readonly Container content;
private readonly LoadingLayer loading;
protected override Container<Drawable> Content => content;
@ -40,11 +42,22 @@ namespace osu.Game.Overlays.Profile.Header.Components
AutoSizeAxes = Axes.X,
RelativeSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = 10 },
}
},
loading = new LoadingLayer(true, false)
}
});
}
protected void ShowLoadingLayer()
{
loading.Show();
}
protected void HideLoadingLayer()
{
loading.Hide();
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{

View File

@ -14,6 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
public abstract partial class ProfileHeaderStatisticsButton : ProfileHeaderButton
{
private readonly OsuSpriteText drawableText;
private readonly Container iconContainer;
protected ProfileHeaderStatisticsButton()
{
@ -26,13 +27,11 @@ namespace osu.Game.Overlays.Profile.Header.Components
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
new SpriteIcon
iconContainer = new Container
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Icon = Icon,
FillMode = FillMode.Fit,
Size = new Vector2(50, 14)
AutoSizeAxes = Axes.Both,
},
drawableText = new OsuSpriteText
{
@ -43,10 +42,24 @@ namespace osu.Game.Overlays.Profile.Header.Components
}
}
};
SetIcon(Icon);
}
protected abstract IconUsage Icon { get; }
protected void SetIcon(IconUsage icon)
{
iconContainer.Child = new SpriteIcon
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Icon = icon,
FillMode = FillMode.Fit,
Size = new Vector2(50, 14)
};
}
protected void SetValue(int value) => drawableText.Text = value.ToLocalisableString("#,##0");
}
}

View File

@ -316,7 +316,7 @@ namespace osu.Game.Screens.Play.HUD
HasQuit.BindValueChanged(_ => updateState());
isFriend = User != null && api.Friends.Any(u => User.OnlineID == u.Id);
isFriend = User != null && api.Friends.Any(u => User.OnlineID == u.TargetID);
}
protected override void LoadComplete()