using LightlessSync.API.Dto.Chat; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using LightlessSync.WebAPI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using LightlessSync.UI.Services; using LightlessSync.LightlessConfiguration; namespace LightlessSync.Services.Chat; public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService { private const int MaxMessageHistory = 150; 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 readonly ApiController _apiController; private readonly DalamudUtilService _dalamudUtilService; private readonly ActorObjectService _actorObjectService; private readonly PairUiService _pairUiService; private readonly ChatConfigService _chatConfigService; 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 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) : base(logger, mediator) { _apiController = apiController; _dalamudUtilService = dalamudUtilService; _actorObjectService = actorObjectService; _pairUiService = pairUiService; _chatConfigService = chatConfigService; _isLoggedIn = _dalamudUtilService.IsLoggedIn; _isConnected = _apiController.IsConnected; _chatEnabled = chatConfigService.Current.AutoEnableChatOnLogin; } public IReadOnlyList GetChannelsSnapshot() { using (_sync.EnterScope()) { 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())); } 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; } } } 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; } } 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"; } } 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) { 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 = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false); 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)await _dalamudUtilService.GetWorldIdAsync().ConfigureAwait(false); 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.TerritoryData.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); 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); 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); } } 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"); } } foreach (var removedGroupId in remainingGroups) { if (_groupDefinitions.TryGetValue(removedGroupId, out var definition)) { var key = BuildChannelKey(definition.Descriptor); if (_channels.TryGetValue(key, out var 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) { 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) { var descriptor = dto.Channel.WithNormalizedCustomKey(); var key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor); var fromSelf = IsMessageFromSelf(dto, key); var message = BuildMessage(dto, fromSelf); bool publishChannelList = false; 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); 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; 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 (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; } } Mediator.Publish(new ChatChannelMessageAdded(key, message)); 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 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.GetPlayerNameAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } 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) { 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; } } private void PublishChannelListChanged() => 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 }); state.IsConnected = _chatEnabled && _isConnected; state.IsAvailable = false; state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled"; _channels[ZoneChannelKey] = state; _lastReadCounts[ZoneChannelKey] = 0; UpdateChannelOrderLocked(); } return state; } private void RemoveZoneStateLocked() { 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 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); }