diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlayV2.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlayV2.cs new file mode 100644 index 0000000000..98574e5d53 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlayV2.cs @@ -0,0 +1,422 @@ +// 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 System.Collections.Generic; +using System.Net; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Overlays.Chat; +using osu.Game.Overlays.Chat.Listing; +using osu.Game.Overlays.Chat.ChannelList; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneChatOverlayV2 : OsuManualInputManagerTestScene + { + private ChatOverlayV2 chatOverlay; + private ChannelManager channelManager; + + private APIUser testUser; + private Channel testPMChannel; + private Channel[] testChannels; + + private Channel testChannel1 => testChannels[0]; + private Channel testChannel2 => testChannels[1]; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [SetUp] + public void SetUp() => Schedule(() => + { + testUser = new APIUser { Username = "test user", Id = 5071479 }; + testPMChannel = new Channel(testUser); + testChannels = Enumerable.Range(1, 10).Select(createPublicChannel).ToArray(); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(ChannelManager), channelManager = new ChannelManager()), + }, + Children = new Drawable[] + { + channelManager, + chatOverlay = new ChatOverlayV2 { RelativeSizeAxes = Axes.Both }, + }, + }; + }); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Setup request handler", () => + { + ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case GetUpdatesRequest getUpdates: + getUpdates.TriggerFailure(new WebException()); + return true; + + case JoinChannelRequest joinChannel: + joinChannel.TriggerSuccess(); + return true; + + case LeaveChannelRequest leaveChannel: + leaveChannel.TriggerSuccess(); + return true; + + case GetMessagesRequest getMessages: + getMessages.TriggerSuccess(createChannelMessages(getMessages.Channel)); + return true; + + case GetUserRequest getUser: + if (getUser.Lookup == testUser.Username) + getUser.TriggerSuccess(testUser); + else + getUser.TriggerFailure(new WebException()); + return true; + + case PostMessageRequest postMessage: + postMessage.TriggerSuccess(new Message(RNG.Next(0, 10000000)) + { + Content = postMessage.Message.Content, + ChannelId = postMessage.Message.ChannelId, + Sender = postMessage.Message.Sender, + Timestamp = new DateTimeOffset(DateTime.Now), + }); + return true; + + default: + Logger.Log($"Unhandled Request Type: {req.GetType()}"); + return false; + } + }; + }); + + AddStep("Add test channels", () => + { + (channelManager.AvailableChannels as BindableList)?.AddRange(testChannels); + }); + } + + [Test] + public void TestShowHide() + { + AddStep("Show overlay", () => chatOverlay.Show()); + AddAssert("Overlay is visible", () => chatOverlay.State.Value == Visibility.Visible); + AddStep("Hide overlay", () => chatOverlay.Hide()); + AddAssert("Overlay is hidden", () => chatOverlay.State.Value == Visibility.Hidden); + } + + [Test] + public void TestChatHeight() + { + Bindable configChatHeight = null; + float newHeight = 0; + + AddStep("Bind config chat height", () => configChatHeight = config.GetBindable(OsuSetting.ChatDisplayHeight).GetBoundCopy()); + AddStep("Set config chat height", () => configChatHeight.Value = 0.4f); + AddStep("Show overlay", () => chatOverlay.Show()); + AddAssert("Overlay uses config height", () => chatOverlay.Height == 0.4f); + AddStep("Drag overlay to new height", () => + { + InputManager.MoveMouseTo(chatOverlayTopBar); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(chatOverlayTopBar, new Vector2(0, -300)); + InputManager.ReleaseButton(MouseButton.Left); + }); + AddStep("Store new height", () => newHeight = chatOverlay.Height); + AddAssert("Config height changed", () => configChatHeight.Value != 0.4f && configChatHeight.Value == newHeight); + AddStep("Hide overlay", () => chatOverlay.Hide()); + AddStep("Show overlay", () => chatOverlay.Show()); + AddAssert("Overlay uses new height", () => chatOverlay.Height == newHeight); + } + + [Test] + public void TestChannelSelection() + { + AddStep("Show overlay", () => chatOverlay.Show()); + AddAssert("Listing is visible", () => listingVisibility == Visibility.Visible); + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); + AddAssert("Listing is hidden", () => listingVisibility == Visibility.Hidden); + AddAssert("Loading is hidden", () => loadingVisibility == Visibility.Hidden); + AddAssert("Current channel is correct", () => channelManager.CurrentChannel.Value == testChannel1); + AddAssert("DrawableChannel is correct", () => currentDrawableChannel.Channel == testChannel1); + } + + [Test] + public void TestSearchInListing() + { + AddStep("Show overlay", () => chatOverlay.Show()); + AddAssert("Listing is visible", () => listingVisibility == Visibility.Visible); + AddStep("Search for 'number 2'", () => chatOverlayTextBox.Text = "number 2"); + AddUntilStep("Only channel 2 visibile", () => + { + IEnumerable listingItems = chatOverlay.ChildrenOfType() + .Where(item => item.IsPresent); + return listingItems.Count() == 1 && listingItems.Single().Channel == testChannel2; + }); + } + + [Test] + public void TestChannelCloseButton() + { + AddStep("Show overlay", () => chatOverlay.Show()); + AddStep("Join PM and public channels", () => + { + channelManager.JoinChannel(testChannel1); + channelManager.JoinChannel(testPMChannel); + }); + AddStep("Select PM channel", () => clickDrawable(getChannelListItem(testPMChannel))); + AddStep("Click close button", () => + { + ChannelListItemCloseButton closeButton = getChannelListItem(testPMChannel).ChildrenOfType().Single(); + clickDrawable(closeButton); + }); + AddAssert("PM channel closed", () => !channelManager.JoinedChannels.Contains(testPMChannel)); + AddStep("Select normal channel", () => clickDrawable(getChannelListItem(testChannel1))); + AddStep("Click close button", () => + { + ChannelListItemCloseButton closeButton = getChannelListItem(testChannel1).ChildrenOfType().Single(); + clickDrawable(closeButton); + }); + AddAssert("Normal channel closed", () => !channelManager.JoinedChannels.Contains(testChannel1)); + } + + [Test] + public void TestChatCommand() + { + AddStep("Show overlay", () => chatOverlay.Show()); + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); + AddStep("Open chat with user", () => channelManager.PostCommand($"chat {testUser.Username}")); + AddAssert("PM channel is selected", () => + channelManager.CurrentChannel.Value.Type == ChannelType.PM && channelManager.CurrentChannel.Value.Users.Single() == testUser); + AddStep("Open chat with non-existent user", () => channelManager.PostCommand("chat user_doesnt_exist")); + AddAssert("Last message is error", () => channelManager.CurrentChannel.Value.Messages.Last() is ErrorMessage); + + // Make sure no unnecessary requests are made when the PM channel is already open. + AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); + AddStep("Unregister request handling", () => ((DummyAPIAccess)API).HandleRequest = null); + AddStep("Open chat with user", () => channelManager.PostCommand($"chat {testUser.Username}")); + AddAssert("PM channel is selected", () => + channelManager.CurrentChannel.Value.Type == ChannelType.PM && channelManager.CurrentChannel.Value.Users.Single() == testUser); + } + + [Test] + public void TestMultiplayerChannelIsNotShown() + { + Channel multiplayerChannel = null; + + AddStep("Show overlay", () => chatOverlay.Show()); + AddStep("Join multiplayer channel", () => channelManager.JoinChannel(multiplayerChannel = new Channel(new APIUser()) + { + Name = "#mp_1", + Type = ChannelType.Multiplayer, + })); + AddAssert("Channel is joined", () => channelManager.JoinedChannels.Contains(multiplayerChannel)); + AddUntilStep("Channel not present in listing", () => !chatOverlay.ChildrenOfType() + .Where(item => item.IsPresent) + .Select(item => item.Channel) + .Contains(multiplayerChannel)); + } + + [Test] + public void TestHighlightOnCurrentChannel() + { + Message message = null; + + AddStep("Show overlay", () => chatOverlay.Show()); + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); + AddStep("Send message in channel 1", () => + { + testChannel1.AddNewMessages(message = new Message + { + ChannelId = testChannel1.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = testUser, + }); + }); + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel1)); + } + + [Test] + public void TestHighlightOnAnotherChannel() + { + Message message = null; + + AddStep("Show overlay", () => chatOverlay.Show()); + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddStep("Join channel 2", () => channelManager.JoinChannel(testChannel2)); + AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); + AddStep("Send message in channel 2", () => + { + testChannel2.AddNewMessages(message = new Message + { + ChannelId = testChannel2.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = testUser, + }); + }); + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel2)); + AddAssert("Channel 2 is selected", () => channelManager.CurrentChannel.Value == testChannel2); + AddAssert("Channel 2 is visible", () => currentDrawableChannel.Channel == testChannel2); + } + + [Test] + public void TestHighlightOnLeftChannel() + { + Message message = null; + + AddStep("Show overlay", () => chatOverlay.Show()); + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddStep("Join channel 2", () => channelManager.JoinChannel(testChannel2)); + AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); + AddStep("Send message in channel 2", () => + { + testChannel2.AddNewMessages(message = new Message + { + ChannelId = testChannel2.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = testUser, + }); + }); + AddStep("Leave channel 2", () => channelManager.LeaveChannel(testChannel2)); + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel2)); + AddAssert("Channel 2 is selected", () => channelManager.CurrentChannel.Value == testChannel2); + AddAssert("Channel 2 is visible", () => currentDrawableChannel.Channel == testChannel2); + } + + [Test] + public void TestHighlightWhileChatNeverOpen() + { + Message message = null; + + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddStep("Send message in channel 1", () => + { + testChannel1.AddNewMessages(message = new Message + { + ChannelId = testChannel1.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = testUser, + }); + }); + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel1)); + } + + [Test] + public void TestHighlightWithNullChannel() + { + Message message = null; + + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddStep("Send message in channel 1", () => + { + testChannel1.AddNewMessages(message = new Message + { + ChannelId = testChannel1.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = testUser, + }); + }); + AddStep("Set null channel", () => channelManager.CurrentChannel.Value = null); + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel1)); + } + + [Test] + public void TextBoxRetainsFocus() + { + AddStep("Show overlay", () => chatOverlay.Show()); + AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); + AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); + AddStep("Click selector", () => clickDrawable(chatOverlay.ChildrenOfType().Single())); + AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); + AddStep("Click listing", () => clickDrawable(chatOverlay.ChildrenOfType().Single())); + AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); + AddStep("Click drawable channel", () => clickDrawable(chatOverlay.ChildrenOfType().Single())); + AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); + AddStep("Click channel list", () => clickDrawable(chatOverlay.ChildrenOfType().Single())); + AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); + AddStep("Click top bar", () => clickDrawable(chatOverlay.ChildrenOfType().Single())); + AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); + AddStep("Hide overlay", () => chatOverlay.Hide()); + AddAssert("TextBox is not focused", () => InputManager.FocusedDrawable == null); + } + + private Visibility listingVisibility => + chatOverlay.ChildrenOfType().Single().State.Value; + + private Visibility loadingVisibility => + chatOverlay.ChildrenOfType().Single().State.Value; + + private DrawableChannel currentDrawableChannel => + chatOverlay.ChildrenOfType>().Single().Child; + + private ChannelListItem getChannelListItem(Channel channel) => + chatOverlay.ChildrenOfType().Single(item => item.Channel == channel); + + private ChatTextBox chatOverlayTextBox => + chatOverlay.ChildrenOfType().Single(); + + private ChatOverlayTopBar chatOverlayTopBar => + chatOverlay.ChildrenOfType().Single(); + + private void clickDrawable(Drawable d) + { + InputManager.MoveMouseTo(d); + InputManager.Click(MouseButton.Left); + } + + private List createChannelMessages(Channel channel) + { + var message = new Message + { + ChannelId = channel.Id, + Content = $"Hello, this is a message in {channel.Name}", + Sender = testUser, + Timestamp = new DateTimeOffset(DateTime.Now), + }; + return new List { message }; + } + + private Channel createPublicChannel(int id) => new Channel + { + Id = id, + Name = $"#channel-{id}", + Topic = $"We talk about the number {id} here", + Type = ChannelType.Public, + }; + } +} diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs index 7c4a72559b..c5ac87f527 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs @@ -25,14 +25,14 @@ namespace osu.Game.Overlays.Chat.ChannelList public event Action? OnRequestSelect; public event Action? OnRequestLeave; + public readonly Channel Channel; + public readonly BindableInt Mentions = new BindableInt(); public readonly BindableBool Unread = new BindableBool(); public readonly BindableBool SelectorActive = new BindableBool(); - private readonly Channel channel; - private Box hoverBox = null!; private Box selectBox = null!; private OsuSpriteText text = null!; @@ -46,7 +46,7 @@ namespace osu.Game.Overlays.Chat.ChannelList public ChannelListItem(Channel channel) { - this.channel = channel; + Channel = channel; } [BackgroundDependencyLoader] @@ -92,7 +92,7 @@ namespace osu.Game.Overlays.Chat.ChannelList { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Text = channel.Name, + Text = Channel.Name, Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold), Colour = colourProvider.Light3, Margin = new MarginPadding { Bottom = 2 }, @@ -111,7 +111,7 @@ namespace osu.Game.Overlays.Chat.ChannelList Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Margin = new MarginPadding { Right = 3 }, - Action = () => OnRequestLeave?.Invoke(channel), + Action = () => OnRequestLeave?.Invoke(Channel), } } }, @@ -119,20 +119,16 @@ namespace osu.Game.Overlays.Chat.ChannelList }, }; - Action = () => OnRequestSelect?.Invoke(channel); + Action = () => OnRequestSelect?.Invoke(Channel); } protected override void LoadComplete() { base.LoadComplete(); - selectedChannel.BindValueChanged(_ => updateSelectState(), true); - SelectorActive.BindValueChanged(_ => updateSelectState(), true); - - Unread.BindValueChanged(change => - { - text.FadeColour(change.NewValue ? colourProvider.Content1 : colourProvider.Light3, 300, Easing.OutQuint); - }, true); + selectedChannel.BindValueChanged(_ => updateState(), true); + SelectorActive.BindValueChanged(_ => updateState(), true); + Unread.BindValueChanged(_ => updateState(), true); } protected override bool OnHover(HoverEvent e) @@ -151,10 +147,10 @@ namespace osu.Game.Overlays.Chat.ChannelList private Drawable createIcon() { - if (channel.Type != ChannelType.PM) + if (Channel.Type != ChannelType.PM) return Drawable.Empty(); - return new UpdateableAvatar(channel.Users.First(), isInteractive: false) + return new UpdateableAvatar(Channel.Users.First(), isInteractive: false) { Size = new Vector2(20), Margin = new MarginPadding { Right = 5 }, @@ -165,12 +161,19 @@ namespace osu.Game.Overlays.Chat.ChannelList }; } - private void updateSelectState() + private void updateState() { - if (selectedChannel.Value == channel && !SelectorActive.Value) + bool selected = selectedChannel.Value == Channel && !SelectorActive.Value; + + if (selected) selectBox.FadeIn(300, Easing.OutQuint); else selectBox.FadeOut(200, Easing.OutQuint); + + if (Unread.Value || selected) + text.FadeColour(colourProvider.Content1, 300, Easing.OutQuint); + else + text.FadeColour(colourProvider.Light3, 200, Easing.OutQuint); } } } diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListSelector.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListSelector.cs index 57ab7584b5..9cba93ffa5 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListSelector.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListSelector.cs @@ -21,6 +21,10 @@ namespace osu.Game.Overlays.Chat.ChannelList private Box hoverBox = null!; private Box selectBox = null!; + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -46,11 +50,11 @@ namespace osu.Game.Overlays.Chat.ChannelList { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Left = 18, Right = 10 }, - Child = new OsuSpriteText + Child = text = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Text = "Add More Channels", + Text = "Add more channels", Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold), Colour = colourProvider.Light3, Margin = new MarginPadding { Bottom = 2 }, @@ -68,9 +72,15 @@ namespace osu.Game.Overlays.Chat.ChannelList SelectorActive.BindValueChanged(selector => { if (selector.NewValue) + { + text.FadeColour(colourProvider.Content1, 300, Easing.OutQuint); selectBox.FadeIn(300, Easing.OutQuint); + } else + { + text.FadeColour(colourProvider.Light3, 200, Easing.OutQuint); selectBox.FadeOut(200, Easing.OutQuint); + } }, true); Action = () => SelectorActive.Value = true; diff --git a/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs b/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs new file mode 100644 index 0000000000..3a8cd1fb91 --- /dev/null +++ b/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Chat +{ + public class ChatOverlayTopBar : Container + { + private Box background = null!; + + private Color4 backgroundColour; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, TextureStore textures) + { + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour = colourProvider.Background3, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 50), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = textures.Get("Icons/Hexacons/messaging"), + Size = new Vector2(18), + }, + // Placeholder text + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = "osu!chat", + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Bottom = 2f }, + }, + }, + }, + }, + }; + } + + protected override bool OnHover(HoverEvent e) + { + background.FadeColour(backgroundColour.Lighten(0.1f), 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + background.FadeColour(backgroundColour, 300, Easing.OutQuint); + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs index ef20149dac..0fa3613d38 100644 --- a/osu.Game/Overlays/Chat/ChatTextBar.cs +++ b/osu.Game/Overlays/Chat/ChatTextBar.cs @@ -11,7 +11,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; using osuTK; @@ -25,14 +26,19 @@ namespace osu.Game.Overlays.Chat public event Action? OnSearchTermsChanged; + public void TextBoxTakeFocus() => chatTextBox.TakeFocus(); + + public void TextBoxKillFocus() => chatTextBox.KillFocus(); + [Resolved] private Bindable currentChannel { get; set; } = null!; - private OsuTextFlowContainer chattingTextContainer = null!; + private Container chattingTextContainer = null!; + private OsuSpriteText chattingText = null!; private Container searchIconContainer = null!; private ChatTextBox chatTextBox = null!; - private const float chatting_text_width = 180; + private const float chatting_text_width = 240; private const float search_icon_width = 40; [BackgroundDependencyLoader] @@ -61,16 +67,20 @@ namespace osu.Game.Overlays.Chat { new Drawable[] { - chattingTextContainer = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 20)) + chattingTextContainer = new Container { - Masking = true, - Width = chatting_text_width, - Padding = new MarginPadding { Left = 10 }, RelativeSizeAxes = Axes.Y, - TextAnchor = Anchor.CentreRight, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Colour = colourProvider.Background1, + Width = chatting_text_width, + Masking = true, + Padding = new MarginPadding { Right = 5 }, + Child = chattingText = new OsuSpriteText + { + Font = OsuFont.Torus.With(size: 20), + Colour = colourProvider.Background1, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Truncate = true, + }, }, searchIconContainer = new Container { @@ -131,15 +141,15 @@ namespace osu.Game.Overlays.Chat switch (newChannel?.Type) { case ChannelType.Public: - chattingTextContainer.Text = $"chatting in {newChannel.Name}"; + chattingText.Text = $"chatting in {newChannel.Name}"; break; case ChannelType.PM: - chattingTextContainer.Text = $"chatting with {newChannel.Name}"; + chattingText.Text = $"chatting with {newChannel.Name}"; break; default: - chattingTextContainer.Text = string.Empty; + chattingText.Text = string.Empty; break; } }, true); diff --git a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs index 526cbcda87..1f0cbae7e2 100644 --- a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs +++ b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs @@ -25,11 +25,11 @@ namespace osu.Game.Overlays.Chat.Listing public event Action? OnRequestJoin; public event Action? OnRequestLeave; - public bool FilteringActive { get; set; } - public IEnumerable FilterTerms => new[] { channel.Name, channel.Topic ?? string.Empty }; - public bool MatchingFilter { set => this.FadeTo(value ? 1f : 0f, 100); } + public readonly Channel Channel; - private readonly Channel channel; + public bool FilteringActive { get; set; } + public IEnumerable FilterTerms => new[] { Channel.Name, Channel.Topic ?? string.Empty }; + public bool MatchingFilter { set => this.FadeTo(value ? 1f : 0f, 100); } private Box hoverBox = null!; private SpriteIcon checkbox = null!; @@ -47,7 +47,7 @@ namespace osu.Game.Overlays.Chat.Listing public ChannelListingItem(Channel channel) { - this.channel = channel; + Channel = channel; } [BackgroundDependencyLoader] @@ -94,7 +94,7 @@ namespace osu.Game.Overlays.Chat.Listing { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Text = channel.Name, + Text = Channel.Name, Font = OsuFont.Torus.With(size: text_size, weight: FontWeight.SemiBold), Margin = new MarginPadding { Bottom = 2 }, }, @@ -102,7 +102,7 @@ namespace osu.Game.Overlays.Chat.Listing { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Text = channel.Topic, + Text = Channel.Topic, Font = OsuFont.Torus.With(size: text_size), Margin = new MarginPadding { Bottom = 2 }, }, @@ -134,7 +134,7 @@ namespace osu.Game.Overlays.Chat.Listing { base.LoadComplete(); - channelJoined = channel.Joined.GetBoundCopy(); + channelJoined = Channel.Joined.GetBoundCopy(); channelJoined.BindValueChanged(change => { const double duration = 500; @@ -155,7 +155,7 @@ namespace osu.Game.Overlays.Chat.Listing } }, true); - Action = () => (channelJoined.Value ? OnRequestLeave : OnRequestJoin)?.Invoke(channel); + Action = () => (channelJoined.Value ? OnRequestLeave : OnRequestJoin)?.Invoke(Channel); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/ChatOverlayV2.cs b/osu.Game/Overlays/ChatOverlayV2.cs new file mode 100644 index 0000000000..cab88136fc --- /dev/null +++ b/osu.Game/Overlays/ChatOverlayV2.cs @@ -0,0 +1,314 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +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.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.Chat; +using osu.Game.Overlays.Chat; +using osu.Game.Overlays.Chat.ChannelList; +using osu.Game.Overlays.Chat.Listing; + +namespace osu.Game.Overlays +{ + public class ChatOverlayV2 : OsuFocusedOverlayContainer, INamedOverlayComponent + { + public string IconTexture => "Icons/Hexacons/messaging"; + public LocalisableString Title => ChatStrings.HeaderTitle; + public LocalisableString Description => ChatStrings.HeaderDescription; + + private ChatOverlayTopBar topBar = null!; + private ChannelList channelList = null!; + private LoadingLayer loading = null!; + private ChannelListing channelListing = null!; + private ChatTextBar textBar = null!; + private Container currentChannelContainer = null!; + + private readonly Bindable chatHeight = new Bindable(); + + private bool isDraggingTopBar; + private float dragStartChatHeight; + + private const int transition_length = 500; + private const float default_chat_height = 0.4f; + private const float top_bar_height = 40; + private const float side_bar_width = 190; + private const float chat_bar_height = 60; + + private readonly BindableBool selectorActive = new BindableBool(); + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private ChannelManager channelManager { get; set; } = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + [Cached] + private readonly Bindable currentChannel = new Bindable(); + + public ChatOverlayV2() + { + Height = default_chat_height; + + Masking = true; + + const float corner_radius = 7f; + + CornerRadius = corner_radius; + + // Hack to hide the bottom edge corner radius off-screen. + Margin = new MarginPadding { Bottom = -corner_radius }; + Padding = new MarginPadding { Bottom = corner_radius }; + + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + } + + [BackgroundDependencyLoader] + private void load() + { + // Required for the pop in/out animation + RelativePositionAxes = Axes.Both; + + Children = new Drawable[] + { + topBar = new ChatOverlayTopBar + { + RelativeSizeAxes = Axes.X, + Height = top_bar_height, + }, + channelList = new ChannelList + { + RelativeSizeAxes = Axes.Y, + Width = side_bar_width, + Padding = new MarginPadding { Top = top_bar_height }, + SelectorActive = { BindTarget = selectorActive }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Padding = new MarginPadding + { + Top = top_bar_height, + Left = side_bar_width, + Bottom = chat_bar_height, + }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + currentChannelContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + loading = new LoadingLayer(true), + channelListing = new ChannelListing + { + RelativeSizeAxes = Axes.Both, + }, + }, + }, + textBar = new ChatTextBar + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Padding = new MarginPadding { Left = side_bar_width }, + ShowSearch = { BindTarget = selectorActive }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + loading.Show(); + + config.BindWith(OsuSetting.ChatDisplayHeight, chatHeight); + + chatHeight.BindValueChanged(height => { Height = height.NewValue; }, true); + + currentChannel.BindTo(channelManager.CurrentChannel); + channelManager.CurrentChannel.BindValueChanged(currentChannelChanged, true); + channelManager.JoinedChannels.BindCollectionChanged(joinedChannelsChanged, true); + channelManager.AvailableChannels.BindCollectionChanged(availableChannelsChanged, true); + + channelList.OnRequestSelect += channel => + { + // Manually selecting a channel should dismiss the selector + selectorActive.Value = false; + channelManager.CurrentChannel.Value = channel; + }; + channelList.OnRequestLeave += channel => channelManager.LeaveChannel(channel); + + channelListing.OnRequestJoin += channel => channelManager.JoinChannel(channel); + channelListing.OnRequestLeave += channel => channelManager.LeaveChannel(channel); + + textBar.OnSearchTermsChanged += searchTerms => channelListing.SearchTerm = searchTerms; + textBar.OnChatMessageCommitted += handleChatMessage; + + selectorActive.BindValueChanged(v => channelListing.State.Value = v.NewValue ? Visibility.Visible : Visibility.Hidden, true); + } + + /// + /// Highlights a certain message in the specified channel. + /// + /// The message to highlight. + /// The channel containing the message. + public void HighlightMessage(Message message, Channel channel) + { + Debug.Assert(channel.Id == message.ChannelId); + + if (currentChannel.Value?.Id != channel.Id) + { + if (!channel.Joined.Value) + channel = channelManager.JoinChannel(channel); + + channelManager.CurrentChannel.Value = channel; + } + + selectorActive.Value = false; + + channel.HighlightedMessage.Value = message; + + Show(); + } + + protected override bool OnDragStart(DragStartEvent e) + { + isDraggingTopBar = topBar.IsHovered; + + if (!isDraggingTopBar) + return base.OnDragStart(e); + + dragStartChatHeight = chatHeight.Value; + return true; + } + + protected override void OnDrag(DragEvent e) + { + if (!isDraggingTopBar) + return; + + float targetChatHeight = dragStartChatHeight - (e.MousePosition.Y - e.MouseDownPosition.Y) / Parent.DrawSize.Y; + chatHeight.Value = targetChatHeight; + } + + protected override void OnDragEnd(DragEndEvent e) + { + isDraggingTopBar = false; + base.OnDragEnd(e); + } + + protected override void PopIn() + { + base.PopIn(); + + this.MoveToY(0, transition_length, Easing.OutQuint); + this.FadeIn(transition_length, Easing.OutQuint); + } + + protected override void PopOut() + { + base.PopOut(); + + this.MoveToY(Height, transition_length, Easing.InSine); + this.FadeOut(transition_length, Easing.InSine); + + textBar.TextBoxKillFocus(); + } + + protected override void OnFocus(FocusEvent e) + { + textBar.TextBoxTakeFocus(); + base.OnFocus(e); + } + + private void currentChannelChanged(ValueChangedEvent channel) + { + Channel? newChannel = channel.NewValue; + + loading.Show(); + + // Channel is null when leaving the currently selected channel + if (newChannel == null) + { + // Find another channel to switch to + newChannel = channelManager.JoinedChannels.FirstOrDefault(c => c != channel.OldValue); + + if (newChannel == null) + selectorActive.Value = true; + else + currentChannel.Value = newChannel; + + return; + } + + LoadComponentAsync(new DrawableChannel(newChannel), loaded => + { + currentChannelContainer.Clear(); + currentChannelContainer.Add(loaded); + loading.Hide(); + }); + } + + private void joinedChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + IEnumerable joinedChannels = filterChannels(args.NewItems); + foreach (var channel in joinedChannels) + channelList.AddChannel(channel); + break; + + case NotifyCollectionChangedAction.Remove: + IEnumerable leftChannels = filterChannels(args.OldItems); + foreach (var channel in leftChannels) + channelList.RemoveChannel(channel); + break; + } + } + + private void availableChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) + => channelListing.UpdateAvailableChannels(channelManager.AvailableChannels); + + private IEnumerable filterChannels(IList channels) + => channels.Cast().Where(c => c.Type == ChannelType.Public || c.Type == ChannelType.PM); + + private void handleChatMessage(string message) + { + if (string.IsNullOrWhiteSpace(message)) + return; + + if (message[0] == '/') + channelManager.PostCommand(message.Substring(1)); + else + channelManager.PostMessage(message); + } + } +}