feat: clickable URLs in chat log

Adds a parsing step when constructing `Message` objects that scans the
message content for anything that looks URL-like, and inserts new
`TextChunk`s into the message content with a URIPayload set.

Hovering over a URL shows an on-hover effect. Clicking a URL opens it in
the default browser. Right clicking shows the hostname, with an option
to open and an option to copy the URL to the clipboard.
This commit is contained in:
Dean Sheather
2024-04-09 00:49:15 +10:00
parent fed420901c
commit 4701bb3f6d
10 changed files with 295 additions and 4 deletions
+6
View File
@@ -0,0 +1,6 @@
[*]
indent_style = space
tab_width = 4
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true
+13
View File
@@ -59,6 +59,19 @@ internal class TextChunk : Chunk {
public TextChunk() : base(ChunkSource.None, null) {
}
#pragma warning restore CS8618
/// <summary>
/// Creates a new TextChunk with identical styling to this one.
/// </summary>
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 {
+88 -1
View File
@@ -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<ChatCode>(code);
this.Sender = BsonMapper.Global.Deserialize<List<Chunk>>(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<List<Chunk>>(content);
this.SenderSource = BsonMapper.Global.Deserialize<SeString>(senderSource);
this.ContentSource = BsonMapper.Global.Deserialize<SeString>(contentSource);
@@ -108,6 +112,8 @@ internal class Message {
this.Date = date;
this.Code = BsonMapper.Global.ToObject<ChatCode>(code);
this.Sender = BsonMapper.Global.Deserialize<List<Chunk>>(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<List<Chunk>>(content);
this.SenderSource = BsonMapper.Global.Deserialize<SeString>(senderSource);
this.ContentSource = BsonMapper.Global.Deserialize<SeString>(contentSource);
@@ -138,4 +144,85 @@ internal class Message {
return Guid.Empty;
}
/// <summary>
/// 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.
/// </summary>
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
);
/// <summary>
/// Finds all URL strings in all TextChunks, splits the parent TextChunk
/// apart and inserts a new TextChunk with a URIPayload.
/// </summary>
private List<Chunk> ReplaceContentURLs(List<Chunk> content)
{
var newChunks = new List<Chunk>();
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<Match>())
{
// 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;
}
}
+54
View File
@@ -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();
}
}
+54
View File
@@ -1040,6 +1040,24 @@ namespace ChatTwo.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Copy link to clipboard.
/// </summary>
internal static string Context_CopyLink {
get {
return ResourceManager.GetString("Context_CopyLink", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Copied link to clipboard.
/// </summary>
internal static string Context_CopyLinkNotification {
get {
return ResourceManager.GetString("Context_CopyLinkNotification", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Hide chat.
/// </summary>
@@ -1121,6 +1139,24 @@ namespace ChatTwo.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Open link in browser.
/// </summary>
internal static string Context_OpenInBrowser {
get {
return ResourceManager.GetString("Context_OpenInBrowser", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to open the link in the browser, please report this issue.
/// </summary>
internal static string Context_OpenInBrowserError {
get {
return ResourceManager.GetString("Context_OpenInBrowserError", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Promote.
/// </summary>
@@ -1202,6 +1238,24 @@ namespace ChatTwo.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to URL at {0}.
/// </summary>
internal static string Context_URLDomain {
get {
return ResourceManager.GetString("Context_URLDomain", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Only open URLs from websites you trust.
/// </summary>
internal static string Context_URLWarning {
get {
return ResourceManager.GetString("Context_URLWarning", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Chinese (full).
/// </summary>
+18
View File
@@ -871,4 +871,22 @@
<data name="Options_TooltipOffset_Desc" xml:space="preserve">
<value>Use this option if you experience cut-off tooltips.</value>
</data>
<data name="Context_CopyLink" xml:space="preserve">
<value>Copy link to clipboard</value>
</data>
<data name="Context_CopyLinkNotification" xml:space="preserve">
<value>Copied link to clipboard</value>
</data>
<data name="Context_OpenInBrowser" xml:space="preserve">
<value>Open link in browser</value>
</data>
<data name="Context_OpenInBrowserError" xml:space="preserve">
<value>Failed to open the link in the browser, please report this issue</value>
</data>
<data name="Context_URLDomain" xml:space="preserve">
<value>URL at {0}</value>
</data>
<data name="Context_URLWarning" xml:space="preserve">
<value>Only open URLs from websites you trust</value>
</data>
</root>
+6
View File
@@ -86,6 +86,11 @@ internal class Store : IDisposable {
["Type"] = new("PartyFinder"),
["Id"] = new(partyFinder.Id),
});
case URIPayload uri:
return new BsonDocument(new Dictionary<string, BsonValue> {
["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,
};
}
+5
View File
@@ -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;
}
+3 -3
View File
@@ -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();
+48
View File
@@ -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";
/// <summary>
/// Create a URIPayload from a raw URI string. If the URI does not have a
/// scheme, it will default to https://.
/// </summary>
/// <exception cref="UriFormatException">
/// If the URI is invalid, or if the scheme is not supported.
/// </exception>
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();
}
}