diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 14f32df653..00ff6a9576 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -412,6 +412,121 @@ namespace osu.Game.Tests.Visual.Online AddAssert("channel left", () => !channelManager.JoinedChannels.Contains(multiplayerChannel)); } + [Test] + public void TestHighlightOnCurrentChannel() + { + Message message = null; + + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Send message in channel 1", () => + { + channel1.AddNewMessages(message = new Message + { + ChannelId = channel1.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "Someone", + } + }); + }); + + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel1)); + } + + [Test] + public void TestHighlightOnAnotherChannel() + { + Message message = null; + + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Join channel 2", () => channelManager.JoinChannel(channel2)); + AddStep("Send message in channel 2", () => + { + channel2.AddNewMessages(message = new Message + { + ChannelId = channel2.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "Someone", + } + }); + }); + + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel2)); + AddAssert("Switched to channel 2", () => channelManager.CurrentChannel.Value == channel2); + } + + [Test] + public void TestHighlightOnLeftChannel() + { + Message message = null; + + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Join channel 2", () => channelManager.JoinChannel(channel2)); + AddStep("Send message in channel 2", () => + { + channel2.AddNewMessages(message = new Message + { + ChannelId = channel2.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "Someone", + } + }); + }); + AddStep("Leave channel 2", () => channelManager.LeaveChannel(channel2)); + + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel2)); + AddAssert("Switched to channel 2", () => channelManager.CurrentChannel.Value == channel2); + } + + [Test] + public void TestHighlightWhileChatHidden() + { + Message message = null; + + AddStep("hide chat", () => chatOverlay.Hide()); + + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Send message in channel 1", () => + { + channel1.AddNewMessages(message = new Message + { + ChannelId = channel1.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "Someone", + } + }); + }); + + AddStep("Highlight message and show chat", () => + { + chatOverlay.HighlightMessage(message, channel1); + chatOverlay.Show(); + }); + } + private void pressChannelHotkey(int number) { var channelKey = Key.Number0 + number; diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index a21647712d..860ef5d565 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -9,6 +9,8 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Chat; using osuTK.Input; @@ -107,49 +109,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestManyMessages() { - AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = admin, - Content = "I am a wang!" - })); - - AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = redUser, - Content = "I am team red." - })); - - AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = redUser, - Content = "I plan to win!" - })); - - AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = blueUser, - Content = "Not on my watch. Prepare to eat saaaaaaaaaand. Lots and lots of saaaaaaand." - })); - - AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = admin, - Content = "Okay okay, calm down guys. Let's do this!" - })); - - AddStep("message from long username", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = longUsernameUser, - Content = "Hi guys, my new username is lit!" - })); - - AddStep("message with new date", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = longUsernameUser, - Content = "Message from the future!", - Timestamp = DateTimeOffset.Now - })); - + sendRegularMessages(); checkScrolledToBottom(); const int messages_per_call = 10; @@ -182,6 +142,64 @@ namespace osu.Game.Tests.Visual.Online checkScrolledToBottom(); } + [Test] + public void TestMessageHighlighting() + { + Message highlighted = null; + + sendRegularMessages(); + + AddStep("highlight first message", () => + { + highlighted = testChannel.Messages[0]; + testChannel.HighlightedMessage.Value = highlighted; + }); + + AddUntilStep("chat scrolled to first message", () => + { + var line = chatDisplay.ChildrenOfType().Single(c => c.Message == highlighted); + return chatDisplay.ScrollContainer.ScreenSpaceDrawQuad.Contains(line.ScreenSpaceDrawQuad.Centre); + }); + + sendMessage(); + checkNotScrolledToBottom(); + + AddStep("highlight last message", () => + { + highlighted = testChannel.Messages[^1]; + testChannel.HighlightedMessage.Value = highlighted; + }); + + AddUntilStep("chat scrolled to last message", () => + { + var line = chatDisplay.ChildrenOfType().Single(c => c.Message == highlighted); + return chatDisplay.ScrollContainer.ScreenSpaceDrawQuad.Contains(line.ScreenSpaceDrawQuad.Centre); + }); + + sendMessage(); + checkScrolledToBottom(); + + AddRepeatStep("highlight other random messages", () => + { + highlighted = testChannel.Messages[RNG.Next(0, testChannel.Messages.Count - 1)]; + testChannel.HighlightedMessage.Value = highlighted; + }, 10); + } + + [Test] + public void TestMessageHighlightingOnFilledChat() + { + int index = 0; + + fillChat(100); + + AddStep("highlight first message", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = 0]); + AddStep("highlight next message", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = Math.Min(index + 1, testChannel.Messages.Count - 1)]); + AddStep("highlight last message", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = testChannel.Messages.Count - 1]); + AddStep("highlight previous message", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = Math.Max(index - 1, 0)]); + AddRepeatStep("highlight random messages", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = RNG.Next(0, testChannel.Messages.Count - 1)], 10); + } + /// /// Tests that when a message gets wrapped by the chat display getting contracted while scrolled to bottom, the chat will still keep scrolling down. /// @@ -286,11 +304,11 @@ namespace osu.Game.Tests.Visual.Online checkScrolledToBottom(); } - private void fillChat() + private void fillChat(int count = 10) { AddStep("fill chat", () => { - for (int i = 0; i < 10; i++) + for (int i = 0; i < count; i++) { testChannel.AddNewMessages(new Message(messageIdSequence++) { @@ -321,6 +339,52 @@ namespace osu.Game.Tests.Visual.Online })); } + private void sendRegularMessages() + { + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = admin, + Content = "I am a wang!" + })); + + AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = redUser, + Content = "I am team red." + })); + + AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = redUser, + Content = "I plan to win!" + })); + + AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = blueUser, + Content = "Not on my watch. Prepare to eat saaaaaaaaaand. Lots and lots of saaaaaaand." + })); + + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = admin, + Content = "Okay okay, calm down guys. Let's do this!" + })); + + AddStep("message from long username", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = longUsernameUser, + Content = "Hi guys, my new username is lit!" + })); + + AddStep("message with new date", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = longUsernameUser, + Content = "Message from the future!", + Timestamp = DateTimeOffset.Now + })); + } + private void checkScrolledToBottom() => AddUntilStep("is scrolled to bottom", () => chatDisplay.ScrolledToBottom); diff --git a/osu.Game/Online/Chat/Channel.cs b/osu.Game/Online/Chat/Channel.cs index 9cbb2f37e4..a37d3084f0 100644 --- a/osu.Game/Online/Chat/Channel.cs +++ b/osu.Game/Online/Chat/Channel.cs @@ -9,6 +9,7 @@ using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Lists; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Chat; namespace osu.Game.Online.Chat { @@ -89,6 +90,12 @@ namespace osu.Game.Online.Chat /// public Bindable Joined = new Bindable(); + /// + /// Signals if there is a message to highlight. + /// This is automatically cleared by the associated after highlighting. + /// + public Bindable HighlightedMessage = new Bindable(); + [JsonConstructor] public Channel() { diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index dcd15f9028..ad004e2881 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -59,7 +59,13 @@ namespace osu.Game.Online.Chat return Id.Value.CompareTo(other.Id.Value); } - public virtual bool Equals(Message other) => Id.HasValue && Id == other?.Id; + public virtual bool Equals(Message other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Id.HasValue && Id == other.Id; + } // ReSharper disable once ImpureMethodCallOnReadonlyValueField public override int GetHashCode() => Id.GetHashCode(); diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index 2c99e9f9b9..bcfec3cc0f 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -114,7 +114,7 @@ namespace osu.Game.Online.Chat if (!notifyOnPrivateMessage.Value || channel.Type != ChannelType.PM) return false; - notifications.Post(new PrivateMessageNotification(message.Sender.Username, channel)); + notifications.Post(new PrivateMessageNotification(message, channel)); return true; } @@ -122,7 +122,7 @@ namespace osu.Game.Online.Chat { if (!notifyOnUsername.Value || !CheckContainsUsername(message.Content, localUser.Value.Username)) return; - notifications.Post(new MentionNotification(message.Sender.Username, channel)); + notifications.Post(new MentionNotification(message, channel)); } /// @@ -136,47 +136,49 @@ namespace osu.Game.Online.Chat return Regex.IsMatch(message, $@"(^|\W)({fullName}|{underscoreName})($|\W)", RegexOptions.IgnoreCase); } - public class PrivateMessageNotification : OpenChannelNotification + public class PrivateMessageNotification : HighlightMessageNotification { - public PrivateMessageNotification(string username, Channel channel) - : base(channel) + public PrivateMessageNotification(Message message, Channel channel) + : base(message, channel) { Icon = FontAwesome.Solid.Envelope; - Text = $"You received a private message from '{username}'. Click to read it!"; + Text = $"You received a private message from '{message.Sender.Username}'. Click to read it!"; } } - public class MentionNotification : OpenChannelNotification + public class MentionNotification : HighlightMessageNotification { - public MentionNotification(string username, Channel channel) - : base(channel) + public MentionNotification(Message message, Channel channel) + : base(message, channel) { Icon = FontAwesome.Solid.At; - Text = $"Your name was mentioned in chat by '{username}'. Click to find out why!"; + Text = $"Your name was mentioned in chat by '{message.Sender.Username}'. Click to find out why!"; } } - public abstract class OpenChannelNotification : SimpleNotification + public abstract class HighlightMessageNotification : SimpleNotification { - protected OpenChannelNotification(Channel channel) + protected HighlightMessageNotification(Message message, Channel channel) { + this.message = message; this.channel = channel; } + private readonly Message message; private readonly Channel channel; public override bool IsImportant => false; [BackgroundDependencyLoader] - private void load(OsuColour colours, ChatOverlay chatOverlay, NotificationOverlay notificationOverlay, ChannelManager channelManager) + private void load(OsuColour colours, ChatOverlay chatOverlay, NotificationOverlay notificationOverlay) { IconBackground.Colour = colours.PurpleDark; Activated = delegate { notificationOverlay.Hide(); + chatOverlay.HighlightMessage(message, channel); chatOverlay.Show(); - channelManager.CurrentChannel.Value = channel; return true; }; diff --git a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs index 58b2b9a075..139c091f03 100644 --- a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs +++ b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs @@ -47,6 +47,12 @@ namespace osu.Game.Overlays.Chat updateTrackState(); } + public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + { + base.ScrollTo(value, animated, distanceDecay); + updateTrackState(); + } + public new void ScrollIntoView(Drawable d, bool animated = true) { base.ScrollIntoView(d, animated); diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 87d1b1a3ad..a1d8cd5d38 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Chat protected virtual float TextSize => 20; - private Color4 customUsernameColour; + private Color4 usernameColour; private OsuSpriteText timestamp; @@ -78,19 +78,22 @@ namespace osu.Game.Overlays.Chat } } - private bool senderHasBackground => !string.IsNullOrEmpty(message.Sender.Colour); + private bool senderHasColour => !string.IsNullOrEmpty(message.Sender.Colour); + + [Resolved] + private OsuColour colours { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - customUsernameColour = colours.ChatBlue; - - bool hasBackground = senderHasBackground; + usernameColour = senderHasColour + ? Color4Extensions.FromHex(message.Sender.Colour) + : username_colours[message.Sender.Id % username_colours.Length]; Drawable effectedUsername = username = new OsuSpriteText { Shadow = false, - Colour = hasBackground ? customUsernameColour : username_colours[message.Sender.Id % username_colours.Length], + Colour = senderHasColour ? colours.ChatBlue : usernameColour, Truncate = true, EllipsisString = "… :", Font = OsuFont.GetFont(size: TextSize, weight: FontWeight.Bold, italics: true), @@ -99,7 +102,7 @@ namespace osu.Game.Overlays.Chat MaxWidth = MessagePadding - TimestampPadding }; - if (hasBackground) + if (senderHasColour) { // Background effect effectedUsername = new Container @@ -126,7 +129,7 @@ namespace osu.Game.Overlays.Chat new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(message.Sender.Colour), + Colour = usernameColour, }, new Container { @@ -177,7 +180,7 @@ namespace osu.Game.Overlays.Chat { t.Font = OsuFont.GetFont(italics: true); - if (senderHasBackground) + if (senderHasColour) t.Colour = Color4Extensions.FromHex(message.Sender.Colour); } @@ -200,13 +203,37 @@ namespace osu.Game.Overlays.Chat FinishTransforms(true); } + private Container highlight; + + /// + /// Performs a highlight animation on this . + /// + public void Highlight() + { + if (highlight?.IsAlive != true) + { + AddInternal(highlight = new Container + { + CornerRadius = 2f, + Masking = true, + RelativeSizeAxes = Axes.Both, + Colour = usernameColour.Darken(1f), + Depth = float.MaxValue, + Child = new Box { RelativeSizeAxes = Axes.Both } + }); + } + + highlight.FadeTo(0.5f).FadeOut(1500, Easing.InQuint); + highlight.Expire(); + } + private void updateMessageContent() { this.FadeTo(message is LocalEchoMessage ? 0.4f : 1.0f, 500, Easing.OutQuint); timestamp.FadeTo(message is LocalEchoMessage ? 0 : 1, 500, Easing.OutQuint); timestamp.Text = $@"{message.Timestamp.LocalDateTime:HH:mm:ss}"; - username.Text = $@"{message.Sender.Username}" + (senderHasBackground || message.IsAction ? "" : ":"); + username.Text = $@"{message.Sender.Username}" + (senderHasColour || message.IsAction ? "" : ":"); // remove non-existent channels from the link list message.Links.RemoveAll(link => link.Action == LinkAction.OpenChannel && chatManager?.AvailableChannels.Any(c => c.Name == link.Argument.ToString()) != true); diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 6220beeb82..632517aa31 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -48,6 +49,8 @@ namespace osu.Game.Overlays.Chat RelativeSizeAxes = Axes.Both; } + private Bindable highlightedMessage; + [BackgroundDependencyLoader] private void load() { @@ -79,6 +82,34 @@ namespace osu.Game.Overlays.Chat Channel.PendingMessageResolved += pendingMessageResolved; } + protected override void LoadComplete() + { + base.LoadComplete(); + + highlightedMessage = Channel.HighlightedMessage.GetBoundCopy(); + highlightedMessage.BindValueChanged(_ => processMessageHighlighting(), true); + } + + /// + /// Processes any pending message in . + /// + // ScheduleAfterChildren is for ensuring the scroll flow has updated with any new chat lines. + private void processMessageHighlighting() => SchedulerAfterChildren.AddOnce(() => + { + if (highlightedMessage.Value == null) + return; + + var chatLine = chatLines.SingleOrDefault(c => c.Message.Equals(highlightedMessage.Value)); + if (chatLine == null) + return; + + float center = scroll.GetChildPosInContent(chatLine, chatLine.DrawSize / 2) - scroll.DisplayableContent / 2; + scroll.ScrollTo(Math.Clamp(center, 0, scroll.ScrollableExtent)); + chatLine.Highlight(); + + highlightedMessage.Value = null; + }); + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -148,6 +179,8 @@ namespace osu.Game.Overlays.Chat // to avoid making the container think the user has scrolled back up and unwantedly disable auto-scrolling. if (newMessages.Any(m => m is LocalMessage)) scroll.ScrollToEnd(); + + processMessageHighlighting(); }); private void pendingMessageResolved(Message existing, Message updated) => Schedule(() => diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index fde9d28b43..3764ac42fc 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osuTK; using osuTK.Graphics; @@ -303,6 +304,26 @@ namespace osu.Game.Overlays channelManager.MarkChannelAsRead(e.NewValue); } + /// + /// 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; + } + + channel.HighlightedMessage.Value = message; + } + private float startDragChatHeight; private bool isDragging;