diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ca3df2c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +[*] +indent_style = space +tab_width = 4 +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/ChatTwo/Chunk.cs b/ChatTwo/Chunk.cs index d88c263..fd755ba 100755 --- a/ChatTwo/Chunk.cs +++ b/ChatTwo/Chunk.cs @@ -59,6 +59,19 @@ internal class TextChunk : Chunk { public TextChunk() : base(ChunkSource.None, null) { } #pragma warning restore CS8618 + + /// + /// Creates a new TextChunk with identical styling to this one. + /// + public TextChunk NewWithStyle(ChunkSource source, Payload? link, string content) + { + return new TextChunk(source, link, content) { + FallbackColour = this.FallbackColour, + Foreground = this.Foreground, + Glow = this.Glow, + Italic = this.Italic, + }; + } } internal class IconChunk : Chunk { diff --git a/ChatTwo/Message.cs b/ChatTwo/Message.cs index bcba4ce..f0cc9fc 100755 --- a/ChatTwo/Message.cs +++ b/ChatTwo/Message.cs @@ -1,7 +1,9 @@ using ChatTwo.Code; +using ChatTwo.Util; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using LiteDB; +using System.Text.RegularExpressions; namespace ChatTwo; @@ -70,7 +72,7 @@ internal class Message { this.Date = DateTime.UtcNow; this.Code = code; this.Sender = sender; - this.Content = content; + this.Content = ReplaceContentURLs(content); this.SenderSource = senderSource; this.ContentSource = contentSource; this.SortCode = new SortCode(this.Code.Type, this.Code.Source); @@ -89,6 +91,8 @@ internal class Message { this.Date = date; this.Code = BsonMapper.Global.ToObject(code); this.Sender = BsonMapper.Global.Deserialize>(sender); + // Don't call ReplaceContentURLs here since we're loading the message + // from the database and it should already have parsed URL data. this.Content = BsonMapper.Global.Deserialize>(content); this.SenderSource = BsonMapper.Global.Deserialize(senderSource); this.ContentSource = BsonMapper.Global.Deserialize(contentSource); @@ -108,6 +112,8 @@ internal class Message { this.Date = date; this.Code = BsonMapper.Global.ToObject(code); this.Sender = BsonMapper.Global.Deserialize>(sender); + // Don't call ReplaceContentURLs here since we're loading the message + // from the database and it should already have parsed URL data. this.Content = BsonMapper.Global.Deserialize>(content); this.SenderSource = BsonMapper.Global.Deserialize(senderSource); this.ContentSource = BsonMapper.Global.Deserialize(contentSource); @@ -138,4 +144,85 @@ internal class Message { return Guid.Empty; } + + /// + /// URLRegex returns a regex object that matches URLs like: + /// - https://example.com + /// - http://example.com + /// - www.example.com + /// - https://sub.example.com + /// - example.com + /// - sub.example.com + /// + /// It matches URLs with www. or https:// prefix, and also matches URLs + /// without a prefix on specific TLDs. + /// + private static Regex URLRegex = new Regex( + @"((https?:\/\/|www\.)[a-z0-9-]+(\.[a-z0-9-]+)*|([a-z0-9-]+(\.[a-z0-9-]+)*\.(com|net|org|co|io|app)))(:[\d]{1,5})?(\/[^\s]+)?", + RegexOptions.Compiled | RegexOptions.IgnoreCase + ); + + /// + /// Finds all URL strings in all TextChunks, splits the parent TextChunk + /// apart and inserts a new TextChunk with a URIPayload. + /// + private List ReplaceContentURLs(List content) + { + var newChunks = new List(); + void AddChunkWithMessage(Chunk chunk) { + chunk.Message = this; + newChunks.Add(chunk); + } + + foreach (var chunk in content) + { + // Use as is if it's not a text chunk or it already has a payload. + if (chunk is not TextChunk text || chunk.Link != null) + { + // No need to call AddChunkWithMessage here since the chunk + // already has the Message field set. + newChunks.Add(chunk); + continue; + } + + // Find all URLs with the regex and insert a new TextChunk with a + // URIPayload. + var matches = URLRegex.Matches(text.Content); + var remainderIndex = 0; + foreach (Match match in matches.Cast()) + { + // Add the text before the URL. + if (match.Index > remainderIndex) + { + AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, text.Content[remainderIndex..match.Index])); + } + + // Update the remainder index. + remainderIndex = match.Index + match.Length; + + // Create a new TextChunk with a URIPayload for the URL text. + try + { + var link = URIPayload.ResolveURI(match.Value); + AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, match.Value)); + } + catch (UriFormatException) + { + Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{match.Value}'"); + // If the URL is invalid, set the remainder index to the + // beginning of the match so it'll get included in the next + // regular text chunk. + remainderIndex = match.Index; + } + } + + // Add the text after the last URL. + if (remainderIndex < text.Content.Length) + { + AddChunkWithMessage(text.NewWithStyle(chunk.Source, null, text.Content[remainderIndex..])); + } + } + + return newChunks; + } } diff --git a/ChatTwo/PayloadHandler.cs b/ChatTwo/PayloadHandler.cs index a971615..e448125 100755 --- a/ChatTwo/PayloadHandler.cs +++ b/ChatTwo/PayloadHandler.cs @@ -21,6 +21,7 @@ using Lumina.Excel.GeneratedSheets; using Action = System.Action; using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload; using ChatTwoPartyFinderPayload = ChatTwo.Util.PartyFinderPayload; +using System.Diagnostics; namespace ChatTwo; @@ -86,6 +87,11 @@ public sealed class PayloadHandler { drawn = true; break; } + case URIPayload uri: { + DrawUriPopup(uri); + drawn = true; + break; + } } ContextFooter(drawn, chunk); @@ -215,6 +221,11 @@ public sealed class PayloadHandler { DoHover(() => HoverItem(item), hoverSize); break; } + case URIPayload uri: + { + DoHover(() => HoverURI(uri), hoverSize); + break; + } } } @@ -334,6 +345,11 @@ public sealed class PayloadHandler { } } + private void HoverURI(URIPayload uri) { + ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority)); + ImGuiUtil.WarningText(Language.Context_URLWarning); + } + private void LeftClickPayload(Chunk chunk, Payload? payload) { switch (payload) { case MapLinkPayload map: { @@ -372,6 +388,10 @@ public sealed class PayloadHandler { break; } + case URIPayload uri: { + TryOpenURI(uri.Uri); + break; + } } } @@ -625,4 +645,38 @@ public sealed class PayloadHandler { return null; } + + private void DrawUriPopup(URIPayload uri) + { + ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority)); + ImGuiUtil.WarningText(Language.Context_URLWarning, false); + ImGui.Separator(); + + if (ImGui.Selectable(Language.Context_OpenInBrowser)) + { + TryOpenURI(uri.Uri); + } + + if (ImGui.Selectable(Language.Context_CopyLink)) + { + ImGui.SetClipboardText(uri.Uri.ToString()); + WrapperUtil.AddNotification(Language.Context_CopyLinkNotification, NotificationType.Info); + } + } + + private void TryOpenURI(Uri uri) + { + new Thread(() => { + try + { + Plugin.Log.Info($"Opening URI {uri} in default browser"); + Process.Start(new ProcessStartInfo(uri.ToString()) { UseShellExecute = true }); + } + catch (Exception ex) + { + Plugin.Log.Error($"Error opening URI: {ex}"); + WrapperUtil.AddNotification(Language.Context_OpenInBrowserError, NotificationType.Error); + } + }).Start(); + } } diff --git a/ChatTwo/Resources/Language.Designer.cs b/ChatTwo/Resources/Language.Designer.cs index 2412b26..deda984 100755 --- a/ChatTwo/Resources/Language.Designer.cs +++ b/ChatTwo/Resources/Language.Designer.cs @@ -1040,6 +1040,24 @@ namespace ChatTwo.Resources { } } + /// + /// Looks up a localized string similar to Copy link to clipboard. + /// + internal static string Context_CopyLink { + get { + return ResourceManager.GetString("Context_CopyLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copied link to clipboard. + /// + internal static string Context_CopyLinkNotification { + get { + return ResourceManager.GetString("Context_CopyLinkNotification", resourceCulture); + } + } + /// /// Looks up a localized string similar to Hide chat. /// @@ -1121,6 +1139,24 @@ namespace ChatTwo.Resources { } } + /// + /// Looks up a localized string similar to Open link in browser. + /// + internal static string Context_OpenInBrowser { + get { + return ResourceManager.GetString("Context_OpenInBrowser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to open the link in the browser, please report this issue. + /// + internal static string Context_OpenInBrowserError { + get { + return ResourceManager.GetString("Context_OpenInBrowserError", resourceCulture); + } + } + /// /// Looks up a localized string similar to Promote. /// @@ -1202,6 +1238,24 @@ namespace ChatTwo.Resources { } } + /// + /// Looks up a localized string similar to URL at {0}. + /// + internal static string Context_URLDomain { + get { + return ResourceManager.GetString("Context_URLDomain", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Only open URLs from websites you trust. + /// + internal static string Context_URLWarning { + get { + return ResourceManager.GetString("Context_URLWarning", resourceCulture); + } + } + /// /// Looks up a localized string similar to Chinese (full). /// diff --git a/ChatTwo/Resources/Language.resx b/ChatTwo/Resources/Language.resx index 4af8f8a..69cda28 100644 --- a/ChatTwo/Resources/Language.resx +++ b/ChatTwo/Resources/Language.resx @@ -871,4 +871,22 @@ Use this option if you experience cut-off tooltips. + + Copy link to clipboard + + + Copied link to clipboard + + + Open link in browser + + + Failed to open the link in the browser, please report this issue + + + URL at {0} + + + Only open URLs from websites you trust + diff --git a/ChatTwo/Store.cs b/ChatTwo/Store.cs index dda6102..5aea584 100755 --- a/ChatTwo/Store.cs +++ b/ChatTwo/Store.cs @@ -86,6 +86,11 @@ internal class Store : IDisposable { ["Type"] = new("PartyFinder"), ["Id"] = new(partyFinder.Id), }); + case URIPayload uri: + return new BsonDocument(new Dictionary { + ["Type"] = new("URI"), + ["Uri"] = new(uri.Uri.ToString()), + }); } return payload?.Encode(); @@ -99,6 +104,7 @@ internal class Store : IDisposable { return bson["Type"].AsString switch { "Achievement" => new AchievementPayload((uint) bson["Id"].AsInt64), "PartyFinder" => new PartyFinderPayload((uint) bson["Id"].AsInt64), + "URI" => new URIPayload(new Uri(bson["Uri"].AsString)), _ => null, }; } diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs index 45368da..b4c01fc 100755 --- a/ChatTwo/Util/ChunkUtil.cs +++ b/ChatTwo/Util/ChunkUtil.cs @@ -1,6 +1,7 @@ using ChatTwo.Code; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; +using System.Text; namespace ChatTwo.Util; @@ -100,6 +101,10 @@ internal static class ChunkUtil { var reader = new BinaryReader(new MemoryStream(rawPayload.Data[4..])); var id = GetInteger(reader); link = new AchievementPayload(id); + } else if (rawPayload.Data.Length > 5 && rawPayload.Data[1] == 0x27 && rawPayload.Data[3] == 0x07) { + // uri payload + var uri = new Uri(Encoding.UTF8.GetString(rawPayload.Data[4..])); + link = new URIPayload(uri); } else if (Equals(rawPayload, RawPayload.LinkTerminator)) { link = null; } diff --git a/ChatTwo/Util/ImGuiUtil.cs b/ChatTwo/Util/ImGuiUtil.cs index 0075556..31c4efc 100755 --- a/ChatTwo/Util/ImGuiUtil.cs +++ b/ChatTwo/Util/ImGuiUtil.cs @@ -197,16 +197,16 @@ internal static class ImGuiUtil { } } - internal static void WarningText(string text) { + internal static void WarningText(string text, bool wrap = true) { var style = StyleModel.GetConfiguredStyle() ?? StyleModel.GetFromCurrent(); var dalamudOrange = style.BuiltInColors?.DalamudOrange; if (dalamudOrange != null) { ImGui.PushStyleColor(ImGuiCol.Text, dalamudOrange.Value); } - ImGui.PushTextWrapPos(); + if (wrap) ImGui.PushTextWrapPos(); ImGui.TextUnformatted(text); - ImGui.PopTextWrapPos(); + if (wrap) ImGui.PopTextWrapPos(); if (dalamudOrange != null) { ImGui.PopStyleColor(); diff --git a/ChatTwo/Util/Payloads.cs b/ChatTwo/Util/Payloads.cs index a1d0a58..06d7028 100755 --- a/ChatTwo/Util/Payloads.cs +++ b/ChatTwo/Util/Payloads.cs @@ -37,3 +37,51 @@ internal class AchievementPayload : Payload { throw new NotImplementedException(); } } + + +internal class URIPayload(Uri uri) : Payload +{ + public override PayloadType Type => (PayloadType) 0x52; + + public Uri Uri { get; init; } = uri; + + private static readonly string[] ExpectedSchemes = ["http", "https"]; + private static readonly string DefaultScheme = "https"; + + /// + /// Create a URIPayload from a raw URI string. If the URI does not have a + /// scheme, it will default to https://. + /// + /// + /// If the URI is invalid, or if the scheme is not supported. + /// + public static URIPayload ResolveURI(string rawURI) + { + ArgumentNullException.ThrowIfNull(rawURI); + + // Check for expected scheme ://, if not add https:// + foreach (var scheme in ExpectedSchemes) + { + if (rawURI.StartsWith($"{scheme}://")) + { + return new URIPayload(new Uri(rawURI)); + } + } + if (rawURI.Contains("://")) + { + throw new UriFormatException($"Unsupported scheme in URL: {rawURI}"); + } + + return new URIPayload(new Uri($"{DefaultScheme}://{rawURI}")); + } + + protected override void DecodeImpl(BinaryReader reader, long endOfStream) + { + throw new NotImplementedException(); + } + + protected override byte[] EncodeImpl() + { + throw new NotImplementedException(); + } +}