diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs index d925141510..124d0c239a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs @@ -11,6 +11,8 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -21,6 +23,7 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Comments; +using osu.Game.Overlays.Comments.Buttons; using osuTK.Input; namespace osu.Game.Tests.Visual.Online @@ -278,6 +281,93 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Request is correct", () => request != null && request.CommentID == 2 && request.Comment == report_text && request.Reason == CommentReportReason.Other); } + [Test] + public void TestReply() + { + addTestComments(); + DrawableComment? targetComment = null; + AddUntilStep("Comment exists", () => + { + var comments = this.ChildrenOfType(); + targetComment = comments.SingleOrDefault(x => x.Comment.Id == 2); + return targetComment != null; + }); + AddStep("Setup request handling", () => + { + requestLock.Reset(); + + dummyAPI.HandleRequest = r => + { + if (!(r is CommentPostRequest req)) + return false; + + if (req.ParentCommentId != 2) + throw new ArgumentException("Wrong parent ID in request!"); + + if (req.CommentableId != 123 || req.Commentable != CommentableType.Beatmapset) + throw new ArgumentException("Wrong commentable data in request!"); + + Task.Run(() => + { + requestLock.Wait(10000); + req.TriggerSuccess(new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 98, + Message = req.Message, + LegacyName = "FirstUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 98, + ParentId = req.ParentCommentId, + } + } + }); + }); + + return true; + }; + }); + AddStep("Click reply button", () => + { + var btn = targetComment.ChildrenOfType().Skip(1).First(); + var texts = btn.ChildrenOfType(); + InputManager.MoveMouseTo(texts.Skip(1).First()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("There is 0 replies", () => + { + var replLabel = targetComment.ChildrenOfType().First().ChildrenOfType().First(); + return replLabel.Text.ToString().Contains('0') && targetComment!.Comment.RepliesCount == 0; + }); + AddStep("Focus field", () => + { + InputManager.MoveMouseTo(targetComment.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddStep("Enter text", () => + { + targetComment.ChildrenOfType().First().Current.Value = "random reply"; + }); + AddStep("Submit", () => + { + InputManager.Key(Key.Enter); + }); + AddStep("Complete request", () => requestLock.Set()); + AddUntilStep("There is 1 reply", () => + { + var replLabel = targetComment.ChildrenOfType().First().ChildrenOfType().First(); + return replLabel.Text.ToString().Contains('1') && targetComment!.Comment.RepliesCount == 1; + }); + AddUntilStep("Submitted comment shown", () => + { + var r = targetComment.ChildrenOfType().Skip(1).FirstOrDefault(); + return r != null && r.Comment.Message == "random reply"; + }); + } + private void addTestComments() { AddStep("set up response", () => diff --git a/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs index aa9b2df7e4..555823e996 100644 --- a/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using Humanizer; using osu.Framework.Bindables; using osu.Framework.Input.Events; @@ -15,7 +13,12 @@ namespace osu.Game.Overlays.Comments.Buttons public ShowRepliesButton(int count) { - Text = "reply".ToQuantity(count); + Count = count; + } + + public int Count + { + set => Text = "reply".ToQuantity(value); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/Comments/CommentEditor.cs b/osu.Game/Overlays/Comments/CommentEditor.cs index 769263b3fc..ff417a2e15 100644 --- a/osu.Game/Overlays/Comments/CommentEditor.cs +++ b/osu.Game/Overlays/Comments/CommentEditor.cs @@ -35,6 +35,8 @@ namespace osu.Game.Overlays.Comments private RoundedButton commitButton = null!; private LoadingSpinner loadingSpinner = null!; + protected TextBox TextBox { get; private set; } = null!; + protected bool ShowLoadingSpinner { set @@ -51,8 +53,6 @@ namespace osu.Game.Overlays.Comments [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - EditorTextBox textBox; - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Masking = true; @@ -74,7 +74,7 @@ namespace osu.Game.Overlays.Comments Direction = FillDirection.Vertical, Children = new Drawable[] { - textBox = new EditorTextBox + TextBox = new EditorTextBox { Height = 40, RelativeSizeAxes = Axes.X, @@ -133,7 +133,7 @@ namespace osu.Game.Overlays.Comments } }); - textBox.OnCommit += (_, _) => commitButton.TriggerClick(); + TextBox.OnCommit += (_, _) => commitButton.TriggerClick(); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index a334fac215..c4e4700674 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -301,7 +301,7 @@ namespace osu.Game.Overlays.Comments void addNewComment(Comment comment) { - var drawableComment = getDrawableComment(comment); + var drawableComment = GetDrawableComment(comment); if (comment.ParentId == null) { @@ -333,7 +333,7 @@ namespace osu.Game.Overlays.Comments if (CommentDictionary.ContainsKey(comment.Id)) continue; - topLevelComments.Add(getDrawableComment(comment)); + topLevelComments.Add(GetDrawableComment(comment)); } if (topLevelComments.Any()) @@ -351,7 +351,7 @@ namespace osu.Game.Overlays.Comments } } - private DrawableComment getDrawableComment(Comment comment) + public DrawableComment GetDrawableComment(Comment comment) { if (CommentDictionary.TryGetValue(comment.Id, out var existing)) return existing; diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 1df65ab4b1..4cc189f3a2 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -22,6 +22,7 @@ using System.Collections.Specialized; using System.Diagnostics; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -74,6 +75,7 @@ namespace osu.Game.Overlays.Comments private OsuSpriteText deletedLabel = null!; private GridContainer content = null!; private VotePill votePill = null!; + private Container replyEditorContainer = null!; [Resolved] private IDialogOverlay? dialogOverlay { get; set; } @@ -232,6 +234,12 @@ namespace osu.Game.Overlays.Comments } } }, + replyEditorContainer = new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Top = 10 }, + }, new Container { AutoSizeAxes = Axes.Both, @@ -254,6 +262,7 @@ namespace osu.Game.Overlays.Comments }, childCommentsVisibilityContainer = new FillFlowContainer { + Name = @"Children comments", RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, @@ -344,6 +353,8 @@ namespace osu.Game.Overlays.Comments actionsContainer.AddLink(CommonStrings.ButtonsPermalink, copyUrl); actionsContainer.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); + actionsContainer.AddLink(CommonStrings.ButtonsReply.ToLower(), toggleReply); + actionsContainer.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); if (Comment.UserId.HasValue && Comment.UserId.Value == api.LocalUser.Value.Id) actionsContainer.AddLink(CommonStrings.ButtonsDelete.ToLower(), deleteComment); @@ -419,8 +430,9 @@ namespace osu.Game.Overlays.Comments if (!ShowDeleted.Value) Hide(); }); - request.Failure += _ => Schedule(() => + request.Failure += e => Schedule(() => { + Logger.Error(e, "Failed to delete comment"); actionsLoading.Hide(); actionsContainer.Show(); }); @@ -433,6 +445,26 @@ namespace osu.Game.Overlays.Comments onScreenDisplay?.Display(new CopyUrlToast()); } + private void toggleReply() + { + if (replyEditorContainer.Count == 0) + { + replyEditorContainer.Add(new ReplyCommentEditor(Comment) + { + OnPost = comments => + { + Comment.RepliesCount += comments.Length; + showRepliesButton.Count = Comment.RepliesCount; + Replies.AddRange(comments); + } + }); + } + else + { + replyEditorContainer.Clear(true); + } + } + protected override void LoadComplete() { ShowDeleted.BindValueChanged(show => @@ -445,8 +477,6 @@ namespace osu.Game.Overlays.Comments base.LoadComplete(); } - public bool ContainsReply(long replyId) => loadedReplies.ContainsKey(replyId); - private void onRepliesAdded(IEnumerable replies) { var page = createRepliesPage(replies); diff --git a/osu.Game/Overlays/Comments/ReplyCommentEditor.cs b/osu.Game/Overlays/Comments/ReplyCommentEditor.cs new file mode 100644 index 0000000000..e738e6e7ec --- /dev/null +++ b/osu.Game/Overlays/Comments/ReplyCommentEditor.cs @@ -0,0 +1,70 @@ +// 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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Comments +{ + public partial class ReplyCommentEditor : CancellableCommentEditor + { + [Resolved] + private CommentsContainer commentsContainer { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private readonly Comment parentComment; + + public Action? OnPost; + + protected override LocalisableString FooterText => default; + protected override LocalisableString CommitButtonText => CommonStrings.ButtonsReply; + protected override LocalisableString TextBoxPlaceholder => CommentsStrings.PlaceholderReply; + + public ReplyCommentEditor(Comment parent) + { + parentComment = parent; + OnCancel = () => this.FadeOut(200).Expire(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + GetContainingInputManager().ChangeFocus(TextBox); + } + + protected override void OnCommit(string text) + { + ShowLoadingSpinner = true; + CommentPostRequest req = new CommentPostRequest(commentsContainer.Type.Value, commentsContainer.Id.Value, text, parentComment.Id); + req.Failure += e => Schedule(() => + { + ShowLoadingSpinner = false; + Logger.Error(e, "Posting reply comment failed."); + }); + req.Success += cb => Schedule(processPostedComments, cb); + api.Queue(req); + } + + private void processPostedComments(CommentBundle cb) + { + foreach (var comment in cb.Comments) + comment.ParentComment = parentComment; + + var drawables = cb.Comments.Select(commentsContainer.GetDrawableComment).ToArray(); + OnPost?.Invoke(drawables); + + OnCancel!.Invoke(); + } + } +}