56f6855da2
- Adds new setting "Enable inactivity hide during battle" (default: true) which determines whether autohide should apply during battle (thanks @aurieh) - Adds new setting "Chat channels considered for activity" which allows customizing which channels incoming messages must match to "bump" the inactivity timer - Adds new per-tab setting "Unhide the chat window on activity" to configure whether it will be considered for "bumping" the inactivity timer when receiving messages that match the new channel filter. Note that the foreground tab is currently always considered. - Extends autohide code to apply to poped-out tabs as well. Each popout window has its own inactivity timer, but focusing the main window will restore all popped out windows. Co-authored-by: Auri <me@aurieh.me>
348 lines
13 KiB
C#
Executable File
348 lines
13 KiB
C#
Executable File
using System.Text;
|
|
using ChatTwo.Code;
|
|
using ChatTwo.Util;
|
|
using Dalamud.Game.Text.SeStringHandling;
|
|
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
|
using System.Text.RegularExpressions;
|
|
using Dalamud.Game.Text;
|
|
using FFXIVClientStructs.FFXIV.Client.Game;
|
|
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
|
using LiteDB;
|
|
using Lumina.Excel.GeneratedSheets;
|
|
|
|
namespace ChatTwo;
|
|
|
|
internal class SortCode
|
|
{
|
|
internal ChatType Type { get; }
|
|
internal ChatSource Source { get; }
|
|
|
|
[BsonCtor] // Used by LegacyMessageImporter
|
|
public SortCode(ChatType type, ChatSource source)
|
|
{
|
|
Type = type;
|
|
Source = source;
|
|
}
|
|
|
|
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)
|
|
{
|
|
return Type == other.Type && Source == other.Source;
|
|
}
|
|
|
|
public override bool Equals(object? obj)
|
|
{
|
|
if (ReferenceEquals(null, obj))
|
|
return false;
|
|
|
|
if (ReferenceEquals(this, obj))
|
|
return true;
|
|
|
|
return obj.GetType() == GetType() && Equals((SortCode) obj);
|
|
}
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
unchecked { return ((int) Type * 397) ^ (int) Source; }
|
|
}
|
|
}
|
|
|
|
internal partial class Message
|
|
{
|
|
internal Guid Id { get; } = Guid.NewGuid();
|
|
internal ulong Receiver { get; }
|
|
internal ulong ContentId { get; set; }
|
|
|
|
internal DateTimeOffset Date { get; }
|
|
internal ChatCode Code { get; }
|
|
internal List<Chunk> Sender { get; }
|
|
internal List<Chunk> Content { get; private set; }
|
|
|
|
internal SeString SenderSource { get; }
|
|
internal SeString ContentSource { get; }
|
|
|
|
internal SortCode SortCode { get; }
|
|
internal Guid ExtraChatChannel { get; }
|
|
|
|
// Not stored in the database:
|
|
internal int Hash { get; }
|
|
internal Dictionary<Guid, float?> Height { get; } = new();
|
|
internal Dictionary<Guid, bool> IsVisible { get; } = new();
|
|
|
|
internal Message(ulong receiver, ulong contentId, ChatCode code, List<Chunk> sender, List<Chunk> content, SeString senderSource, SeString contentSource)
|
|
{
|
|
var extraChatChannel = ExtractExtraChatChannel(contentSource);
|
|
Receiver = receiver;
|
|
ContentId = contentId;
|
|
Date = DateTimeOffset.UtcNow;
|
|
Code = code;
|
|
Sender = sender;
|
|
Content = CheckMessageContent(content, extraChatChannel);
|
|
SenderSource = senderSource;
|
|
ContentSource = contentSource;
|
|
SortCode = new SortCode(Code.Type, Code.Source);
|
|
ExtraChatChannel = extraChatChannel;
|
|
Hash = GenerateHash();
|
|
|
|
foreach (var chunk in sender.Concat(content))
|
|
chunk.Message = this;
|
|
}
|
|
|
|
internal Message(Guid id, ulong receiver, ulong contentId, DateTimeOffset date, ChatCode code, List<Chunk> sender, List<Chunk> content, SeString senderSource, SeString contentSource, SortCode sortCode, Guid extraChatChannel)
|
|
{
|
|
Id = id;
|
|
Receiver = receiver;
|
|
ContentId = contentId;
|
|
Date = date;
|
|
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 = content;
|
|
SenderSource = senderSource;
|
|
ContentSource = contentSource;
|
|
SortCode = sortCode;
|
|
ExtraChatChannel = extraChatChannel;
|
|
Hash = GenerateHash();
|
|
|
|
foreach (var chunk in sender.Concat(content))
|
|
chunk.Message = this;
|
|
}
|
|
|
|
internal static Message FakeMessage(List<Chunk> content, ChatCode code)
|
|
{
|
|
return new Message(0, 0, code, [], content, new SeString(), new SeString());
|
|
}
|
|
|
|
internal bool Matches(Dictionary<ChatType, ChatSource> channels, bool allExtraChatChannels, HashSet<Guid> extraChatChannels)
|
|
{
|
|
if (ExtraChatChannel != Guid.Empty)
|
|
return allExtraChatChannels || extraChatChannels.Contains(ExtraChatChannel);
|
|
|
|
return Code.Type.IsGm()
|
|
|| channels.TryGetValue(Code.Type, out var sources)
|
|
&& (Code.Source is 0 or (ChatSource) 1
|
|
|| sources.HasFlag(Code.Source));
|
|
}
|
|
|
|
private int GenerateHash()
|
|
{
|
|
return SortCode.GetHashCode()
|
|
^ ExtraChatChannel.GetHashCode()
|
|
^ string.Join("", Sender.Select(c => c.StringValue())).GetHashCode()
|
|
^ string.Join("", Content.Select(c => c.StringValue())).GetHashCode();
|
|
}
|
|
|
|
private static Guid ExtractExtraChatChannel(SeString contentSource)
|
|
{
|
|
if (contentSource.Payloads.Count > 0 && contentSource.Payloads[0] is RawPayload raw)
|
|
{
|
|
// this does an encode and clone every time it's accessed, so cache
|
|
var data = raw.Data;
|
|
if (data[1] == 0x27 && data[2] == 18 && data[3] == 0x20)
|
|
return new Guid(data[4..^1]);
|
|
}
|
|
|
|
return Guid.Empty;
|
|
}
|
|
|
|
private List<Chunk> CheckMessageContent(List<Chunk> oldChunks, Guid extraChatChannel)
|
|
{
|
|
var newChunks = new List<Chunk>();
|
|
void AddChunkWithMessage(TextChunk chunk)
|
|
{
|
|
if (string.IsNullOrEmpty(chunk.Content))
|
|
return;
|
|
|
|
chunk.Message = this;
|
|
newChunks.Add(chunk);
|
|
}
|
|
|
|
var nextIsAutoTranslate = false;
|
|
var checkForEmotes = (Code.IsPlayerMessage() || extraChatChannel != Guid.Empty) && Plugin.Config.ShowEmotes;
|
|
foreach (var chunk in oldChunks)
|
|
{
|
|
// Use as is if it's not a text chunk, it already has a payload, or is auto translate
|
|
if (chunk is not TextChunk text || chunk.Link != null || nextIsAutoTranslate)
|
|
{
|
|
nextIsAutoTranslate = chunk is IconChunk { Icon: BitmapFontIcon.AutoTranslateBegin };
|
|
|
|
// No need to call AddChunkWithMessage here since the chunk
|
|
// already has the Message field set.
|
|
newChunks.Add(chunk);
|
|
continue;
|
|
}
|
|
|
|
var wordBuilder = new StringBuilder();
|
|
var sentenceBuilder = new StringBuilder();
|
|
foreach (var token in Tokenizer.PrecedenceBasedRegexTokenizer.Tokenize(text.Content))
|
|
{
|
|
if (token.TokenType == Tokenizer.TokenType.StringValue)
|
|
{
|
|
wordBuilder.Append(token.Value);
|
|
continue;
|
|
}
|
|
|
|
var word = wordBuilder.ToString();
|
|
wordBuilder.Clear();
|
|
|
|
|
|
var wordUsed = false;
|
|
var tokenUsed = false;
|
|
|
|
if (checkForEmotes && EmoteCache.Exists(word) && !Plugin.Config.BlockedEmotes.Contains(word))
|
|
{
|
|
// Add the previous sentence before adding the emote
|
|
AddChunkWithMessage(text.NewWithStyle(chunk, sentenceBuilder.ToString()));
|
|
AddChunkWithMessage(new TextChunk(chunk.Source, EmotePayload.ResolveEmote(word), word) { FallbackColour = text.FallbackColour });
|
|
|
|
wordUsed = true;
|
|
sentenceBuilder.Clear();
|
|
}
|
|
|
|
if (token.TokenType == Tokenizer.TokenType.UrlString)
|
|
{
|
|
// Add the previous sentence before adding the url
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, sentenceBuilder.Append(!wordUsed ? word : "").ToString()));
|
|
try
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, UriPayload.ResolveURI(token.Value), token.Value));
|
|
}
|
|
catch (UriFormatException)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, token.Value));
|
|
Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{token.Value}'");
|
|
}
|
|
|
|
wordUsed = true;
|
|
tokenUsed = true;
|
|
sentenceBuilder.Clear();
|
|
}
|
|
|
|
// Append match if we haven't reached end of string yet
|
|
if (token.TokenType != Tokenizer.TokenType.SequenceTerminator)
|
|
{
|
|
sentenceBuilder.Append(!wordUsed ? word : "");
|
|
sentenceBuilder.Append(!tokenUsed ? token.Value : "");
|
|
continue;
|
|
}
|
|
|
|
// End of string reached, we add our leftover
|
|
AddChunkWithMessage(text.NewWithStyle(chunk, sentenceBuilder.Append(!wordUsed ? word : "").ToString()));
|
|
}
|
|
}
|
|
|
|
return newChunks;
|
|
}
|
|
|
|
public unsafe void DecodeTextParam()
|
|
{
|
|
var newChunks = new List<Chunk>();
|
|
void AddChunkWithMessage(TextChunk chunk)
|
|
{
|
|
if (string.IsNullOrEmpty(chunk.Content))
|
|
return;
|
|
|
|
chunk.Message = this;
|
|
newChunks.Add(chunk);
|
|
}
|
|
|
|
foreach (var chunk in Content)
|
|
{
|
|
// Use as is if it's not a text chunk or it already has a payload.
|
|
if (chunk is not TextChunk text || chunk.Link != null)
|
|
{
|
|
// No need to call AddChunkWithMessage here since the chunk
|
|
// already has the Message field set.
|
|
newChunks.Add(chunk);
|
|
continue;
|
|
}
|
|
|
|
if (!text.Content.Contains("<item>") && !text.Content.Contains("<flag>"))
|
|
{
|
|
newChunks.Add(chunk);
|
|
continue;
|
|
}
|
|
|
|
var nextIsMatch = false;
|
|
foreach (var split in TextParamRegex().Split(text.Content))
|
|
{
|
|
if (split == "" || !nextIsMatch)
|
|
{
|
|
nextIsMatch = true;
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
|
|
continue;
|
|
}
|
|
|
|
nextIsMatch = false;
|
|
try
|
|
{
|
|
if (split == "<item>")
|
|
{
|
|
var agentChat = AgentChatLog.Instance();
|
|
var item = agentChat->LinkedItem;
|
|
|
|
if (item.ItemId == 0)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
|
|
continue;
|
|
}
|
|
|
|
var kind = item.ItemId switch
|
|
{
|
|
< 500_000 => ItemPayload.ItemKind.Normal,
|
|
< 1_000_000 => ItemPayload.ItemKind.Collectible,
|
|
< 2_000_000 => ItemPayload.ItemKind.Hq,
|
|
_ => ItemPayload.ItemKind.EventItem
|
|
};
|
|
|
|
var name = kind != ItemPayload.ItemKind.EventItem
|
|
? Plugin.DataManager.GetExcelSheet<Item>()!.GetRow(item.ItemId)!.Name.ToString()
|
|
: Plugin.DataManager.GetExcelSheet<EventItem>()!.GetRow(item.ItemId)!.Name.ToString();
|
|
|
|
var link = new ItemPayload(item.ItemId, kind, $"{SeIconChar.LinkMarker.ToIconChar()}{name}");
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, link.DisplayName ?? "Unknown"));
|
|
}
|
|
else
|
|
{
|
|
var agentMap = AgentMap.Instance();
|
|
if (agentMap->IsFlagMarkerSet == 0)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
|
|
continue;
|
|
}
|
|
|
|
var mapCoords = agentMap->FlagMapMarker;
|
|
var rawX = (int)(MathF.Round(mapCoords.XFloat, 3, MidpointRounding.AwayFromZero) * 1000);
|
|
var rawY = (int)(MathF.Round(mapCoords.YFloat, 3, MidpointRounding.AwayFromZero) * 1000);
|
|
|
|
var link = new MapLinkPayload(mapCoords.TerritoryId, mapCoords.MapId, rawX, rawY);
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, $"{SeIconChar.LinkMarker.ToIconChar()}{link.PlaceName} {link.CoordinateString}"));
|
|
}
|
|
|
|
}
|
|
catch (Exception)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
|
|
Plugin.Log.Debug($"Failed to parse the text param: '{split}'");
|
|
}
|
|
}
|
|
}
|
|
|
|
Content = newChunks;
|
|
}
|
|
|
|
[GeneratedRegex("(<item>|<flag>)")]
|
|
private static partial Regex TextParamRegex();
|
|
}
|