boom
This commit is contained in:
275
LightlessSync/Services/Chat/ChatEmoteService.cs
Normal file
275
LightlessSync/Services/Chat/ChatEmoteService.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using LightlessSync.UI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LightlessSync.Services.Chat;
|
||||
|
||||
public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
|
||||
|
||||
private readonly ILogger<ChatEmoteService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _downloadGate = new(3, 3);
|
||||
|
||||
private readonly object _loadLock = new();
|
||||
private Task? _loadTask;
|
||||
|
||||
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_uiSharedService = uiSharedService;
|
||||
}
|
||||
|
||||
public void EnsureGlobalEmotesLoaded()
|
||||
{
|
||||
lock (_loadLock)
|
||||
{
|
||||
if (_loadTask is not null && !_loadTask.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_emotes.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loadTask = Task.Run(LoadGlobalEmotesAsync);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetEmoteNames()
|
||||
{
|
||||
EnsureGlobalEmotesLoaded();
|
||||
var names = _emotes.Keys.ToArray();
|
||||
Array.Sort(names, StringComparer.OrdinalIgnoreCase);
|
||||
return names;
|
||||
}
|
||||
|
||||
public bool TryGetEmote(string code, out IDalamudTextureWrap? texture)
|
||||
{
|
||||
texture = null;
|
||||
EnsureGlobalEmotesLoaded();
|
||||
|
||||
if (!_emotes.TryGetValue(code, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.Texture is not null)
|
||||
{
|
||||
texture = entry.Texture;
|
||||
return true;
|
||||
}
|
||||
|
||||
entry.EnsureLoading(QueueEmoteDownload);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var entry in _emotes.Values)
|
||||
{
|
||||
entry.Texture?.Dispose();
|
||||
}
|
||||
|
||||
_downloadGate.Dispose();
|
||||
}
|
||||
|
||||
private async Task LoadGlobalEmotesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = await _httpClient.GetStreamAsync(GlobalEmoteSetUrl).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
|
||||
|
||||
if (!document.RootElement.TryGetProperty("emotes", out var emotes))
|
||||
{
|
||||
_logger.LogWarning("7TV emote set response missing emotes array");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var emoteElement in emotes.EnumerateArray())
|
||||
{
|
||||
if (!emoteElement.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = nameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var url = TryBuildEmoteUrl(emoteElement);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_emotes.TryAdd(name, new EmoteEntry(url));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load 7TV emote set");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryBuildEmoteUrl(JsonElement emoteElement)
|
||||
{
|
||||
if (!emoteElement.TryGetProperty("data", out var dataElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!dataElement.TryGetProperty("host", out var hostElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hostElement.TryGetProperty("url", out var urlElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseUrl = urlElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (baseUrl.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
baseUrl = "https:" + baseUrl;
|
||||
}
|
||||
|
||||
if (!hostElement.TryGetProperty("files", out var filesElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileName = PickBestStaticFile(filesElement);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return baseUrl.TrimEnd('/') + "/" + fileName;
|
||||
}
|
||||
|
||||
private static string? PickBestStaticFile(JsonElement filesElement)
|
||||
{
|
||||
string? png1x = null;
|
||||
string? webp1x = null;
|
||||
string? pngFallback = null;
|
||||
string? webpFallback = null;
|
||||
|
||||
foreach (var file in filesElement.EnumerateArray())
|
||||
{
|
||||
if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = nameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
png1x = name;
|
||||
}
|
||||
else if (name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
webp1x = name;
|
||||
}
|
||||
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
|
||||
{
|
||||
pngFallback = name;
|
||||
}
|
||||
else if (name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
|
||||
{
|
||||
webpFallback = name;
|
||||
}
|
||||
}
|
||||
|
||||
return png1x ?? webp1x ?? pngFallback ?? webpFallback;
|
||||
}
|
||||
|
||||
private void QueueEmoteDownload(EmoteEntry entry)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await _downloadGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false);
|
||||
var texture = _uiSharedService.LoadImage(data);
|
||||
entry.SetTexture(texture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url);
|
||||
entry.MarkFailed();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadGate.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class EmoteEntry
|
||||
{
|
||||
private int _loadingState;
|
||||
|
||||
public EmoteEntry(string url)
|
||||
{
|
||||
Url = url;
|
||||
}
|
||||
|
||||
public string Url { get; }
|
||||
public IDalamudTextureWrap? Texture { get; private set; }
|
||||
|
||||
public void EnsureLoading(Action<EmoteEntry> queueDownload)
|
||||
{
|
||||
if (Texture is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _loadingState, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
queueDownload(this);
|
||||
}
|
||||
|
||||
public void SetTexture(IDalamudTextureWrap texture)
|
||||
{
|
||||
Texture = texture;
|
||||
Interlocked.Exchange(ref _loadingState, 0);
|
||||
}
|
||||
|
||||
public void MarkFailed()
|
||||
{
|
||||
Interlocked.Exchange(ref _loadingState, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using LightlessSync.API.Dto.Chat;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -25,6 +26,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
private readonly ActorObjectService _actorObjectService;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly ChatConfigService _chatConfigService;
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
|
||||
@@ -37,6 +39,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
private readonly Dictionary<string, bool> _lastPresenceStates = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _selfTokens = new(StringComparer.Ordinal);
|
||||
private readonly List<PendingSelfMessage> _pendingSelfMessages = new();
|
||||
private readonly Dictionary<string, List<ChatMessageEntry>> _messageHistoryCache = new(StringComparer.Ordinal);
|
||||
private List<ChatChannelSnapshot>? _cachedChannelSnapshots;
|
||||
private bool _channelsSnapshotDirty = true;
|
||||
|
||||
@@ -54,7 +57,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
ApiController apiController,
|
||||
DalamudUtilService dalamudUtilService,
|
||||
ActorObjectService actorObjectService,
|
||||
PairUiService pairUiService)
|
||||
PairUiService pairUiService,
|
||||
ServerConfigurationManager serverConfigurationManager)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_apiController = apiController;
|
||||
@@ -62,6 +66,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
_actorObjectService = actorObjectService;
|
||||
_pairUiService = pairUiService;
|
||||
_chatConfigService = chatConfigService;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
|
||||
_isLoggedIn = _dalamudUtilService.IsLoggedIn;
|
||||
_isConnected = _apiController.IsConnected;
|
||||
@@ -776,6 +781,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
using (_sync.EnterScope())
|
||||
{
|
||||
var remainingGroups = new HashSet<string>(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase);
|
||||
var allowRemoval = _isConnected;
|
||||
|
||||
foreach (var info in infoList)
|
||||
{
|
||||
@@ -791,18 +797,19 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
var key = BuildChannelKey(descriptor);
|
||||
if (!_channels.TryGetValue(key, out var state))
|
||||
{
|
||||
state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor);
|
||||
state.IsConnected = _chatEnabled && _isConnected;
|
||||
state.IsAvailable = _chatEnabled && _isConnected;
|
||||
state.StatusText = !_chatEnabled
|
||||
? "Chat services disabled"
|
||||
: (_isConnected ? null : "Disconnected from chat server");
|
||||
_channels[key] = state;
|
||||
_lastReadCounts[key] = 0;
|
||||
if (_chatEnabled)
|
||||
{
|
||||
descriptorsToJoin.Add(descriptor);
|
||||
}
|
||||
state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor);
|
||||
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||
state.IsConnected = _chatEnabled && _isConnected;
|
||||
state.IsAvailable = _chatEnabled && _isConnected;
|
||||
state.StatusText = !_chatEnabled
|
||||
? "Chat services disabled"
|
||||
: (_isConnected ? null : "Disconnected from chat server");
|
||||
_channels[key] = state;
|
||||
_lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||
if (_chatEnabled)
|
||||
{
|
||||
descriptorsToJoin.Add(descriptor);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -816,26 +823,30 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var removedGroupId in remainingGroups)
|
||||
if (allowRemoval)
|
||||
{
|
||||
if (_groupDefinitions.TryGetValue(removedGroupId, out var definition))
|
||||
foreach (var removedGroupId in remainingGroups)
|
||||
{
|
||||
var key = BuildChannelKey(definition.Descriptor);
|
||||
if (_channels.TryGetValue(key, out var state))
|
||||
if (_groupDefinitions.TryGetValue(removedGroupId, out var definition))
|
||||
{
|
||||
descriptorsToLeave.Add(state.Descriptor);
|
||||
_channels.Remove(key);
|
||||
_lastReadCounts.Remove(key);
|
||||
_lastPresenceStates.Remove(BuildPresenceKey(state.Descriptor));
|
||||
_selfTokens.Remove(key);
|
||||
_pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, key, StringComparison.Ordinal));
|
||||
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
|
||||
var key = BuildChannelKey(definition.Descriptor);
|
||||
if (_channels.TryGetValue(key, out var state))
|
||||
{
|
||||
_activeChannelKey = null;
|
||||
CacheMessagesLocked(state);
|
||||
descriptorsToLeave.Add(state.Descriptor);
|
||||
_channels.Remove(key);
|
||||
_lastReadCounts.Remove(key);
|
||||
_lastPresenceStates.Remove(BuildPresenceKey(state.Descriptor));
|
||||
_selfTokens.Remove(key);
|
||||
_pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, key, StringComparison.Ordinal));
|
||||
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
|
||||
{
|
||||
_activeChannelKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_groupDefinitions.Remove(removedGroupId);
|
||||
_groupDefinitions.Remove(removedGroupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,13 +1024,14 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
descriptor.Type,
|
||||
displayName,
|
||||
descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor);
|
||||
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||
|
||||
state.IsConnected = _isConnected;
|
||||
state.IsAvailable = descriptor.Type == ChatChannelType.Group && _isConnected;
|
||||
state.StatusText = descriptor.Type == ChatChannelType.Zone ? ZoneUnavailableMessage : (_isConnected ? null : "Disconnected from chat server");
|
||||
|
||||
_channels[key] = state;
|
||||
_lastReadCounts[key] = 0;
|
||||
_lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||
publishChannelList = true;
|
||||
}
|
||||
|
||||
@@ -1159,6 +1171,15 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
|
||||
if (dto.Sender.Kind == ChatSenderKind.IdentifiedUser && dto.Sender.User is not null)
|
||||
{
|
||||
if (dto.Channel.Type != ChatChannelType.Group || _chatConfigService.Current.ShowNotesInSyncshellChat)
|
||||
{
|
||||
var note = _serverConfigurationManager.GetNoteForUid(dto.Sender.User.UID);
|
||||
if (!string.IsNullOrWhiteSpace(note))
|
||||
{
|
||||
return note;
|
||||
}
|
||||
}
|
||||
|
||||
return dto.Sender.User.AliasOrUID;
|
||||
}
|
||||
|
||||
@@ -1288,11 +1309,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
if (!_channels.TryGetValue(ZoneChannelKey, out var state))
|
||||
{
|
||||
state = new ChatChannelState(ZoneChannelKey, ChatChannelType.Zone, "Zone Chat", new ChatChannelDescriptor { Type = ChatChannelType.Zone });
|
||||
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||
state.IsConnected = _chatEnabled && _isConnected;
|
||||
state.IsAvailable = false;
|
||||
state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled";
|
||||
_channels[ZoneChannelKey] = state;
|
||||
_lastReadCounts[ZoneChannelKey] = 0;
|
||||
_lastReadCounts[ZoneChannelKey] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||
UpdateChannelOrderLocked();
|
||||
}
|
||||
|
||||
@@ -1301,6 +1323,11 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
|
||||
private void RemoveZoneStateLocked()
|
||||
{
|
||||
if (_channels.TryGetValue(ZoneChannelKey, out var existing))
|
||||
{
|
||||
CacheMessagesLocked(existing);
|
||||
}
|
||||
|
||||
if (_channels.Remove(ZoneChannelKey))
|
||||
{
|
||||
_lastReadCounts.Remove(ZoneChannelKey);
|
||||
@@ -1315,6 +1342,28 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheMessagesLocked(ChatChannelState state)
|
||||
{
|
||||
if (state.Messages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_messageHistoryCache[state.Key] = new List<ChatMessageEntry>(state.Messages);
|
||||
}
|
||||
|
||||
private int RestoreCachedMessagesLocked(ChatChannelState state)
|
||||
{
|
||||
if (_messageHistoryCache.TryGetValue(state.Key, out var cached) && cached.Count > 0)
|
||||
{
|
||||
state.Messages.AddRange(cached);
|
||||
_messageHistoryCache.Remove(state.Key);
|
||||
return cached.Count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private sealed class ChatChannelState
|
||||
{
|
||||
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
|
||||
|
||||
Reference in New Issue
Block a user