All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
2.0.0 Changes: - Reworked shell finder UI with compact or list view with profile tags showing with the listing, allowing moderators to broadcast the syncshell as well to have it be used more. - Reworked user list in syncshell admin screen to have filter visible and moved away from table to its own thing, allowing to copy uid/note/alias when clicking on the name. - Reworked download bars and download box to make it look more modern, removed the jitter around, so it shouldn't vibrate around much. - Chat has been added to the top menu, working in Zone or in Syncshells to be used there. - Paired system has been revamped to make pausing and unpausing faster, and loading people should be faster as well. - Moved to the internal object table to have faster load times for users; people should load in faster - Compactor is running on a multi-threaded level instead of single-threaded; this should increase the speed of compacting files - Nameplate Service has been reworked so it wouldn't use the nameplate handler anymore. - Files can be resized when downloading to reduce load on users if they aren't compressed. (can be toggled to resize all). - Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many syncshells in your list. - Lightfinder plates have been moved away from using Nameplates, but will use an overlay. - Main UI has been changed a bit with a gradient, and on hover will glow up now. - Reworked Profile UI for Syncshell and Users to be more user-facing with more customizable items. - Reworked Settings UI to look more modern. - Performance should be better due to new systems that would dispose of the collections and better caching of items. Co-authored-by: defnotken <itsdefnotken@gmail.com> Co-authored-by: azyges <aaaaaa@aaa.aaa> Co-authored-by: choco <choco@patat.nl> Co-authored-by: cake <admin@cakeandbanana.nl> Co-authored-by: Minmoose <KennethBohr@outlook.com> Reviewed-on: #92
1316 lines
44 KiB
C#
1316 lines
44 KiB
C#
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<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 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)
|
|
: 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<ChatChannelSnapshot> GetChannelsSnapshot()
|
|
{
|
|
using (_sync.EnterScope())
|
|
{
|
|
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()));
|
|
}
|
|
|
|
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<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;
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|
|
|
|
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 = 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<ChatChannelDescriptor?> 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<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.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<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);
|
|
|
|
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<GroupChatChannelInfoDto>());
|
|
return;
|
|
}
|
|
|
|
var infos = new List<GroupChatChannelInfoDto>(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<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);
|
|
|
|
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<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;
|
|
}
|
|
}
|
|
|
|
private void PublishChannelListChanged() => 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 });
|
|
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<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);
|
|
}
|