diff --git a/osu.Game/Online/Chat/Channel.cs b/osu.Game/Online/Chat/Channel.cs index 01685cc7dc..0c10ddde97 100644 --- a/osu.Game/Online/Chat/Channel.cs +++ b/osu.Game/Online/Chat/Channel.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Newtonsoft.Json; using osu.Framework.Configuration; @@ -26,6 +27,8 @@ namespace osu.Game.Online.Chat public readonly SortedList Messages = new SortedList(Comparer.Default); + private readonly List pendingMessages = new List(); + public Bindable Joined = new Bindable(); public bool ReadOnly => Name != "#lazer"; @@ -38,12 +41,23 @@ namespace osu.Game.Online.Chat } public event Action> NewMessagesArrived; + public event Action PendingMessageResolved; + public event Action MessageRemoved; + + public void AddLocalEcho(LocalEchoMessage message) + { + pendingMessages.Add(message); + Messages.Add(message); + + NewMessagesArrived?.Invoke(new[] { message }); + } public void AddNewMessages(params Message[] messages) { messages = messages.Except(Messages).ToArray(); - Messages.AddRange(messages); + foreach (Message message in messages) + Messages.Add(message); purgeOldMessages(); @@ -52,11 +66,42 @@ namespace osu.Game.Online.Chat private void purgeOldMessages() { - int messageCount = Messages.Count; + // never purge local echos + int messageCount = Messages.Count - pendingMessages.Count; if (messageCount > MAX_HISTORY) Messages.RemoveRange(0, messageCount - MAX_HISTORY); } + /// + /// Replace or remove a message from the channel. + /// + /// The local echo message (client-side). + /// The response message, or null if the message became invalid. + public void ReplaceMessage(LocalEchoMessage echo, Message final) + { + if (!pendingMessages.Remove(echo)) + Trace.Fail("Attempted to remove echo that wasn't present"); + + Messages.Remove(echo); + + if (final == null) + { + MessageRemoved?.Invoke(echo); + return; + } + + if (Messages.Contains(final)) + { + // message already inserted, so let's throw away this update. + // we may want to handle this better in the future, but for the time being api requests are single-threaded so order is assumed. + MessageRemoved?.Invoke(echo); + return; + } + + Messages.Add(final); + PendingMessageResolved?.Invoke(echo, final); + } + public override string ToString() => Name; } } diff --git a/osu.Game/Online/Chat/LocalEchoMessage.cs b/osu.Game/Online/Chat/LocalEchoMessage.cs new file mode 100644 index 0000000000..079ec58686 --- /dev/null +++ b/osu.Game/Online/Chat/LocalEchoMessage.cs @@ -0,0 +1,12 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +namespace osu.Game.Online.Chat +{ + public class LocalEchoMessage : Message + { + public LocalEchoMessage() : base(null) + { + } + } +} diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index 4c7e099647..fc61b9f37a 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -11,7 +11,7 @@ namespace osu.Game.Online.Chat public class Message : IComparable, IEquatable { [JsonProperty(@"message_id")] - public readonly long Id; + public readonly long? Id; //todo: this should be inside sender. [JsonProperty(@"sender_id")] @@ -37,14 +37,23 @@ namespace osu.Game.Online.Chat { } - public Message(long id) + public Message(long? id) { Id = id; } - public int CompareTo(Message other) => Id.CompareTo(other.Id); + public int CompareTo(Message other) + { - public bool Equals(Message other) => Id == other?.Id; + if (!Id.HasValue) + return other.Id.HasValue ? 1 : Timestamp.CompareTo(other.Timestamp); + if (!other.Id.HasValue) + return -1; + + return Id.Value.CompareTo(other.Id.Value); + } + + public virtual bool Equals(Message other) => Id == other?.Id; public override int GetHashCode() => Id.GetHashCode(); } diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index fcebca6fe3..afc6589f57 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -2,26 +2,24 @@ // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System; +using OpenTK; +using OpenTK.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; -using OpenTK; -using OpenTK.Graphics; -using osu.Framework.Graphics.Effects; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Allocation; using osu.Game.Users; -using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Chat { public class ChatLine : Container { - public readonly Message Message; - - private static readonly Color4[] username_colours = { + private static readonly Color4[] username_colours = + { OsuColour.FromHex("588c7e"), OsuColour.FromHex("b2a367"), OsuColour.FromHex("c98f65"), @@ -69,6 +67,8 @@ namespace osu.Game.Overlays.Chat private Color4 customUsernameColour; + private OsuSpriteText timestamp; + public ChatLine(Message message) { Message = message; @@ -79,6 +79,26 @@ namespace osu.Game.Overlays.Chat Padding = new MarginPadding { Left = padding, Right = padding }; } + private Message message; + private OsuSpriteText username; + private OsuTextFlowContainer contentFlow; + + public Message Message + { + get { return message; } + set + { + if (message == value) return; + + message = value; + + if (!IsLoaded) + return; + + updateMessageContent(); + } + } + [BackgroundDependencyLoader(true)] private void load(OsuColour colours, UserProfileOverlay profile) { @@ -86,49 +106,54 @@ namespace osu.Game.Overlays.Chat loadProfile = u => profile?.ShowUser(u); } + private bool senderHasBackground => !string.IsNullOrEmpty(message.Sender.Colour); + protected override void LoadComplete() { base.LoadComplete(); - bool hasBackground = !string.IsNullOrEmpty(Message.Sender.Colour); - Drawable username = new OsuSpriteText + bool hasBackground = senderHasBackground; + + Drawable effectedUsername = username = new OsuSpriteText { Font = @"Exo2.0-BoldItalic", - Text = $@"{Message.Sender.Username}" + (hasBackground ? "" : ":"), - Colour = hasBackground ? customUsernameColour : username_colours[Message.UserId % username_colours.Length], + Colour = hasBackground ? customUsernameColour : username_colours[message.Sender.Id % username_colours.Length], TextSize = text_size, }; if (hasBackground) { // Background effect - username = username.WithEffect(new EdgeEffect + effectedUsername = new Container { + AutoSizeAxes = Axes.Both, + Masking = true, CornerRadius = 4, - Parameters = new EdgeEffectParameters - { - Radius = 1, - Colour = OsuColour.FromHex(Message.Sender.Colour), - Type = EdgeEffectType.Shadow, - } - }, d => - { - d.Padding = new MarginPadding { Left = 3, Right = 3, Bottom = 1, Top = -3 }; - d.Y = 3; - }) - // Drop shadow effect - .WithEffect(new EdgeEffect - { - CornerRadius = 4, - Parameters = new EdgeEffectParameters + EdgeEffect = new EdgeEffectParameters { Roundness = 1, Offset = new Vector2(0, 3), Radius = 3, Colour = Color4.Black.Opacity(0.3f), Type = EdgeEffectType.Shadow, + }, + // Drop shadow effect + Child = new Container + { + AutoSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 4, + EdgeEffect = new EdgeEffectParameters + { + Radius = 1, + Colour = OsuColour.FromHex(message.Sender.Colour), + Type = EdgeEffectType.Shadow, + }, + Padding = new MarginPadding { Left = 3, Right = 3, Bottom = 1, Top = -3 }, + Y = 3, + Child = username, } - }); + }; } Children = new Drawable[] @@ -138,23 +163,21 @@ namespace osu.Game.Overlays.Chat Size = new Vector2(message_padding, text_size), Children = new Drawable[] { - new OsuSpriteText + timestamp = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = @"Exo2.0-SemiBold", - Text = $@"{Message.Timestamp.LocalDateTime:HH:mm:ss}", FixedWidth = true, TextSize = text_size * 0.75f, - Alpha = 0.4f, }, new ClickableContainer { AutoSizeAxes = Axes.Both, Origin = Anchor.TopRight, Anchor = Anchor.TopRight, - Child = username, - Action = () => loadProfile(Message.Sender), + Child = effectedUsername, + Action = () => loadProfile(message.Sender), }, } }, @@ -165,18 +188,27 @@ namespace osu.Game.Overlays.Chat Padding = new MarginPadding { Left = message_padding + padding }, Children = new Drawable[] { - new OsuTextFlowContainer(t => + contentFlow = new OsuTextFlowContainer(t => { t.TextSize = text_size; }) { - t.TextSize = text_size; - }) - { - Text = Message.Content, AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, } } } }; + + updateMessageContent(); + FinishTransforms(true); + } + + 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 ? "" : ":"); + contentFlow.Text = message.Content; } } } diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 8a2fa95ed1..38a736993e 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using OpenTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,8 +16,19 @@ namespace osu.Game.Overlays.Chat { public class DrawableChannel : Container { + private class ChatLineContainer : FillFlowContainer + { + protected override int Compare(Drawable x, Drawable y) + { + var xC = (ChatLine)x; + var yC = (ChatLine)y; + + return xC.Message.CompareTo(yC.Message); + } + } + public readonly Channel Channel; - private readonly FillFlowContainer flow; + private readonly ChatLineContainer flow; private readonly ScrollContainer scroll; public DrawableChannel(Channel channel) @@ -32,20 +45,19 @@ namespace osu.Game.Overlays.Chat // Some chat lines have effects that slightly protrude to the bottom, // which we do not want to mask away, hence the padding. Padding = new MarginPadding { Bottom = 5 }, - Children = new Drawable[] + Child = flow = new ChatLineContainer { - flow = new FillFlowContainer - { - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = 20, Right = 20 } - } - } + Padding = new MarginPadding { Left = 20, Right = 20 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, } }; - channel.NewMessagesArrived += newMessagesArrived; + Channel.NewMessagesArrived += newMessagesArrived; + Channel.MessageRemoved += messageRemoved; + Channel.PendingMessageResolved += pendingMessageResolved; } [BackgroundDependencyLoader] @@ -63,15 +75,19 @@ namespace osu.Game.Overlays.Chat protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + Channel.NewMessagesArrived -= newMessagesArrived; + Channel.MessageRemoved -= messageRemoved; + Channel.PendingMessageResolved -= pendingMessageResolved; } private void newMessagesArrived(IEnumerable newMessages) { + // Add up to last Channel.MAX_HISTORY messages var displayMessages = newMessages.Skip(Math.Max(0, newMessages.Count() - Channel.MAX_HISTORY)); - //up to last Channel.MAX_HISTORY messages - flow.AddRange(displayMessages.Select(m => new ChatLine(m))); + foreach (Message m in displayMessages) + flow.Add(new ChatLine(m)); if (!IsLoaded) return; @@ -90,6 +106,24 @@ namespace osu.Game.Overlays.Chat } } + private void pendingMessageResolved(Message existing, Message updated) + { + var found = flow.Children.LastOrDefault(c => c.Message == existing); + if (found != null) + { + Trace.Assert(updated.Id.HasValue, "An updated message was returned with no ID."); + + flow.Remove(found); + found.Message = updated; + flow.Add(found); + } + } + + private void messageRemoved(Message removed) + { + flow.Children.FirstOrDefault(c => c.Message == removed)?.FadeColour(Color4.Red, 400).FadeOut(600).Expire(); + } + private void scrollToEnd() => ScheduleAfterChildren(() => scroll.ScrollToEnd()); } } diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index e7a4c1c0ab..705772aec3 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -6,23 +6,23 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using OpenTK; +using OpenTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Threading; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.Chat; -using osu.Game.Graphics.UserInterface; -using osu.Framework.Graphics.UserInterface; -using OpenTK.Graphics; -using osu.Framework.Input; -using osu.Game.Configuration; -using osu.Game.Graphics; using osu.Game.Overlays.Chat; -using osu.Game.Graphics.Containers; namespace osu.Game.Overlays { @@ -37,7 +37,7 @@ namespace osu.Game.Overlays private readonly LoadingAnimation loading; - private readonly FocusedTextBox inputTextBox; + private readonly FocusedTextBox textbox; private APIAccess api; @@ -130,7 +130,7 @@ namespace osu.Game.Overlays }, Children = new Drawable[] { - inputTextBox = new FocusedTextBox + textbox = new FocusedTextBox { RelativeSizeAxes = Axes.Both, Height = 1, @@ -175,7 +175,7 @@ namespace osu.Game.Overlays if (state == Visibility.Visible) { - inputTextBox.HoldFocus = false; + textbox.HoldFocus = false; if (1f - chatHeight.Value < channel_selection_min_height) { chatContainer.ResizeHeightTo(1f - channel_selection_min_height, 800, Easing.OutQuint); @@ -186,7 +186,7 @@ namespace osu.Game.Overlays } else { - inputTextBox.HoldFocus = true; + textbox.HoldFocus = true; } }; } @@ -242,8 +242,8 @@ namespace osu.Game.Overlays protected override void OnFocus(InputState state) { - //this is necessary as inputTextBox is masked away and therefore can't get focus :( - GetContainingInputManager().ChangeFocus(inputTextBox); + //this is necessary as textbox is masked away and therefore can't get focus :( + GetContainingInputManager().ChangeFocus(textbox); base.OnFocus(state); } @@ -252,7 +252,7 @@ namespace osu.Game.Overlays this.MoveToY(0, transition_length, Easing.OutQuint); this.FadeIn(transition_length, Easing.OutQuint); - inputTextBox.HoldFocus = true; + textbox.HoldFocus = true; base.PopIn(); } @@ -261,7 +261,7 @@ namespace osu.Game.Overlays this.MoveToY(Height, transition_length, Easing.InSine); this.FadeOut(transition_length, Easing.InSine); - inputTextBox.HoldFocus = false; + textbox.HoldFocus = false; base.PopOut(); } @@ -336,7 +336,7 @@ namespace osu.Game.Overlays currentChannel = value; - inputTextBox.Current.Disabled = currentChannel.ReadOnly; + textbox.Current.Disabled = currentChannel.ReadOnly; channelTabs.Current.Value = value; var loaded = loadedChannels.Find(d => d.Channel == value); @@ -437,51 +437,42 @@ namespace osu.Game.Overlays { var postText = textbox.Text; + textbox.Text = string.Empty; + if (string.IsNullOrEmpty(postText)) return; + var target = currentChannel; + + if (target == null) return; + if (!api.IsLoggedIn) { - currentChannel?.AddNewMessages(new ErrorMessage("Please login to participate in chat!")); - textbox.Text = string.Empty; + target.AddNewMessages(new ErrorMessage("Please login to participate in chat!")); return; } - if (currentChannel == null) return; - if (postText[0] == '/') { // TODO: handle commands - currentChannel.AddNewMessages(new ErrorMessage("Chat commands are not supported yet!")); - textbox.Text = string.Empty; + target.AddNewMessages(new ErrorMessage("Chat commands are not supported yet!")); return; } - var message = new Message + var message = new LocalEchoMessage { Sender = api.LocalUser.Value, Timestamp = DateTimeOffset.Now, - TargetType = TargetType.Channel, //TODO: read this from currentChannel - TargetId = currentChannel.Id, + TargetType = TargetType.Channel, //TODO: read this from channel + TargetId = target.Id, Content = postText }; - textbox.ReadOnly = true; var req = new PostMessageRequest(message); - req.Failure += e => - { - textbox.FlashColour(Color4.Red, 1000); - textbox.ReadOnly = false; - }; - - req.Success += m => - { - currentChannel.AddNewMessages(m); - - textbox.ReadOnly = false; - textbox.Text = string.Empty; - }; + target.AddLocalEcho(message); + req.Failure += e => target.ReplaceMessage(message, null); + req.Success += m => target.ReplaceMessage(message, m); api.Queue(req); } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 07ab58bffc..655bc08248 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -101,6 +101,7 @@ +