4701bb3f6d
Adds a parsing step when constructing `Message` objects that scans the message content for anything that looks URL-like, and inserts new `TextChunk`s into the message content with a URIPayload set. Hovering over a URL shows an on-hover effect. Clicking a URL opens it in the default browser. Right clicking shows the hostname, with an option to open and an option to copy the URL to the clipboard.
229 lines
8.5 KiB
C#
Executable File
229 lines
8.5 KiB
C#
Executable File
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 SortCode(ChatType type, ChatSource source) {
|
|
this.Type = type;
|
|
this.Source = source;
|
|
}
|
|
|
|
public SortCode() {
|
|
}
|
|
|
|
private bool Equals(SortCode other) {
|
|
return this.Type == other.Type && this.Source == other.Source;
|
|
}
|
|
|
|
public override bool Equals(object? obj) {
|
|
if (ReferenceEquals(null, obj)) {
|
|
return false;
|
|
}
|
|
|
|
if (ReferenceEquals(this, obj)) {
|
|
return true;
|
|
}
|
|
|
|
return obj.GetType() == this.GetType() && this.Equals((SortCode) obj);
|
|
}
|
|
|
|
public override int GetHashCode() {
|
|
unchecked {
|
|
return ((int) this.Type * 397) ^ (int) this.Source;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal class Message {
|
|
// ReSharper disable once UnusedMember.Global
|
|
internal ObjectId Id { get; } = ObjectId.NewObjectId();
|
|
internal ulong Receiver { get; }
|
|
internal ulong ContentId { get; set; }
|
|
|
|
[BsonIgnore]
|
|
internal float? Height;
|
|
|
|
[BsonIgnore]
|
|
internal bool IsVisible;
|
|
|
|
internal DateTime Date { get; }
|
|
internal ChatCode Code { get; }
|
|
internal List<Chunk> Sender { get; }
|
|
internal List<Chunk> Content { get; }
|
|
|
|
internal SeString SenderSource { get; }
|
|
internal SeString ContentSource { get; }
|
|
|
|
internal SortCode SortCode { get; }
|
|
internal Guid ExtraChatChannel { get; }
|
|
|
|
internal int Hash { get; }
|
|
|
|
internal Message(ulong receiver, ChatCode code, List<Chunk> sender, List<Chunk> content, SeString senderSource, SeString contentSource) {
|
|
this.Receiver = receiver;
|
|
this.Date = DateTime.UtcNow;
|
|
this.Code = code;
|
|
this.Sender = sender;
|
|
this.Content = ReplaceContentURLs(content);
|
|
this.SenderSource = senderSource;
|
|
this.ContentSource = contentSource;
|
|
this.SortCode = new SortCode(this.Code.Type, this.Code.Source);
|
|
this.ExtraChatChannel = this.ExtractExtraChatChannel();
|
|
this.Hash = this.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) {
|
|
this.Id = id;
|
|
this.Receiver = receiver;
|
|
this.ContentId = contentId;
|
|
this.Date = date;
|
|
this.Code = BsonMapper.Global.ToObject<ChatCode>(code);
|
|
this.Sender = BsonMapper.Global.Deserialize<List<Chunk>>(sender);
|
|
// Don't call ReplaceContentURLs here since we're loading the message
|
|
// from the database and it should already have parsed URL data.
|
|
this.Content = BsonMapper.Global.Deserialize<List<Chunk>>(content);
|
|
this.SenderSource = BsonMapper.Global.Deserialize<SeString>(senderSource);
|
|
this.ContentSource = BsonMapper.Global.Deserialize<SeString>(contentSource);
|
|
this.SortCode = BsonMapper.Global.ToObject<SortCode>(sortCode);
|
|
this.ExtraChatChannel = this.ExtractExtraChatChannel();
|
|
this.Hash = this.GenerateHash();
|
|
|
|
foreach (var chunk in this.Sender.Concat(this.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) {
|
|
this.Id = id;
|
|
this.Receiver = receiver;
|
|
this.ContentId = contentId;
|
|
this.Date = date;
|
|
this.Code = BsonMapper.Global.ToObject<ChatCode>(code);
|
|
this.Sender = BsonMapper.Global.Deserialize<List<Chunk>>(sender);
|
|
// Don't call ReplaceContentURLs here since we're loading the message
|
|
// from the database and it should already have parsed URL data.
|
|
this.Content = BsonMapper.Global.Deserialize<List<Chunk>>(content);
|
|
this.SenderSource = BsonMapper.Global.Deserialize<SeString>(senderSource);
|
|
this.ContentSource = BsonMapper.Global.Deserialize<SeString>(contentSource);
|
|
this.SortCode = BsonMapper.Global.ToObject<SortCode>(sortCode);
|
|
this.ExtraChatChannel = BsonMapper.Global.Deserialize<Guid>(extraChatChannel);
|
|
this.Hash = this.GenerateHash();
|
|
|
|
foreach (var chunk in this.Sender.Concat(this.Content)) {
|
|
chunk.Message = this;
|
|
}
|
|
}
|
|
|
|
private int GenerateHash() {
|
|
return this.SortCode.GetHashCode()
|
|
^ this.ExtraChatChannel.GetHashCode()
|
|
^ string.Join("", this.Sender.Select(c => c.StringValue())).GetHashCode()
|
|
^ string.Join("", this.Content.Select(c => c.StringValue())).GetHashCode();
|
|
}
|
|
|
|
private Guid ExtractExtraChatChannel() {
|
|
if (this.ContentSource.Payloads.Count > 0 && this.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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// URLRegex returns a regex object that matches URLs like:
|
|
/// - https://example.com
|
|
/// - http://example.com
|
|
/// - www.example.com
|
|
/// - https://sub.example.com
|
|
/// - example.com
|
|
/// - sub.example.com
|
|
///
|
|
/// It matches URLs with www. or https:// prefix, and also matches URLs
|
|
/// without a prefix on specific TLDs.
|
|
/// </summary>
|
|
private static Regex URLRegex = new Regex(
|
|
@"((https?:\/\/|www\.)[a-z0-9-]+(\.[a-z0-9-]+)*|([a-z0-9-]+(\.[a-z0-9-]+)*\.(com|net|org|co|io|app)))(:[\d]{1,5})?(\/[^\s]+)?",
|
|
RegexOptions.Compiled | RegexOptions.IgnoreCase
|
|
);
|
|
|
|
/// <summary>
|
|
/// Finds all URL strings in all TextChunks, splits the parent TextChunk
|
|
/// apart and inserts a new TextChunk with a URIPayload.
|
|
/// </summary>
|
|
private List<Chunk> ReplaceContentURLs(List<Chunk> content)
|
|
{
|
|
var newChunks = new List<Chunk>();
|
|
void AddChunkWithMessage(Chunk chunk) {
|
|
chunk.Message = this;
|
|
newChunks.Add(chunk);
|
|
}
|
|
|
|
foreach (var chunk in content)
|
|
{
|
|
// Use as is if it's not a text chunk or it already has a payload.
|
|
if (chunk is not TextChunk text || chunk.Link != null)
|
|
{
|
|
// No need to call AddChunkWithMessage here since the chunk
|
|
// already has the Message field set.
|
|
newChunks.Add(chunk);
|
|
continue;
|
|
}
|
|
|
|
// Find all URLs with the regex and insert a new TextChunk with a
|
|
// URIPayload.
|
|
var matches = URLRegex.Matches(text.Content);
|
|
var remainderIndex = 0;
|
|
foreach (Match match in matches.Cast<Match>())
|
|
{
|
|
// Add the text before the URL.
|
|
if (match.Index > remainderIndex)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, text.Content[remainderIndex..match.Index]));
|
|
}
|
|
|
|
// Update the remainder index.
|
|
remainderIndex = match.Index + match.Length;
|
|
|
|
// Create a new TextChunk with a URIPayload for the URL text.
|
|
try
|
|
{
|
|
var link = URIPayload.ResolveURI(match.Value);
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, match.Value));
|
|
}
|
|
catch (UriFormatException)
|
|
{
|
|
Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{match.Value}'");
|
|
// If the URL is invalid, set the remainder index to the
|
|
// beginning of the match so it'll get included in the next
|
|
// regular text chunk.
|
|
remainderIndex = match.Index;
|
|
}
|
|
}
|
|
|
|
// Add the text after the last URL.
|
|
if (remainderIndex < text.Content.Length)
|
|
{
|
|
AddChunkWithMessage(text.NewWithStyle(chunk.Source, null, text.Content[remainderIndex..]));
|
|
}
|
|
}
|
|
|
|
return newChunks;
|
|
}
|
|
}
|