Files
LightlessClient/LightlessSync/Services/Chat/ZoneChatService.cs
defnotken 72a62b7449
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m9s
2.1.0 (#123)
# Patchnotes 2.1.0
The changes in this update are more than just "patches". With a new UI, a new feature, and a bunch of bug fixes, improvements and a new member on the dev team, we thought this was more of a minor update.

We would like to introduce @tsubasahane of MareCN to the team! We’re happy to work with them to bring Lightless and its features to the CN client as well as having another talented dev bring features and ideas to us. Speaking of which:

# Location Sharing (Big shout out to @tsubasahane for bringing this feature)

- Are you TIRED of scrambling to find the address of the venue you're in to share with your friends? We are introducing Location Sharing! An optional feature where you can share your location with direct pairs temporarily [30 minutes, 1 hour, 3 hours] minutes or until you turn it off for them. That's up to you! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To share your location with a pair, click the three dots beside the pair and choose a duration to share with them. [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To view the location of someone who's shared with you, simply hover over the globe icon! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)

[1]

# Model Optimization (Mesh Decimating)
 - This new option can automatically “simplify” incoming character meshes to help performance by reducing triangle counts. You choose how strong the reduction is (default/recommended is 80%). [#131](<#131>)
 - Decimation only kicks in when a mesh is above a certain triangle threshold, and only for the items that qualify for it and you selected for. [#131](<#131>)
 - Hair meshes is always excluded, since simplifying hair meshes is very prone to breaking.
 - You can find everything under Settings → Performance → Model Optimization. [#131](<#131>)
+ ** IF YOU HAVE USED DECIMATION IN TESTING, PLEASE CLEAR YOUR CACHE  **

[2]

# Animation (PAP) Validation (Safer animations)
 - Lightless now checks your currently animations to see if they work with your local skeleton/bone mod. If an animation matches, it’s included in what gets sent to other players. If it doesn’t, Lightless will skip it and write a warning to your log showing how many were skipped due to skeleton changes. Its defaulted to Unsafe (off). turn it on if you experience crashes from others users. [#131](<#131>)
 - Lightless also does the same kind of check for incoming animation files, to make sure they match the body/skeleton they were sent with. [#131](<#131>)
 - Because these checks can sometimes be a little picky, you can adjust how strict they are in Settings -> General -> Animation & Bones to reduce false positives. [#131](<#131>)

# UI Changes (Thanks to @kyuwu for UI Changes)
- The top part of the main screen has gotten a makeover. You can adjust the colors of the gradiant in the Color settings of Lightless. [#127](<#127>)

[3]

- Settings have gotten some changes as well to make this change more universal, and will use the same color settings. [#127](<#127>)
- The particle effects of the gradient are toggleable in 'Settings -> UI -> Behavior' [#127](<#127>)
- Instead of showing download/upload on bottom of Main UI, it will show VRAM usage and triangles with their optimization options next to it [#138](<#138>)

# LightFinder / ShellFinder
- UI Changes that follow our new design follow the color codes for the Gradient top as the main screen does.  [#127](<#127>)

[4]

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: celine <aaa@aaa.aaa>
Co-authored-by: celine <celine@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Reviewed-on: #123
2026-01-20 19:43:00 +00:00

1871 lines
61 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;
using LightlessSync.LightlessConfiguration.Models;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LightlessSync.Services.Chat;
public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService
{
private const int MaxMessageHistory = 200;
internal const int MaxOutgoingLength = 200;
private const int MaxUnreadCount = 999;
private const string ZoneUnavailableMessage = "Zone chat is only available in major cities.";
private const string ZoneChannelKey = "zone";
private const int MaxReportReasonLength = 100;
private const int MaxReportContextLength = 1000;
private static readonly JsonSerializerOptions PersistedHistorySerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ApiController _apiController;
private readonly DalamudUtilService _dalamudUtilService;
private readonly ActorObjectService _actorObjectService;
private readonly PairUiService _pairUiService;
private readonly ChatConfigService _chatConfigService;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly Lock _sync = new();
private readonly Dictionary<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)
{
LoadPersistedSyncshellHistory();
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)
{
ChatChannelDescriptor descriptor = dto.Channel.WithNormalizedCustomKey();
string key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
bool fromSelf = IsMessageFromSelf(dto, key);
ChatMessageEntry message = BuildMessage(dto, fromSelf);
bool mentionNotificationsEnabled = _chatConfigService.Current.EnableMentionNotifications;
bool notifyMention = mentionNotificationsEnabled
&& !fromSelf
&& descriptor.Type == ChatChannelType.Group
&& TryGetSelfMentionToken(dto.Message, out _);
string? mentionChannelName = null;
string? mentionSenderName = null;
bool publishChannelList = false;
bool shouldPersistHistory = _chatConfigService.Current.PersistSyncshellHistory;
List<PersistedChatMessage>? persistedMessages = null;
string? persistedChannelKey = null;
using (_sync.EnterScope())
{
if (!_channels.TryGetValue(key, out var state))
{
var displayName = descriptor.Type switch
{
ChatChannelType.Zone => _zoneDefinitions.TryGetValue(descriptor.CustomKey ?? string.Empty, out var def)
? def.DisplayName
: "Zone Chat",
ChatChannelType.Group => descriptor.CustomKey ?? "Syncshell",
_ => descriptor.CustomKey ?? "Chat"
};
state = new ChatChannelState(
key,
descriptor.Type,
displayName,
descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor);
var restoredCount = RestoreCachedMessagesLocked(state);
state.IsConnected = _isConnected;
state.IsAvailable = descriptor.Type == ChatChannelType.Group && _isConnected;
state.StatusText = descriptor.Type == ChatChannelType.Zone ? ZoneUnavailableMessage : (_isConnected ? null : "Disconnected from chat server");
_channels[key] = state;
_lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
publishChannelList = true;
}
state.Descriptor = descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor;
state.Messages.Add(message);
if (state.Messages.Count > MaxMessageHistory)
{
state.Messages.RemoveAt(0);
}
if (notifyMention)
{
mentionChannelName = state.DisplayName;
mentionSenderName = message.DisplayName;
}
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
{
state.HasUnread = false;
state.UnreadCount = 0;
_lastReadCounts[key] = state.Messages.Count;
}
else
{
var lastRead = _lastReadCounts.TryGetValue(key, out var readCount) ? readCount : 0;
var unreadFromHistory = Math.Max(0, state.Messages.Count - lastRead);
var incrementalUnread = Math.Min(state.UnreadCount + 1, MaxUnreadCount);
state.UnreadCount = Math.Min(Math.Max(unreadFromHistory, incrementalUnread), MaxUnreadCount);
state.HasUnread = state.UnreadCount > 0;
}
MarkChannelsSnapshotDirtyLocked();
if (shouldPersistHistory && state.Type == ChatChannelType.Group)
{
persistedChannelKey = state.Key;
persistedMessages = BuildPersistedHistoryLocked(state);
}
}
Mediator.Publish(new ChatChannelMessageAdded(key, message));
if (persistedMessages is not null && persistedChannelKey is not null)
{
PersistSyncshellHistory(persistedChannelKey, persistedMessages);
}
if (notifyMention)
{
string channelName = mentionChannelName ?? "Syncshell";
string senderName = mentionSenderName ?? "Someone";
string notificationText = $"You were mentioned by {senderName} in {channelName}.";
Mediator.Publish(new NotificationMessage("Syncshell mention", notificationText, NotificationType.Info));
}
if (publishChannelList)
{
using (_sync.EnterScope())
{
UpdateChannelOrderLocked();
}
PublishChannelListChanged();
}
}
private bool IsMessageFromSelf(ChatMessageDto dto, string channelKey)
{
if (dto.Sender.User?.UID is { } uid && string.Equals(uid, _apiController.UID, StringComparison.Ordinal))
{
using (_sync.EnterScope())
{
_selfTokens[channelKey] = dto.Sender.Token;
}
return true;
}
using (_sync.EnterScope())
{
if (_selfTokens.TryGetValue(channelKey, out var token) &&
string.Equals(token, dto.Sender.Token, StringComparison.Ordinal))
{
return true;
}
var index = _pendingSelfMessages.FindIndex(p =>
string.Equals(p.ChannelKey, channelKey, StringComparison.Ordinal) &&
string.Equals(p.Message, dto.Message, StringComparison.Ordinal));
if (index >= 0)
{
_pendingSelfMessages.RemoveAt(index);
_selfTokens[channelKey] = dto.Sender.Token;
return true;
}
}
return false;
}
private bool TryGetSelfMentionToken(string message, out string matchedToken)
{
matchedToken = string.Empty;
if (string.IsNullOrWhiteSpace(message))
{
return false;
}
HashSet<string> tokens = BuildSelfMentionTokens();
if (tokens.Count == 0)
{
return false;
}
return TryFindMentionToken(message, tokens, out matchedToken);
}
private HashSet<string> BuildSelfMentionTokens()
{
HashSet<string> tokens = new(StringComparer.OrdinalIgnoreCase);
string uid = _apiController.UID;
if (IsValidMentionToken(uid))
{
tokens.Add(uid);
}
string displayName = _apiController.DisplayName;
if (IsValidMentionToken(displayName))
{
tokens.Add(displayName);
}
return tokens;
}
private static bool IsValidMentionToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
for (int i = 0; i < value.Length; i++)
{
if (!IsMentionChar(value[i]))
{
return false;
}
}
return true;
}
private static bool TryFindMentionToken(string message, IReadOnlyCollection<string> tokens, out string matchedToken)
{
matchedToken = string.Empty;
if (tokens.Count == 0 || string.IsNullOrEmpty(message))
{
return false;
}
int index = 0;
while (index < message.Length)
{
if (message[index] != '@')
{
index++;
continue;
}
if (index > 0 && IsMentionChar(message[index - 1]))
{
index++;
continue;
}
int start = index + 1;
int end = start;
while (end < message.Length && IsMentionChar(message[end]))
{
end++;
}
if (end == start)
{
index++;
continue;
}
string token = message.Substring(start, end - start);
if (tokens.Contains(token))
{
matchedToken = token;
return true;
}
index = end;
}
return false;
}
private static bool IsMentionChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\'';
}
private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf)
{
var displayName = ResolveDisplayName(dto, fromSelf);
return new ChatMessageEntry(dto, displayName, fromSelf, DateTime.UtcNow);
}
private ChatMessageEntry AddZoneSeparatorLocked(ChatChannelState state, string zoneDisplayName)
{
var separator = new ChatMessageEntry(
null,
string.Empty,
false,
DateTime.UtcNow,
new ChatSystemEntry(ChatSystemEntryType.ZoneSeparator, zoneDisplayName));
state.Messages.Add(separator);
if (state.Messages.Count > MaxMessageHistory)
{
state.Messages.RemoveAt(0);
}
if (string.Equals(_activeChannelKey, ZoneChannelKey, StringComparison.Ordinal))
{
state.HasUnread = false;
state.UnreadCount = 0;
_lastReadCounts[ZoneChannelKey] = state.Messages.Count;
}
else if (_lastReadCounts.TryGetValue(ZoneChannelKey, out var readCount))
{
_lastReadCounts[ZoneChannelKey] = readCount + 1;
}
else
{
_lastReadCounts[ZoneChannelKey] = state.Messages.Count;
}
return separator;
}
private string ResolveDisplayName(ChatMessageDto dto, bool fromSelf)
{
var isZone = dto.Channel.Type == ChatChannelType.Zone;
if (!string.IsNullOrEmpty(dto.Sender.HashedCid) &&
_actorObjectService.TryGetValidatedActorByHash(dto.Sender.HashedCid, out var descriptor) &&
!string.IsNullOrWhiteSpace(descriptor.Name))
{
return descriptor.Name;
}
if (fromSelf && isZone && dto.Sender.CanResolveProfile)
{
try
{
return _dalamudUtilService.GetPlayerName();
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to resolve self name for chat message");
}
}
if (dto.Sender.Kind == ChatSenderKind.IdentifiedUser && dto.Sender.User is not null)
{
if (dto.Channel.Type != ChatChannelType.Group || _chatConfigService.Current.ShowNotesInSyncshellChat)
{
var note = _serverConfigurationManager.GetNoteForUid(dto.Sender.User.UID);
if (!string.IsNullOrWhiteSpace(note))
{
return note;
}
}
return dto.Sender.User.AliasOrUID;
}
if (!string.IsNullOrWhiteSpace(dto.Sender.DisplayName))
{
return dto.Sender.DisplayName!;
}
return dto.Sender.Token;
}
private void UpdateChannelOrderLocked()
{
_channelOrder.Clear();
var configuredOrder = _chatConfigService.Current.ChannelOrder;
if (configuredOrder.Count > 0)
{
var seen = new HashSet<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 void LoadPersistedSyncshellHistory()
{
if (!_chatConfigService.Current.PersistSyncshellHistory)
{
return;
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persisted.Count == 0)
{
return;
}
List<string> invalidKeys = new();
foreach (KeyValuePair<string, string> entry in persisted)
{
if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value))
{
invalidKeys.Add(entry.Key);
continue;
}
if (!TryDecodePersistedHistory(entry.Value, out List<PersistedChatMessage> persistedMessages))
{
invalidKeys.Add(entry.Key);
continue;
}
if (persistedMessages.Count == 0)
{
invalidKeys.Add(entry.Key);
continue;
}
if (persistedMessages.Count > MaxMessageHistory)
{
int startIndex = Math.Max(0, persistedMessages.Count - MaxMessageHistory);
persistedMessages = persistedMessages.GetRange(startIndex, persistedMessages.Count - startIndex);
}
List<ChatMessageEntry> restoredMessages = new(persistedMessages.Count);
foreach (PersistedChatMessage persistedMessage in persistedMessages)
{
if (!TryBuildRestoredMessage(entry.Key, persistedMessage, out ChatMessageEntry restoredMessage))
{
continue;
}
restoredMessages.Add(restoredMessage);
}
if (restoredMessages.Count == 0)
{
invalidKeys.Add(entry.Key);
continue;
}
using (_sync.EnterScope())
{
_messageHistoryCache[entry.Key] = restoredMessages;
}
}
if (invalidKeys.Count > 0)
{
foreach (string key in invalidKeys)
{
persisted.Remove(key);
}
_chatConfigService.Save();
}
}
private List<PersistedChatMessage> BuildPersistedHistoryLocked(ChatChannelState state)
{
int startIndex = Math.Max(0, state.Messages.Count - MaxMessageHistory);
List<PersistedChatMessage> persistedMessages = new(state.Messages.Count - startIndex);
for (int i = startIndex; i < state.Messages.Count; i++)
{
ChatMessageEntry entry = state.Messages[i];
if (entry.Payload is not { } payload)
{
continue;
}
persistedMessages.Add(new PersistedChatMessage(
payload.Message,
entry.DisplayName,
entry.FromSelf,
entry.ReceivedAtUtc,
payload.SentAtUtc));
}
return persistedMessages;
}
private void PersistSyncshellHistory(string channelKey, List<PersistedChatMessage> persistedMessages)
{
if (!_chatConfigService.Current.PersistSyncshellHistory)
{
return;
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persistedMessages.Count == 0)
{
if (persisted.Remove(channelKey))
{
_chatConfigService.Save();
}
return;
}
string? base64 = EncodePersistedMessages(persistedMessages);
if (string.IsNullOrWhiteSpace(base64))
{
if (persisted.Remove(channelKey))
{
_chatConfigService.Save();
}
return;
}
persisted[channelKey] = base64;
_chatConfigService.Save();
}
private static string? EncodePersistedMessages(List<PersistedChatMessage> persistedMessages)
{
if (persistedMessages.Count == 0)
{
return null;
}
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(persistedMessages, PersistedHistorySerializerOptions);
return Convert.ToBase64String(jsonBytes);
}
private static bool TryDecodePersistedHistory(string base64, out List<PersistedChatMessage> persistedMessages)
{
persistedMessages = new List<PersistedChatMessage>();
if (string.IsNullOrWhiteSpace(base64))
{
return false;
}
try
{
byte[] jsonBytes = Convert.FromBase64String(base64);
List<PersistedChatMessage>? decoded = JsonSerializer.Deserialize<List<PersistedChatMessage>>(jsonBytes, PersistedHistorySerializerOptions);
if (decoded is null)
{
return false;
}
persistedMessages = decoded;
return true;
}
catch
{
return false;
}
}
private static bool TryBuildRestoredMessage(string channelKey, PersistedChatMessage persistedMessage, out ChatMessageEntry restoredMessage)
{
restoredMessage = default;
string messageText = persistedMessage.Message;
DateTime sentAtUtc = persistedMessage.SentAtUtc;
if (string.IsNullOrWhiteSpace(messageText) && persistedMessage.LegacyPayload is { } legacy)
{
messageText = legacy.Message;
sentAtUtc = legacy.SentAtUtc;
}
if (string.IsNullOrWhiteSpace(messageText))
{
return false;
}
ChatChannelDescriptor descriptor = BuildDescriptorFromChannelKey(channelKey);
ChatSenderDescriptor sender = new ChatSenderDescriptor(
ChatSenderKind.Anonymous,
string.Empty,
null,
null,
null,
false);
ChatMessageDto payload = new ChatMessageDto(descriptor, sender, messageText, sentAtUtc, string.Empty);
restoredMessage = new ChatMessageEntry(payload, persistedMessage.DisplayName, persistedMessage.FromSelf, persistedMessage.ReceivedAtUtc);
return true;
}
private static ChatChannelDescriptor BuildDescriptorFromChannelKey(string channelKey)
{
if (string.Equals(channelKey, ZoneChannelKey, StringComparison.Ordinal))
{
return new ChatChannelDescriptor { Type = ChatChannelType.Zone };
}
int separatorIndex = channelKey.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex <= 0 || separatorIndex >= channelKey.Length - 1)
{
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
}
string typeValue = channelKey[..separatorIndex];
if (!int.TryParse(typeValue, out int parsedType))
{
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
}
string customKey = channelKey[(separatorIndex + 1)..];
ChatChannelType channelType = parsedType switch
{
(int)ChatChannelType.Zone => ChatChannelType.Zone,
(int)ChatChannelType.Group => ChatChannelType.Group,
_ => ChatChannelType.Group
};
return new ChatChannelDescriptor
{
Type = channelType,
CustomKey = customKey
};
}
public void ClearPersistedSyncshellHistory(bool clearLoadedMessages)
{
bool shouldPublish = false;
bool saveConfig = false;
using (_sync.EnterScope())
{
Dictionary<string, List<ChatMessageEntry>> cache = _messageHistoryCache;
if (cache.Count > 0)
{
List<string> keysToRemove = new();
foreach (string key in cache.Keys)
{
if (!string.Equals(key, ZoneChannelKey, StringComparison.Ordinal))
{
keysToRemove.Add(key);
}
}
foreach (string key in keysToRemove)
{
cache.Remove(key);
}
if (keysToRemove.Count > 0)
{
shouldPublish = true;
}
}
if (clearLoadedMessages)
{
foreach (ChatChannelState state in _channels.Values)
{
if (state.Type != ChatChannelType.Group)
{
continue;
}
if (state.Messages.Count == 0 && state.UnreadCount == 0 && !state.HasUnread)
{
continue;
}
state.Messages.Clear();
state.HasUnread = false;
state.UnreadCount = 0;
_lastReadCounts[state.Key] = 0;
shouldPublish = true;
}
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persisted.Count > 0)
{
persisted.Clear();
saveConfig = true;
}
if (shouldPublish)
{
MarkChannelsSnapshotDirtyLocked();
}
}
if (saveConfig)
{
_chatConfigService.Save();
}
if (shouldPublish)
{
PublishChannelListChanged();
}
}
private sealed class ChatChannelState
{
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
{
Key = key;
Type = type;
DisplayName = displayName;
Descriptor = descriptor;
Messages = new List<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);
public sealed record PersistedChatMessage(
string Message = "",
string DisplayName = "",
bool FromSelf = false,
DateTime ReceivedAtUtc = default,
DateTime SentAtUtc = default,
[property: JsonPropertyName("Payload")] ChatMessageDto? LegacyPayload = null);
}