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();
+ }
+}