diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatTicker.cs b/osu.Game.Tests/Visual/Online/TestSceneChatTicker.cs new file mode 100644 index 000000000000..3144d6806051 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneChatTicker.cs @@ -0,0 +1,169 @@ +// 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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +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; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public partial class TestSceneChatTicker : OsuManualInputManagerTestScene + { + private APIUser friend; + private APIUser importantPerson; + private Channel publicChannel; + private Channel privateMessageChannel; + private TestContainer testContainer; + + private int messageIdCounter; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + if (API is DummyAPIAccess daa) + { + daa.HandleRequest = dummyAPIHandleRequest; + } + + friend = new APIUser { Id = 0, Username = "SomeFriend" }; + importantPerson = new APIUser { Username = @"i-am-important", Id = 42, Colour = "#250cc9" }; + publicChannel = new Channel { Id = 1, Name = "#osu" }; + privateMessageChannel = new Channel(friend) { Id = 2, Name = friend.Username, Type = ChannelType.PM }; + + Schedule(() => + { + Child = testContainer = new TestContainer(API, new[] { publicChannel, privateMessageChannel }) + { + RelativeSizeAxes = Axes.Both, + }; + }); + }); + + private bool dummyAPIHandleRequest(APIRequest request) + { + switch (request) + { + case GetMessagesRequest messagesRequest: + messagesRequest.TriggerSuccess(new List(0)); + return true; + + case CreateChannelRequest createChannelRequest: + var apiChatChannel = new APIChatChannel + { + RecentMessages = new List(0), + ChannelID = (int)createChannelRequest.Channel.Id + }; + createChannelRequest.TriggerSuccess(apiChatChannel); + return true; + + case ListChannelsRequest listChannelsRequest: + listChannelsRequest.TriggerSuccess(new List(1) { publicChannel }); + return true; + + case GetUpdatesRequest updatesRequest: + updatesRequest.TriggerSuccess(new GetUpdatesResponse + { + Messages = new List(0), + Presence = new List(0) + }); + return true; + + case JoinChannelRequest joinChannelRequest: + joinChannelRequest.TriggerSuccess(); + return true; + + default: + return false; + } + } + + [Test] + public void TestChatTicker() + { + AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel); + + AddStep("receive public message", () => receiveMessage(friend, publicChannel, "Hello everyone")); + + AddStep("receive message containing mention", () => receiveMessage(friend, publicChannel, $"Hello {API.LocalUser.Value.Username.ToLowerInvariant()}!")); + + AddStep("receive message from VIP", () => receiveMessage(importantPerson, publicChannel, "Hello everyone!")); + + AddStep("receive message from VIP containing mention", () => receiveMessage(importantPerson, publicChannel, $"Hello {API.LocalUser.Value.Username.ToLowerInvariant()}!")); + + AddStep("receive very long message", () => receiveMessage(importantPerson, publicChannel, string.Concat(Enumerable.Repeat("Hello everyone! ", 50)))); + + AddToggleStep("toggle show ticker", b => config.SetValue(OsuSetting.ChatTicker, b)); + } + + private void receiveMessage(APIUser sender, Channel channel, string content) => channel.AddNewMessages(createMessage(sender, channel, content)); + + private Message createMessage(APIUser sender, Channel channel, string content) => new Message(messageIdCounter++) + { + Content = content, + Sender = sender, + ChannelId = channel.Id + }; + + private partial class TestContainer : Container + { + [Cached] + public ChannelManager ChannelManager { get; } + + [Cached(typeof(INotificationOverlay))] + public NotificationOverlay NotificationOverlay { get; } = new NotificationOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }; + + [Cached] + public ChatTicker ChatTicker { get; } = new ChatTicker(); + + [Cached] + public ChatOverlay ChatOverlay { get; } = new ChatOverlay(); + + private readonly MessageNotifier messageNotifier = new MessageNotifier(); + + private readonly Channel[] channels; + + public TestContainer(IAPIProvider api, Channel[] channels) + { + this.channels = channels; + ChannelManager = new ChannelManager(api); + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + ChannelManager, + ChatOverlay, + ChatTicker, + messageNotifier, + }; + + ((BindableList)ChannelManager.AvailableChannels).AddRange(channels); + + foreach (var channel in channels) + ChannelManager.JoinChannel(channel); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs index 274d7f0c515e..ac596e0b71f2 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -32,6 +33,9 @@ public partial class TestSceneMessageNotifier : OsuManualInputManagerTestScene private int messageIdCounter; + [Resolved] + private OsuConfigManager config { get; set; } = null!; + [SetUp] public void Setup() => Schedule(() => { @@ -44,6 +48,8 @@ public void Setup() => Schedule(() => publicChannel = new Channel { Id = 1, Name = "#osu" }; privateMessageChannel = new Channel(friend) { Id = 2, Name = friend.Username, Type = ChannelType.PM }; + config.SetValue(OsuSetting.ChatTicker, false); + Schedule(() => { Child = testContainer = new TestContainer(API, new[] { publicChannel, privateMessageChannel }) @@ -222,6 +228,41 @@ public void TestSendInUnresolvedChannel() AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0); } + [Test] + public void TestChatTicker() + { + AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel); + + AddStep("receive message on channel", () => receiveMessage(friend, publicChannel, "Hello everyone!")); + AddAssert("ticker is hidden", () => !testContainer.ChatTicker.IsPresent); + + AddStep("toggle show ticker on", () => config.SetValue(OsuSetting.ChatTicker, true)); + + AddStep("receive message on channel", () => receiveMessage(friend, publicChannel, "Hello everyone!")); + AddAssert("ticker is hidden", () => !testContainer.ChatTicker.IsPresent); + + AddStep("close overlay", () => testContainer.ChatOverlay.Hide()); + + AddStep("receive PM", () => receiveMessage(friend, privateMessageChannel, "hey hey")); + AddAssert("ticker is hidden", () => !testContainer.ChatTicker.IsPresent); + + AddStep("receive message on channel", () => receiveMessage(friend, publicChannel, "Hello everyone!")); + AddAssert("ticker is present", () => testContainer.ChatTicker.IsPresent); + + AddStep("receive last message only", () => + { + List messages = new List { createMessage(friend, publicChannel, "This should be my first message") }; + messages.AddRange(Enumerable.Range(0, 10).Select(i => createMessage(friend, publicChannel, $"Hey hey {i}"))); + messages.Add(createMessage(friend, publicChannel, "This should be my last message.")); + + publicChannel.AddNewMessages(messages.ToArray()); + }); + AddAssert("ticker is present", () => testContainer.ChatTicker.IsPresent); + + AddStep("toggle show ticker off", () => config.SetValue(OsuSetting.ChatTicker, false)); + AddAssert("ticker is hidden", () => !testContainer.ChatTicker.IsPresent); + } + private void receiveMessage(APIUser sender, Channel channel, string content) => channel.AddNewMessages(createMessage(sender, channel, content)); private Message createMessage(APIUser sender, Channel channel, string content) => new Message(messageIdCounter++) @@ -251,6 +292,9 @@ private partial class TestContainer : Container Origin = Anchor.TopRight, }; + [Cached] + public ChatTicker ChatTicker { get; } = new ChatTicker(); + [Cached] public ChatOverlay ChatOverlay { get; } = new ChatOverlay(); @@ -271,6 +315,7 @@ private void load() { ChannelManager, ChatOverlay, + ChatTicker, NotificationOverlay, messageNotifier, }; diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index b48421cafdb9..5a6eadaeed30 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -103,6 +103,7 @@ protected override void InitialiseDefaults() SetDefault(OsuSetting.ShowOnlineExplicitContent, false); + SetDefault(OsuSetting.ChatTicker, false); SetDefault(OsuSetting.NotifyOnUsernameMentioned, true); SetDefault(OsuSetting.NotifyOnPrivateMessage, true); SetDefault(OsuSetting.NotifyOnFriendPresenceChange, true); @@ -449,6 +450,7 @@ public enum OsuSetting ScalingBackgroundDim, UIScale, IntroSequence, + ChatTicker, NotifyOnUsernameMentioned, NotifyOnPrivateMessage, NotifyOnFriendPresenceChange, diff --git a/osu.Game/Graphics/Containers/OsuClickableContainer.cs b/osu.Game/Graphics/Containers/OsuClickableContainer.cs index dbc354ae074d..fa2597a118a7 100644 --- a/osu.Game/Graphics/Containers/OsuClickableContainer.cs +++ b/osu.Game/Graphics/Containers/OsuClickableContainer.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -16,6 +17,8 @@ public partial class OsuClickableContainer : ClickableContainer, IHasTooltip { private readonly HoverSampleSet sampleSet; + public readonly Bindable MuteSounds = new Bindable(); + private readonly Container content = new Container { RelativeSizeAxes = Axes.Both }; private HoverSounds samples = null!; @@ -28,7 +31,7 @@ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => protected override Container Content => content; - protected virtual HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet) { Enabled = { BindTarget = Enabled } }; + protected virtual HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet) { Enabled = { BindTarget = Enabled }, MuteSounds = { BindTarget = MuteSounds } }; public OsuClickableContainer(HoverSampleSet sampleSet = HoverSampleSet.Default) { diff --git a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs index f1c14eb6b530..3de222639756 100644 --- a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs @@ -55,7 +55,12 @@ protected override bool OnClick(ClickEvent e) return base.OnClick(e); } - public void PlayClickSample() => + public void PlayClickSample() + { + if (MuteSounds.Value) + return; + SamplePlaybackHelper.PlayWithRandomPitch(Enabled.Value ? sampleClick : sampleClickDisabled, pitchVariation: 0.01); + } } } diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index b305104eeeda..c72e4ce919c4 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs @@ -21,6 +21,8 @@ public partial class HoverSounds : HoverSampleDebounceComponent { public readonly Bindable Enabled = new Bindable(true); + public readonly Bindable MuteSounds = new Bindable(); + private Sample sampleHover; protected readonly HoverSampleSet SampleSet; @@ -40,7 +42,7 @@ private void load(AudioManager audio) public override void PlayHoverSample() { - if (!Enabled.Value) + if (!Enabled.Value || MuteSounds.Value) return; SamplePlaybackHelper.PlayWithRandomPitch(sampleHover, pitchVariation: 0.02); diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs index 98364a3f5a53..fe7fbc61b53e 100644 --- a/osu.Game/Localisation/OnlineSettingsStrings.cs +++ b/osu.Game/Localisation/OnlineSettingsStrings.cs @@ -19,6 +19,11 @@ public static class OnlineSettingsStrings /// public static LocalisableString AlertsAndPrivacyHeader => new TranslatableString(getKey(@"alerts_and_privacy_header"), @"Alerts and Privacy"); + /// + /// "Chat ticker" + /// + public static LocalisableString ChatTicker => new TranslatableString(getKey(@"chat_ticker"), @"Chat ticker"); + /// /// "Show a notification when someone mentions your name" /// diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index 0d3072b9e321..8ad0ed3c54f4 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -36,6 +36,9 @@ public partial class MessageNotifier : Component [Resolved] private ChatOverlay chatOverlay { get; set; } + [Resolved] + private ChatTicker chatTicker { get; set; } + [Resolved] private ChannelManager channelManager { get; set; } @@ -99,11 +102,16 @@ private void checkNewMessages(IEnumerable messages) if (channel == null) return; + var sortedMessages = messages.OrderByDescending(m => m.Id); + + if (!chatOverlay.IsPresent && channelManager.CurrentChannel.Value == channel) + chatTicker.PostMessage(sortedMessages.First()); + // Only send notifications if ChatOverlay or the target channel aren't visible, or if the window is unfocused if (chatOverlay.IsPresent && channelManager.CurrentChannel.Value == channel && host.IsActive.Value) return; - foreach (var message in messages.OrderByDescending(m => m.Id)) + foreach (var message in sortedMessages) { // ignore messages that already have been read if (message.Id <= channel.LastReadId) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index caf2a6279a8e..d4d4c8ca6969 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1148,6 +1148,8 @@ protected override void LoadComplete() ScreenStack.ScreenPushed += screenPushed; ScreenStack.ScreenExited += screenExited; + loadComponentSingleFile(new ChatTicker(), topMostOverlayContent.Add, true); + loadComponentSingleFile(fpsCounter = new FPSCounter { Anchor = Anchor.BottomRight, diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 427d874f121a..ca449a95862e 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -54,6 +54,8 @@ public Message Message protected virtual float UsernameWidth => 150; + protected bool UsernameIsClickable { get; init; } = true; + [Resolved] private ChannelManager? chatManager { get; set; } @@ -196,6 +198,8 @@ private void load(OsuConfigManager configManager) Margin = new MarginPadding { Horizontal = Spacing }, AccentColour = UsernameColour, Inverted = !string.IsNullOrEmpty(message.Sender.Colour), + Enabled = { Value = UsernameIsClickable }, + MuteSounds = { Value = !UsernameIsClickable }, }, drawableContentFlow = new LinkFlowContainer(styleMessageContent) { diff --git a/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs b/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs index 3ecdb0997686..d45940f59dc8 100644 --- a/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs +++ b/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs @@ -7,7 +7,6 @@ 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; @@ -21,7 +20,7 @@ public partial class ChatOverlayTopBar : Container public Drawable DragBar { get; private set; } = null!; [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, TextureStore textures) + private void load(OverlayColourProvider colourProvider) { Children = new[] { diff --git a/osu.Game/Overlays/Chat/DrawableChatUsername.cs b/osu.Game/Overlays/Chat/DrawableChatUsername.cs index 59a4985d0836..b0ddb0a1153b 100644 --- a/osu.Game/Overlays/Chat/DrawableChatUsername.cs +++ b/osu.Game/Overlays/Chat/DrawableChatUsername.cs @@ -231,9 +231,22 @@ private void openUserProfile() profileOverlay?.ShowUser(user); } + protected override bool OnClick(ClickEvent e) + { + if (!Enabled.Value) + { + return false; + } + + return base.OnClick(e); + } + protected override bool OnHover(HoverEvent e) { - colouredDrawable.FadeColour(AccentColour.Lighten(0.6f), 30, Easing.OutQuint); + if (Enabled.Value) + { + colouredDrawable.FadeColour(AccentColour.Lighten(0.6f), 30, Easing.OutQuint); + } return base.OnHover(e); } @@ -242,7 +255,10 @@ protected override void OnHoverLost(HoverLostEvent e) { base.OnHoverLost(e); - colouredDrawable.FadeColour(AccentColour, 800, Easing.OutQuint); + if (Enabled.Value) + { + colouredDrawable.FadeColour(AccentColour, 800, Easing.OutQuint); + } } } } diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index e7422d6f86da..bb33619e61b8 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -66,6 +66,9 @@ public partial class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComp [Resolved] private ChannelManager channelManager { get; set; } = null!; + [Resolved] + private ChatTicker? chatTicker { get; set; } + [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); @@ -200,6 +203,9 @@ protected override void LoadComplete() textBar.OnSearchTermsChanged += searchTerms => channelListing.SearchTerm = searchTerms; textBar.OnChatMessageCommitted += handleChatMessage; + + if (chatTicker != null) + State.BindValueChanged(_ => chatTicker.PostMessage(null)); } /// diff --git a/osu.Game/Overlays/ChatTicker.cs b/osu.Game/Overlays/ChatTicker.cs new file mode 100644 index 000000000000..937c2d874569 --- /dev/null +++ b/osu.Game/Overlays/ChatTicker.cs @@ -0,0 +1,84 @@ +// 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.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Online.Chat; +using osu.Game.Overlays.Chat; + +namespace osu.Game.Overlays +{ + public partial class ChatTicker : VisibilityContainer + { + private readonly BindableBool showChatTicker = new BindableBool(); + + private TickerLine? tickerLine; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Height = 18; + RelativeSizeAxes = Axes.X; + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + + Add(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(0.1f), + }); + + config.BindWith(OsuSetting.ChatTicker, showChatTicker); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + showChatTicker.BindValueChanged(showTicker => + { + PostMessage(null); + State.Value = showTicker.NewValue ? Visibility.Visible : Visibility.Hidden; + }, true); + } + + public void PostMessage(Message? message) + { + if (message == null || !showChatTicker.Value) + { + PopOut(); + return; + } + + if (tickerLine != null) + Remove(tickerLine, false); + + Add(tickerLine = new TickerLine(message)); + + this.FadeOutFromOne(10000); + } + + protected override void PopIn() + { + } + + protected override void PopOut() => this.FadeOut(100); + + protected partial class TickerLine : ChatLine + { + protected override float Spacing => 5; + protected override float UsernameWidth => 90; + + public TickerLine(Message message) + : base(message) + { + UsernameIsClickable = false; + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index 227d3feeaf23..0aa3c63a2720 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -19,6 +19,11 @@ private void load(OsuConfigManager config) { Children = new Drawable[] { + new SettingsItemV2(new FormCheckBox + { + Caption = OnlineSettingsStrings.ChatTicker, + Current = config.GetBindable(OsuSetting.ChatTicker) + }), new SettingsItemV2(new FormCheckBox { Caption = OnlineSettingsStrings.NotifyOnMentioned,