Files
LightlessClient/LightlessSync/Services/Chat/ZoneChatService.cs
2025-12-27 21:17:26 -06:00

1404 lines
47 KiB
C#

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;
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 ServerConfigurationManager _serverConfigurationManager;
private readonly Lock _sync = new();
private readonly Dictionary<string, ChatChannelState> _channels = new(StringComparer.Ordinal);
private readonly List<string> _channelOrder = new();
private readonly Dictionary<uint, string> _territoryToZoneKey = new();
private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, GroupChannelDefinition> _groupDefinitions = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, int> _lastReadCounts = new(StringComparer.Ordinal);
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;
private bool _isLoggedIn;
private bool _isConnected;
private ChatChannelDescriptor? _lastZoneDescriptor;
private string? _activeChannelKey;
private bool _chatEnabled = false;
private bool _chatHandlerRegistered;
public ZoneChatService(
ILogger<ZoneChatService> 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<ChatChannelSnapshot> GetChannelsSnapshot()
{
using (_sync.EnterScope())
{
if (!_channelsSnapshotDirty && _cachedChannelSnapshots is not null)
{
return _cachedChannelSnapshots;
}
var snapshots = new List<ChatChannelSnapshot>(_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<string>(_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<ChatChannelDescriptor> 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<bool> 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<bool> 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<ChatReportResult> 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<ChatReportResult> 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<DalamudLoginMessage>(this, _ => HandleLogin());
Mediator.Subscribe<DalamudLogoutMessage>(this, _ => HandleLogout());
Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ScheduleZonePresenceUpdate());
Mediator.Subscribe<WorldChangedMessage>(this, _ => ScheduleZonePresenceUpdate(force: true));
Mediator.Subscribe<ConnectedMessage>(this, _ => HandleConnected());
Mediator.Subscribe<HubReconnectedMessage>(this, _ => HandleConnected());
Mediator.Subscribe<HubReconnectingMessage>(this, _ => HandleReconnecting());
Mediator.Subscribe<DisconnectedMessage>(this, _ => HandleReconnecting());
Mediator.Subscribe<PairUiUpdatedMessage>(this, _ => RefreshGroupsFromPairManager());
Mediator.Subscribe<BroadcastStatusChangedMessage>(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<ChatChannelDescriptor?> 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<ZoneChatChannelInfoDto>? infos)
{
var infoList = infos ?? Array.Empty<ZoneChatChannelInfoDto>();
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<string>(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<GroupChatChannelInfoDto>? infos)
{
var infoList = infos ?? Array.Empty<GroupChatChannelInfoDto>();
var descriptorsToJoin = new List<ChatChannelDescriptor>();
var descriptorsToLeave = new List<ChatChannelDescriptor>();
using (_sync.EnterScope())
{
var remainingGroups = new HashSet<string>(_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<GroupChatChannelInfoDto>());
return;
}
var infos = new List<GroupChatChannelInfoDto>(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<ChatChannelDescriptor> 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);
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 (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();
}
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.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<string>(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<string> 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<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)
{
Key = key;
Type = type;
DisplayName = displayName;
Descriptor = descriptor;
Messages = new List<ChatMessageEntry>();
}
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<ChatMessageEntry> Messages { get; }
}
private readonly record struct ZoneChannelDefinition(
string Key,
string DisplayName,
ChatChannelDescriptor Descriptor,
HashSet<string> TerritoryNames);
private readonly record struct GroupChannelDefinition(
string GroupId,
string DisplayName,
ChatChannelDescriptor Descriptor,
bool IsOwner);
private readonly record struct PendingSelfMessage(string ChannelKey, string Message);
}