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; using LightlessSync.UI.Services; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using System.Text.Json; using System.Text.Json.Serialization; namespace LightlessSync.Services.Chat; public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService { private const int MaxMessageHistory = 200; internal const int MaxOutgoingLength = 200; private const int MaxUnreadCount = 999; private const string ZoneUnavailableMessage = "Zone chat is only available in major cities."; private const string ZoneChannelKey = "zone"; private const int MaxReportReasonLength = 100; private const int MaxReportContextLength = 1000; private static readonly JsonSerializerOptions PersistedHistorySerializerOptions = new() { PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly ApiController _apiController; private readonly DalamudUtilService _dalamudUtilService; private readonly ActorObjectService _actorObjectService; private readonly PairUiService _pairUiService; private readonly ChatConfigService _chatConfigService; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly Lock _sync = new(); private readonly Dictionary _channels = new(StringComparer.Ordinal); private readonly List _channelOrder = new(); private readonly Dictionary _territoryToZoneKey = new(); private readonly Dictionary _zoneDefinitions = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _groupDefinitions = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _lastReadCounts = new(StringComparer.Ordinal); private readonly Dictionary _lastPresenceStates = new(StringComparer.Ordinal); private readonly Dictionary _selfTokens = new(StringComparer.Ordinal); private readonly List _pendingSelfMessages = new(); private readonly Dictionary> _messageHistoryCache = new(StringComparer.Ordinal); private List? _cachedChannelSnapshots; private bool _channelsSnapshotDirty = true; private bool _isLoggedIn; private bool _isConnected; private ChatChannelDescriptor? _lastZoneDescriptor; private string? _activeChannelKey; private bool _chatEnabled = false; private bool _chatHandlerRegistered; public ZoneChatService( ILogger logger, LightlessMediator mediator, ChatConfigService chatConfigService, ApiController apiController, DalamudUtilService dalamudUtilService, ActorObjectService actorObjectService, PairUiService pairUiService, ServerConfigurationManager serverConfigurationManager) : base(logger, mediator) { _apiController = apiController; _dalamudUtilService = dalamudUtilService; _actorObjectService = actorObjectService; _pairUiService = pairUiService; _chatConfigService = chatConfigService; _serverConfigurationManager = serverConfigurationManager; _isLoggedIn = _dalamudUtilService.IsLoggedIn; _isConnected = _apiController.IsConnected; _chatEnabled = chatConfigService.Current.AutoEnableChatOnLogin; } public IReadOnlyList GetChannelsSnapshot() { using (_sync.EnterScope()) { if (!_channelsSnapshotDirty && _cachedChannelSnapshots is not null) { return _cachedChannelSnapshots; } var snapshots = new List(_channelOrder.Count); foreach (var key in _channelOrder) { if (!_channels.TryGetValue(key, out var state)) continue; var statusText = state.StatusText; if (!_chatEnabled) { statusText = "Chat services disabled"; } else if (!_isConnected) { statusText = "Disconnected from chat server"; } snapshots.Add(new ChatChannelSnapshot( state.Key, state.Descriptor, state.DisplayName, state.Type, state.IsConnected, state.IsConnected && state.IsAvailable, statusText, state.HasUnread, state.UnreadCount, state.Messages.ToList())); } _cachedChannelSnapshots = snapshots; _channelsSnapshotDirty = false; return snapshots; } } public bool IsChatEnabled { get { using (_sync.EnterScope()) { return _chatEnabled; } } } public bool IsChatConnected { get { using (_sync.EnterScope()) { return _chatEnabled && _isConnected; } } } public void SetActiveChannel(string? key) { using (_sync.EnterScope()) { _activeChannelKey = key; if (key is not null && _channels.TryGetValue(key, out var state)) { state.HasUnread = false; state.UnreadCount = 0; _lastReadCounts[key] = state.Messages.Count; } MarkChannelsSnapshotDirtyLocked(); } } public void MoveChannel(string draggedKey, string targetKey) { if (string.IsNullOrWhiteSpace(draggedKey) || string.IsNullOrWhiteSpace(targetKey)) { return; } bool updated = false; using (_sync.EnterScope()) { if (!_channels.ContainsKey(draggedKey) || !_channels.ContainsKey(targetKey)) { return; } var fromIndex = _channelOrder.IndexOf(draggedKey); var toIndex = _channelOrder.IndexOf(targetKey); if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) { return; } _channelOrder.RemoveAt(fromIndex); var insertIndex = Math.Clamp(toIndex, 0, _channelOrder.Count); _channelOrder.Insert(insertIndex, draggedKey); _chatConfigService.Current.ChannelOrder = new List(_channelOrder); _chatConfigService.Save(); updated = true; } if (updated) { PublishChannelListChanged(); } } public Task SetChatEnabledAsync(bool enabled) => enabled ? EnableChatAsync() : DisableChatAsync(); private async Task EnableChatAsync() { bool wasEnabled; using (_sync.EnterScope()) { wasEnabled = _chatEnabled; if (!wasEnabled) { _chatEnabled = true; MarkChannelsSnapshotDirtyLocked(); } } if (wasEnabled) return; RegisterChatHandler(); await RefreshChatChannelDefinitionsAsync().ConfigureAwait(false); ScheduleZonePresenceUpdate(force: true); await EnsureGroupPresenceAsync(force: true).ConfigureAwait(false); } private async Task DisableChatAsync() { bool wasEnabled; List groupDescriptors; ChatChannelDescriptor? zoneDescriptor; using (_sync.EnterScope()) { wasEnabled = _chatEnabled; if (!wasEnabled) { return; } _chatEnabled = false; zoneDescriptor = _lastZoneDescriptor; _lastZoneDescriptor = null; groupDescriptors = _channels.Values .Where(state => state.Type == ChatChannelType.Group) .Select(state => state.Descriptor) .ToList(); _selfTokens.Clear(); _pendingSelfMessages.Clear(); foreach (var state in _channels.Values) { state.IsConnected = false; state.IsAvailable = false; state.StatusText = "Chat services disabled"; } MarkChannelsSnapshotDirtyLocked(); } UnregisterChatHandler(); if (zoneDescriptor.HasValue) { await SendPresenceAsync(zoneDescriptor.Value, 0, isActive: false, force: true).ConfigureAwait(false); } foreach (var descriptor in groupDescriptors) { await SendPresenceAsync(descriptor, 0, isActive: false, force: true).ConfigureAwait(false); } PublishChannelListChanged(); } public async Task SendMessageAsync(ChatChannelDescriptor descriptor, string message) { if (!_chatEnabled) return false; if (string.IsNullOrWhiteSpace(message)) return false; var sanitized = message.Trim().ReplaceLineEndings(" "); if (sanitized.Length == 0) return false; if (sanitized.Length > MaxOutgoingLength) sanitized = sanitized[..MaxOutgoingLength]; var pendingMessage = EnqueuePendingSelfMessage(descriptor, sanitized); try { await _apiController.SendChatMessage(new ChatSendRequestDto(descriptor, sanitized)).ConfigureAwait(false); return true; } catch (Exception ex) { RemovePendingSelfMessage(pendingMessage); Logger.LogWarning(ex, "Failed to send chat message"); return false; } } public async Task SetParticipantMuteAsync(ChatChannelDescriptor descriptor, string token, bool mute) { if (string.IsNullOrWhiteSpace(token)) return false; try { await _apiController.SetChatParticipantMute(new ChatParticipantMuteRequestDto(descriptor, token, mute)).ConfigureAwait(false); return true; } catch (Exception ex) { Logger.LogWarning(ex, "Failed to update chat participant mute state"); return false; } } public Task ReportMessageAsync(ChatChannelDescriptor descriptor, string messageId, string reason, string? additionalContext) { if (string.IsNullOrWhiteSpace(messageId)) { return Task.FromResult(new ChatReportResult(false, "Unable to locate the selected message.")); } var trimmedReason = reason?.Trim() ?? string.Empty; if (trimmedReason.Length == 0) { return Task.FromResult(new ChatReportResult(false, "Please describe why you are reporting this message.")); } using (_sync.EnterScope()) { if (!_chatEnabled) { return Task.FromResult(new ChatReportResult(false, "Enable chat before reporting messages.")); } if (!_isConnected) { return Task.FromResult(new ChatReportResult(false, "Connect to the chat server before reporting messages.")); } } if (trimmedReason.Length > MaxReportReasonLength) { trimmedReason = trimmedReason[..MaxReportReasonLength]; } string? context = null; if (!string.IsNullOrWhiteSpace(additionalContext)) { context = additionalContext.Trim(); if (context.Length > MaxReportContextLength) { context = context[..MaxReportContextLength]; } } var normalizedDescriptor = descriptor.WithNormalizedCustomKey(); return ReportMessageInternalAsync(normalizedDescriptor, messageId.Trim(), trimmedReason, context); } private async Task ReportMessageInternalAsync(ChatChannelDescriptor descriptor, string messageId, string reason, string? additionalContext) { try { await _apiController.ReportChatMessage(new ChatReportSubmitDto(descriptor, messageId, reason, additionalContext)).ConfigureAwait(false); return new ChatReportResult(true, null); } catch (Exception ex) { Logger.LogWarning(ex, "Failed to submit chat report"); return new ChatReportResult(false, "Failed to submit report. Please try again."); } } public Task StartAsync(CancellationToken cancellationToken) { LoadPersistedSyncshellHistory(); Mediator.Subscribe(this, _ => HandleLogin()); Mediator.Subscribe(this, _ => HandleLogout()); Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate()); Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate(force: true)); Mediator.Subscribe(this, _ => HandleConnected()); Mediator.Subscribe(this, _ => HandleConnected()); Mediator.Subscribe(this, _ => HandleReconnecting()); Mediator.Subscribe(this, _ => HandleReconnecting()); Mediator.Subscribe(this, _ => RefreshGroupsFromPairManager()); Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate(force: true)); if (_chatEnabled) { RegisterChatHandler(); _ = RefreshChatChannelDefinitionsAsync(); ScheduleZonePresenceUpdate(force: true); _ = EnsureGroupPresenceAsync(force: true); } else { UpdateChannelsForDisabledState(); PublishChannelListChanged(); } return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { UnregisterChatHandler(); UnsubscribeAll(); return Task.CompletedTask; } protected override void Dispose(bool disposing) { if (disposing) { UnregisterChatHandler(); UnsubscribeAll(); } base.Dispose(disposing); } private void HandleLogin() { _isLoggedIn = true; if (_chatEnabled) { ScheduleZonePresenceUpdate(force: true); _ = EnsureGroupPresenceAsync(force: true); } } private void HandleLogout() { _isLoggedIn = false; if (_chatEnabled) { ScheduleZonePresenceUpdate(force: true); } } private void HandleConnected() { _isConnected = true; using (_sync.EnterScope()) { _selfTokens.Clear(); _pendingSelfMessages.Clear(); foreach (var state in _channels.Values) { state.IsConnected = _chatEnabled; if (_chatEnabled && state.Type == ChatChannelType.Group) { state.IsAvailable = true; state.StatusText = null; } else if (!_chatEnabled) { state.IsAvailable = false; state.StatusText = "Chat services disabled"; } } } PublishChannelListChanged(); if (_chatEnabled) { _ = RefreshChatChannelDefinitionsAsync(); ScheduleZonePresenceUpdate(force: true); _ = EnsureGroupPresenceAsync(force: true); } } private void HandleReconnecting() { _isConnected = false; using (_sync.EnterScope()) { _selfTokens.Clear(); _pendingSelfMessages.Clear(); foreach (var state in _channels.Values) { state.IsConnected = false; if (_chatEnabled) { state.StatusText = "Disconnected from chat server"; if (state.Type == ChatChannelType.Group) { state.IsAvailable = false; } } else { state.StatusText = "Chat services disabled"; state.IsAvailable = false; } } } PublishChannelListChanged(); } private async Task RefreshChatChannelDefinitionsAsync() { if (!_chatEnabled) return; try { var zones = await _apiController.GetZoneChatChannelsAsync().ConfigureAwait(false); var groups = await _apiController.GetGroupChatChannelsAsync().ConfigureAwait(false); ApplyZoneDefinitions(zones); ApplyGroupDefinitions(groups); } catch (Exception ex) { Logger.LogDebug(ex, "Failed to refresh chat channel definitions"); } } private void RegisterChatHandler() { if (_chatHandlerRegistered) return; _apiController.RegisterChatMessageHandler(OnChatMessageReceived); _chatHandlerRegistered = true; } private void UnregisterChatHandler() { if (!_chatHandlerRegistered) return; _apiController.UnregisterChatMessageHandler(OnChatMessageReceived); _chatHandlerRegistered = false; } private void UpdateChannelsForDisabledState() { using (_sync.EnterScope()) { foreach (var state in _channels.Values) { state.IsConnected = false; state.IsAvailable = false; state.StatusText = "Chat services disabled"; } } } private void ScheduleZonePresenceUpdate(bool force = false) { if (!_chatEnabled) return; _ = UpdateZonePresenceAsync(force); } private async Task UpdateZonePresenceAsync(bool force = false) { if (!_chatEnabled) return; if (!_isLoggedIn || !_apiController.IsConnected) { await LeaveCurrentZoneAsync(force, 0, 0).ConfigureAwait(false); return; } try { var location = _dalamudUtilService.GetMapData(); var territoryId = (ushort)location.TerritoryId; var worldId = (ushort)location.ServerId; string? zoneKey; ZoneChannelDefinition? definition = null; using (_sync.EnterScope()) { _territoryToZoneKey.TryGetValue(territoryId, out zoneKey); if (zoneKey is not null) { _zoneDefinitions.TryGetValue(zoneKey, out var def); definition = def; } } if (definition is null) { await LeaveCurrentZoneAsync(force, territoryId, worldId).ConfigureAwait(false); return; } var descriptor = await BuildZoneDescriptorAsync(definition.Value).ConfigureAwait(false); if (descriptor is null) { await LeaveCurrentZoneAsync(force, territoryId, worldId).ConfigureAwait(false); return; } bool shouldForceSend; ChatMessageEntry? zoneSeparatorEntry = null; using (_sync.EnterScope()) { var state = EnsureZoneStateLocked(); state.DisplayName = definition.Value.DisplayName; state.Descriptor = descriptor.Value; state.IsConnected = _chatEnabled && _isConnected; state.IsAvailable = _chatEnabled; state.StatusText = _chatEnabled ? null : "Chat services disabled"; var previousDescriptor = _lastZoneDescriptor; var zoneChanged = previousDescriptor.HasValue && !ChannelDescriptorsMatch(previousDescriptor.Value, descriptor.Value); _activeChannelKey = ZoneChannelKey; shouldForceSend = force || !previousDescriptor.HasValue || zoneChanged; if (zoneChanged && state.Messages.Any(m => !m.IsSystem)) { zoneSeparatorEntry = AddZoneSeparatorLocked(state, definition.Value.DisplayName); } _lastZoneDescriptor = descriptor; } if (zoneSeparatorEntry is not null) { Mediator.Publish(new ChatChannelMessageAdded(ZoneChannelKey, zoneSeparatorEntry)); } PublishChannelListChanged(); await SendPresenceAsync(descriptor.Value, territoryId, isActive: true, force: shouldForceSend).ConfigureAwait(false); } catch (Exception ex) { Logger.LogDebug(ex, "Failed to update zone chat presence"); } } private async Task LeaveCurrentZoneAsync(bool force, ushort territoryId, ushort worldId) { ChatChannelDescriptor? descriptor = null; using (_sync.EnterScope()) { descriptor = _lastZoneDescriptor; _lastZoneDescriptor = null; if (_channels.TryGetValue(ZoneChannelKey, out var state)) { state.IsConnected = _isConnected; state.IsAvailable = false; state.StatusText = !_chatEnabled ? "Chat services disabled" : (_isConnected ? ZoneUnavailableMessage : "Disconnected from chat server"); if (territoryId != 0 && _dalamudUtilService.TerritoryData.Value.TryGetValue(territoryId, out var territoryName) && !string.IsNullOrWhiteSpace(territoryName)) { state.DisplayName = territoryName; } else { state.DisplayName = "Zone Chat"; } if (worldId != 0) { state.Descriptor = new ChatChannelDescriptor { Type = ChatChannelType.Zone, WorldId = worldId, ZoneId = territoryId, CustomKey = string.Empty }; } } if (string.Equals(_activeChannelKey, ZoneChannelKey, StringComparison.Ordinal)) { _activeChannelKey = _channelOrder.FirstOrDefault(key => !string.Equals(key, ZoneChannelKey, StringComparison.Ordinal)); } } PublishChannelListChanged(); if (descriptor.HasValue) { await SendPresenceAsync(descriptor.Value, territoryId, isActive: false, force: force).ConfigureAwait(false); } } private async Task BuildZoneDescriptorAsync(ZoneChannelDefinition definition) { try { var worldId = (ushort)_dalamudUtilService.GetWorldId(); return definition.Descriptor with { WorldId = worldId }; } catch (Exception ex) { Logger.LogDebug(ex, "Failed to obtain world id for zone chat"); return null; } } private void ApplyZoneDefinitions(IReadOnlyList? infos) { var infoList = infos ?? Array.Empty(); using (_sync.EnterScope()) { _zoneDefinitions.Clear(); _territoryToZoneKey.Clear(); foreach (var info in infoList) { var descriptor = info.Channel.WithNormalizedCustomKey(); var key = descriptor.CustomKey ?? string.Empty; if (string.IsNullOrWhiteSpace(key)) continue; var territories = info.Territories? .SelectMany(EnumerateTerritoryKeys) .Where(n => n.Length > 0) .ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet(StringComparer.OrdinalIgnoreCase); _zoneDefinitions[key] = new ZoneChannelDefinition(key, info.DisplayName ?? key, descriptor, territories); } var territoryData = _dalamudUtilService.TerritoryDataEnglish.Value; foreach (var kvp in territoryData) { foreach (var variant in EnumerateTerritoryKeys(kvp.Value)) { foreach (var def in _zoneDefinitions.Values) { if (def.TerritoryNames.Contains(variant)) { _territoryToZoneKey[kvp.Key] = def.Key; break; } } } } if (_zoneDefinitions.Count == 0) { RemoveZoneStateLocked(); } else { var state = EnsureZoneStateLocked(); state.DisplayName = "Zone Chat"; state.IsConnected = _chatEnabled && _isConnected; state.IsAvailable = false; state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled"; } UpdateChannelOrderLocked(); } PublishChannelListChanged(); } private void ApplyGroupDefinitions(IReadOnlyList? infos) { var infoList = infos ?? Array.Empty(); var descriptorsToJoin = new List(); var descriptorsToLeave = new List(); using (_sync.EnterScope()) { var remainingGroups = new HashSet(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase); var allowRemoval = _isConnected; foreach (var info in infoList) { var descriptor = info.Channel.WithNormalizedCustomKey(); var groupId = info.GroupId; if (string.IsNullOrWhiteSpace(groupId)) continue; remainingGroups.Remove(groupId); _groupDefinitions[groupId] = new GroupChannelDefinition(groupId, info.DisplayName ?? groupId, descriptor, info.IsOwner); var key = BuildChannelKey(descriptor); if (!_channels.TryGetValue(key, out var state)) { 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 { state.DisplayName = info.DisplayName ?? groupId; state.Descriptor = descriptor; state.IsConnected = _chatEnabled && _isConnected; state.IsAvailable = _chatEnabled && _isConnected; state.StatusText = !_chatEnabled ? "Chat services disabled" : (_isConnected ? null : "Disconnected from chat server"); } } if (allowRemoval) { foreach (var removedGroupId in remainingGroups) { if (_groupDefinitions.TryGetValue(removedGroupId, out var definition)) { var key = BuildChannelKey(definition.Descriptor); if (_channels.TryGetValue(key, out var state)) { 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); } } } UpdateChannelOrderLocked(); } foreach (var descriptor in descriptorsToLeave) { _ = SendPresenceAsync(descriptor, 0, isActive: false, force: true); } foreach (var descriptor in descriptorsToJoin) { _ = SendPresenceAsync(descriptor, 0, isActive: true, force: true); } PublishChannelListChanged(); } private void RefreshGroupsFromPairManager() { var snapshot = _pairUiService.GetSnapshot(); var groups = snapshot.Groups.ToList(); if (groups.Count == 0) { ApplyGroupDefinitions(Array.Empty()); return; } var infos = new List(groups.Count); foreach (var group in groups) { // basically prune the channel if it's disabled if (group.GroupPermissions.IsDisableChat()) { continue; } var descriptor = new ChatChannelDescriptor { Type = ChatChannelType.Group, WorldId = 0, ZoneId = 0, CustomKey = group.Group.GID }; var displayName = string.IsNullOrWhiteSpace(group.Group.Alias) ? group.Group.GID : group.Group.Alias; var isOwner = string.Equals(group.Owner.UID, _apiController.UID, StringComparison.Ordinal); infos.Add(new GroupChatChannelInfoDto(descriptor, displayName, group.Group.GID, isOwner)); } ApplyGroupDefinitions(infos); } private async Task EnsureGroupPresenceAsync(bool force = false) { if (!_chatEnabled) return; List descriptors; using (_sync.EnterScope()) { descriptors = _channels.Values .Where(state => state.Type == ChatChannelType.Group) .Select(state => state.Descriptor) .ToList(); } foreach (var descriptor in descriptors) { await SendPresenceAsync(descriptor, 0, isActive: true, force: force).ConfigureAwait(false); } } private async Task SendPresenceAsync(ChatChannelDescriptor descriptor, ushort territoryId, bool isActive, bool force) { if (!_apiController.IsConnected) return; if (!_chatEnabled && isActive) return; var presenceKey = BuildPresenceKey(descriptor); bool stateMatches; using (_sync.EnterScope()) { stateMatches = !force && _lastPresenceStates.TryGetValue(presenceKey, out var lastState) && lastState == isActive; } if (stateMatches) return; try { await _apiController.UpdateChatPresence(new ChatPresenceUpdateDto(descriptor, territoryId, isActive)).ConfigureAwait(false); using (_sync.EnterScope()) { if (isActive) { _lastPresenceStates[presenceKey] = true; } else { _lastPresenceStates.Remove(presenceKey); } } } catch (Exception ex) { Logger.LogDebug(ex, "Failed to update chat presence"); } } private PendingSelfMessage EnqueuePendingSelfMessage(ChatChannelDescriptor descriptor, string message) { var normalized = descriptor.WithNormalizedCustomKey(); var key = normalized.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(normalized); var pending = new PendingSelfMessage(key, message); using (_sync.EnterScope()) { _pendingSelfMessages.Add(pending); while (_pendingSelfMessages.Count > 20) { _pendingSelfMessages.RemoveAt(0); } } return pending; } private void RemovePendingSelfMessage(PendingSelfMessage pending) { using (_sync.EnterScope()) { var index = _pendingSelfMessages.FindIndex(p => string.Equals(p.ChannelKey, pending.ChannelKey, StringComparison.Ordinal) && string.Equals(p.Message, pending.Message, StringComparison.Ordinal)); if (index >= 0) { _pendingSelfMessages.RemoveAt(index); } } } private void OnChatMessageReceived(ChatMessageDto dto) { ChatChannelDescriptor descriptor = dto.Channel.WithNormalizedCustomKey(); string key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor); bool fromSelf = IsMessageFromSelf(dto, key); ChatMessageEntry message = BuildMessage(dto, fromSelf); bool mentionNotificationsEnabled = _chatConfigService.Current.EnableMentionNotifications; bool notifyMention = mentionNotificationsEnabled && !fromSelf && descriptor.Type == ChatChannelType.Group && TryGetSelfMentionToken(dto.Message, out _); string? mentionChannelName = null; string? mentionSenderName = null; bool publishChannelList = false; bool shouldPersistHistory = _chatConfigService.Current.PersistSyncshellHistory; List? persistedMessages = null; string? persistedChannelKey = null; using (_sync.EnterScope()) { if (!_channels.TryGetValue(key, out var state)) { var displayName = descriptor.Type switch { ChatChannelType.Zone => _zoneDefinitions.TryGetValue(descriptor.CustomKey ?? string.Empty, out var def) ? def.DisplayName : "Zone Chat", ChatChannelType.Group => descriptor.CustomKey ?? "Syncshell", _ => descriptor.CustomKey ?? "Chat" }; state = new ChatChannelState( key, 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] = restoredCount > 0 ? state.Messages.Count : 0; publishChannelList = true; } state.Descriptor = descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor; state.Messages.Add(message); if (state.Messages.Count > MaxMessageHistory) { state.Messages.RemoveAt(0); } if (notifyMention) { mentionChannelName = state.DisplayName; mentionSenderName = message.DisplayName; } if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal)) { state.HasUnread = false; state.UnreadCount = 0; _lastReadCounts[key] = state.Messages.Count; } else { var lastRead = _lastReadCounts.TryGetValue(key, out var readCount) ? readCount : 0; var unreadFromHistory = Math.Max(0, state.Messages.Count - lastRead); var incrementalUnread = Math.Min(state.UnreadCount + 1, MaxUnreadCount); state.UnreadCount = Math.Min(Math.Max(unreadFromHistory, incrementalUnread), MaxUnreadCount); state.HasUnread = state.UnreadCount > 0; } MarkChannelsSnapshotDirtyLocked(); if (shouldPersistHistory && state.Type == ChatChannelType.Group) { persistedChannelKey = state.Key; persistedMessages = BuildPersistedHistoryLocked(state); } } Mediator.Publish(new ChatChannelMessageAdded(key, message)); if (persistedMessages is not null && persistedChannelKey is not null) { PersistSyncshellHistory(persistedChannelKey, persistedMessages); } if (notifyMention) { string channelName = mentionChannelName ?? "Syncshell"; string senderName = mentionSenderName ?? "Someone"; string notificationText = $"You were mentioned by {senderName} in {channelName}."; Mediator.Publish(new NotificationMessage("Syncshell mention", notificationText, NotificationType.Info)); } if (publishChannelList) { using (_sync.EnterScope()) { UpdateChannelOrderLocked(); } PublishChannelListChanged(); } } private bool IsMessageFromSelf(ChatMessageDto dto, string channelKey) { if (dto.Sender.User?.UID is { } uid && string.Equals(uid, _apiController.UID, StringComparison.Ordinal)) { using (_sync.EnterScope()) { _selfTokens[channelKey] = dto.Sender.Token; } return true; } using (_sync.EnterScope()) { if (_selfTokens.TryGetValue(channelKey, out var token) && string.Equals(token, dto.Sender.Token, StringComparison.Ordinal)) { return true; } var index = _pendingSelfMessages.FindIndex(p => string.Equals(p.ChannelKey, channelKey, StringComparison.Ordinal) && string.Equals(p.Message, dto.Message, StringComparison.Ordinal)); if (index >= 0) { _pendingSelfMessages.RemoveAt(index); _selfTokens[channelKey] = dto.Sender.Token; return true; } } return false; } private bool TryGetSelfMentionToken(string message, out string matchedToken) { matchedToken = string.Empty; if (string.IsNullOrWhiteSpace(message)) { return false; } HashSet tokens = BuildSelfMentionTokens(); if (tokens.Count == 0) { return false; } return TryFindMentionToken(message, tokens, out matchedToken); } private HashSet BuildSelfMentionTokens() { HashSet tokens = new(StringComparer.OrdinalIgnoreCase); string uid = _apiController.UID; if (IsValidMentionToken(uid)) { tokens.Add(uid); } string displayName = _apiController.DisplayName; if (IsValidMentionToken(displayName)) { tokens.Add(displayName); } return tokens; } private static bool IsValidMentionToken(string value) { if (string.IsNullOrWhiteSpace(value)) { return false; } for (int i = 0; i < value.Length; i++) { if (!IsMentionChar(value[i])) { return false; } } return true; } private static bool TryFindMentionToken(string message, IReadOnlyCollection tokens, out string matchedToken) { matchedToken = string.Empty; if (tokens.Count == 0 || string.IsNullOrEmpty(message)) { return false; } int index = 0; while (index < message.Length) { if (message[index] != '@') { index++; continue; } if (index > 0 && IsMentionChar(message[index - 1])) { index++; continue; } int start = index + 1; int end = start; while (end < message.Length && IsMentionChar(message[end])) { end++; } if (end == start) { index++; continue; } string token = message.Substring(start, end - start); if (tokens.Contains(token)) { matchedToken = token; return true; } index = end; } return false; } private static bool IsMentionChar(char value) { return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\''; } private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf) { var displayName = ResolveDisplayName(dto, fromSelf); return new ChatMessageEntry(dto, displayName, fromSelf, DateTime.UtcNow); } private ChatMessageEntry AddZoneSeparatorLocked(ChatChannelState state, string zoneDisplayName) { var separator = new ChatMessageEntry( null, string.Empty, false, DateTime.UtcNow, new ChatSystemEntry(ChatSystemEntryType.ZoneSeparator, zoneDisplayName)); state.Messages.Add(separator); if (state.Messages.Count > MaxMessageHistory) { state.Messages.RemoveAt(0); } if (string.Equals(_activeChannelKey, ZoneChannelKey, StringComparison.Ordinal)) { state.HasUnread = false; state.UnreadCount = 0; _lastReadCounts[ZoneChannelKey] = state.Messages.Count; } else if (_lastReadCounts.TryGetValue(ZoneChannelKey, out var readCount)) { _lastReadCounts[ZoneChannelKey] = readCount + 1; } else { _lastReadCounts[ZoneChannelKey] = state.Messages.Count; } return separator; } private string ResolveDisplayName(ChatMessageDto dto, bool fromSelf) { var isZone = dto.Channel.Type == ChatChannelType.Zone; if (!string.IsNullOrEmpty(dto.Sender.HashedCid) && _actorObjectService.TryGetValidatedActorByHash(dto.Sender.HashedCid, out var descriptor) && !string.IsNullOrWhiteSpace(descriptor.Name)) { return descriptor.Name; } if (fromSelf && isZone && dto.Sender.CanResolveProfile) { try { return _dalamudUtilService.GetPlayerName(); } catch (Exception ex) { Logger.LogDebug(ex, "Failed to resolve self name for chat message"); } } 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; } if (!string.IsNullOrWhiteSpace(dto.Sender.DisplayName)) { return dto.Sender.DisplayName!; } return dto.Sender.Token; } private void UpdateChannelOrderLocked() { _channelOrder.Clear(); var configuredOrder = _chatConfigService.Current.ChannelOrder; if (configuredOrder.Count > 0) { var seen = new HashSet(StringComparer.Ordinal); foreach (var key in configuredOrder) { if (_channels.ContainsKey(key) && seen.Add(key)) { _channelOrder.Add(key); } } var remaining = _channels.Values .Where(state => !seen.Contains(state.Key)) .ToList(); if (remaining.Count > 0) { var zoneKeys = remaining .Where(state => state.Type == ChatChannelType.Zone) .Select(state => state.Key); var groupKeys = remaining .Where(state => state.Type == ChatChannelType.Group) .OrderBy(state => state.DisplayName, StringComparer.OrdinalIgnoreCase) .Select(state => state.Key); _channelOrder.AddRange(zoneKeys); _channelOrder.AddRange(groupKeys); } } else { if (_channels.ContainsKey(ZoneChannelKey)) { _channelOrder.Add(ZoneChannelKey); } var groups = _channels.Values .Where(state => state.Type == ChatChannelType.Group) .OrderBy(state => state.DisplayName, StringComparer.OrdinalIgnoreCase) .Select(state => state.Key); _channelOrder.AddRange(groups); } if (_activeChannelKey is null && _channelOrder.Count > 0) { _activeChannelKey = _channelOrder[0]; } else if (_activeChannelKey is not null && !_channelOrder.Contains(_activeChannelKey, StringComparer.Ordinal)) { _activeChannelKey = _channelOrder.Count > 0 ? _channelOrder[0] : null; } MarkChannelsSnapshotDirtyLocked(); } private void MarkChannelsSnapshotDirty() { using (_sync.EnterScope()) { _channelsSnapshotDirty = true; } } private void MarkChannelsSnapshotDirtyLocked() => _channelsSnapshotDirty = true; private void PublishChannelListChanged() { MarkChannelsSnapshotDirty(); Mediator.Publish(new ChatChannelsUpdated()); } private static IEnumerable EnumerateTerritoryKeys(string? value) { if (string.IsNullOrWhiteSpace(value)) yield break; var normalizedFull = NormalizeKey(value); if (normalizedFull.Length > 0) yield return normalizedFull; var segments = value.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (segments.Length <= 1) yield break; for (var i = 1; i < segments.Length; i++) { var composite = string.Join(" - ", segments[i..]); var normalized = NormalizeKey(composite); if (normalized.Length > 0) yield return normalized; } } private static string NormalizeKey(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); private static string BuildChannelKey(ChatChannelDescriptor descriptor) => $"{(int)descriptor.Type}:{NormalizeKey(descriptor.CustomKey)}"; private static string BuildPresenceKey(ChatChannelDescriptor descriptor) => $"{(int)descriptor.Type}:{descriptor.WorldId}:{NormalizeKey(descriptor.CustomKey)}"; private static bool ChannelDescriptorsMatch(ChatChannelDescriptor left, ChatChannelDescriptor right) => left.Type == right.Type && string.Equals(NormalizeKey(left.CustomKey), NormalizeKey(right.CustomKey), StringComparison.Ordinal) && left.WorldId == right.WorldId; private ChatChannelState EnsureZoneStateLocked() { 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] = restoredCount > 0 ? state.Messages.Count : 0; UpdateChannelOrderLocked(); } return state; } private void RemoveZoneStateLocked() { if (_channels.TryGetValue(ZoneChannelKey, out var existing)) { CacheMessagesLocked(existing); } if (_channels.Remove(ZoneChannelKey)) { _lastReadCounts.Remove(ZoneChannelKey); _lastPresenceStates.Remove(BuildPresenceKey(new ChatChannelDescriptor { Type = ChatChannelType.Zone })); _selfTokens.Remove(ZoneChannelKey); _pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, ZoneChannelKey, StringComparison.Ordinal)); if (string.Equals(_activeChannelKey, ZoneChannelKey, StringComparison.Ordinal)) { _activeChannelKey = null; } UpdateChannelOrderLocked(); } } private void CacheMessagesLocked(ChatChannelState state) { if (state.Messages.Count == 0) { return; } _messageHistoryCache[state.Key] = new List(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 void LoadPersistedSyncshellHistory() { if (!_chatConfigService.Current.PersistSyncshellHistory) { return; } Dictionary persisted = _chatConfigService.Current.SyncshellChannelHistory; if (persisted.Count == 0) { return; } List invalidKeys = new(); foreach (KeyValuePair entry in persisted) { if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value)) { invalidKeys.Add(entry.Key); continue; } if (!TryDecodePersistedHistory(entry.Value, out List persistedMessages)) { invalidKeys.Add(entry.Key); continue; } if (persistedMessages.Count == 0) { invalidKeys.Add(entry.Key); continue; } if (persistedMessages.Count > MaxMessageHistory) { int startIndex = Math.Max(0, persistedMessages.Count - MaxMessageHistory); persistedMessages = persistedMessages.GetRange(startIndex, persistedMessages.Count - startIndex); } List restoredMessages = new(persistedMessages.Count); foreach (PersistedChatMessage persistedMessage in persistedMessages) { if (!TryBuildRestoredMessage(entry.Key, persistedMessage, out ChatMessageEntry restoredMessage)) { continue; } restoredMessages.Add(restoredMessage); } if (restoredMessages.Count == 0) { invalidKeys.Add(entry.Key); continue; } using (_sync.EnterScope()) { _messageHistoryCache[entry.Key] = restoredMessages; } } if (invalidKeys.Count > 0) { foreach (string key in invalidKeys) { persisted.Remove(key); } _chatConfigService.Save(); } } private List BuildPersistedHistoryLocked(ChatChannelState state) { int startIndex = Math.Max(0, state.Messages.Count - MaxMessageHistory); List persistedMessages = new(state.Messages.Count - startIndex); for (int i = startIndex; i < state.Messages.Count; i++) { ChatMessageEntry entry = state.Messages[i]; if (entry.Payload is not { } payload) { continue; } persistedMessages.Add(new PersistedChatMessage( payload.Message, entry.DisplayName, entry.FromSelf, entry.ReceivedAtUtc, payload.SentAtUtc)); } return persistedMessages; } private void PersistSyncshellHistory(string channelKey, List persistedMessages) { if (!_chatConfigService.Current.PersistSyncshellHistory) { return; } Dictionary persisted = _chatConfigService.Current.SyncshellChannelHistory; if (persistedMessages.Count == 0) { if (persisted.Remove(channelKey)) { _chatConfigService.Save(); } return; } string? base64 = EncodePersistedMessages(persistedMessages); if (string.IsNullOrWhiteSpace(base64)) { if (persisted.Remove(channelKey)) { _chatConfigService.Save(); } return; } persisted[channelKey] = base64; _chatConfigService.Save(); } private static string? EncodePersistedMessages(List persistedMessages) { if (persistedMessages.Count == 0) { return null; } byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(persistedMessages, PersistedHistorySerializerOptions); return Convert.ToBase64String(jsonBytes); } private static bool TryDecodePersistedHistory(string base64, out List persistedMessages) { persistedMessages = new List(); if (string.IsNullOrWhiteSpace(base64)) { return false; } try { byte[] jsonBytes = Convert.FromBase64String(base64); List? decoded = JsonSerializer.Deserialize>(jsonBytes, PersistedHistorySerializerOptions); if (decoded is null) { return false; } persistedMessages = decoded; return true; } catch { return false; } } private static bool TryBuildRestoredMessage(string channelKey, PersistedChatMessage persistedMessage, out ChatMessageEntry restoredMessage) { restoredMessage = default; string messageText = persistedMessage.Message; DateTime sentAtUtc = persistedMessage.SentAtUtc; if (string.IsNullOrWhiteSpace(messageText) && persistedMessage.LegacyPayload is { } legacy) { messageText = legacy.Message; sentAtUtc = legacy.SentAtUtc; } if (string.IsNullOrWhiteSpace(messageText)) { return false; } ChatChannelDescriptor descriptor = BuildDescriptorFromChannelKey(channelKey); ChatSenderDescriptor sender = new ChatSenderDescriptor( ChatSenderKind.Anonymous, string.Empty, null, null, null, false); ChatMessageDto payload = new ChatMessageDto(descriptor, sender, messageText, sentAtUtc, string.Empty); restoredMessage = new ChatMessageEntry(payload, persistedMessage.DisplayName, persistedMessage.FromSelf, persistedMessage.ReceivedAtUtc); return true; } private static ChatChannelDescriptor BuildDescriptorFromChannelKey(string channelKey) { if (string.Equals(channelKey, ZoneChannelKey, StringComparison.Ordinal)) { return new ChatChannelDescriptor { Type = ChatChannelType.Zone }; } int separatorIndex = channelKey.IndexOf(':', StringComparison.Ordinal); if (separatorIndex <= 0 || separatorIndex >= channelKey.Length - 1) { return new ChatChannelDescriptor { Type = ChatChannelType.Group }; } string typeValue = channelKey[..separatorIndex]; if (!int.TryParse(typeValue, out int parsedType)) { return new ChatChannelDescriptor { Type = ChatChannelType.Group }; } string customKey = channelKey[(separatorIndex + 1)..]; ChatChannelType channelType = parsedType switch { (int)ChatChannelType.Zone => ChatChannelType.Zone, (int)ChatChannelType.Group => ChatChannelType.Group, _ => ChatChannelType.Group }; return new ChatChannelDescriptor { Type = channelType, CustomKey = customKey }; } public void ClearPersistedSyncshellHistory(bool clearLoadedMessages) { bool shouldPublish = false; bool saveConfig = false; using (_sync.EnterScope()) { Dictionary> cache = _messageHistoryCache; if (cache.Count > 0) { List keysToRemove = new(); foreach (string key in cache.Keys) { if (!string.Equals(key, ZoneChannelKey, StringComparison.Ordinal)) { keysToRemove.Add(key); } } foreach (string key in keysToRemove) { cache.Remove(key); } if (keysToRemove.Count > 0) { shouldPublish = true; } } if (clearLoadedMessages) { foreach (ChatChannelState state in _channels.Values) { if (state.Type != ChatChannelType.Group) { continue; } if (state.Messages.Count == 0 && state.UnreadCount == 0 && !state.HasUnread) { continue; } state.Messages.Clear(); state.HasUnread = false; state.UnreadCount = 0; _lastReadCounts[state.Key] = 0; shouldPublish = true; } } Dictionary persisted = _chatConfigService.Current.SyncshellChannelHistory; if (persisted.Count > 0) { persisted.Clear(); saveConfig = true; } if (shouldPublish) { MarkChannelsSnapshotDirtyLocked(); } } if (saveConfig) { _chatConfigService.Save(); } if (shouldPublish) { PublishChannelListChanged(); } } private sealed class ChatChannelState { public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor) { Key = key; Type = type; DisplayName = displayName; Descriptor = descriptor; Messages = new List(); } public string Key { get; } public ChatChannelType Type { get; } public string DisplayName { get; set; } public ChatChannelDescriptor Descriptor { get; set; } public bool IsConnected { get; set; } public bool IsAvailable { get; set; } public string? StatusText { get; set; } public bool HasUnread { get; set; } public int UnreadCount { get; set; } public List Messages { get; } } private readonly record struct ZoneChannelDefinition( string Key, string DisplayName, ChatChannelDescriptor Descriptor, HashSet TerritoryNames); private readonly record struct GroupChannelDefinition( string GroupId, string DisplayName, ChatChannelDescriptor Descriptor, bool IsOwner); private readonly record struct PendingSelfMessage(string ChannelKey, string Message); public sealed record PersistedChatMessage( string Message = "", string DisplayName = "", bool FromSelf = false, DateTime ReceivedAtUtc = default, DateTime SentAtUtc = default, [property: JsonPropertyName("Payload")] ChatMessageDto? LegacyPayload = null); }