diff --git a/osu.Game.Tests/Visual/TestCaseChatDisplay.cs b/osu.Game.Tests/Visual/TestCaseChatDisplay.cs index 85ee224a5e..38f5a7cbe0 100644 --- a/osu.Game.Tests/Visual/TestCaseChatDisplay.cs +++ b/osu.Game.Tests/Visual/TestCaseChatDisplay.cs @@ -2,7 +2,9 @@ // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System.ComponentModel; +using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; using osu.Game.Overlays; namespace osu.Game.Tests.Visual @@ -10,12 +12,26 @@ namespace osu.Game.Tests.Visual [Description("Testing chat api and overlay")] internal class TestCaseChatDisplay : OsuTestCase { + private BeatmapSetOverlay beatmapSetOverlay; + + private DependencyContainer dependencies; + + protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(parent); + public TestCaseChatDisplay() { Add(new ChatOverlay { State = Visibility.Visible }); + + Add(beatmapSetOverlay = new BeatmapSetOverlay()); + } + + [BackgroundDependencyLoader] + private void load() + { + dependencies.Cache(beatmapSetOverlay); } } } diff --git a/osu.Game/Graphics/Containers/OsuLinkTextFlowContainer.cs b/osu.Game/Graphics/Containers/OsuLinkTextFlowContainer.cs new file mode 100644 index 0000000000..b5490d42f9 --- /dev/null +++ b/osu.Game/Graphics/Containers/OsuLinkTextFlowContainer.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; +using osu.Game.Graphics.Sprites; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace osu.Game.Graphics.Containers +{ + public class OsuLinkTextFlowContainer : OsuLinkTextFlowContainer + { + public OsuLinkTextFlowContainer(Action defaultCreationParameters = null) + : base(defaultCreationParameters) + { + } + } + + public class OsuLinkTextFlowContainer : OsuTextFlowContainer + where T : OsuLinkSpriteText, new() + { + public override bool HandleInput => true; + + public OsuLinkTextFlowContainer(Action defaultCreationParameters = null) : base(defaultCreationParameters) + { + } + + protected override SpriteText CreateSpriteText() => new T(); + + public void AddLink(string text, string url, Action creationParameters = null) + { + AddText(text, link => + { + ((T)link).Url = url; + creationParameters?.Invoke(link); + }); + } + } +} diff --git a/osu.Game/Graphics/Sprites/OsuLinkSpriteText.cs b/osu.Game/Graphics/Sprites/OsuLinkSpriteText.cs new file mode 100644 index 0000000000..f41bca62ec --- /dev/null +++ b/osu.Game/Graphics/Sprites/OsuLinkSpriteText.cs @@ -0,0 +1,98 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using OpenTK.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace osu.Game.Graphics.Sprites +{ + public class OsuLinkSpriteText : OsuSpriteText + { + private readonly OsuHoverContainer content; + + private BeatmapSetOverlay beatmapSetOverlay; + + public override bool HandleInput => content.Action != null; + + protected override Container Content => content ?? (Container)this; + + protected override IEnumerable FlowingChildren => Children; + + private string url; + + public string Url + { + get + { + return url; + } + set + { + if (value != null) + { + url = value; + loadAction(); + } + } + } + + public OsuLinkSpriteText() + { + AddInternal(content = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + }); + } + + [BackgroundDependencyLoader] + private void load(BeatmapSetOverlay beatmapSetOverlay) + { + this.beatmapSetOverlay = beatmapSetOverlay; + } + + private void loadAction() + { + if (Url == null || String.IsNullOrEmpty(Url)) + return; + + var url = Url; + + if (url.StartsWith("https://")) url = url.Substring(8); + else if (url.StartsWith("http://")) url = url.Substring(7); + else content.Action = () => Process.Start(Url); + + if (url.StartsWith("osu.ppy.sh/")) + { + url = url.Substring(11); + if (url.StartsWith("s") || url.StartsWith("beatmapsets")) + content.Action = () => beatmapSetOverlay.ShowBeatmapSet(getIdFromUrl(url)); + else if (url.StartsWith("b") || url.StartsWith("beatmaps")) + content.Action = () => beatmapSetOverlay.ShowBeatmap(getIdFromUrl(url)); + // else if (url.StartsWith("d")) Maybe later + } + } + + private int getIdFromUrl(string url) + { + var lastSlashIndex = url.LastIndexOf('/'); + if (lastSlashIndex == url.Length) + { + url = url.Substring(url.Length - 1); + lastSlashIndex = url.LastIndexOf('/'); + } + + return int.Parse(url.Substring(lastSlashIndex + 1)); + } + } +} diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs new file mode 100644 index 0000000000..7a72402c1e --- /dev/null +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs @@ -0,0 +1,21 @@ +using osu.Game.Beatmaps; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace osu.Game.Online.API.Requests +{ + public class GetBeatmapRequest : APIRequest + { + private readonly int beatmapId; + + public GetBeatmapRequest(int beatmapId) + { + this.beatmapId = beatmapId; + } + + protected override string Target => $@"beatmaps/{beatmapId}"; + } +} diff --git a/osu.Game/Online/Chat/ChatLinkSpriteText.cs b/osu.Game/Online/Chat/ChatLinkSpriteText.cs new file mode 100644 index 0000000000..560579a007 --- /dev/null +++ b/osu.Game/Online/Chat/ChatLinkSpriteText.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using OpenTK.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace osu.Game.Online.Chat +{ + public class ChatLinkSpriteText : OsuLinkSpriteText + { + public int LinkId; + + private Color4 hoverColour; + private Color4 urlColour; + + protected override bool OnHover(InputState state) + { + // Every word is one sprite in chat (for word wrap) so we need to find all other sprites that display the same link + var otherSpritesWithSameLink = ((Container)Parent).Children.Where(child => (child as ChatLinkSpriteText)?.LinkId == LinkId && !Equals(child)); + + var hoverResult = base.OnHover(state); + + if (!otherSpritesWithSameLink.Any(sprite => sprite.IsHovered)) + foreach (ChatLinkSpriteText sprite in otherSpritesWithSameLink) + sprite.TriggerOnHover(state); + + Content.FadeColour(hoverColour, 500, Easing.OutQuint); + + return hoverResult; + } + + protected override void OnHoverLost(InputState state) + { + var spritesWithSameLink = ((Container)Parent).Children.Where(child => (child as ChatLinkSpriteText)?.LinkId == LinkId); + + if (spritesWithSameLink.Any(sprite => sprite.IsHovered)) + { + // We have to do this so this sprite does not fade its colour back + Content.FadeColour(hoverColour, 500, Easing.OutQuint); + return; + } + + foreach (ChatLinkSpriteText sprite in spritesWithSameLink) + sprite.Content.FadeColour(urlColour, 500, Easing.OutQuint); + + base.OnHoverLost(state); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + hoverColour = colours.Yellow; + urlColour = colours.Blue; + } + } +} diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index 79b5c4fc1a..355abfda59 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -2,6 +2,8 @@ // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System; +using System.Collections; +using System.Collections.Generic; using System.ComponentModel; using Newtonsoft.Json; using osu.Game.Users; @@ -40,6 +42,8 @@ namespace osu.Game.Online.Chat { } + public List Links; + public Message(long? id) { Id = id; diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs new file mode 100644 index 0000000000..a712cb1f2b --- /dev/null +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -0,0 +1,170 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace osu.Game.Online.Chat +{ + public static class MessageFormatter + { + // [[Performance Points]] -> wiki:Performance Points (https://osu.ppy.sh/wiki/Performance_Points) + private static Regex wikiRegex = new Regex(@"\[\[([^\]]+)\]\]"); + + // (test)[https://osu.ppy.sh/b/1234] -> test (https://osu.ppy.sh/b/1234) + private static Regex oldLinkRegex = new Regex(@"\(([^\)]*)\)\[([a-z]+://[^ ]+)\]"); + + // [https://osu.ppy.sh/b/1234 Beatmap [Hard] (poop)] -> Beatmap [hard] (poop) (https://osu.ppy.sh/b/1234) + private static Regex newLinkRegex = new Regex(@"\[([a-z]+://[^ ]+) ([^\[\]]*(((?\[)[^\[\]]*)+((?\])[^\[\]]*)+)*(?(open)(?!)))\]"); + + // advanced, RFC-compatible regular expression that matches any possible URL, *but* allows certain invalid characters that are widely used + // This is in the format (, [optional]): + // http[s]://.[:port][/path][?query][#fragment] + private static Regex advancedLinkRegex = new Regex(@"(?\([^)]*)?" + + @"(?https?:\/\/" + + @"(?(?:[a-z0-9]\.|[a-z0-9][a-z0-9-]*[a-z0-9]\.)*[a-z][a-z0-9-]*[a-z0-9]" + // domain, TLD + @"(?::\d+)?)" + // port + @"(?(?:(?:\/+(?:[a-z0-9$_\.\+!\*\',;:\(\)@&~=-]|%[0-9a-f]{2})*)*" + // path + @"(?:\?(?:[a-z0-9$_\+!\*\',;:\(\)@&=\/~-]|%[0-9a-f]{2})*)?)?" + // query + @"(?:#(?:[a-z0-9$_\+!\*\',;:\(\)@&=\/~-]|%[0-9a-f]{2})*)?)?)", // fragment + RegexOptions.IgnoreCase); + + // 00:00:000 (1,2,3) - test + private static Regex timeRegex = new Regex(@"\d\d:\d\d:\d\d\d? [^-]*"); + + // #osu + private static Regex channelRegex = new Regex(@"#[a-zA-Z]+[a-zA-Z0-9]+"); + + // Unicode emojis + private static Regex emojiRegex = new Regex(@"(\uD83D[\uDC00-\uDE4F])"); + + private static void handleMatches(Regex regex, string display, string link, MessageFormatterResult result, int startIndex = 0) + { + int captureOffset = 0; + foreach (Match m in regex.Matches(result.Text, startIndex)) + { + var index = m.Index - captureOffset; + + var displayText = string.Format(display, + m.Groups[0], + m.Groups.Count > 1 ? m.Groups[1].Value : "", + m.Groups.Count > 2 ? m.Groups[2].Value : "").Trim(); + + var linkText = string.Format(link, + m.Groups[0], + m.Groups.Count > 1 ? m.Groups[1].Value : "", + m.Groups.Count > 2 ? m.Groups[2].Value : "").Trim(); + + if (displayText.Length == 0 || linkText.Length == 0) continue; + + // Check for encapsulated links + if (result.Links.Find(l => (l.Index <= index && l.Index + l.Length >= index + m.Length) || index <= l.Index && index + m.Length >= l.Index + l.Length) == null) + { + result.Text = result.Text.Remove(index, m.Length).Insert(index, displayText); + + //since we just changed the line display text, offset any already processed links. + result.Links.ForEach(l => l.Index -= l.Index > index ? m.Length - displayText.Length : 0); + + result.Links.Add(new Link(linkText, index, displayText.Length)); + + //adjust the offset for processing the current matches group. + captureOffset += (m.Length - displayText.Length); + } + } + } + + private static void handleAdvanced(Regex regex, MessageFormatterResult result, int startIndex = 0) + { + foreach (Match m in regex.Matches(result.Text, startIndex)) + { + var index = m.Index; + var prefix = m.Groups["paren"].Value; + var link = m.Groups["link"].Value; + var indexLength = link.Length; + + if (!String.IsNullOrEmpty(prefix)) + { + index += prefix.Length; + if (link.EndsWith(")")) + { + indexLength = indexLength - 1; + link = link.Remove(link.Length - 1); + } + } + + result.Links.Add(new Link(link, index, indexLength)); + } + } + + private static MessageFormatterResult format(string toFormat, int startIndex = 0, int space = 3) + { + var result = new MessageFormatterResult(toFormat); + + // handle the [link display] format + handleMatches(newLinkRegex, "{2}", "{1}", result, startIndex); + + // handle the ()[] link format + handleMatches(oldLinkRegex, "{1}", "{2}", result, startIndex); + + // handle wiki links + handleMatches(wikiRegex, "wiki:{1}", "https://osu.ppy.sh/wiki/{1}", result, startIndex); + + // handle bare links + handleAdvanced(advancedLinkRegex, result, startIndex); + + // handle editor times + handleMatches(timeRegex, "{0}", "osu://edit/{0}", result, startIndex); + + // handle channels + handleMatches(channelRegex, "{0}", "osu://chan/{0}", result, startIndex); + + var empty = ""; + while (space-- > 0) + empty += "\0"; + + handleMatches(emojiRegex, empty, "{0}", result, startIndex); + + return result; + } + + public static Message FormatMessage(Message inputMessage) + { + var result = format(inputMessage.Content); + var formatted = inputMessage; + + formatted.Content = result.Text; + formatted.Links = result.Links; + return formatted; + } + + public class MessageFormatterResult + { + public List Links = new List(); + public string Text; + public string OriginalText; + + public MessageFormatterResult(string text) + { + OriginalText = Text = text; + } + } + + public class Link + { + public string Url; + public int Index; + public int Length; + + public Link(string url, int startIndex, int length) + { + Url = url; + Index = startIndex; + Length = length; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/PreviewButton.cs b/osu.Game/Overlays/BeatmapSet/PreviewButton.cs index 52edd1714f..ef248c02d3 100644 --- a/osu.Game/Overlays/BeatmapSet/PreviewButton.cs +++ b/osu.Game/Overlays/BeatmapSet/PreviewButton.cs @@ -80,7 +80,7 @@ namespace osu.Game.Overlays.BeatmapSet { base.Update(); - if (Playing.Value && preview != null) + if (Playing.Value && preview != null && preview.Length > 0) { progress.Width = (float)(preview.CurrentTime / preview.Length); } diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index 0d658b346e..17fbe907ca 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -139,6 +139,20 @@ namespace osu.Game.Overlays return true; } + public void ShowBeatmap(int beatmapId) + { + var req = new GetBeatmapRequest(beatmapId); + req.Success += res => + { + if (!res.OnlineBeatmapSetID.HasValue) + return; + + ShowBeatmapSet(res.OnlineBeatmapSetID.Value); + }; + + api.Queue(req); + } + public void ShowBeatmapSet(int beatmapSetId) { // todo: display the overlay while we are loading here. we need to support setting BeatmapSet to null for this to work. diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 32f933ff42..bf5855c45b 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -67,12 +67,13 @@ namespace osu.Game.Overlays.Chat private const float text_size = 20; private Color4 customUsernameColour; + private Color4 urlColour; private OsuSpriteText timestamp; public ChatLine(Message message) { - Message = message; + Message = MessageFormatter.FormatMessage(message); RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -82,7 +83,7 @@ namespace osu.Game.Overlays.Chat private Message message; private OsuSpriteText username; - private OsuTextFlowContainer contentFlow; + private OsuLinkTextFlowContainer contentFlow; public Message Message { @@ -104,6 +105,7 @@ namespace osu.Game.Overlays.Chat private void load(OsuColour colours) { customUsernameColour = colours.ChatBlue; + urlColour = colours.Blue; } private bool senderHasBackground => !string.IsNullOrEmpty(message.Sender.Colour); @@ -187,7 +189,7 @@ namespace osu.Game.Overlays.Chat Padding = new MarginPadding { Left = message_padding + padding }, Children = new Drawable[] { - contentFlow = new OsuTextFlowContainer(t => { t.TextSize = text_size; }) + contentFlow = new OsuLinkTextFlowContainer(t => { t.TextSize = text_size; }) { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -210,16 +212,40 @@ namespace osu.Game.Overlays.Chat timestamp.Text = $@"{message.Timestamp.LocalDateTime:HH:mm:ss}"; username.Text = $@"{message.Sender.Username}" + (senderHasBackground ? "" : ":"); - if (message.IsAction) + contentFlow.Clear(); + + if (message.Links == null || message.Links.Count == 0) { - contentFlow.Clear(); - contentFlow.AddText("[", sprite => sprite.Padding = new MarginPadding { Right = action_padding }); - contentFlow.AddText(message.Content, sprite => sprite.Font = @"Exo2.0-MediumItalic"); - contentFlow.AddText("]", sprite => sprite.Padding = new MarginPadding { Left = action_padding }); + contentFlow.AddText(message.Content, sprite => + { + if (message.IsAction) + sprite.Font = @"Exo2.0-MediumItalic"; + }); + + return; } else - contentFlow.Text = message.Content; + { + int prevIndex = 0; + foreach (var link in message.Links) + { + contentFlow.AddText(message.Content.Substring(prevIndex, link.Index - prevIndex)); + prevIndex = link.Index + link.Length; + contentFlow.AddLink(message.Content.Substring(link.Index, link.Length), link.Url, sprite => + { + if (message.IsAction) + sprite.Font = @"Exo2.0-MediumItalic"; + sprite.Colour = urlColour; + // We want to use something that is unique to every formatted link, so I just use the position of the link + ((ChatLinkSpriteText)sprite).LinkId = link.Index; + }); + } + + // Add text after the last link + var lastLink = message.Links[message.Links.Count - 1]; + contentFlow.AddText(message.Content.Substring(lastLink.Index + lastLink.Length)); + } } private class MessageSender : OsuClickableContainer, IHasContextMenu diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index c7bc5c1d93..af3e97db27 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Profile public class ProfileHeader : Container { private readonly OsuTextFlowContainer infoTextLeft; - private readonly LinkFlowContainer infoTextRight; + private readonly OsuLinkTextFlowContainer infoTextRight; private readonly FillFlowContainer scoreText, scoreNumberText; private readonly Container coverContainer, chartContainer, supporterTag; @@ -119,7 +119,7 @@ namespace osu.Game.Overlays.Profile } } }, - new LinkFlowContainer.ProfileLink(user) + new ProfileLink(user) { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, @@ -160,7 +160,7 @@ namespace osu.Game.Overlays.Profile ParagraphSpacing = 0.8f, LineSpacing = 0.2f }, - infoTextRight = new LinkFlowContainer(t => + infoTextRight = new OsuLinkTextFlowContainer(t => { t.TextSize = 14; t.Font = @"Exo2.0-RegularItalic"; @@ -488,57 +488,16 @@ namespace osu.Game.Overlays.Profile } } - private class LinkFlowContainer : OsuTextFlowContainer + private class ProfileLink : OsuLinkSpriteText, IHasTooltip { - public override bool HandleInput => true; + public string TooltipText => "View Profile in Browser"; - public LinkFlowContainer(Action defaultCreationParameters = null) : base(defaultCreationParameters) + public ProfileLink(User user) { - } - - protected override SpriteText CreateSpriteText() => new LinkText(); - - public void AddLink(string text, string url) => AddText(text, link => ((LinkText)link).Url = url); - - public class LinkText : OsuSpriteText - { - private readonly OsuHoverContainer content; - - public override bool HandleInput => content.Action != null; - - protected override Container Content => content ?? (Container)this; - - protected override IEnumerable FlowingChildren => Children; - - public string Url - { - set - { - if(value != null) - content.Action = () => Process.Start(value); - } - } - - public LinkText() - { - AddInternal(content = new OsuHoverContainer - { - AutoSizeAxes = Axes.Both, - }); - } - } - - public class ProfileLink : LinkText, IHasTooltip - { - public string TooltipText => "View Profile in Browser"; - - public ProfileLink(User user) - { - Text = user.Username; - Url = $@"https://osu.ppy.sh/users/{user.Id}"; - Font = @"Exo2.0-RegularItalic"; - TextSize = 30; - } + Text = user.Username; + Url = $@"https://osu.ppy.sh/users/{user.Id}"; + Font = @"Exo2.0-RegularItalic"; + TextSize = 30; } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 8b6bdefc6c..24ca01a3ad 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -268,6 +268,8 @@ + + @@ -284,8 +286,11 @@ 20171119065731_AddBeatmapOnlineIDUniqueConstraint.cs + + +