diff --git a/.gitignore b/.gitignore
index 1db30bf..b9fb6f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -363,3 +363,7 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
+
+TestResults
+*.db-shm
+*.db-wal
diff --git a/ChatTwo.Tests/ChatTwo.Tests.csproj b/ChatTwo.Tests/ChatTwo.Tests.csproj
new file mode 100644
index 0000000..2771b9e
--- /dev/null
+++ b/ChatTwo.Tests/ChatTwo.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0-windows
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\AppData\Roaming\XIVLauncher\addon\Hooks\dev\Dalamud.dll
+
+
+
+
\ No newline at end of file
diff --git a/ChatTwo.Tests/MessageStoreTest.cs b/ChatTwo.Tests/MessageStoreTest.cs
new file mode 100644
index 0000000..e7199f7
--- /dev/null
+++ b/ChatTwo.Tests/MessageStoreTest.cs
@@ -0,0 +1,289 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using ChatTwo.Code;
+using ChatTwo.Util;
+using Dalamud.Game.Text.SeStringHandling;
+using Dalamud.Game.Text.SeStringHandling.Payloads;
+using JetBrains.Annotations;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Chat2PartyFinderPayload = ChatTwo.Util.PartyFinderPayload;
+
+namespace ChatTwo.Tests;
+
+[TestClass]
+[TestSubject(typeof(MessageStore))]
+public class MessageStoreTest {
+ // From Message.cs
+ private static readonly byte[] ExtraChatChannelPayloadBytes = [0, 0x27, 18, 0x20];
+
+ public TestContext TestContext { get; set; }
+
+ public static string GetImportPath() {
+ string[] importPaths = [
+ @".\TestData",
+ @"..\TestData",
+ @"..\..\TestData",
+ @"..\..\..\TestData",
+ ];
+ var importPath = importPaths.FirstOrDefault(Directory.Exists);
+ if (string.IsNullOrEmpty(importPath)) {
+ throw new DirectoryNotFoundException("Could not find the import path");
+ }
+ return importPath;
+ }
+
+ [TestMethod]
+ [Timeout(5000)]
+ public void StoreAndRetrieve() {
+ var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
+ var dbPath = Path.Join(tempDir.FullName, "test.db");
+ TestContext.WriteLine("Using database path: " + dbPath);
+ using var store = new MessageStore(dbPath);
+
+ // Write the message.
+ var input = BigMessage();
+ store.UpsertMessage(input);
+
+ // Read the message back.
+ var messages = store.GetMostRecentMessages().ToList();
+ Assert.AreEqual(1, messages.Count);
+ AssertMessagesEqual(input, messages.First());
+ }
+
+ [TestMethod]
+ [Timeout(5000)]
+ public void RetrieveMultiple() {
+ var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
+ var dbPath = Path.Join(tempDir.FullName, "test.db");
+ TestContext.WriteLine("Using database path: " + dbPath);
+ using var store = new MessageStore(dbPath);
+
+ // Insert 10 messages in the wrong order of date.
+ var messages = new List();
+ const uint receiver = 12345;
+ var now = DateTimeOffset.UtcNow;
+ for (var i = 0; i < 10; i++) {
+ var message = BigMessage(true, receiver, now.AddSeconds(-i));
+ TestContext.WriteLine($"Inserting message {i}: {message.Id}");
+ store.UpsertMessage(message);
+ messages.Add(message);
+ }
+
+ // Insert a message for a different receiver. This shouldn't be returned
+ // because of the receiver filtering.
+ var otherReceiverMsg = BigMessage(receiver: receiver + 1, dateTime: now.AddSeconds(1));
+ TestContext.WriteLine($"Inserting other receiver message: {otherReceiverMsg.Id}");
+ store.UpsertMessage(otherReceiverMsg);
+
+ // Query the most recent 5 messages. Should return the 4 newest messages
+ // from the list, as well as the different receiver message because we
+ // aren't filtering.
+ var outputMessages = store.GetMostRecentMessages(count: 5).ToList();
+ var gotIds = outputMessages.Select(m => m.Id).ToList();
+ TestContext.WriteLine($"Query 1 got IDs: {string.Join(", ", gotIds)}");
+ AssertGuidsEqual(new List {
+ messages[3].Id,
+ messages[2].Id,
+ messages[1].Id,
+ messages[0].Id,
+ otherReceiverMsg.Id
+ }, gotIds);
+
+ // Query the most recent 5 messages but filter by receiver ID.
+ outputMessages = store.GetMostRecentMessages(receiver: receiver, count: 5).ToList();
+ gotIds = outputMessages.Select(m => m.Id).ToList();
+ TestContext.WriteLine($"Query 2 got IDs: {string.Join(", ", gotIds)}");
+ AssertGuidsEqual(new List {
+ messages[4].Id,
+ messages[3].Id,
+ messages[2].Id,
+ messages[1].Id,
+ messages[0].Id,
+ }, gotIds);
+
+ // Query the most recent 5 messages but only since a specific date.
+ outputMessages = store.GetMostRecentMessages(receiver, since: messages[1].Date, count: 5).ToList();
+ gotIds = outputMessages.Select(m => m.Id).ToList();
+ TestContext.WriteLine($"Query 3 got IDs: {string.Join(", ", gotIds)}");
+ AssertGuidsEqual(new List {
+ messages[1].Id,
+ messages[0].Id,
+ }, gotIds);
+ }
+
+ [TestMethod]
+ [Timeout(5000)]
+ // This test guards against the data format changing in an incompatible way.
+ public void RetrieveExisting() {
+ var input = BigMessage(uniqId: false);
+
+ var dbPath = Path.Join(GetImportPath(), "existing.db");
+ TestContext.WriteLine($"Using existing database: {dbPath}");
+ Assert.IsTrue(File.Exists(dbPath));
+
+ // Uncomment this section to regenerate the existing database.
+ /*
+ File.Delete(dbPath);
+ using (var newStore = new MessageStore(dbPath)) {
+ newStore.UpsertMessage(input);
+ }
+ */
+
+ using var store = new MessageStore(dbPath);
+ var output = store.GetMostRecentMessages().ToList();
+ Assert.AreEqual(1, output.Count);
+ AssertMessagesEqual(input, output[0]);
+ }
+
+ [TestMethod]
+ [Timeout(30_000)]
+ public void ProfileMany() {
+ const int count = 20_000;
+
+ var tempDir = Directory.CreateTempSubdirectory("ChatTwo_test_");
+ var dbPath = Path.Join(tempDir.FullName, "test.db");
+ TestContext.WriteLine("Using database path: " + dbPath);
+ using var store = new MessageStore(dbPath);
+
+ for (var i = 0; i < count; i++) {
+ var message = BigMessage(uniqId: true);
+ store.UpsertMessage(message);
+ }
+
+ var messages = store.GetMostRecentMessages(count: count).ToList();
+ Assert.AreEqual(count, messages.Count);
+ foreach (var message in messages) {
+ // Load the message because they are lazily parsed.
+ Assert.IsTrue(message.Id != Guid.Empty);
+ }
+ }
+
+ private static Message BigMessage(bool uniqId = true, uint receiver = 12345, DateTimeOffset? dateTime = null) {
+ // NOTE: These values aren't valid in the game.
+ // NOTE: we can't test UiForeground, UiGlow, or AutoTranslatePayload
+ // because they load data from the game.
+ var senderSeString = new SeStringBuilder()
+ .AddText("<")
+ .Add(new PlayerPayload("Player Name", 12345))
+ .AddItalics("Player Name")
+ .Add(RawPayload.LinkTerminator)
+ .AddText(">: ")
+ .Build();
+ var extraChatId = Guid.Parse("03d9e6d4-dc1a-4005-bbe7-66b8c3529277");
+ var contentSeString = new SeStringBuilder()
+ .Add(new RawPayload(ExtraChatChannelPayloadBytes.Concat(extraChatId.ToByteArray()).ToArray()))
+ .AddIcon(BitmapFontIcon.IslandSanctuary)
+ .AddMapLink(1, 2, 3, 4)
+ .AddText("map")
+ .Add(RawPayload.LinkTerminator)
+ .AddQuestLink(12345)
+ .AddText("quest")
+ .Add(RawPayload.LinkTerminator)
+ .Add(new DalamudLinkPayload())
+ .AddText("dalamud")
+ .Add(RawPayload.LinkTerminator)
+ .AddStatusLink(12345)
+ .AddText("status")
+ .Add(RawPayload.LinkTerminator)
+ .AddPartyFinderLink(12345)
+ .AddText("party finder")
+ .Add(RawPayload.LinkTerminator)
+ .Build();
+
+ // Add Chat 2 specific payloads (that can't be serialized into the
+ // SeString).
+ var contentChunks = ChunkUtil.ToChunks(contentSeString, ChunkSource.Content, ChatType.Say).ToList();
+ contentChunks = contentChunks.Concat([
+ new TextChunk(ChunkSource.Content, new Chat2PartyFinderPayload(12345), "chat 2 party finder"),
+ new TextChunk(ChunkSource.Content, new AchievementPayload(12345), "chat 2 achievement"),
+ new TextChunk(ChunkSource.Content, new UriPayload(new Uri("https://dalamud.dev")), "chat 2 uri"),
+ ]).ToList();
+
+ return new Message(
+ uniqId ? Guid.NewGuid() : Guid.Parse("f011343e-6a21-49e5-a6f9-238f0f1f8c2c"),
+ receiver,
+ 54321,
+ dateTime ?? DateTimeOffset.FromUnixTimeMilliseconds(1713520182440),
+ new ChatCode(12345),
+ ChunkUtil.ToChunks(senderSeString, ChunkSource.Sender, ChatType.Debug).ToList(),
+ contentChunks,
+ senderSeString,
+ contentSeString,
+ new SortCode(ChatType.Crafting, ChatSource.AlliancePet),
+ extraChatId
+ );
+ }
+
+ private void AssertMessagesEqual(Message input, Message output) {
+ // Check basic fields.
+ Assert.AreEqual(input.Id, output.Id);
+ Assert.AreEqual(input.Receiver, output.Receiver);
+ Assert.AreEqual(input.ContentId, output.ContentId);
+ // Assert time is within 1 second
+ TestContext.WriteLine($"Input date: {input.Date.ToUniversalTime()}");
+ TestContext.WriteLine($"Output date: {output.Date.ToUniversalTime()}");
+ var timeDifference = Math.Abs(input.Date.ToUniversalTime().Subtract(output.Date.ToUniversalTime()).TotalSeconds);
+ TestContext.WriteLine($"Time difference: {timeDifference}s");
+ Assert.IsTrue(timeDifference < 1);
+ Assert.AreEqual(input.Code.Raw, output.Code.Raw);
+ Assert.AreEqual($"{input.SenderSource.Encode():X}", $"{output.SenderSource.Encode():X}");
+ Assert.AreEqual($"{input.ContentSource.Encode():X}", $"{output.ContentSource.Encode():X}");
+ Assert.AreEqual(input.SortCode, output.SortCode);
+ Assert.AreEqual(input.ExtraChatChannel, output.ExtraChatChannel);
+
+ // Check chunks.
+ AssertChunksEqual(input.Sender, output.Sender);
+ AssertChunksEqual(input.Content, output.Content);
+ }
+
+ private static void AssertChunksEqual(IReadOnlyList inputChunks, IReadOnlyList outputChunks) {
+ Assert.AreEqual(inputChunks.Count, outputChunks.Count);
+ for (var i = 0; i < inputChunks.Count; i++) {
+ var inputChunk = inputChunks[i];
+ var outputChunk = outputChunks[i];
+ Assert.AreEqual(inputChunk.Source, outputChunk.Source);
+ switch (inputChunk.Link) {
+ case AchievementPayload inputAchievementPayload:
+ Assert.AreEqual(inputAchievementPayload.Id, ((AchievementPayload) outputChunk.Link)!.Id);
+ break;
+ case Chat2PartyFinderPayload inputPartyFinderPayload:
+ Assert.AreEqual(inputPartyFinderPayload.Id, ((Chat2PartyFinderPayload) outputChunk.Link)!.Id);
+ break;
+ case UriPayload inputUriPayload:
+ Assert.AreEqual(inputUriPayload.Uri, ((UriPayload) outputChunk.Link)!.Uri);
+ break;
+ case null:
+ Assert.IsTrue(outputChunk.Link == null);
+ break;
+ default:
+ Assert.AreEqual($"{inputChunk.Link.Encode():X}", $"{outputChunk.Link!.Encode():X}");
+ break;
+ }
+
+ switch (inputChunk) {
+ case TextChunk inputTextChunk:
+ var outputTextChunk = (TextChunk)outputChunk;
+ Assert.AreEqual(inputTextChunk.FallbackColour, outputTextChunk.FallbackColour);
+ Assert.AreEqual(inputTextChunk.Foreground, outputTextChunk.Foreground);
+ Assert.AreEqual(inputTextChunk.Glow, outputTextChunk.Glow);
+ Assert.AreEqual(inputTextChunk.Italic, outputTextChunk.Italic);
+ Assert.AreEqual(inputTextChunk.Content, outputTextChunk.Content);
+ break;
+ case IconChunk inputIconChunk:
+ Assert.AreEqual(inputIconChunk.Icon, ((IconChunk) outputChunk).Icon);
+ break;
+ default:
+ throw new Exception("Unknown chunk type");
+ }
+ }
+ }
+
+ private static void AssertGuidsEqual(IReadOnlyList expected, IReadOnlyList got) {
+ Assert.AreEqual(expected.Count, got.Count);
+ for (var i = 0; i < expected.Count; i++) {
+ Assert.AreEqual(expected[i].ToString(), got[i].ToString());
+ }
+ }
+}
diff --git a/ChatTwo.Tests/testdata/existing.db b/ChatTwo.Tests/testdata/existing.db
new file mode 100644
index 0000000..b67eb8c
Binary files /dev/null and b/ChatTwo.Tests/testdata/existing.db differ
diff --git a/ChatTwo.sln b/ChatTwo.sln
index aaa4b4a..3f09dbc 100755
--- a/ChatTwo.sln
+++ b/ChatTwo.sln
@@ -2,6 +2,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatTwo", "ChatTwo\ChatTwo.csproj", "{739F75E6-B65F-41EF-9D90-F7BC519E4875}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatTwo.Tests", "ChatTwo.Tests\ChatTwo.Tests.csproj", "{A9FE423A-240C-4EDA-ACC6-21474B562128}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -12,5 +14,9 @@ Global
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Debug|Any CPU.Build.0 = Debug|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|Any CPU.ActiveCfg = Release|Any CPU
{739F75E6-B65F-41EF-9D90-F7BC519E4875}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A9FE423A-240C-4EDA-ACC6-21474B562128}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A9FE423A-240C-4EDA-ACC6-21474B562128}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A9FE423A-240C-4EDA-ACC6-21474B562128}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A9FE423A-240C-4EDA-ACC6-21474B562128}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/ChatTwo/ChatTwo.csproj b/ChatTwo/ChatTwo.csproj
index 615361c..f466723 100755
--- a/ChatTwo/ChatTwo.csproj
+++ b/ChatTwo/ChatTwo.csproj
@@ -50,7 +50,8 @@
-
+
+
diff --git a/ChatTwo/Chunk.cs b/ChatTwo/Chunk.cs
index 245b26c..fe5aa44 100755
--- a/ChatTwo/Chunk.cs
+++ b/ChatTwo/Chunk.cs
@@ -1,15 +1,22 @@
using ChatTwo.Code;
using Dalamud.Game.Text.SeStringHandling;
-using LiteDB;
+using MessagePack;
namespace ChatTwo;
-internal abstract class Chunk {
- [BsonIgnore]
+[Union(0, typeof(TextChunk))]
+[Union(1, typeof(IconChunk))]
+[MessagePackObject]
+public abstract class Chunk {
+ [IgnoreMember]
internal Message? Message { get; set; }
- internal ChunkSource Source { get; set; }
- internal Payload? Link { get; set; }
+ [Key(0)]
+ public ChunkSource Source { get; set; }
+
+ [Key(1)]
+ [MessagePackFormatter(typeof(PayloadMessagePackFormatter))]
+ public Payload? Link { get; set; }
protected Chunk(ChunkSource source, Payload? link) {
Source = source;
@@ -38,27 +45,38 @@ internal abstract class Chunk {
}
}
-internal enum ChunkSource {
+public enum ChunkSource {
None,
Sender,
Content,
}
-internal class TextChunk : Chunk {
- internal ChatType? FallbackColour { get; set; }
- internal uint? Foreground { get; set; }
- internal uint? Glow { get; set; }
- internal bool Italic { get; set; }
- internal string Content { get; set; }
+[MessagePackObject]
+public class TextChunk : Chunk {
+ [Key(2)]
+ public ChatType? FallbackColour { get; set; }
+ [Key(3)]
+ public uint? Foreground { get; set; }
+ [Key(4)]
+ public uint? Glow { get; set; }
+ [Key(5)]
+ public bool Italic { get; set; }
+ [Key(6)]
+ public string Content { get; set; }
internal TextChunk(ChunkSource source, Payload? link, string content) : base(source, link) {
Content = content;
}
- #pragma warning disable CS8618
- public TextChunk() : base(ChunkSource.None, null) {
+ // ReSharper disable once UnusedMember.Global // Used by MessagePack
+ public TextChunk(ChunkSource source, Payload? link, ChatType? fallbackColour, uint? foreground, uint? glow,
+ bool italic, string content) : base(source, link) {
+ FallbackColour = fallbackColour;
+ Foreground = foreground;
+ Glow = glow;
+ Italic = italic;
+ Content = content;
}
- #pragma warning restore CS8618
///
/// Creates a new TextChunk with identical styling to this one.
@@ -74,13 +92,12 @@ internal class TextChunk : Chunk {
}
}
-internal class IconChunk : Chunk {
- internal BitmapFontIcon Icon { get; set; }
+[MessagePackObject]
+public class IconChunk : Chunk {
+ [Key(2)]
+ public BitmapFontIcon Icon { get; set; }
public IconChunk(ChunkSource source, Payload? link, BitmapFontIcon icon) : base(source, link) {
Icon = icon;
}
-
- public IconChunk() : base(ChunkSource.None, null) {
- }
}
diff --git a/ChatTwo/Code/ChatCode.cs b/ChatTwo/Code/ChatCode.cs
index 029c06d..a90e0ac 100755
--- a/ChatTwo/Code/ChatCode.cs
+++ b/ChatTwo/Code/ChatCode.cs
@@ -1,5 +1,3 @@
-using LiteDB;
-
namespace ChatTwo.Code;
internal class ChatCode
@@ -21,15 +19,6 @@ internal class ChatCode
Target = SourceFrom(7);
}
- [BsonCtor]
- public ChatCode(ushort raw, ChatType type, ChatSource source, ChatSource target)
- {
- Raw = raw;
- Type = type;
- Source = source;
- Target = target;
- }
-
internal ChatType Parent() => Type switch
{
ChatType.Say => ChatType.Say,
diff --git a/ChatTwo/Code/ChatType.cs b/ChatTwo/Code/ChatType.cs
index 28d836f..63a653d 100755
--- a/ChatTwo/Code/ChatType.cs
+++ b/ChatTwo/Code/ChatType.cs
@@ -1,7 +1,7 @@
namespace ChatTwo.Code;
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1028:Enum Storage should be Int32")]
-internal enum ChatType : ushort
+public enum ChatType : ushort
{
Debug = 1,
Urgent = 2,
diff --git a/ChatTwo/Configuration.cs b/ChatTwo/Configuration.cs
index 14446cb..3a697bf 100755
--- a/ChatTwo/Configuration.cs
+++ b/ChatTwo/Configuration.cs
@@ -35,7 +35,6 @@ internal class Configuration : IPluginConfiguration
public bool DatabaseBattleMessages;
public bool LoadPreviousSession;
public bool FilterIncludePreviousSessions;
- public bool SharedMode;
public bool SortAutoTranslate;
public bool CollapseDuplicateMessages;
public bool PlaySounds = true;
@@ -80,7 +79,6 @@ internal class Configuration : IPluginConfiguration
DatabaseBattleMessages = other.DatabaseBattleMessages;
LoadPreviousSession = other.LoadPreviousSession;
FilterIncludePreviousSessions = other.FilterIncludePreviousSessions;
- SharedMode = other.SharedMode;
SortAutoTranslate = other.SortAutoTranslate;
CollapseDuplicateMessages = other.CollapseDuplicateMessages;
PlaySounds = other.PlaySounds;
@@ -197,16 +195,16 @@ internal class Tab
[NonSerialized]
public List Messages = new();
+ [NonSerialized]
+ public HashSet TrackedMessageIds = new();
~Tab() { MessagesMutex.Dispose(); }
- internal bool Contains(Message message)
- {
- return Messages.Any(m => m.Hash == message.Hash);
+ internal bool Contains(Message message) {
+ return TrackedMessageIds.Contains(message.Id);
}
- internal bool Matches(Message message)
- {
+ internal bool Matches(Message message) {
if (message.ExtraChatChannel != Guid.Empty)
return ExtraChatAll || ExtraChatChannels.Contains(message.ExtraChatChannel);
@@ -216,23 +214,25 @@ internal class Tab
|| sources.HasFlag(message.Code.Source));
}
- internal void AddMessage(Message message, bool unread = true)
- {
+ internal void AddMessage(Message message, bool unread = true) {
+ if (Contains(message)) return;
MessagesMutex.Wait();
+ TrackedMessageIds.Add(message.Id);
Messages.Add(message);
- while (Messages.Count > Store.MessagesLimit)
+ while (Messages.Count > MessageManager.MessageDisplayLimit) {
+ TrackedMessageIds.Remove(Messages[0].Id);
Messages.RemoveAt(0);
-
+ }
MessagesMutex.Release();
if (unread)
Unread += 1;
}
- internal void Clear()
- {
+ internal void Clear() {
MessagesMutex.Wait();
Messages.Clear();
+ TrackedMessageIds.Clear();
MessagesMutex.Release();
}
diff --git a/ChatTwo/Message.cs b/ChatTwo/Message.cs
index 4f5803e..1f83b63 100755
--- a/ChatTwo/Message.cs
+++ b/ChatTwo/Message.cs
@@ -2,21 +2,26 @@ 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;
internal class SortCode {
- internal ChatType Type { get; set; }
- internal ChatSource Source { get; set; }
+ internal ChatType Type { get; }
+ internal ChatSource Source { get; }
internal SortCode(ChatType type, ChatSource source) {
Type = type;
Source = source;
}
- public SortCode() {
+ internal SortCode(uint raw) {
+ Type = (ChatType)(raw >> 16);
+ Source = (ChatSource)(raw & 0xFFFF);
+ }
+
+ internal uint Encode() {
+ return ((uint) Type << 16) | (uint) Source;
}
private bool Equals(SortCode other) {
@@ -43,18 +48,11 @@ internal class SortCode {
}
internal class Message {
- // ReSharper disable once UnusedMember.Global
- internal ObjectId Id { get; } = ObjectId.NewObjectId();
+ internal Guid Id { get; } = Guid.NewGuid();
internal ulong Receiver { get; }
internal ulong ContentId { get; set; }
- [BsonIgnore]
- internal float? Height;
-
- [BsonIgnore]
- internal bool IsVisible;
-
- internal DateTime Date { get; }
+ internal DateTimeOffset Date { get; }
internal ChatCode Code { get; }
internal List Sender { get; }
internal List Content { get; }
@@ -65,11 +63,14 @@ internal class Message {
internal SortCode SortCode { get; }
internal Guid ExtraChatChannel { get; }
+ // Not stored in the database:
internal int Hash { get; }
+ internal float? Height { get; set; }
+ internal bool IsVisible { get; set; }
internal Message(ulong receiver, ChatCode code, List sender, List content, SeString senderSource, SeString contentSource) {
Receiver = receiver;
- Date = DateTime.UtcNow;
+ Date = DateTimeOffset.UtcNow;
Code = code;
Sender = sender;
Content = ReplaceContentURLs(content);
@@ -84,44 +85,23 @@ internal class Message {
}
}
- internal Message(ObjectId id, ulong receiver, ulong contentId, DateTime date, BsonDocument code, BsonArray sender, BsonArray content, BsonValue senderSource, BsonValue contentSource, BsonDocument sortCode) {
+ internal Message(Guid id, ulong receiver, ulong contentId, DateTimeOffset date, ChatCode code, List sender, List content, SeString senderSource, SeString contentSource, SortCode sortCode, Guid extraChatChannel) {
Id = id;
Receiver = receiver;
ContentId = contentId;
Date = date;
- Code = BsonMapper.Global.ToObject(code);
- Sender = BsonMapper.Global.Deserialize>(sender);
+ Code = code;
+ Sender = sender;
// Don't call ReplaceContentURLs here since we're loading the message
// from the database and it should already have parsed URL data.
- Content = BsonMapper.Global.Deserialize>(content);
- SenderSource = BsonMapper.Global.Deserialize(senderSource);
- ContentSource = BsonMapper.Global.Deserialize(contentSource);
- SortCode = BsonMapper.Global.ToObject(sortCode);
- ExtraChatChannel = ExtractExtraChatChannel();
+ Content = content;
+ SenderSource = senderSource;
+ ContentSource = contentSource;
+ SortCode = sortCode;
+ ExtraChatChannel = extraChatChannel;
Hash = GenerateHash();
- foreach (var chunk in Sender.Concat(Content)) {
- chunk.Message = this;
- }
- }
-
- internal Message(ObjectId id, ulong receiver, ulong contentId, DateTime date, BsonDocument code, BsonArray sender, BsonArray content, BsonValue senderSource, BsonValue contentSource, BsonDocument sortCode, BsonValue extraChatChannel) {
- Id = id;
- Receiver = receiver;
- ContentId = contentId;
- Date = date;
- Code = BsonMapper.Global.ToObject(code);
- 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.
- Content = BsonMapper.Global.Deserialize>(content);
- SenderSource = BsonMapper.Global.Deserialize(senderSource);
- ContentSource = BsonMapper.Global.Deserialize(contentSource);
- SortCode = BsonMapper.Global.ToObject(sortCode);
- ExtraChatChannel = BsonMapper.Global.Deserialize(extraChatChannel);
- Hash = GenerateHash();
-
- foreach (var chunk in Sender.Concat(Content)) {
+ foreach (var chunk in sender.Concat(content)) {
chunk.Message = this;
}
}
@@ -203,7 +183,7 @@ internal class Message {
// Create a new TextChunk with a URIPayload for the URL text.
try
{
- var link = URIPayload.ResolveURI(match.Value);
+ var link = UriPayload.ResolveURI(match.Value);
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, match.Value));
}
catch (UriFormatException)
diff --git a/ChatTwo/MessageManager.cs b/ChatTwo/MessageManager.cs
new file mode 100644
index 0000000..f79d7e5
--- /dev/null
+++ b/ChatTwo/MessageManager.cs
@@ -0,0 +1,201 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using ChatTwo.Code;
+using ChatTwo.Resources;
+using ChatTwo.Util;
+using Dalamud.Game.Text;
+using Dalamud.Game.Text.SeStringHandling;
+using Dalamud.Interface.Internal.Notifications;
+using Dalamud.Plugin.Services;
+using Lumina.Excel.GeneratedSheets;
+
+namespace ChatTwo;
+
+internal class MessageManager : IDisposable {
+ internal const int MessageDisplayLimit = 10_000;
+
+ private Plugin Plugin { get; }
+ internal MessageStore Store { get; }
+
+ private ConcurrentQueue<(uint, Message)> Pending { get; } = new();
+ private Stopwatch MaintenanceTimer { get; } = new();
+ private Dictionary Formats { get; } = new();
+ private ulong LastContentId { get; set; }
+
+ internal ulong CurrentContentId {
+ get {
+ var contentId = Plugin.ClientState.LocalContentId;
+ return contentId == 0 ? LastContentId : contentId;
+ }
+ }
+
+ internal MessageManager(Plugin plugin) {
+ Plugin = plugin;
+ MaintenanceTimer.Start();
+ Store = new MessageStore(DatabasePath());
+
+ Plugin.ChatGui.ChatMessageUnhandled += ChatMessage;
+ Plugin.Framework.Update += GetMessageInfo;
+ Plugin.Framework.Update += UpdateReceiver;
+ Plugin.ClientState.Logout += Logout;
+ }
+
+ public void Dispose() {
+ Plugin.ClientState.Logout -= Logout;
+ Plugin.Framework.Update -= UpdateReceiver;
+ Plugin.Framework.Update -= GetMessageInfo;
+ Plugin.ChatGui.ChatMessageUnhandled -= ChatMessage;
+
+ Store.Dispose();
+ }
+
+ internal static string DatabasePath() {
+ var dir = Plugin.Interface.ConfigDirectory;
+ dir.Create();
+ return Path.Join(dir.FullName, "chat-sqlite.db");
+ }
+
+ private void Logout() {
+ LastContentId = 0;
+ }
+
+ private void UpdateReceiver(IFramework framework) {
+ var contentId = Plugin.ClientState.LocalContentId;
+ if (contentId != 0)
+ LastContentId = contentId;
+ }
+
+ private void GetMessageInfo(IFramework framework) {
+ if (MaintenanceTimer.Elapsed > TimeSpan.FromMinutes(5)) {
+ MaintenanceTimer.Restart();
+ new Thread(() => Store.PerformMaintenance()).Start();
+ }
+
+ if (!Pending.TryDequeue(out var entry))
+ return;
+
+ var contentId = Plugin.Functions.Chat.GetContentIdForEntry(entry.Item1);
+ entry.Item2.ContentId = contentId ?? 0;
+ if (Plugin.Config.DatabaseBattleMessages || !entry.Item2.Code.IsBattle())
+ Store.UpsertMessage(entry.Item2);
+ }
+
+ internal void AddMessage(Message message, Tab? currentTab) {
+ if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
+ Store.UpsertMessage(message);
+
+ var currentMatches = currentTab?.Matches(message) ?? false;
+
+ foreach (var tab in Plugin.Config.Tabs) {
+ var unread = !(tab.UnreadMode == UnreadMode.Unseen && currentTab != tab && currentMatches);
+
+ if (tab.Matches(message))
+ tab.AddMessage(message, unread);
+ }
+ }
+
+ internal void FilterAllTabs(bool unread = true) {
+ DateTimeOffset? since = null;
+ if (!Plugin.Config.FilterIncludePreviousSessions)
+ since = Plugin.GameStarted;
+
+ var messages = Store.GetMostRecentMessages(CurrentContentId, since);
+ foreach (var message in messages) {
+ foreach (var tab in Plugin.Config.Tabs.Where(tab => tab.Matches(message))) {
+ tab.AddMessage(message, unread);
+ }
+ }
+
+ if (messages.DidError)
+ WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error);
+ }
+
+ 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);
+
+ NameFormatting? formatting = null;
+ if (sender.Payloads.Count > 0)
+ 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) {
+ FallbackColour = chatCode.Type,
+ });
+ senderChunks.AddRange(ChunkUtil.ToChunks(sender, ChunkSource.Sender, chatCode.Type));
+ senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.After) {
+ FallbackColour = chatCode.Type,
+ });
+ }
+
+ var messageChunks = ChunkUtil.ToChunks(message, ChunkSource.Content, chatCode.Type).ToList();
+
+ var msg = new Message(CurrentContentId, chatCode, senderChunks, messageChunks, sender, message);
+ AddMessage(msg, Plugin.ChatLogWindow.CurrentTab ?? null);
+
+ var idx = Plugin.Functions.GetCurrentChatLogEntryIndex();
+ if (idx != null)
+ Pending.Enqueue((idx.Value - 1, msg));
+ }
+
+ internal class NameFormatting {
+ internal string Before { get; private set; } = string.Empty;
+ internal string After { get; private set; } = string.Empty;
+ internal bool IsPresent { get; private set; } = true;
+
+ internal static NameFormatting Empty() {
+ return new NameFormatting { IsPresent = false, };
+ }
+
+ internal static NameFormatting Of(string before, string after) {
+ return new NameFormatting
+ {
+ Before = before,
+ After = after,
+ };
+ }
+ }
+
+ private NameFormatting? FormatFor(ChatType type) {
+ if (Formats.TryGetValue(type, out var cached))
+ return cached;
+
+ var logKind = Plugin.DataManager.GetExcelSheet()!.GetRow((ushort) type);
+ if (logKind == null)
+ return null;
+
+ var format = (SeString) logKind.Format;
+ static bool IsStringParam(Payload payload, byte num) {
+ var data = payload.Encode();
+ return data.Length >= 5 && data[1] == 0x29 && data[4] == num + 1;
+ }
+
+ var firstStringParam = format.Payloads.FindIndex(payload => IsStringParam(payload, 1));
+ var secondStringParam = format.Payloads.FindIndex(payload => IsStringParam(payload, 2));
+
+ if (firstStringParam == -1 || secondStringParam == -1)
+ return NameFormatting.Empty();
+
+ var before = format.Payloads
+ .GetRange(0, firstStringParam)
+ .Where(payload => payload is ITextProvider)
+ .Cast()
+ .Select(text => text.Text);
+ var after = format.Payloads
+ .GetRange(firstStringParam + 1, secondStringParam - firstStringParam)
+ .Where(payload => payload is ITextProvider)
+ .Cast()
+ .Select(text => text.Text);
+
+ var nameFormatting = NameFormatting.Of(
+ string.Join("", before),
+ string.Join("", after)
+ );
+
+ Formats[type] = nameFormatting;
+
+ return nameFormatting;
+ }
+}
diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs
new file mode 100644
index 0000000..611ebd3
--- /dev/null
+++ b/ChatTwo/MessageStore.cs
@@ -0,0 +1,356 @@
+using System.Buffers;
+using System.Collections;
+using System.Data.Common;
+using ChatTwo.Code;
+using ChatTwo.Util;
+using Dalamud.Game.Text.SeStringHandling;
+using MessagePack;
+using MessagePack.Formatters;
+using MessagePack.Resolvers;
+using Microsoft.Data.Sqlite;
+using DalamudUtil = Dalamud.Utility.Util;
+using Encoding = System.Text.Encoding;
+
+namespace ChatTwo;
+
+internal static class DbExtensions {
+ internal static void Execute(this DbConnection conn, string sql) {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.ExecuteNonQuery();
+ }
+}
+
+internal enum PayloadMessagePackType : byte {
+ Achievement,
+ PartyFinder,
+ Uri,
+ Other = 255,
+}
+
+public class PayloadMessagePackFormatter : IMessagePackFormatter {
+ public void Serialize(ref MessagePackWriter writer, Payload? value, MessagePackSerializerOptions options) {
+ if (value == null) {
+ writer.WriteNil();
+ return;
+ }
+
+ writer.WriteArrayHeader(2);
+ switch (value) {
+ case AchievementPayload achievementPayload:
+ writer.WriteUInt8((byte) PayloadMessagePackType.Achievement);
+ writer.WriteUInt32(achievementPayload.Id);
+ break;
+ case PartyFinderPayload partyFinderPayload:
+ writer.WriteUInt8((byte) PayloadMessagePackType.PartyFinder);
+ writer.WriteUInt32(partyFinderPayload.Id);
+ break;
+ case UriPayload uriPayload:
+ writer.WriteUInt8((byte) PayloadMessagePackType.Uri);
+ writer.WriteString(Encoding.UTF8.GetBytes(uriPayload.Uri.ToString()));
+ break;
+ default:
+ writer.WriteUInt8((byte) PayloadMessagePackType.Other);
+ writer.Write(value.Encode());
+ break;
+ }
+ }
+
+ public Payload? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
+ if (reader.TryReadNil())
+ return null;
+
+ if (reader.ReadArrayHeader() != 2)
+ throw new InvalidOperationException("Invalid array count for Payload object");
+
+ var type = (PayloadMessagePackType) reader.ReadByte();
+ switch (type) {
+ case PayloadMessagePackType.Achievement:
+ return new AchievementPayload(reader.ReadUInt32());
+ case PayloadMessagePackType.PartyFinder:
+ return new PartyFinderPayload(reader.ReadUInt32());
+ case PayloadMessagePackType.Uri:
+ return new UriPayload(new Uri(reader.ReadString() ?? ""));
+ case PayloadMessagePackType.Other:
+ default:
+ var bytes = reader.ReadBytes() ?? new ReadOnlySequence();
+ var binReader = new BinaryReader(new MemoryStream(bytes.ToArray()));
+ return Payload.Decode(binReader);
+ }
+ }
+}
+
+public class SeStringMessagePackFormatter : IMessagePackFormatter {
+ public void Serialize(ref MessagePackWriter writer, SeString value, MessagePackSerializerOptions options) {
+ options.Resolver.GetFormatter>()!.Serialize(ref writer, value.Payloads, options);
+ }
+
+ public SeString Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) {
+ return new SeString(options.Resolver.GetFormatter>()!.Deserialize(ref reader, options));
+ }
+}
+
+internal class MessageStore : IDisposable {
+ internal const int MessageQueryLimit = 10_000;
+
+ private string DbPath { get; }
+
+ private SqliteConnection Connection { get; set; }
+
+ internal static readonly MessagePackSerializerOptions MsgPackOptions = MessagePackSerializerOptions.Standard
+ .WithResolver(CompositeResolver.Create(
+ new IMessagePackFormatter[] {
+ new PayloadMessagePackFormatter(),
+ new SeStringMessagePackFormatter(),
+ },
+ new IFormatterResolver[] { StandardResolver.Instance }));
+
+ internal MessageStore(string dbPath) {
+ DbPath = dbPath;
+ Connection = Connect();
+ Migrate();
+ }
+
+ public void Dispose() {
+ Connection.Close();
+ Connection.Dispose();
+ // Closing the connection doesn't immediately release the file.
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ }
+
+ private SqliteConnection Connect() {
+ var uriBuilder = new SqliteConnectionStringBuilder {
+ DataSource = DbPath,
+ DefaultTimeout = 5,
+ Pooling = false,
+ Mode = SqliteOpenMode.ReadWriteCreate,
+ };
+ var conn = new SqliteConnection(uriBuilder.ToString());
+ conn.Open();
+ conn.Execute(@"PRAGMA journal_mode=WAL;");
+ conn.Execute(@"PRAGMA synchronous=NORMAL;");
+ if (DalamudUtil.IsWine())
+ conn.Execute(@"PRAGMA cache_size = 32768;");
+ return conn;
+ }
+
+ private void Migrate() {
+ // TODO: this should be improved/swapped out for a library at some
+ // point.
+ Connection.Execute(@"
+ CREATE TABLE IF NOT EXISTS messages (
+ Id BLOB PRIMARY KEY NOT NULL, -- Guid
+ Receiver INTEGER NOT NULL, -- uint64 (first bits are always 0)
+ ContentId INTEGER NOT NULL, -- uint64 (first bits are always 0)
+ Date INTEGER NOT NULL, -- unix timestamp with millisecond precision
+ Code INTEGER NOT NULL, -- ChatCode encoding
+ Sender BLOB NOT NULL, -- Chunk[] msgpack
+ Content BLOB NOT NULL, -- Chunk[] msgpack
+ SenderSource BLOB NOT NULL, -- SeString
+ ContentSource BLOB NOT NULL, -- SeString
+ SortCode INTEGER NOT NULL, -- SortCode encoding
+ ExtraChatChannel BLOB NOT NULL -- Guid
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_messages_receiver ON messages (Receiver);
+ CREATE INDEX IF NOT EXISTS idx_messages_date ON messages (Date);
+ ");
+ }
+
+ internal void Reconnect() {
+ Connection.Close();
+ Connection.Dispose();
+ Connection = Connect();
+ }
+
+ internal void ClearMessages() {
+ Connection.Execute("DELETE FROM messages;");
+ PerformMaintenance();
+ }
+
+ internal void PerformMaintenance() {
+ Connection.Execute(@"
+ VACUUM;
+ REINDEX messages;
+ ANALYZE;
+ ");
+ }
+
+ internal long DatabaseSize() {
+ return !File.Exists(DbPath) ? 0 : new FileInfo(DbPath).Length;
+ }
+
+ private string LogPath => DbPath + "-wal";
+
+ internal long DatabaseLogSize() {
+ return !File.Exists(LogPath) ? 0 : new FileInfo(LogPath).Length;
+ }
+
+ internal int MessageCount()
+ {
+ var cmd = Connection.CreateCommand();
+ cmd.CommandText = "SELECT COUNT(*) FROM messages;";
+ return Convert.ToInt32(cmd.ExecuteScalar());
+ }
+
+ internal void UpsertMessage(Message message) {
+ var cmd = Connection.CreateCommand();
+ cmd.CommandText = @"
+ INSERT INTO messages (
+ Id,
+ Receiver,
+ ContentId,
+ Date,
+ Code,
+ Sender,
+ Content,
+ SenderSource,
+ ContentSource,
+ SortCode,
+ ExtraChatChannel
+ ) VALUES (
+ $Id,
+ $Receiver,
+ $ContentId,
+ $Date,
+ $Code,
+ $Sender,
+ $Content,
+ $SenderSource,
+ $ContentSource,
+ $SortCode,
+ $ExtraChatChannel
+ )
+ ON CONFLICT (id) DO UPDATE SET
+ Receiver = excluded.Receiver,
+ ContentId = excluded.ContentId,
+ Date = excluded.Date,
+ Code = excluded.Code,
+ Sender = excluded.Sender,
+ Content = excluded.Content,
+ SenderSource = excluded.SenderSource,
+ ContentSource = excluded.ContentSource,
+ SortCode = excluded.SortCode,
+ ExtraChatChannel = excluded.ExtraChatChannel;
+ ";
+
+ cmd.Parameters.AddWithValue("$Id", message.Id);
+ cmd.Parameters.AddWithValue("$Receiver", message.Receiver);
+ cmd.Parameters.AddWithValue("$ContentId", message.ContentId);
+ cmd.Parameters.AddWithValue("$Date", message.Date.ToUnixTimeMilliseconds());
+ cmd.Parameters.AddWithValue("$Code", message.Code.Raw);
+ cmd.Parameters.AddWithValue("$Sender", MessagePackSerializer.Serialize(message.Sender, MsgPackOptions));
+ cmd.Parameters.AddWithValue("$Content", MessagePackSerializer.Serialize(message.Content, MsgPackOptions));
+ cmd.Parameters.AddWithValue("$SenderSource", MessagePackSerializer.Serialize(message.SenderSource, MsgPackOptions));
+ cmd.Parameters.AddWithValue("$ContentSource", MessagePackSerializer.Serialize(message.ContentSource, MsgPackOptions));
+ cmd.Parameters.AddWithValue("$SortCode", message.SortCode.Encode());
+ cmd.Parameters.AddWithValue("$ExtraChatChannel", message.ExtraChatChannel);
+
+ cmd.ExecuteNonQuery();
+ }
+
+ ///
+ /// Get the most recent messages.
+ ///
+ /// The receiver content ID to filter by. If null, no filtering is performed.
+ /// Only show messages since this date. If null, no filtering is performed.
+ /// The amount to return. Defaults to 10,000.
+ internal MessageEnumerator GetMostRecentMessages(ulong? receiver = null, DateTimeOffset? since = null, int count = MessageQueryLimit) {
+ var whereClauses = new List();
+ if (receiver != null)
+ whereClauses.Add("Receiver = $Receiver");
+ if (since != null)
+ whereClauses.Add("Date >= $Since");
+
+ var whereClause = whereClauses.Count > 0 ? "WHERE " + string.Join(" AND ", whereClauses) : "";
+
+ var cmd = Connection.CreateCommand();
+ // Select last N messages by date DESC, but reverse the order to get
+ // them in ascending order.
+ cmd.CommandText = @"
+ SELECT *
+ FROM (
+ SELECT
+ Id,
+ Receiver,
+ ContentId,
+ Date,
+ Code,
+ Sender,
+ Content,
+ SenderSource,
+ ContentSource,
+ SortCode,
+ ExtraChatChannel
+ FROM messages
+ " + whereClause + @"
+ ORDER BY Date DESC
+ LIMIT $Count
+ )
+ ORDER BY Date ASC;
+ ";
+ cmd.CommandTimeout = 120; // this could take a while on slow computers
+
+ if (receiver != null)
+ cmd.Parameters.AddWithValue("$Receiver", receiver);
+ if (since != null)
+ cmd.Parameters.AddWithValue("$Since", since.Value.ToUnixTimeMilliseconds());
+
+ cmd.Parameters.AddWithValue("$Count", count);
+
+ return new MessageEnumerator(cmd.ExecuteReader());
+ }
+}
+
+internal class MessageEnumerator(DbDataReader reader) : IEnumerable {
+ private const int MaxErrorLogs = 10;
+
+ private int _errorCount;
+ public bool DidError => _errorCount > 0;
+
+ public IEnumerator GetEnumerator() {
+ while (reader.Read()) {
+ var id = Guid.Empty;
+ Message msg;
+ try {
+ id = reader.GetGuid(0);
+ msg = new Message(
+ id,
+ (ulong)reader.GetInt64(1),
+ (ulong)reader.GetInt64(2),
+ DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3)),
+ new ChatCode((ushort)reader.GetInt32(4)),
+ MessagePackSerializer.Deserialize>(reader.GetFieldValue(5),
+ MessageStore.MsgPackOptions),
+ MessagePackSerializer.Deserialize>(reader.GetFieldValue(6),
+ MessageStore.MsgPackOptions),
+ MessagePackSerializer.Deserialize(reader.GetFieldValue(7),
+ MessageStore.MsgPackOptions),
+ MessagePackSerializer.Deserialize(reader.GetFieldValue(8),
+ MessageStore.MsgPackOptions),
+ new SortCode((uint)reader.GetInt32(9)),
+ reader.GetGuid(10)
+ );
+ } catch (Exception e) {
+ if (_errorCount < MaxErrorLogs)
+ Plugin.Log.Error($"Exception while reading message '{id}' from database: {e}");
+ _errorCount++;
+ if (_errorCount == MaxErrorLogs)
+ Plugin.Log.Error("Further parsing errors will not be logged");
+
+#if DEBUG
+ throw;
+#else
+ continue;
+#endif
+ }
+
+ yield return msg;
+ }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() {
+ return GetEnumerator();
+ }
+}
diff --git a/ChatTwo/PayloadHandler.cs b/ChatTwo/PayloadHandler.cs
index be86f32..035fc71 100755
--- a/ChatTwo/PayloadHandler.cs
+++ b/ChatTwo/PayloadHandler.cs
@@ -92,7 +92,7 @@ public sealed class PayloadHandler {
DrawItemPopup(item);
drawn = true;
break;
- case URIPayload uri:
+ case UriPayload uri:
DrawUriPopup(uri);
drawn = true;
break;
@@ -252,7 +252,7 @@ public sealed class PayloadHandler {
DoHover(() => HoverItem(item), hoverSize);
break;
- case URIPayload uri:
+ case UriPayload uri:
DoHover(() => HoverURI(uri), hoverSize);
break;
}
@@ -376,7 +376,7 @@ public sealed class PayloadHandler {
}
}
- private void HoverURI(URIPayload uri)
+ private void HoverURI(UriPayload uri)
{
ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority));
ImGuiUtil.WarningText(Language.Context_URLWarning);
@@ -411,7 +411,7 @@ public sealed class PayloadHandler {
if (Equals(raw, ChunkUtil.PeriodicRecruitmentLink))
GameFunctions.GameFunctions.OpenPartyFinder();
break;
- case URIPayload uri:
+ case UriPayload uri:
TryOpenURI(uri.Uri);
break;
default:
@@ -659,7 +659,7 @@ public sealed class PayloadHandler {
return null;
}
- private void DrawUriPopup(URIPayload uri)
+ private void DrawUriPopup(UriPayload uri)
{
ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority));
ImGuiUtil.WarningText(Language.Context_URLWarning, false);
diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs
index afba168..5186025 100755
--- a/ChatTwo/Plugin.cs
+++ b/ChatTwo/Plugin.cs
@@ -55,7 +55,7 @@ public sealed class Plugin : IDalamudPlugin
internal XivCommonBase Common { get; }
internal TextureCache TextureCache { get; }
internal GameFunctions.GameFunctions Functions { get; }
- internal Store Store { get; }
+ internal MessageManager MessageManager { get; }
internal IpcManager Ipc { get; }
internal ExtraChat ExtraChat { get; }
internal FontManager FontManager { get; }
@@ -102,13 +102,13 @@ public sealed class Plugin : IDalamudPlugin
Interface.UiBuilder.DisableCutsceneUiHide = true;
Interface.UiBuilder.DisableGposeUiHide = true;
- Store = new Store(this); // requires Ui
+ MessageManager = new MessageManager(this); // requires Ui
// let all the other components register, then initialise commands
Commands.Initialise();
if (Interface.Reason is not PluginLoadReason.Boot) {
- Store.FilterAllTabs(false);
+ MessageManager.FilterAllTabs(false);
}
Framework.Update += FrameworkUpdate;
@@ -141,7 +141,7 @@ public sealed class Plugin : IDalamudPlugin
ExtraChat?.Dispose();
Ipc?.Dispose();
- Store?.Dispose();
+ MessageManager?.Dispose();
Functions?.Dispose();
TextureCache?.Dispose();
Common?.Dispose();
diff --git a/ChatTwo/Properties/AssemblyInfo.cs b/ChatTwo/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..123e4be
--- /dev/null
+++ b/ChatTwo/Properties/AssemblyInfo.cs
@@ -0,0 +1 @@
+[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ChatTwo.Tests")]
diff --git a/ChatTwo/Resources/Language.Designer.cs b/ChatTwo/Resources/Language.Designer.cs
index 5cb6019..cb6e6a2 100755
--- a/ChatTwo/Resources/Language.Designer.cs
+++ b/ChatTwo/Resources/Language.Designer.cs
@@ -1463,6 +1463,15 @@ namespace ChatTwo.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to An error occurred while loading chat history. Please see plugin logs for more information to report this issue..
+ ///
+ internal static string LoadMessages_Error {
+ get {
+ return ResourceManager.GetString("LoadMessages_Error", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to {0} is performing a database migration..
///
@@ -2237,33 +2246,6 @@ namespace ChatTwo.Resources {
}
}
- ///
- /// Looks up a localized string similar to Allow multiple clients to run {0} at the same time, sharing the same database..
- ///
- internal static string Options_SharedMode_Description {
- get {
- return ResourceManager.GetString("Options_SharedMode_Description", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Enable multi-client mode.
- ///
- internal static string Options_SharedMode_Name {
- get {
- return ResourceManager.GetString("Options_SharedMode_Name", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to This option is not recommended. No support will be offered if you enable this option. This option will hurt the performance of {0}..
- ///
- internal static string Options_SharedMode_Warning {
- get {
- return ResourceManager.GetString("Options_SharedMode_Warning", resourceCulture);
- }
- }
-
///
/// Looks up a localized string similar to Show the Novice Network join button next to the settings button if logged in as a mentor..
///
diff --git a/ChatTwo/Resources/Language.de.resx b/ChatTwo/Resources/Language.de.resx
index 0964a0b..3840875 100644
--- a/ChatTwo/Resources/Language.de.resx
+++ b/ChatTwo/Resources/Language.de.resx
@@ -1,17 +1,17 @@
-
@@ -461,15 +461,6 @@ Sie wurden gewarnt.
Erweitert
-
- Mehrere Clients verwenden
-
-
- Erlaubt es mehreren Clients, {0} gleichzeitig zu verwenden und Daten zu teilen.
-
-
- Diese Einstellung ist nicht empfohlen. Es wird keine Hilfe angeboten, falls diese Option genutzt wird. Dadurch sinkt zudem die Leistung von {0}.
-
Herauslösen
diff --git a/ChatTwo/Resources/Language.es.resx b/ChatTwo/Resources/Language.es.resx
index 256defd..08c659d 100644
--- a/ChatTwo/Resources/Language.es.resx
+++ b/ChatTwo/Resources/Language.es.resx
@@ -1,17 +1,17 @@
-
@@ -460,15 +460,6 @@
Avanzado
-
- Habilitar modo multiventana
-
-
- Permitir que múltiples clientes ejecuten {0} al mismo tiempo, compartiendo la misma base de datos.
-
-
- Esta opción no es recomendada. No se ofrecerá ningún soporte si activas esta opción. Esta opción perjudicará el rendimiento dé {0}.
-
Nueva pestaña
diff --git a/ChatTwo/Resources/Language.fr.resx b/ChatTwo/Resources/Language.fr.resx
index 9437c29..58ea691 100644
--- a/ChatTwo/Resources/Language.fr.resx
+++ b/ChatTwo/Resources/Language.fr.resx
@@ -1,17 +1,17 @@
-
@@ -460,15 +460,6 @@
Avancé
-
- Activer le mode multi-client
-
-
- Permet à plusieurs clients d'exécuter {0} en même temps, en partageant la même base de données.
-
-
- Cette option n'est pas recommandée. Aucune assistance ne sera offerte si vous activez cette option. Cette option nuira aux performances de {0}.
-
Détacher
diff --git a/ChatTwo/Resources/Language.nl.resx b/ChatTwo/Resources/Language.nl.resx
index d8d0abe..d23b272 100644
--- a/ChatTwo/Resources/Language.nl.resx
+++ b/ChatTwo/Resources/Language.nl.resx
@@ -460,15 +460,6 @@
Geavanceerd
-
- Modus voor meerdere vensters inschakelen
-
-
- Meerdere vensters toestaan om {0} tegelijkertijd uit te voeren en dezelfde database te delen.
-
-
- Deze optie wordt niet aanbevolen. Er wordt geen ondersteuning aangeboden als je deze optie inschakelt. Deze optie is schadelijk voor de snelheid van {0}.
-
Uitvouwen
diff --git a/ChatTwo/Resources/Language.pt-BR.resx b/ChatTwo/Resources/Language.pt-BR.resx
index 6b6ca48..e9658f6 100644
--- a/ChatTwo/Resources/Language.pt-BR.resx
+++ b/ChatTwo/Resources/Language.pt-BR.resx
@@ -1,17 +1,17 @@
-
@@ -460,15 +460,6 @@
Avançado
-
- Ativar modo de mútiplos clientes
-
-
- Permitir que vários clientes sejam executados {0} ao mesmo tempo, compartilhando o mesmo banco de dados.
-
-
- Esta opção não é recomendada. Nenhum suporte será oferecido se você ativar esta opção. Esta opção irá prejudicar o desempenho de {0}.
-
Separar da janela
diff --git a/ChatTwo/Resources/Language.resx b/ChatTwo/Resources/Language.resx
index d8c212c..e938260 100644
--- a/ChatTwo/Resources/Language.resx
+++ b/ChatTwo/Resources/Language.resx
@@ -460,15 +460,6 @@
Advanced
-
- Enable multi-client mode
-
-
- Allow multiple clients to run {0} at the same time, sharing the same database.
-
-
- This option is not recommended. No support will be offered if you enable this option. This option will hurt the performance of {0}.
-
Pop out
@@ -1000,4 +991,7 @@
Limits the amount of log lines to show in the chat window. This may slightly improve performance.
+
+ An error occurred while loading chat history. Please see plugin logs for more information to report this issue.
+
diff --git a/ChatTwo/Resources/Language.ro.resx b/ChatTwo/Resources/Language.ro.resx
index 35d1804..938e221 100644
--- a/ChatTwo/Resources/Language.ro.resx
+++ b/ChatTwo/Resources/Language.ro.resx
@@ -1,17 +1,17 @@
-
@@ -460,15 +460,6 @@
Avansat
-
- Activează modul instanțelor multiple
-
-
- Permite mai multor instanțe sa folosească {0} simultan, folosind aceeași data de baze.
-
-
- Această opțiune nu este recomandată. Dacă o activați nu veți primi niciun suport. Această opțiune va afecta performanța {0}-ului.
-
Mută tabul într-o fereastra noua
diff --git a/ChatTwo/Resources/Language.ru.resx b/ChatTwo/Resources/Language.ru.resx
index 267ee9a..92428b4 100644
--- a/ChatTwo/Resources/Language.ru.resx
+++ b/ChatTwo/Resources/Language.ru.resx
@@ -1,17 +1,17 @@
-
@@ -460,15 +460,6 @@
Расширенные
-
- Включить режим нескольких клиентов
-
-
- Разрешить нескольким клиентам запускать {0} одновременно, обмениваясь одной и той же базой данных.
-
-
- Эта опция не рекомендуется. Если вы включите эту опцию, то поддержка не будет предоставляться. Эта опция значитьльно навредит производительности {0}.
-
Отделить
diff --git a/ChatTwo/Resources/Language.sv.resx b/ChatTwo/Resources/Language.sv.resx
index b94f6b6..e9bade7 100644
--- a/ChatTwo/Resources/Language.sv.resx
+++ b/ChatTwo/Resources/Language.sv.resx
@@ -1,17 +1,17 @@
-
@@ -460,15 +460,6 @@
Avancerat
-
- Aktivera flerklientsläge
-
-
- Tillåt flera klienter att köra {0} samtidigt med samma databas.
-
-
- Den här inställningen rekommenderas inte. Inget stöd kommer erbjudas om du aktiverar den här inställningen. Den här inställningen kommer att förvärra prestandan av {0}.
-
Separera
diff --git a/ChatTwo/Resources/Language.zh-Hans.resx b/ChatTwo/Resources/Language.zh-Hans.resx
index 3454a92..2b6a197 100644
--- a/ChatTwo/Resources/Language.zh-Hans.resx
+++ b/ChatTwo/Resources/Language.zh-Hans.resx
@@ -1,17 +1,17 @@
-
@@ -460,15 +460,6 @@
高级选项
-
- 启用多客户端模式
-
-
- 允许多个客户端同时运行 {0} ,共享同一个数据库。
-
-
- 不推荐此选项。如果您启用此选项,不会提供任何支持。此选项将损害 {0} 的性能。
-
弹出
diff --git a/ChatTwo/Resources/Language.zh-Hant.resx b/ChatTwo/Resources/Language.zh-Hant.resx
index 9f4a1ea..153b1c0 100644
--- a/ChatTwo/Resources/Language.zh-Hant.resx
+++ b/ChatTwo/Resources/Language.zh-Hant.resx
@@ -1,17 +1,17 @@
-
@@ -461,15 +461,6 @@
高階選項
-
- 啓用多客戸端模式
-
-
- 允許多個客戸端同時執行 {0} ,共享同一個資料庫。
-
-
- 不推薦此選項。 如果啓用此選項,不會提供任何支持。 此選項將損害 {0} 的效能。
-
彈出
diff --git a/ChatTwo/Store.cs b/ChatTwo/Store.cs
deleted file mode 100755
index 63be71b..0000000
--- a/ChatTwo/Store.cs
+++ /dev/null
@@ -1,387 +0,0 @@
-using System.Collections.Concurrent;
-using System.Diagnostics;
-using ChatTwo.Code;
-using ChatTwo.Util;
-using Dalamud.Game.Text;
-using Dalamud.Game.Text.SeStringHandling;
-using Dalamud.Plugin.Services;
-using LiteDB;
-using Lumina.Excel.GeneratedSheets;
-
-namespace ChatTwo;
-
-internal class Store : IDisposable
-{
- internal const int MessagesLimit = 10_000;
-
- private Plugin Plugin { get; }
-
- private ConcurrentQueue<(uint, Message)> Pending { get; } = new();
- private Stopwatch CheckpointTimer { get; } = new();
- internal ILiteDatabase Database { get; private set; }
- private ILiteCollection Messages => Database.GetCollection("messages");
-
- private Dictionary Formats { get; } = new();
- private ulong LastContentId { get; set; }
-
- private ulong CurrentContentId
- {
- get
- {
- var contentId = Plugin.ClientState.LocalContentId;
- return contentId == 0 ? LastContentId : contentId;
- }
- }
-
- internal Store(Plugin plugin)
- {
- Plugin = plugin;
- CheckpointTimer.Start();
-
- BsonMapper.Global = new BsonMapper
- {
- IncludeNonPublic = true,
- TrimWhitespace = false,
- // EnumAsInteger = true,
- };
-
- BsonMapper.Global.Entity()
- .Id(msg => msg.Id)
- .Ctor(doc => new Message(
- doc["_id"].AsObjectId,
- (ulong) doc["Receiver"].AsInt64,
- (ulong) doc["ContentId"].AsInt64,
- DateTime.UnixEpoch.AddMilliseconds(doc["Date"].AsInt64),
- doc["Code"].AsDocument,
- doc["Sender"].AsArray,
- doc["Content"].AsArray,
- doc["SenderSource"],
- doc["ContentSource"],
- doc["SortCode"].AsDocument,
- doc["ExtraChatChannel"]
- ));
-
- BsonMapper.Global.RegisterType(
- payload =>
- {
- switch (payload)
- {
- case AchievementPayload achievement:
- return new BsonDocument(new Dictionary {
- ["Type"] = new("Achievement"),
- ["Id"] = new(achievement.Id),
- });
- case PartyFinderPayload partyFinder:
- return new BsonDocument(new Dictionary {
- ["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();
- },
- bson =>
- {
- if (bson.IsNull)
- return null;
-
- if (bson.IsDocument)
- {
- 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,
- };
- }
-
- return Payload.Decode(new BinaryReader(new MemoryStream(bson.AsBinary)));
- });
- BsonMapper.Global.RegisterType(
- seString => seString == null
- ? null
- : new BsonArray(seString.Payloads.Select(payload => new BsonValue(payload.Encode()))),
- bson =>
- {
- if (bson.IsNull)
- return null;
-
- var array = bson.IsArray ? bson.AsArray : bson["Payloads"].AsArray;
- var payloads = array
- .Select(payload => Payload.Decode(new BinaryReader(new MemoryStream(payload.AsBinary))))
- .ToList();
- return new SeString(payloads);
- }
- );
- BsonMapper.Global.RegisterType(
- type => (int) type,
- bson => (ChatType) bson.AsInt32
- );
- BsonMapper.Global.RegisterType(
- source => (int) source,
- bson => (ChatSource) bson.AsInt32
- );
- BsonMapper.Global.RegisterType(
- dateTime => dateTime.Subtract(DateTime.UnixEpoch).TotalMilliseconds,
- bson => DateTime.UnixEpoch.AddMilliseconds(bson.AsInt64)
- );
- Database = Connect();
-
- Plugin.ChatGui.ChatMessageUnhandled += ChatMessage;
- Plugin.Framework.Update += GetMessageInfo;
- Plugin.Framework.Update += UpdateReceiver;
- Plugin.ClientState.Logout += Logout;
- }
-
- public void Dispose() {
- Plugin.ClientState.Logout -= Logout;
- Plugin.Framework.Update -= UpdateReceiver;
- Plugin.Framework.Update -= GetMessageInfo;
- Plugin.ChatGui.ChatMessageUnhandled -= ChatMessage;
-
- Database.Dispose();
- }
-
- internal static string DatabasePath()
- {
- var dir = Plugin.Interface.ConfigDirectory;
- dir.Create();
- return Path.Join(dir.FullName, "chat.db");
- }
-
- private LiteDatabase Connect() {
- var dbPath = DatabasePath();
- var connection = Plugin.Config.SharedMode ? "shared" : "direct";
- var connString = $"Filename='{dbPath}';Connection={connection}";
- var conn = new LiteDatabase(connString, BsonMapper.Global)
- {
- CheckpointSize = 1_000,
- Timeout = TimeSpan.FromSeconds(1),
- };
- var messages = conn.GetCollection("messages");
- messages.EnsureIndex(msg => msg.Date);
- messages.EnsureIndex(msg => msg.SortCode);
- messages.EnsureIndex(msg => msg.ExtraChatChannel);
- return conn;
- }
-
- internal void Reconnect()
- {
- Database.Dispose();
- Database = Connect();
- }
-
- internal void ClearDatabase()
- {
- Messages.DeleteAll();
- Database.Rebuild();
- }
-
- internal static long DatabaseSize()
- {
- var dbPath = DatabasePath();
- return !File.Exists(dbPath) ? 0 : new FileInfo(dbPath).Length;
- }
-
- internal static long DatabaseLogSize()
- {
- var dbLogPath = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat-log.db");
- return !File.Exists(dbLogPath) ? 0 : new FileInfo(dbLogPath).Length;
- }
-
- internal int MessageCount() => Messages.Count();
-
- private void Logout()
- {
- LastContentId = 0;
- }
-
- private void UpdateReceiver(IFramework framework)
- {
- var contentId = Plugin.ClientState.LocalContentId;
- if (contentId != 0)
- LastContentId = contentId;
- }
-
- private void GetMessageInfo(IFramework framework)
- {
- if (CheckpointTimer.Elapsed > TimeSpan.FromMinutes(5))
- {
- CheckpointTimer.Restart();
- new Thread(() => Database.Checkpoint()).Start();
- }
-
- if (!Pending.TryDequeue(out var entry))
- return;
-
- var contentId = Plugin.Functions.Chat.GetContentIdForEntry(entry.Item1);
- entry.Item2.ContentId = contentId ?? 0;
- if (Plugin.Config.DatabaseBattleMessages || !entry.Item2.Code.IsBattle())
- Messages.Update(entry.Item2);
- }
-
- internal void AddMessage(Message message, Tab? currentTab)
- {
- if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
- Messages.Insert(message);
-
- var currentMatches = currentTab?.Matches(message) ?? false;
-
- foreach (var tab in Plugin.Config.Tabs)
- {
- var unread = !(tab.UnreadMode == UnreadMode.Unseen && currentTab != tab && currentMatches);
-
- if (tab.Matches(message))
- tab.AddMessage(message, unread);
- }
- }
-
- internal void FilterAllTabs(bool unread = true)
- {
- foreach (var tab in Plugin.Config.Tabs)
- FilterTab(tab, unread);
- }
-
- internal void FilterTab(Tab tab, bool unread)
- {
- var sortCodes = new List();
- foreach (var (type, sources) in tab.ChatCodes)
- {
- sortCodes.Add(new SortCode(type, 0));
- sortCodes.Add(new SortCode(type, (ChatSource) 1));
-
- if (!type.HasSource())
- continue;
-
- foreach (var source in Enum.GetValues())
- if (sources.HasFlag(source))
- sortCodes.Add(new SortCode(type, source));
- }
-
- var query = Messages
- .Query()
- .OrderByDescending(msg => msg.Date)
- .Where(msg => sortCodes.Contains(msg.SortCode) || msg.ExtraChatChannel != Guid.Empty)
- .Where(msg => msg.Receiver == CurrentContentId);
-
- if (!Plugin.Config.FilterIncludePreviousSessions)
- query = query.Where(msg => msg.Date >= Plugin.GameStarted);
-
- var messages = query.Limit(MessagesLimit).ToEnumerable().Reverse();
-
- foreach (var message in messages)
- {
- // check primarily for startup double posting messages
- if (tab.Contains(message))
- continue;
-
- // redundant matches check for extrachat
- if (tab.Matches(message))
- tab.AddMessage(message, unread);
- }
- }
-
- 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);
-
- NameFormatting? formatting = null;
- if (sender.Payloads.Count > 0)
- 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)
- {
- FallbackColour = chatCode.Type,
- });
- senderChunks.AddRange(ChunkUtil.ToChunks(sender, ChunkSource.Sender, chatCode.Type));
- senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.After)
- {
- FallbackColour = chatCode.Type,
- });
- }
-
- var messageChunks = ChunkUtil.ToChunks(message, ChunkSource.Content, chatCode.Type).ToList();
-
- var msg = new Message(CurrentContentId, chatCode, senderChunks, messageChunks, sender, message);
- AddMessage(msg, Plugin.ChatLogWindow.CurrentTab ?? null);
-
- var idx = Plugin.Functions.GetCurrentChatLogEntryIndex();
- if (idx != null)
- Pending.Enqueue((idx.Value - 1, msg));
- }
-
- internal class NameFormatting
- {
- internal string Before { get; private set; } = string.Empty;
- internal string After { get; private set; } = string.Empty;
- internal bool IsPresent { get; private set; } = true;
-
- internal static NameFormatting Empty()
- {
- return new NameFormatting { IsPresent = false, };
- }
-
- internal static NameFormatting Of(string before, string after)
- {
- return new NameFormatting
- {
- Before = before,
- After = after,
- };
- }
- }
-
- private NameFormatting? FormatFor(ChatType type)
- {
- if (Formats.TryGetValue(type, out var cached))
- return cached;
-
- var logKind = Plugin.DataManager.GetExcelSheet()!.GetRow((ushort) type);
- if (logKind == null)
- return null;
-
- var format = (SeString) logKind.Format;
- static bool IsStringParam(Payload payload, byte num)
- {
- var data = payload.Encode();
- return data.Length >= 5 && data[1] == 0x29 && data[4] == num + 1;
- }
-
- var firstStringParam = format.Payloads.FindIndex(payload => IsStringParam(payload, 1));
- var secondStringParam = format.Payloads.FindIndex(payload => IsStringParam(payload, 2));
-
- if (firstStringParam == -1 || secondStringParam == -1)
- return NameFormatting.Empty();
-
- var before = format.Payloads
- .GetRange(0, firstStringParam)
- .Where(payload => payload is ITextProvider)
- .Cast()
- .Select(text => text.Text);
- var after = format.Payloads
- .GetRange(firstStringParam + 1, secondStringParam - firstStringParam)
- .Where(payload => payload is ITextProvider)
- .Cast()
- .Select(text => text.Text);
-
- var nameFormatting = NameFormatting.Of(
- string.Join("", before),
- string.Join("", after)
- );
-
- Formats[type] = nameFormatting;
-
- return nameFormatting;
- }
-}
diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs
index b8fe4d6..ea015e2 100644
--- a/ChatTwo/Ui/ChatLogWindow.cs
+++ b/ChatTwo/Ui/ChatLogWindow.cs
@@ -139,7 +139,7 @@ public sealed class ChatLogWindow : Window, IUiComponent
private void Login()
{
- Plugin.Store.FilterAllTabs(false);
+ Plugin.MessageManager.FilterAllTabs(false);
}
private void Activated(ChatActivatedArgs args) {
diff --git a/ChatTwo/Ui/SeStringDebugger.cs b/ChatTwo/Ui/SeStringDebugger.cs
index 7e7dd92..1c25afa 100644
--- a/ChatTwo/Ui/SeStringDebugger.cs
+++ b/ChatTwo/Ui/SeStringDebugger.cs
@@ -42,7 +42,7 @@ public class SeStringDebugger : Window
public override void Draw()
{
- if (Plugin.Store.LastMessage.Sender == null)
+ if (Plugin.MessageManager.LastMessage.Sender == null)
{
ImGui.TextUnformatted("Nothing to show");
return;
@@ -51,15 +51,15 @@ public class SeStringDebugger : Window
// TODO: Make SeString freely selectable through chat
ImGui.TextUnformatted("Sender Content");
ImGui.Spacing();
- if (Plugin.Store.LastMessage.Sender != null)
- ProcessPayloads(Plugin.Store.LastMessage.Sender.Payloads);
+ if (Plugin.MessageManager.LastMessage.Sender != null)
+ ProcessPayloads(Plugin.MessageManager.LastMessage.Sender.Payloads);
else
ImGui.TextUnformatted("Nothing to show");
ImGui.TextUnformatted("Message Content");
ImGui.Spacing();
- if (Plugin.Store.LastMessage.Message != null)
- ProcessPayloads(Plugin.Store.LastMessage.Message.Payloads);
+ if (Plugin.MessageManager.LastMessage.Message != null)
+ ProcessPayloads(Plugin.MessageManager.LastMessage.Message.Payloads);
else
ImGui.TextUnformatted("Nothing to show");
}
diff --git a/ChatTwo/Ui/Settings.cs b/ChatTwo/Ui/Settings.cs
index 2786523..4abae74 100755
--- a/ChatTwo/Ui/Settings.cs
+++ b/ChatTwo/Ui/Settings.cs
@@ -151,7 +151,6 @@ public sealed class SettingsWindow : Window, IUiComponent
|| Math.Abs(Mutable.JapaneseFontSize - Plugin.Config.JapaneseFontSize) > 0.001
|| Math.Abs(Mutable.SymbolsFontSize - Plugin.Config.SymbolsFontSize) > 0.001;
var langChanged = Mutable.LanguageOverride != Plugin.Config.LanguageOverride;
- var sharedChanged = Mutable.SharedMode != Plugin.Config.SharedMode;
config.UpdateFrom(Mutable);
@@ -159,7 +158,7 @@ public sealed class SettingsWindow : Window, IUiComponent
// commit any changes that cause a crash
Plugin.DeferredSaveFrames = 60;
- Plugin.Store.FilterAllTabs(false);
+ Plugin.MessageManager.FilterAllTabs(false);
if (fontChanged || fontSizeChanged) {
Plugin.FontManager.BuildFonts();
@@ -169,10 +168,6 @@ public sealed class SettingsWindow : Window, IUiComponent
Plugin.LanguageChanged(Plugin.Interface.UiLanguage);
}
- if (sharedChanged) {
- Plugin.Store.Reconnect();
- }
-
if (!Mutable.HideChat && hideChatChanged) {
GameFunctions.GameFunctions.SetChatInteractable(true);
}
diff --git a/ChatTwo/Ui/SettingsTabs/Database.cs b/ChatTwo/Ui/SettingsTabs/Database.cs
index 2ca9f43..3441550 100755
--- a/ChatTwo/Ui/SettingsTabs/Database.cs
+++ b/ChatTwo/Ui/SettingsTabs/Database.cs
@@ -1,5 +1,9 @@
+using System.Diagnostics;
+using ChatTwo.Code;
using ChatTwo.Resources;
using ChatTwo.Util;
+using Dalamud.Game.Text.SeStringHandling;
+using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.Internal.Notifications;
using ImGuiNET;
@@ -46,13 +50,6 @@ internal sealed class Database : ISettingsTab
Mutable.LoadPreviousSession = false;
}
- ImGuiUtil.OptionCheckbox(
- ref Mutable.SharedMode,
- Language.Options_SharedMode_Name,
- string.Format(Language.Options_SharedMode_Description, Plugin.PluginName)
- );
- ImGuiUtil.WarningText(string.Format(Language.Options_SharedMode_Warning, Plugin.PluginName));
-
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
@@ -65,18 +62,18 @@ internal sealed class Database : ISettingsTab
// constant stat calls and spamming the database.
if (DatabaseLastRefreshTicks + 5 * 1000 < Environment.TickCount64)
{
- DatabaseSize = Store.DatabaseSize();
- DatabaseLogSize = Store.DatabaseLogSize();
- DatabaseMessageCount = Plugin.Store.MessageCount();
+ DatabaseSize = Plugin.MessageManager.Store.DatabaseSize();
+ DatabaseLogSize = Plugin.MessageManager.Store.DatabaseLogSize();
+ DatabaseMessageCount = Plugin.MessageManager.Store.MessageCount();
DatabaseLastRefreshTicks = Environment.TickCount64;
}
- ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_Path, Store.DatabasePath()));
+ ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_Path, MessageManager.DatabasePath()));
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
{
// Copy the directory path instead of the file path so people can
// paste it into their file explorer.
- var path = Path.GetDirectoryName(Store.DatabasePath());
+ var path = Path.GetDirectoryName(MessageManager.DatabasePath());
ImGui.SetClipboardText(path);
WrapperUtil.AddNotification(Language.Options_Database_Metadata_CopyConfigPathNotification, NotificationType.Info);
}
@@ -95,12 +92,12 @@ internal sealed class Database : ISettingsTab
if (ImGui.IsItemHovered())
ImGui.SetTooltip(DatabaseLogSize.ToString("N0") + "B");
- ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_MessageCount, DatabaseMessageCount, Store.MessagesLimit));
+ ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_MessageCount, DatabaseMessageCount, MessageStore.MessageQueryLimit));
if (ImGuiUtil.CtrlShiftButton(Language.Options_ClearDatabase_Button, Language.Options_ClearDatabase_Tooltip))
{
- Plugin.Log.Warning("Clearing database");
- Plugin.Store.ClearDatabase();
+ Plugin.Log.Warning("Clearing messages from database");
+ Plugin.MessageManager.Store.ClearMessages();
foreach (var tab in Plugin.Config.Tabs)
tab.Clear();
@@ -117,11 +114,18 @@ internal sealed class Database : ISettingsTab
ImGui.PushTextWrapPos();
ImGuiUtil.WarningText(Language.Options_Database_Advanced_Warning);
- if (ImGuiUtil.CtrlShiftButton("Checkpoint", "Ctrl+Shift: Database.Checkpoint()"))
- Plugin.Store.Database.Checkpoint();
+ if (ImGuiUtil.CtrlShiftButton("Perform maintenance", "Ctrl+Shift: MessageManager.Store.PerformMaintenance()"))
+ Plugin.MessageManager.Store.PerformMaintenance();
- if (ImGuiUtil.CtrlShiftButton("Rebuild", "Ctrl+Shift: Database.Rebuild()"))
- Plugin.Store.Database.Rebuild();
+ if (ImGuiUtil.CtrlShiftButton("Reload messages from database",
+ "Ctrl+Shift: MessageManager.FilterAllTabs(false)")) {
+ foreach (var tab in Plugin.Config.Tabs)
+ tab.Clear();
+ Plugin.MessageManager.FilterAllTabs(false);
+ }
+
+ if (ImGuiUtil.CtrlShiftButton("Inject 10,000 messages", "Ctrl+Shift: creates 10,000 unique messages (async)"))
+ new Thread(() => InsertMessages(10_000)).Start();
ImGui.PopTextWrapPos();
ImGui.TreePop();
@@ -129,4 +133,78 @@ internal sealed class Database : ISettingsTab
ImGui.Spacing();
}
+
+ private void InsertMessages(int count) {
+ Plugin.Log.Info($"Inserting {count} messages due to user request");
+
+ // Generate
+ var stopwatch = Stopwatch.StartNew();
+ var playerName = Plugin.ClientState.LocalPlayer?.Name.ToString() ?? "Unknown Player";
+ var worldId = Plugin.ClientState.LocalPlayer?.HomeWorld.Id ?? 0;
+ var senderSource = new SeStringBuilder()
+ .AddText("<")
+ .Add(new PlayerPayload(playerName, worldId))
+ .AddText("Random Message")
+ .Add(RawPayload.LinkTerminator)
+ .AddText(">: ")
+ .Build();
+ var senderChunks = ChunkUtil.ToChunks(senderSource, ChunkSource.Sender, ChatType.Debug).ToList();
+ var messages = new List(count);
+ for (var i = 0; i < count; i++) {
+ var contentSource = new SeStringBuilder()
+ .AddText("Random message payload - ")
+ .AddItalics(Guid.NewGuid().ToString())
+ .Build();
+ var contentChunks = ChunkUtil.ToChunks(contentSource, ChunkSource.Content, ChatType.Debug).ToList();
+
+ messages.Add(new Message(
+ Guid.NewGuid(),
+ Plugin.MessageManager.CurrentContentId,
+ Plugin.MessageManager.CurrentContentId,
+ DateTimeOffset.UtcNow,
+ new ChatCode(10),
+ senderChunks,
+ contentChunks,
+ senderSource,
+ contentSource,
+ new SortCode(ChatType.Debug, ChatSource.Self),
+ Guid.Empty
+ ));
+ }
+
+ var elapsedTicks = stopwatch.ElapsedTicks;
+ stopwatch.Stop();
+ Plugin.Log.Info($"Crafted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
+
+ // Insert
+ stopwatch = Stopwatch.StartNew();
+ foreach (var message in messages) {
+ Plugin.MessageManager.Store.UpsertMessage(message);
+ }
+
+ elapsedTicks = stopwatch.ElapsedTicks;
+ stopwatch.Stop();
+ Plugin.Log.Info($"Upserted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
+
+ // Clear tabs during framework frame
+ Plugin.Framework.Run(() => {
+ stopwatch = Stopwatch.StartNew();
+ foreach (var tab in Plugin.Config.Tabs)
+ tab.Clear();
+
+ elapsedTicks = stopwatch.ElapsedTicks;
+ stopwatch.Stop();
+ Plugin.Log.Info(
+ $"Cleared {Plugin.Config.Tabs.Count} tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
+ }).Wait();
+
+ // Fetch and filter during framework frame
+ Plugin.Framework.Run(() => {
+ stopwatch = Stopwatch.StartNew();
+ Plugin.MessageManager.FilterAllTabs(false);
+ elapsedTicks = stopwatch.ElapsedTicks;
+ stopwatch.Stop();
+ Plugin.Log.Info($"Fetched and filtered all tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
+ }).Wait();
+ }
}
diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs
index 3874c77..ada1040 100755
--- a/ChatTwo/Util/ChunkUtil.cs
+++ b/ChatTwo/Util/ChunkUtil.cs
@@ -108,7 +108,7 @@ internal static class ChunkUtil {
} 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);
+ link = new UriPayload(uri);
} else if (Equals(rawPayload, RawPayload.LinkTerminator)) {
link = null;
}
diff --git a/ChatTwo/Util/Payloads.cs b/ChatTwo/Util/Payloads.cs
index 92311d2..6fc81fc 100755
--- a/ChatTwo/Util/Payloads.cs
+++ b/ChatTwo/Util/Payloads.cs
@@ -39,11 +39,11 @@ internal class AchievementPayload : Payload {
}
-internal class URIPayload(Uri uri) : Payload
+internal class UriPayload(Uri uri) : Payload
{
public override PayloadType Type => (PayloadType) 0x52;
- public Uri Uri { get; init; } = uri;
+ public Uri Uri { get; } = uri;
private static readonly string[] ExpectedSchemes = ["http", "https"];
private static readonly string DefaultScheme = "https";
@@ -55,7 +55,7 @@ internal class URIPayload(Uri uri) : Payload
///
/// If the URI is invalid, or if the scheme is not supported.
///
- public static URIPayload ResolveURI(string rawURI)
+ public static UriPayload ResolveURI(string rawURI)
{
ArgumentNullException.ThrowIfNull(rawURI);
@@ -64,7 +64,7 @@ internal class URIPayload(Uri uri) : Payload
{
if (rawURI.StartsWith($"{scheme}://"))
{
- return new URIPayload(new Uri(rawURI));
+ return new UriPayload(new Uri(rawURI));
}
}
if (rawURI.Contains("://"))
@@ -72,7 +72,7 @@ internal class URIPayload(Uri uri) : Payload
throw new UriFormatException($"Unsupported scheme in URL: {rawURI}");
}
- return new URIPayload(new Uri($"{DefaultScheme}://{rawURI}"));
+ return new UriPayload(new Uri($"{DefaultScheme}://{rawURI}"));
}
protected override void DecodeImpl(BinaryReader reader, long endOfStream)
diff --git a/ChatTwo/packages.lock.json b/ChatTwo/packages.lock.json
index b2f267f..18f7d6b 100644
--- a/ChatTwo/packages.lock.json
+++ b/ChatTwo/packages.lock.json
@@ -8,11 +8,26 @@
"resolved": "2.1.12",
"contentHash": "Sc0PVxvgg4NQjcI8n10/VfUQBAS4O+Fw2pZrAqBdRMbthYGeogzu5+xmIGCGmsEZ/ukMOBuAqiNiB5qA3MRalg=="
},
- "LiteDB": {
+ "MessagePack": {
"type": "Direct",
- "requested": "[5.0.17, )",
- "resolved": "5.0.17",
- "contentHash": "cKPvkdlzIts3ZKu/BzoIc/Y71e4VFKlij4LyioPFATZMot+wB7EAm1FFbZSJez6coJmQUoIg/3yHE1MMU+zOdg=="
+ "requested": "[2.5.140, )",
+ "resolved": "2.5.140",
+ "contentHash": "nkIsgy8BkIfv40bSz9XZb4q//scI1PF3AYeB5X66nSlIhBIqbdpLz8Qk3gHvnjV3RZglQLO/ityK3eNfLii2NA==",
+ "dependencies": {
+ "MessagePack.Annotations": "2.5.140",
+ "Microsoft.NET.StringTools": "17.6.3",
+ "System.Runtime.CompilerServices.Unsafe": "6.0.0"
+ }
+ },
+ "Microsoft.Data.Sqlite": {
+ "type": "Direct",
+ "requested": "[8.0.4, )",
+ "resolved": "8.0.4",
+ "contentHash": "vgLm03wS+CfsolO7qk4KVuvt0CtzgdjKmoORuwxMmiIF1ow1JlOo1vwfDHfwXnGa5+QEbvOUy3169bBcHshfTg==",
+ "dependencies": {
+ "Microsoft.Data.Sqlite.Core": "8.0.4",
+ "SQLitePCLRaw.bundle_e_sqlite3": "2.1.6"
+ }
},
"Pidgin": {
"type": "Direct",
@@ -37,6 +52,24 @@
"resolved": "9.0.0",
"contentHash": "avaBp3FmSCi/PiQhntCeBDYOHejdyTWmFtz4pRBVQQ8vHkmRx+YTk1la9dkYBMlXxRXKckEdH1iI1Fu61JlE7w=="
},
+ "MessagePack.Annotations": {
+ "type": "Transitive",
+ "resolved": "2.5.140",
+ "contentHash": "JE3vwluOrsJ4t3hnfXzIxJUh6lhx6M/KR8Sark/HOUw1DJ5UKu5JsAnnuaQngg6poFkRx1lzHSLTkxHNJO7+uQ=="
+ },
+ "Microsoft.Data.Sqlite.Core": {
+ "type": "Transitive",
+ "resolved": "8.0.4",
+ "contentHash": "x5FE5m1h31UIDEk0j3r38HtYvsa0fxd5jXzvE/SARI7LecXt/jm4z2SUl6TEoJGQOo9Ow2wg3a0MU2E1TVVAdA==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.6"
+ }
+ },
+ "Microsoft.NET.StringTools": {
+ "type": "Transitive",
+ "resolved": "17.6.3",
+ "contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA=="
+ },
"Microsoft.NETCore.Platforms": {
"type": "Transitive",
"resolved": "1.1.0",
@@ -232,6 +265,36 @@
"SharpDX": "4.2.0"
}
},
+ "SQLitePCLRaw.bundle_e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.6",
+ "contentHash": "BmAf6XWt4TqtowmiWe4/5rRot6GerAeklmOPfviOvwLoF5WwgxcJHAxZtySuyW9r9w+HLILnm8VfJFLCUJYW8A==",
+ "dependencies": {
+ "SQLitePCLRaw.lib.e_sqlite3": "2.1.6",
+ "SQLitePCLRaw.provider.e_sqlite3": "2.1.6"
+ }
+ },
+ "SQLitePCLRaw.core": {
+ "type": "Transitive",
+ "resolved": "2.1.6",
+ "contentHash": "wO6v9GeMx9CUngAet8hbO7xdm+M42p1XeJq47ogyRoYSvNSp0NGLI+MgC0bhrMk9C17MTVFlLiN6ylyExLCc5w==",
+ "dependencies": {
+ "System.Memory": "4.5.3"
+ }
+ },
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.6",
+ "contentHash": "2ObJJLkIUIxRpOUlZNGuD4rICpBnrBR5anjyfUFQep4hMOIeqW+XGQYzrNmHSVz5xSWZ3klSbh7sFR6UyDj68Q=="
+ },
+ "SQLitePCLRaw.provider.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.6",
+ "contentHash": "PQ2Oq3yepLY4P7ll145P3xtx2bX8xF4PzaKPRpw9jZlKvfe4LE/saAV82inND9usn1XRpmxXk7Lal3MTI+6CNg==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.6"
+ }
+ },
"System.AppContext": {
"type": "Transitive",
"resolved": "4.3.0",
@@ -476,6 +539,11 @@
"System.Threading": "4.3.0"
}
},
+ "System.Memory": {
+ "type": "Transitive",
+ "resolved": "4.5.3",
+ "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA=="
+ },
"System.Net.Http": {
"type": "Transitive",
"resolved": "4.3.0",
@@ -641,6 +709,11 @@
"Microsoft.NETCore.Targets": "1.1.0"
}
},
+ "System.Runtime.CompilerServices.Unsafe": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
+ },
"System.Runtime.Extensions": {
"type": "Transitive",
"resolved": "4.3.0",