diff --git a/ChatTwo/ChatTwo.csproj b/ChatTwo/ChatTwo.csproj
index 8263e85..c459b02 100755
--- a/ChatTwo/ChatTwo.csproj
+++ b/ChatTwo/ChatTwo.csproj
@@ -55,6 +55,7 @@
+
diff --git a/ChatTwo/Configuration.cs b/ChatTwo/Configuration.cs
index 13b9c68..db45f12 100755
--- a/ChatTwo/Configuration.cs
+++ b/ChatTwo/Configuration.cs
@@ -37,6 +37,7 @@ internal class Configuration : IPluginConfiguration
public bool SortAutoTranslate;
public bool CollapseDuplicateMessages;
public bool PlaySounds = true;
+ public bool KeepInputFocus = true;
public int MaxLinesToRender = 10_000;
public bool FontsEnabled = true;
@@ -85,6 +86,7 @@ internal class Configuration : IPluginConfiguration
SortAutoTranslate = other.SortAutoTranslate;
CollapseDuplicateMessages = other.CollapseDuplicateMessages;
PlaySounds = other.PlaySounds;
+ KeepInputFocus = other.KeepInputFocus;
MaxLinesToRender = other.MaxLinesToRender;
FontsEnabled = other.FontsEnabled;
ExtraGlyphRanges = other.ExtraGlyphRanges;
diff --git a/ChatTwo/EmoteCache.cs b/ChatTwo/EmoteCache.cs
new file mode 100644
index 0000000..a1ba6f4
--- /dev/null
+++ b/ChatTwo/EmoteCache.cs
@@ -0,0 +1,262 @@
+using System.Numerics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Dalamud.Interface.Internal;
+using Dalamud.Utility;
+using ImGuiNET;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace ChatTwo;
+
+public static class EmoteCache
+{
+ private struct Top100
+ {
+ [JsonPropertyName("emote")]
+ public Emote Emote { get; set; }
+ }
+
+ public struct Emote
+ {
+ [JsonPropertyName("id")]
+ public string Id { get; set; }
+ [JsonPropertyName("code")]
+ public string Code { get; set; }
+ [JsonPropertyName("imageType")]
+ public string ImageType { get; set; }
+ [JsonPropertyName("animated")]
+ public bool Animated { get; set; }
+ };
+
+ private const string BetterTTV = "https://api.betterttv.net/3";
+ private const string GlobalEmotes = $"{BetterTTV}/cached/emotes/global";
+ private const string Top100Emotes = $"{BetterTTV}/emotes/shared/top?limit=100";
+ private const string EmotePath = "https://cdn.betterttv.net/emote/{0}/3x";
+
+ private static readonly bool IsBTTVDataLoaded;
+ private static readonly Dictionary EmoteImages = new();
+
+ private static readonly Dictionary Cache = new();
+
+ private static readonly string[] EmoteCodeArray = [];
+
+ static EmoteCache()
+ {
+ try
+ {
+ var globalCache = new HttpClient().GetAsync(GlobalEmotes)
+ .Result
+ .Content
+ .ReadAsStringAsync()
+ .Result;
+
+ foreach (var emote in JsonSerializer.Deserialize(globalCache)!)
+ Cache.TryAdd(emote.Code, emote);
+
+ var top100 = new HttpClient().GetAsync(Top100Emotes)
+ .Result
+ .Content
+ .ReadAsStringAsync()
+ .Result;
+
+ Plugin.Log.Information(top100);
+ var json = JsonSerializer.Deserialize>(top100);
+ foreach (var emote in json)
+ {
+ Plugin.Log.Information($"emote {emote.Emote.Code}");
+ Cache.TryAdd(emote.Emote.Code, emote.Emote);
+ }
+
+ EmoteCodeArray = Cache.Keys.ToArray();
+ IsBTTVDataLoaded = true;
+ }
+ catch (Exception ex)
+ {
+ Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized1");
+ }
+ }
+
+ internal static bool Exists(string code)
+ {
+ return IsBTTVDataLoaded && EmoteCodeArray.Contains(code);
+ }
+
+ internal static IEmote? GetEmote(string code)
+ {
+ if (!IsBTTVDataLoaded)
+ return null;
+
+ if (!Cache.TryGetValue(code, out var emoteDetail))
+ return null;
+
+ if (EmoteImages.TryGetValue(emoteDetail.Id, out var emote))
+ return emote;
+
+ try
+ {
+ if (emoteDetail.ImageType == "gif")
+ {
+ var animatedEmote = new ImGuiGif().Prepare(emoteDetail);
+ EmoteImages.Add(emoteDetail.Id, animatedEmote);
+ return animatedEmote;
+ }
+
+ var staticEmote = new ImGuiEmote().Prepare(emoteDetail);
+ EmoteImages.Add(emoteDetail.Id, staticEmote);
+
+ return staticEmote;
+ }
+ catch
+ {
+ Plugin.Log.Error("Failed to convert");
+ return null;
+ }
+ }
+
+ public class IEmote
+ {
+ public bool IsLoaded = false;
+
+ public bool IsAnimated = false;
+ public IDalamudTextureWrap Texture;
+
+ public virtual void Draw(Vector2 size)
+ {
+ ImGui.Image(Texture.ImGuiHandle, size);
+ }
+
+ internal static async Task LoadAsync(Emote emote)
+ {
+ var dir = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "emotes");
+ Directory.CreateDirectory(dir);
+
+ byte[] image;
+ var filePath = Path.Join(dir, $"{emote.Id}.{emote.ImageType}");
+ if (File.Exists(filePath))
+ {
+ image = await File.ReadAllBytesAsync(filePath);
+ }
+ else
+ {
+ var content = await new HttpClient().GetAsync(EmotePath.Format(emote.Id));
+ image = await content.Content.ReadAsByteArrayAsync();
+
+ await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
+ stream.Write(image, 0, image.Length);
+ }
+
+ return image;
+ }
+ }
+
+ public sealed class ImGuiEmote : IEmote
+ {
+ public ImGuiEmote Prepare(Emote emote)
+ {
+ Task.Run(() => Load(emote));
+ return this;
+ }
+
+ private async void Load(Emote emote)
+ {
+ try
+ {
+ var image = await LoadAsync(emote);
+ if (image.Length <= 0)
+ return;
+
+ Texture = await Plugin.Interface.UiBuilder.LoadImageAsync(image);
+ IsLoaded = true;
+ }
+ catch (Exception ex)
+ {
+ Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
+ }
+ }
+ }
+
+ public sealed class ImGuiGif : IEmote
+ {
+ private List<(IDalamudTextureWrap Texture, float Delay)> Frames = [];
+ private float FrameTimer;
+ private int CurrentFrame;
+ private ulong GlobalFrameCount;
+
+ public bool IsPaused;
+
+ public override void Draw(Vector2 size)
+ {
+ if (Frames.Count == 0)
+ return;
+
+ if (CurrentFrame >= Frames.Count)
+ {
+ CurrentFrame = 0;
+ FrameTimer = -1f;
+ }
+
+ var frame = Frames[CurrentFrame];
+ if (FrameTimer <= 0.0f)
+ FrameTimer = frame.Delay;
+
+ ImGui.Image(frame.Texture.ImGuiHandle, size);
+
+ if (IsPaused)
+ return;
+
+ if (GlobalFrameCount != Plugin.Interface.UiBuilder.FrameCount)
+ {
+ GlobalFrameCount = Plugin.Interface.UiBuilder.FrameCount;
+
+ FrameTimer -= ImGui.GetIO().DeltaTime;
+ if (FrameTimer <= 0f)
+ CurrentFrame++;
+ }
+ }
+
+ public void Dispose()
+ {
+ Frames.ForEach(f => f.Texture.Dispose());
+ Frames.Clear();
+ }
+
+ public ImGuiGif Prepare(Emote emote)
+ {
+ Task.Run(() => Load(emote));
+ return this;
+ }
+
+ private async void Load(Emote emote)
+ {
+ try
+ {
+ var image = await LoadAsync(emote);
+ if (image.Length <= 0)
+ return;
+
+ using var ms = new MemoryStream(image);
+ using var img = Image.Load(ms);
+ if (img.Frames.Count == 0)
+ return;
+
+ var frames = new List<(IDalamudTextureWrap Tex, float Delay)>();
+ foreach (var frame in img.Frames)
+ {
+ var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
+ var buffer = new byte[4 * frame.Width * frame.Height];
+ frame.CopyPixelDataTo(buffer);
+ var tex = await Plugin.Interface.UiBuilder.LoadImageRawAsync(buffer, frame.Width, frame.Height, 4);
+ frames.Add((tex, delay));
+ }
+
+ Frames = frames;
+ IsLoaded = true;
+ }
+ catch (Exception ex)
+ {
+ Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ChatTwo/Message.cs b/ChatTwo/Message.cs
index b25416c..bbc799b 100755
--- a/ChatTwo/Message.cs
+++ b/ChatTwo/Message.cs
@@ -1,3 +1,4 @@
+using System.Text;
using ChatTwo.Code;
using ChatTwo.Util;
using Dalamud.Game.Text.SeStringHandling;
@@ -77,7 +78,7 @@ internal class Message
Date = DateTimeOffset.UtcNow;
Code = code;
Sender = sender;
- Content = ReplaceContentURLs(content);
+ Content = CheckMessageContent(content);
SenderSource = senderSource;
ContentSource = contentSource;
SortCode = new SortCode(Code.Type, Code.Source);
@@ -130,28 +131,7 @@ 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(
- @"((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)
+ private List CheckMessageContent(List content)
{
var newChunks = new List();
void AddChunkWithMessage(Chunk chunk)
@@ -171,42 +151,65 @@ internal class Message
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())
+ // We replace every emote before checking for URLs
+ var builder = new StringBuilder();
+ foreach (var word in text.Content.Split(" "))
{
- // Add the text before the URL.
- if (match.Index > remainderIndex)
+ if (EmoteCache.Exists(word))
{
- AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, text.Content[remainderIndex..match.Index]));
+ // We add all the previous collected text parts
+ AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, builder.ToString()));
+ builder.Clear();
+
+ newChunks.Add(new TextChunk(chunk.Source, EmotePayload.ResolveEmote(word), "Cool BetterTTV"));
+ builder.Append(' ');
+ continue;
}
- // Update the remainder index.
- remainderIndex = match.Index + match.Length;
+ if (URLRegex.IsMatch(word))
+ {
+ // We add all the previous collected text parts
+ AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, builder.ToString()));
+ builder.Clear();
- // 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;
+ // Create a new TextChunk with a URIPayload for the URL text.
+ try
+ {
+ var link = UriPayload.ResolveURI(word);
+ AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, word));
+ builder.Append(' ');
+
+ continue;
+ }
+ catch (UriFormatException)
+ {
+ Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{word}'");
+ }
}
+
+ builder.Append($"{word} ");
}
-
- // Add the text after the last URL.
- if (remainderIndex < text.Content.Length)
- AddChunkWithMessage(text.NewWithStyle(chunk.Source, null, text.Content[remainderIndex..]));
+ // We add the leftovers
+ AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, builder.ToString()[..^1]));
}
return newChunks;
}
+
+ ///
+ /// 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(
+ @"((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
+ );
}
diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs
index 92a1954..dbdf802 100644
--- a/ChatTwo/MessageStore.cs
+++ b/ChatTwo/MessageStore.cs
@@ -28,6 +28,7 @@ internal enum PayloadMessagePackType : byte
Achievement,
PartyFinder,
Uri,
+ Emote,
Other = 255,
}
@@ -56,6 +57,10 @@ public class PayloadMessagePackFormatter : IMessagePackFormatter
writer.WriteUInt8((byte)PayloadMessagePackType.Uri);
writer.WriteString(Encoding.UTF8.GetBytes(uriPayload.Uri.ToString()));
break;
+ case EmotePayload emotePayload:
+ writer.WriteUInt8((byte)PayloadMessagePackType.Emote);
+ writer.WriteString(Encoding.UTF8.GetBytes(emotePayload.Code));
+ break;
default:
writer.WriteUInt8((byte)PayloadMessagePackType.Other);
writer.Write(value.Encode());
@@ -80,6 +85,8 @@ public class PayloadMessagePackFormatter : IMessagePackFormatter
return new PartyFinderPayload(reader.ReadUInt32());
case PayloadMessagePackType.Uri:
return new UriPayload(new Uri(reader.ReadString() ?? ""));
+ case PayloadMessagePackType.Emote:
+ return EmotePayload.ResolveEmote(reader.ReadString() ?? "");
case PayloadMessagePackType.Other:
default:
var bytes = reader.ReadBytes() ?? new ReadOnlySequence();
diff --git a/ChatTwo/Resources/Language.Designer.cs b/ChatTwo/Resources/Language.Designer.cs
index 08342b0..70e050e 100755
--- a/ChatTwo/Resources/Language.Designer.cs
+++ b/ChatTwo/Resources/Language.Designer.cs
@@ -2093,6 +2093,24 @@ namespace ChatTwo.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Keeps the input focus, even if you enter battle or do other actions.
+ ///
+ internal static string Options_KeepInputFocus_Description {
+ get {
+ return ResourceManager.GetString("Options_KeepInputFocus_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Keep input focus.
+ ///
+ internal static string Options_KeepInputFocus_Name {
+ get {
+ return ResourceManager.GetString("Options_KeepInputFocus_Name", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The way in which {0} should handle keybinds..
///
diff --git a/ChatTwo/Resources/Language.resx b/ChatTwo/Resources/Language.resx
index ea62279..1a23366 100644
--- a/ChatTwo/Resources/Language.resx
+++ b/ChatTwo/Resources/Language.resx
@@ -1012,4 +1012,10 @@
Attention, this change applies immediately and is not discardable!
+
+ Keep input focus
+
+
+ Keeps the input focus, even if you enter battle or do other actions
+
diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs
index 94013d2..1f81f82 100644
--- a/ChatTwo/Ui/ChatLogWindow.cs
+++ b/ChatTwo/Ui/ChatLogWindow.cs
@@ -122,18 +122,6 @@ public sealed class ChatLogWindow : Window
Plugin.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "ItemDetail", PayloadHandler.MoveTooltip);
}
- public override void PreDraw()
- {
- if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
- StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push();
- }
-
- public override void PostDraw()
- {
- if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
- StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop();
- }
-
public void Dispose()
{
Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostRequestedUpdate, "ItemDetail", PayloadHandler.MoveTooltip);
@@ -474,6 +462,21 @@ public sealed class ChatLogWindow : Window
return !IsHidden;
}
+ public override void PreDraw()
+ {
+ if (Plugin.Config.KeepInputFocus && Activate)
+ ImGui.SetWindowFocus(WindowName);
+
+ if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
+ StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push();
+ }
+
+ public override void PostDraw()
+ {
+ if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
+ StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop();
+ }
+
public override void Draw()
{
DrawChatLog();
@@ -1496,6 +1499,20 @@ public sealed class ChatLogWindow : Window
if (chunk is not TextChunk text)
return;
+ if (chunk.Link?.Type == (PayloadType)0x53)
+ {
+ var emoteSize = ImGui.CalcTextSize("W");
+ emoteSize = emoteSize with { Y = emoteSize.X } * 1.5f;
+
+ var emotePayload = (EmotePayload) chunk.Link;
+ var image = EmoteCache.GetEmote(emotePayload.Code);
+ if (image is { IsLoaded: true })
+ image.Draw(emoteSize);
+ else
+ ImGui.Dummy(emoteSize);
+ return;
+ }
+
var colour = text.Foreground;
if (colour == null && text.FallbackColour != null)
{
diff --git a/ChatTwo/Ui/SettingsTabs/ChatLog.cs b/ChatTwo/Ui/SettingsTabs/ChatLog.cs
index ef21153..11c3560 100644
--- a/ChatTwo/Ui/SettingsTabs/ChatLog.cs
+++ b/ChatTwo/Ui/SettingsTabs/ChatLog.cs
@@ -23,6 +23,9 @@ internal sealed class ChatLog : ISettingsTab
{
ImGui.PushTextWrapPos();
+ ImGuiUtil.OptionCheckbox(ref Mutable.KeepInputFocus, Language.Options_KeepInputFocus_Name, Language.Options_KeepInputFocus_Description);
+ ImGui.Spacing();
+
ImGuiUtil.OptionCheckbox(ref Mutable.PlaySounds, Language.Options_PlaySounds_Name, Language.Options_PlaySounds_Description);
ImGui.Spacing();
diff --git a/ChatTwo/Util/Payloads.cs b/ChatTwo/Util/Payloads.cs
index 6fc81fc..f8761ad 100755
--- a/ChatTwo/Util/Payloads.cs
+++ b/ChatTwo/Util/Payloads.cs
@@ -85,3 +85,25 @@ internal class UriPayload(Uri uri) : Payload
throw new NotImplementedException();
}
}
+
+internal class EmotePayload : Payload
+{
+ public override PayloadType Type => (PayloadType) 0x53;
+
+ public string Code;
+
+ public static EmotePayload ResolveEmote(string code)
+ {
+ return new EmotePayload { Code = code };
+ }
+
+ protected override void DecodeImpl(BinaryReader reader, long endOfStream)
+ {
+ throw new NotImplementedException();
+ }
+
+ protected override byte[] EncodeImpl()
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/ChatTwo/packages.lock.json b/ChatTwo/packages.lock.json
index f3eb1f5..dfeed34 100644
--- a/ChatTwo/packages.lock.json
+++ b/ChatTwo/packages.lock.json
@@ -52,6 +52,12 @@
"SharpDX.DXGI": "4.2.0"
}
},
+ "SixLabors.ImageSharp": {
+ "type": "Direct",
+ "requested": "[3.1.4, )",
+ "resolved": "3.1.4",
+ "contentHash": "lFIdxgGDA5iYkUMRFOze7BGLcdpoLFbR+a20kc1W7NepvzU7ejtxtWOg9RvgG7kb9tBoJ3ONYOK6kLil/dgF1w=="
+ },
"XivCommon": {
"type": "Direct",
"requested": "[9.0.0, )",