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 f1ca361..4452d09 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);
@@ -103,9 +109,7 @@ public sealed class PayloadHandler {
ImGui.Separator();
var contentId = chunk.Message?.ContentId ?? 0;
- var sender = chunk.Message?.Sender
- .Select(chunk => chunk.Link)
- .FirstOrDefault(chunk => chunk is PlayerPayload) as PlayerPayload;
+ var sender = chunk.Message?.Sender.Select(c => c.Link).FirstOrDefault(p => p is PlayerPayload) as PlayerPayload;
if (ImGui.BeginMenu(Language.Context_Integrations)) {
var cursor = ImGui.GetCursorPos();
@@ -229,6 +233,11 @@ public sealed class PayloadHandler {
DoHover(() => HoverItem(item), hoverSize);
break;
}
+ case URIPayload uri:
+ {
+ DoHover(() => HoverURI(uri), hoverSize);
+ break;
+ }
}
}
@@ -348,6 +357,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: {
@@ -386,6 +400,10 @@ public sealed class PayloadHandler {
break;
}
+ case URIPayload uri: {
+ TryOpenURI(uri.Uri);
+ break;
+ }
}
}
@@ -639,4 +657,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/Plugin.cs b/ChatTwo/Plugin.cs
index 45c1092..3a06b03 100755
--- a/ChatTwo/Plugin.cs
+++ b/ChatTwo/Plugin.cs
@@ -42,6 +42,7 @@ public sealed class Plugin : IDalamudPlugin {
public SettingsWindow SettingsWindow { get; }
public ChatLogWindow ChatLogWindow { get; }
public CommandHelpWindow CommandHelpWindow { get; }
+ public SeStringDebugger SeStringDebugger { get; }
internal Configuration Config { get; }
internal Commands Commands { get; }
@@ -81,10 +82,12 @@ public sealed class Plugin : IDalamudPlugin {
ChatLogWindow = new ChatLogWindow(this);
SettingsWindow = new SettingsWindow(this);
CommandHelpWindow = new CommandHelpWindow(ChatLogWindow);
+ SeStringDebugger = new SeStringDebugger(this);
WindowSystem.AddWindow(ChatLogWindow);
WindowSystem.AddWindow(SettingsWindow);
WindowSystem.AddWindow(CommandHelpWindow);
+ WindowSystem.AddWindow(SeStringDebugger);
FontManager.BuildFonts();
Interface.UiBuilder.DisableCutsceneUiHide = true;
@@ -114,6 +117,7 @@ public sealed class Plugin : IDalamudPlugin {
WindowSystem.RemoveAllWindows();
ChatLogWindow.Dispose();
SettingsWindow.Dispose();
+ SeStringDebugger.Dispose();
ExtraChat.Dispose();
Ipc.Dispose();
diff --git a/ChatTwo/Resources/Language.Designer.cs b/ChatTwo/Resources/Language.Designer.cs
index 98170f2..8696c23 100755
--- a/ChatTwo/Resources/Language.Designer.cs
+++ b/ChatTwo/Resources/Language.Designer.cs
@@ -1067,6 +1067,23 @@ 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.
///
@@ -1148,6 +1165,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.
///
@@ -1229,6 +1264,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 d0dce97..374e688 100644
--- a/ChatTwo/Resources/Language.resx
+++ b/ChatTwo/Resources/Language.resx
@@ -871,6 +871,7 @@
Use this option if you experience cut-off tooltips.
+
Copy content
@@ -880,4 +881,22 @@
Copied message to clipboard
+
+ 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..60a6209 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,
};
}
@@ -257,6 +263,7 @@ internal class Store : IDisposable {
}
}
+ public (SeString? Sender, SeString? Message) LastMessage = (null, null);
private void ChatMessage(XivChatType type, uint senderId, SeString sender, SeString message) {
var chatCode = new ChatCode((ushort) type);
@@ -265,6 +272,7 @@ internal class Store : IDisposable {
formatting = FormatFor(chatCode.Type);
}
+ LastMessage = (sender, message);
var senderChunks = new List();
if (formatting is { IsPresent: true }) {
senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.Before) {
diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs
index c5f73f6..193985b 100644
--- a/ChatTwo/Ui/ChatLogWindow.cs
+++ b/ChatTwo/Ui/ChatLogWindow.cs
@@ -75,12 +75,8 @@ public sealed class ChatLogWindow : Window, IUiComponent {
Plugin = plugin;
Salt = new Random().Next().ToString();
+ Size = new Vector2(500, 250);
SizeCondition = ImGuiCond.FirstUseEver;
- SizeConstraints = new WindowSizeConstraints
- {
- MinimumSize = new Vector2(500, 250),
- MaximumSize = new Vector2(float.MaxValue, float.MaxValue)
- };
PayloadHandler = new PayloadHandler(this);
HandlerLender = new Lender(() => new PayloadHandler(this));
@@ -1257,10 +1253,8 @@ public sealed class ChatLogWindow : Window, IUiComponent {
|| cmd.Alias.RawString == command
|| cmd.ShortCommand.RawString == command
|| cmd.ShortAlias.RawString == command);
- if (cmd != null) {
+ if (cmd != null)
Plugin.CommandHelpWindow.UpdateContent(cmd);
- Plugin.CommandHelpWindow.IsOpen = true;
- }
}
if (data->EventFlag != ImGuiInputTextFlags.CallbackHistory) {
@@ -1332,15 +1326,15 @@ public sealed class ChatLogWindow : Window, IUiComponent {
private void DrawChunk(Chunk chunk, bool wrap = true, PayloadHandler? handler = null, float lineWidth = 0f) {
if (chunk is IconChunk icon && _fontIcon != null) {
- var bounds = IconUtil.GetBounds((byte) icon.Icon);
- if (bounds != null) {
+ var bounds = IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out var entry);
+ if (bounds) {
var texSize = new Vector2(_fontIcon.Width, _fontIcon.Height);
- var sizeRatio = Plugin.Config.FontSize / bounds.Value.W;
- var size = new Vector2(bounds.Value.Z, bounds.Value.W) * sizeRatio * ImGuiHelpers.GlobalScale;
+ var sizeRatio = Plugin.Config.FontSize / entry.Height;
+ var size = new Vector2(entry.Width, entry.Height) * sizeRatio * ImGuiHelpers.GlobalScale;
- var uv0 = new Vector2(bounds.Value.X, bounds.Value.Y - 2) / texSize;
- var uv1 = new Vector2(bounds.Value.X + bounds.Value.Z, bounds.Value.Y - 2 + bounds.Value.W) / texSize;
+ var uv0 = new Vector2(entry.Left, entry.Top) / texSize;
+ var uv1 = new Vector2(entry.Left + entry.Width, entry.Top + entry.Height) / texSize;
ImGui.Image(_fontIcon.ImGuiHandle, size, uv0, uv1);
ImGuiUtil.PostPayload(chunk, handler);
}
diff --git a/ChatTwo/Ui/CommandHelpWindow.cs b/ChatTwo/Ui/CommandHelpWindow.cs
index ab9ac55..23590a3 100644
--- a/ChatTwo/Ui/CommandHelpWindow.cs
+++ b/ChatTwo/Ui/CommandHelpWindow.cs
@@ -20,6 +20,7 @@ public class CommandHelpWindow : Window {
ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.AlwaysAutoResize;
}
+ // Sets IsOpen to true if it should be drawn
public void UpdateContent(TextCommand command)
{
Command = command;
@@ -36,6 +37,7 @@ public class CommandHelpWindow : Window {
break;
case CommandHelpSide.None:
default:
+ IsOpen = false;
return;
}
@@ -45,6 +47,8 @@ public class CommandHelpWindow : Window {
MinimumSize = new Vector2(width, 0),
MaximumSize = LogWindow.LastWindowSize with { X = width }
};
+
+ IsOpen = true;
}
public override void Draw()
diff --git a/ChatTwo/Ui/SeStringDebugger.cs b/ChatTwo/Ui/SeStringDebugger.cs
new file mode 100644
index 0000000..4bddcc3
--- /dev/null
+++ b/ChatTwo/Ui/SeStringDebugger.cs
@@ -0,0 +1,319 @@
+using System.Numerics;
+using System.Text;
+using ChatTwo.Util;
+using Dalamud.Game.Text.SeStringHandling.Payloads;
+using Dalamud.Interface.Windowing;
+using ImGuiNET;
+using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload;
+
+namespace ChatTwo.Ui;
+
+public class SeStringDebugger : Window
+{
+ private readonly Plugin Plugin;
+
+ public SeStringDebugger(Plugin plugin) : base($"SeString Debugger###chat2-sestringdebugger")
+ {
+ Plugin = plugin;
+
+ SizeConstraints = new WindowSizeConstraints
+ {
+ MinimumSize = new Vector2(475, 600),
+ MaximumSize = new Vector2(float.MaxValue, float.MaxValue)
+ };
+
+ Plugin.Commands.Register("/chat2Debugger").Execute += Toggle;
+ }
+
+ public void Dispose()
+ {
+ Plugin.Commands.Register("/chat2Debugger").Execute -= Toggle;
+ }
+
+ private void Toggle(string _, string __) => Toggle();
+
+ public override void Draw()
+ {
+ ImGui.TextUnformatted("SeString Content");
+ ImGui.Spacing();
+
+ if (Plugin.Store.LastMessage.Sender == null)
+ {
+ ImGui.TextUnformatted("Nothing to show");
+ return;
+ }
+
+ // TODO: Make SeString freely selectable through chat
+ foreach (var payload in Plugin.Store.LastMessage.Sender.Payloads)
+ {
+ switch (payload)
+ {
+ case UIForegroundPayload color:
+ {
+ RenderMetadataDictionary("Link ForegroundColor", new Dictionary
+ {
+ { "Enabled?", color.IsEnabled.ToString() },
+ { "ColorKey", color.IsEnabled ? color.ColorKey.ToString() : "Color Ended" },
+ });
+ break;
+ }
+ case MapLinkPayload map:
+ {
+ RenderMetadataDictionary("Link MapLinkPayload", new Dictionary
+ {
+ { "Map.RowId", map.Map?.RowId.ToString() },
+ { "Map.PlaceName", map.Map?.PlaceName.Value?.Name.ToString() },
+ { "Map.PlaceNameRegion", map.Map?.PlaceNameRegion.Value?.Name.ToString() },
+ { "Map.PlaceNameSub", map.Map?.PlaceNameSub.Value?.Name.ToString() },
+ { "TerritoryType.RowId", map.TerritoryType?.RowId.ToString() },
+ { "RawX", map.RawX.ToString() },
+ { "RawY", map.RawY.ToString() },
+ { "XCoord", map.XCoord.ToString() },
+ { "YCoord", map.YCoord.ToString() },
+ { "CoordinateString", map.CoordinateString },
+ { "DataString", map.DataString },
+ });
+ break;
+ }
+ case QuestPayload quest:
+ {
+ RenderMetadataDictionary("Link QuestPayload", new Dictionary
+ {
+ { "Quest.RowId", quest.Quest?.RowId.ToString() },
+ { "Quest.Name", quest.Quest?.Name.ToString() },
+ });
+ break;
+ }
+ case DalamudLinkPayload link:
+ {
+ RenderMetadataDictionary("Link DalamudLinkPayload", new Dictionary
+ {
+ { "CommandId", link.CommandId.ToString() },
+ { "Plugin", link.Plugin },
+ });
+ break;
+ }
+ case DalamudPartyFinderPayload pf:
+ {
+ RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary
+ {
+ { "ListingId", pf.ListingId.ToString() },
+ { "LinkType", EnumName(pf.LinkType) },
+ });
+ break;
+ }
+ case PlayerPayload player:
+ {
+ RenderMetadataDictionary("Link PlayerPayload", new Dictionary
+ {
+ { "Real", player.DisplayedName },
+ { "PlayerName", player.PlayerName },
+ { "World.Name", player.World.Name },
+ });
+ break;
+ }
+ case ItemPayload item:
+ {
+ RenderMetadataDictionary("Link ItemPayload", new Dictionary
+ {
+ { "ItemId", item.ItemId.ToString() },
+ { "RawItemId", item.RawItemId.ToString() },
+ { "Kind", EnumName(item.Kind) },
+ { "IsHQ", item.IsHQ.ToString() },
+ { "Item.Name", item.Item?.Name.ToString() },
+ });
+ break;
+ }
+ case AutoTranslatePayload at:
+ {
+ RenderMetadataDictionary("Link AutoTranslatePayload", new Dictionary
+ {
+ { "Text", at.Text },
+ });
+ break;
+ }
+ case IconPayload icon:
+ {
+ var found = IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out var entry);
+ RenderMetadataDictionary("Link IconPayload", new Dictionary
+ {
+ { "Found", found.ToString() },
+ { "Icon ID", ((uint) icon.Icon).ToString() },
+ });
+ break;
+ }
+ case RawPayload raw:
+ {
+ var colorPayload = ColorPayload.From(raw.Data);
+ if (colorPayload != null)
+ {
+ var push = colorPayload.Enabled && colorPayload.Color != 0;
+ // if (push) ImGui.PushStyleColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(colorPayload.U));
+ RenderMetadataDictionary("Link ColorPayload", new Dictionary
+ {
+ { "Unshifted", colorPayload.UnshiftedColor.ToString("X8") },
+ { "Color", colorPayload.Color.ToString("X8") },
+ { "Enabled?", colorPayload.Enabled.ToString() },
+ });
+ // if (push) ImGui.PopStyleColor();
+ }
+ else
+ {
+ RenderMetadataDictionary("Link RawPayload", new Dictionary
+ {
+ { "Data", string.Join(" ", raw.Data.Select(b => b.ToString("X2"))) },
+ { "Type", EnumName(raw.Type) },
+ });
+ }
+ break;
+ }
+ case StatusPayload status:
+ {
+ RenderMetadataDictionary("Link StatusPayload", new Dictionary
+ {
+ { "Status.RowId", status.Status.RowId.ToString() },
+ { "Status.Name", status.Status.Name },
+ { "Status.Icon", status.Status.Icon.ToString() }
+ });
+ break;
+ }
+
+ case Util.PartyFinderPayload pf:
+ {
+ RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary
+ {
+ { "Id", pf.Id.ToString() }
+ });
+ break;
+ }
+ case AchievementPayload achievement:
+ {
+ RenderMetadataDictionary("Link AchievementPayload", new Dictionary
+ {
+ { "Id", achievement.Id.ToString() }
+ });
+ break;
+ }
+ default:
+ var payloadData = payload.Encode();
+
+ var initialByte = payloadData.First();
+ if (initialByte != 0x02)
+ {
+ RenderMetadataDictionary("Text Payload", new Dictionary
+ {
+ { "Content", Encoding.UTF8.GetString(payloadData) },
+ });
+ }
+ else
+ {
+ var unknown = new RawPayload(payloadData);
+ RenderMetadataDictionary("Link Unknown", new Dictionary
+ {
+ { "Unknown", string.Join(" ", unknown.Data.Select(b => b.ToString("X2"))) },
+ });
+ }
+ break;
+ }
+ }
+ }
+
+ private static string? EnumName(T? value) where T : Enum
+ {
+ if (value == null)
+ {
+ return null;
+ }
+ var rawValue = Convert.ChangeType(value, value.GetTypeCode());
+ return (Enum.GetName(value.GetType(), value) ?? "Unknown") + $" ({rawValue})";
+ }
+
+ private static void RenderMetadataDictionary(string name, Dictionary metadata)
+ {
+ var style = ImGui.GetStyle();
+
+ ImGui.Text($"{name}:");
+ ImGui.Indent(style.IndentSpacing);
+ if (!ImGui.BeginTable($"##chat3-{name}", 2, 0))
+ {
+ ImGui.EndTable();
+ ImGui.Unindent(style.IndentSpacing);
+ return;
+ }
+ ImGui.TableSetupColumn($"##chat3-{name}-key", 0, 0.4f);
+ ImGui.TableSetupColumn($"##chat3-{name}-value");
+ for (var i = 0; i < metadata.Count; i++)
+ {
+ var (key, value) = metadata.ElementAt(i);
+ ImGui.PushID(i);
+ ImGui.TableNextColumn();
+ ImGui.Text(key);
+ ImGui.TableNextColumn();
+ ImGuiTextVisibleWhitespace(value);
+ ImGui.PopID();
+ }
+ ImGui.EndTable();
+ ImGui.Unindent(style.IndentSpacing);
+ ImGui.NewLine();
+ }
+
+ // ImGuiTextVisibleWhitespace replaces leading and trailing whitespace with
+ // visible characters. The extra characters are rendered with a muted font.
+ private static void ImGuiTextVisibleWhitespace(string? original, bool wrap = true)
+ {
+ if (string.IsNullOrEmpty(original))
+ {
+ var str = original == null ? "(null)" : "(empty)";
+ ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f));
+ ImGui.TextUnformatted(str);
+ ImGui.PopStyleColor();
+ return;
+ }
+
+ var text = original;
+ var start = 0;
+ var end = text.Length;
+
+ ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(0, 0));
+
+ void WriteText(string text)
+ {
+ if (wrap)
+ {
+ ImGui.TextWrapped(text);
+ }
+ else
+ {
+ ImGui.TextUnformatted(text);
+ }
+ }
+
+ while (start < end && char.IsWhiteSpace(text[start]))
+ {
+ start++;
+ }
+ if (start > 0)
+ {
+ ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f));
+ WriteText(new string('_', start));
+ ImGui.PopStyleColor();
+ ImGui.SameLine();
+ }
+
+ while (end > start && char.IsWhiteSpace(text[end - 1]))
+ {
+ end--;
+ }
+
+ WriteText(text[start..end]);
+ if (end < text.Length)
+ {
+ ImGui.SameLine();
+ ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f));
+ WriteText(new string('_', text.Length - end));
+ ImGui.PopStyleColor();
+ }
+
+ ImGui.PopStyleVar();
+ }
+}
\ No newline at end of file
diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs
index 45368da..3874c77 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;
@@ -68,15 +69,19 @@ internal static class ChunkUtil {
break;
case PayloadType.Unknown:
var rawPayload = (RawPayload) payload;
- if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x13)
+ var colorPayload = ColorPayload.From(rawPayload.Data);
+ if (colorPayload != null)
{
- if (foreground.Count > 0) {
- foreground.Pop();
- }
- else if (rawPayload.Data.Length > 6 && rawPayload.Data[2] == 0x05 && rawPayload.Data[3] == 0xF6)
+ if (colorPayload.Enabled)
{
- var (r, g, b) = (rawPayload.Data[4], rawPayload.Data[5], rawPayload.Data[6]);
- foreground.Push(ColourUtil.ComponentsToRgba(r, g, b));
+ if (colorPayload.Color > 0)
+ foreground.Push(colorPayload.Color);
+ else if (foreground.Count > 0) // Push the previous color as we don't want invisible text
+ foreground.Push(foreground.Peek());
+ }
+ else if (foreground.Count > 0)
+ {
+ foreground.Pop();
}
}
else if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x14)
@@ -100,6 +105,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/ColourUtil.cs b/ChatTwo/Util/ColourUtil.cs
index 0887e4c..145afe6 100755
--- a/ChatTwo/Util/ColourUtil.cs
+++ b/ChatTwo/Util/ColourUtil.cs
@@ -12,8 +12,8 @@ internal static class ColourUtil {
}
internal static uint RgbaToAbgr(uint rgba) {
- var (r, g, b, a) = RgbaToComponents(rgba);
- return (uint) ((a << 24) | (b << 16) | (g << 8) | r);
+ var tmp = ((rgba << 8) & 0xFF00FF00) | ((rgba >> 8) & 0xFF00FF);
+ return (tmp << 16) | (tmp >> 16);
}
internal static Vector3 RgbaToVector3(uint rgba) {
@@ -38,6 +38,13 @@ internal static class ColourUtil {
));
}
+ public static unsafe uint ArgbToRgba(uint x)
+ {
+ var buf = (byte*)&x;
+ (buf[1], buf[2], buf[3], buf[0]) = (buf[0], buf[1], buf[2], buf[3]);
+ return x;
+ }
+
internal static uint ComponentsToRgba(byte red, byte green, byte blue, byte alpha = 0xFF) => alpha
| (uint) (red << 24)
| (uint) (green << 16)
diff --git a/ChatTwo/Util/ExtraPayload.cs b/ChatTwo/Util/ExtraPayload.cs
new file mode 100644
index 0000000..9e736c9
--- /dev/null
+++ b/ChatTwo/Util/ExtraPayload.cs
@@ -0,0 +1,109 @@
+using Dalamud.Utility;
+using FFXIVClientStructs.FFXIV.Client.UI.Misc;
+using FFXIVClientStructs.FFXIV.Component.Text;
+
+namespace ChatTwo.Util;
+
+public class ColorPayload
+{
+ private const byte START_BYTE = 2;
+
+ public bool Enabled;
+ public uint Color;
+ public uint UnshiftedColor;
+
+ public static ColorPayload? From(byte[] data)
+ {
+ using var stream = new MemoryStream(data);
+ if (stream.ReadByte() != START_BYTE || stream.ReadByte() != 0x13)
+ return null;
+
+ stream.ReadByte(); // skip the length byte;
+
+ var typeByte = stream.ReadByte();
+ var payload = new ColorPayload();
+ if (typeByte == 0xEC)
+ {
+ payload.Enabled = false;
+ return payload;
+ }
+
+ if (typeByte == 0xE9)
+ {
+ var param = stream.ReadByte();
+ var ok = TryGetGNumDefault((uint) (param - 2), out var value);
+ if (!ok)
+ {
+ Plugin.Log.Error($"Unable to GetGNum for param {param - 2}");
+ return null;
+ }
+
+ payload.Enabled = true;
+ payload.UnshiftedColor = value;
+ payload.Color = ColourUtil.ArgbToRgba(value);
+
+ return payload;
+ }
+
+ if (typeByte is >= 0xF0 and <= 0xFE)
+ {
+ // From: https://github.com/NotAdam/Lumina/blob/master/src/Lumina/Text/Expressions/IntegerExpression.cs#L119-L128
+ uint ShiftAndThrowIfZero(int v, int shift)
+ {
+ return v switch
+ {
+ -1 => throw new ArgumentException("Encountered premature end of input (unexpected EOF).", nameof(v)),
+ 0 => throw new ArgumentException("Encountered premature end of input (unexpected null character).", nameof(v)),
+ _ => (uint)v << shift
+ };
+ }
+
+ typeByte += 1;
+ var value = 0u;
+ if ((typeByte & 8) != 0)
+ value |= ShiftAndThrowIfZero(stream.ReadByte(), 24);
+ else
+ value |= 0xff000000u;
+
+ if( (typeByte & 4) != 0 ) value |= ShiftAndThrowIfZero( stream.ReadByte(), 16 );
+ if( (typeByte & 2) != 0 ) value |= ShiftAndThrowIfZero( stream.ReadByte(), 8 );
+ if( (typeByte & 1) != 0 ) value |= ShiftAndThrowIfZero( stream.ReadByte(), 0 );
+
+ payload.Enabled = true;
+ payload.Color = ColourUtil.ArgbToRgba(value);
+
+ return payload;
+ }
+
+ return null;
+ }
+
+ private static unsafe bool TryGetGNumDefault(uint parameterIndex, out uint value)
+ {
+ value = 0u;
+
+ var rtm = RaptureTextModule.Instance();
+ if (rtm is null)
+ return false;
+
+ if (!ThreadSafety.IsMainThread)
+ {
+ Plugin.Log.Error("Global parameters may only be used from the main thread.");
+ return false;
+ }
+
+ ref var gp = ref rtm->TextModule.MacroDecoder.GlobalParameters;
+ if (parameterIndex >= gp.MySize)
+ return false;
+
+ var p = rtm->TextModule.MacroDecoder.GlobalParameters.Get(parameterIndex);
+ switch (p.Type)
+ {
+ case TextParameterType.Integer:
+ value = (uint)p.IntValue;
+ return true;
+ default:
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ChatTwo/Util/IconUtil.cs b/ChatTwo/Util/IconUtil.cs
index d4e68de..11cd426 100755
--- a/ChatTwo/Util/IconUtil.cs
+++ b/ChatTwo/Util/IconUtil.cs
@@ -1,95 +1,148 @@
-using System.Numerics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
namespace ChatTwo.Util;
-internal static class IconUtil {
- internal static Vector4? GetBounds(byte id) => id switch {
- 1 => new Vector4(0, 342, 40, 40),
- 2 => new Vector4(40, 342, 40, 40),
- 3 => new Vector4(80, 342, 40, 40),
- 4 => new Vector4(120, 342, 40, 40),
- 5 => new Vector4(160, 342, 40, 40),
- 6 => new Vector4(0, 382, 40, 40),
- 7 => new Vector4(40, 382, 40, 40),
- 8 => new Vector4(80, 382, 40, 40),
- 9 => new Vector4(120, 382, 40, 40),
- 10 => new Vector4(160, 382, 40, 40),
- 11 => new Vector4(0, 422, 40, 40),
- 12 => new Vector4(40, 422, 40, 40),
- 13 => new Vector4(80, 422, 40, 40),
- 14 => new Vector4(120, 422, 40, 40),
- 15 => new Vector4(160, 422, 40, 40),
- 16 => new Vector4(120, 542, 40, 40),
- 17 => new Vector4(160, 542, 40, 40),
- 18 => new Vector4(0, 462, 108, 40),
- 19 => new Vector4(108, 462, 108, 40),
- 20 => new Vector4(120, 502, 40, 40),
- 21 => new Vector4(0, 502, 56, 40),
- 22 => new Vector4(56, 502, 64, 40),
- 23 => new Vector4(160, 502, 40, 40),
- 24 => new Vector4(0, 542, 56, 40),
- 25 => new Vector4(56, 542, 64, 40),
- 51 => new Vector4(248, 342, 40, 40),
- 52 => new Vector4(288, 342, 40, 40),
- 53 => new Vector4(328, 342, 40, 40),
- 54 => new Vector4(200, 342, 24, 40),
- 55 => new Vector4(224, 342, 24, 40),
- 56 => new Vector4(200, 382, 40, 40),
- 57 => new Vector4(240, 382, 40, 40),
- 58 => new Vector4(280, 382, 40, 40),
- 59 => new Vector4(200, 422, 40, 40),
- 60 => new Vector4(240, 422, 40, 40),
- 61 => new Vector4(280, 422, 40, 40),
- 62 => new Vector4(320, 382, 40, 40),
- 63 => new Vector4(320, 422, 40, 40),
- 64 => new Vector4(368, 342, 40, 40),
- 65 => new Vector4(408, 342, 40, 40),
- 66 => new Vector4(448, 342, 40, 40),
- 67 => new Vector4(360, 382, 40, 40),
- 68 => new Vector4(400, 382, 40, 40),
- 70 => new Vector4(360, 422, 40, 40),
- 71 => new Vector4(400, 422, 40, 40),
- 72 => new Vector4(440, 422, 40, 40),
- 73 => new Vector4(440, 382, 40, 40),
- 74 => new Vector4(216, 462, 40, 40),
- 75 => new Vector4(256, 462, 40, 40),
- 76 => new Vector4(296, 462, 40, 40),
- 77 => new Vector4(336, 462, 40, 40),
- 78 => new Vector4(376, 462, 40, 40),
- 79 => new Vector4(416, 462, 40, 40),
- 80 => new Vector4(456, 462, 40, 40),
- 81 => new Vector4(200, 502, 40, 40),
- 82 => new Vector4(240, 502, 40, 40),
- 83 => new Vector4(280, 502, 40, 40),
- 84 => new Vector4(320, 502, 40, 40),
- 85 => new Vector4(360, 502, 40, 40),
- 86 => new Vector4(400, 502, 40, 40),
- 87 => new Vector4(440, 502, 40, 40),
- 88 => new Vector4(200, 542, 40, 40),
- 89 => new Vector4(240, 542, 40, 40),
- 90 => new Vector4(280, 542, 40, 40),
- 91 => new Vector4(320, 542, 40, 40),
- 92 => new Vector4(360, 542, 40, 40),
- 93 => new Vector4(400, 542, 40, 40),
- 94 => new Vector4(440, 542, 40, 40),
- 95 => new Vector4(0, 582, 40, 40),
- 96 => new Vector4(40, 582, 40, 40),
- 97 => new Vector4(80, 582, 40, 40),
- 98 => new Vector4(120, 582, 40, 40),
- 99 => new Vector4(160, 582, 40, 40),
- 100 => new Vector4(200, 582, 40, 40),
- 101 => new Vector4(240, 582, 40, 40),
- 102 => new Vector4(280, 582, 40, 40),
- 103 => new Vector4(320, 582, 40, 40),
- 104 => new Vector4(360, 582, 40, 40),
- 105 => new Vector4(400, 582, 40, 40),
- 106 => new Vector4(440, 582, 40, 40),
- 107 => new Vector4(0, 622, 40, 40),
- 108 => new Vector4(40, 622, 40, 40),
- 109 => new Vector4(80, 622, 40, 40),
- 110 => new Vector4(120, 622, 40, 40),
- 111 => new Vector4(160, 622, 40, 40),
- 112 => new Vector4(200, 622, 40, 40),
- _ => null,
- };
+// From Kizer: https://github.com/Soreepeong/Dalamud/blob/feature/log-wordwrap/Dalamud/Interface/Spannables/Internal/GfdFileView.cs
+public readonly unsafe ref struct GfdFileView
+{
+ private readonly ReadOnlySpan Span;
+ private readonly bool DirectLookup;
+
+ /// Initializes a new instance of the struct.
+ /// The data.
+ public GfdFileView(ReadOnlySpan span)
+ {
+ Span = span;
+ if (span.Length < sizeof(GfdHeader))
+ throw new InvalidDataException($"Not enough space for a {nameof(GfdHeader)}");
+ if (span.Length < sizeof(GfdHeader) + (Header.Count * sizeof(GfdEntry)))
+ throw new InvalidDataException($"Not enough space for all the {nameof(GfdEntry)}");
+
+ var entries = Entries;
+ DirectLookup = true;
+ for (var i = 0; i < entries.Length && DirectLookup; i++)
+ DirectLookup &= i + 1 == entries[i].Id;
+ }
+
+ /// Gets the header.
+ private ref readonly GfdHeader Header => ref MemoryMarshal.AsRef(Span);
+
+ /// Gets the entries.
+ private ReadOnlySpan Entries => MemoryMarshal.Cast(Span[sizeof(GfdHeader)..]);
+
+ /// Attempts to get an entry.
+ /// The icon ID.
+ /// The entry.
+ /// Whether to follow redirects.
+ /// true if found.
+ public bool TryGetEntry(uint iconId, out GfdEntry entry, bool followRedirect = true)
+ {
+ if (iconId == 0)
+ {
+ entry = default;
+ return false;
+ }
+
+ var entries = Entries;
+ if (DirectLookup)
+ {
+ if (iconId <= entries.Length)
+ {
+ entry = entries[(int)(iconId - 1)];
+ return !entry.IsEmpty;
+ }
+
+ entry = default;
+ return false;
+ }
+
+ var lo = 0;
+ var hi = entries.Length;
+ while (lo <= hi)
+ {
+ var i = lo + ((hi - lo) >> 1);
+ if (entries[i].Id == iconId)
+ {
+ if (followRedirect && entries[i].Redirect != 0)
+ {
+ iconId = entries[i].Redirect;
+ lo = 0;
+ hi = entries.Length;
+ continue;
+ }
+
+ entry = entries[i];
+ return !entry.IsEmpty;
+ }
+
+ if (entries[i].Id < iconId)
+ lo = i + 1;
+ else
+ hi = i - 1;
+ }
+
+ entry = default;
+ return false;
+ }
+
+ /// Header of a .gfd file.
+ [StructLayout(LayoutKind.Sequential)]
+ public struct GfdHeader
+ {
+ /// Signature: "gftd0100".
+ public fixed byte Signature[8];
+
+ /// Number of entries.
+ public int Count;
+
+ /// Unused/unknown.
+ public fixed byte Padding[4];
+ }
+
+ /// An entry of a .gfd file.
+ [StructLayout(LayoutKind.Sequential, Size = 0x10)]
+ public struct GfdEntry
+ {
+ /// ID of the entry.
+ public ushort Id;
+
+ /// The left offset of the entry.
+ public ushort Left;
+
+ /// The top offset of the entry.
+ public ushort Top;
+
+ /// The width of the entry.
+ public ushort Width;
+
+ /// The height of the entry.
+ public ushort Height;
+
+ /// Unknown/unused.
+ public ushort Unk0A;
+
+ /// The redirected entry, maybe.
+ public ushort Redirect;
+
+ /// Unknown/unused.
+ public ushort Unk0E;
+
+ /// Gets a value indicating whether this entry is effectively empty.
+ public bool IsEmpty => Width == 0 || Height == 0;
+ }
+}
+
+
+
+internal static class IconUtil {
+ private static byte[]? GfdFile;
+ public static unsafe GfdFileView GfdFileView
+ {
+ get
+ {
+ GfdFile ??= Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
+ return new GfdFileView(new ReadOnlySpan(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length));
+ }
+ }
}
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();
+ }
+}