Compare commits

4 Commits

28 changed files with 817 additions and 140 deletions

3
.gitignore vendored
View File

@@ -3,6 +3,7 @@
## ##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
.idea .idea
qodana.yaml
# User-specific files # User-specific files
*.rsuser *.rsuser
*.suo *.suo
@@ -13,6 +14,8 @@
MareSynchronos/.DS_Store MareSynchronos/.DS_Store
*.zip *.zip
UmbraServer_extracted/ UmbraServer_extracted/
NuGet.config
Directory.Build.props
# User-specific files (MonoDevelop/Xamarin Studio) # User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs *.userprefs

Submodule MareAPI updated: deb911cb0a...d105d20507

View File

@@ -87,6 +87,8 @@ public class MareConfig : IMareConfiguration
public bool ExtraChatTags { get; set; } = false; public bool ExtraChatTags { get; set; } = false;
public bool TypingIndicatorShowOnNameplates { get; set; } = true; public bool TypingIndicatorShowOnNameplates { get; set; } = true;
public bool TypingIndicatorShowOnPartyList { get; set; } = true; public bool TypingIndicatorShowOnPartyList { get; set; } = true;
public bool TypingIndicatorEnabled { get; set; } = true;
public bool TypingIndicatorShowSelf { get; set; } = true;
public TypingIndicatorBubbleSize TypingIndicatorBubbleSize { get; set; } = TypingIndicatorBubbleSize.Large; public TypingIndicatorBubbleSize TypingIndicatorBubbleSize { get; set; } = TypingIndicatorBubbleSize.Large;
public bool MareAPI { get; set; } = true; public bool MareAPI { get; set; } = true;

View File

@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.MareConfiguration.Configurations;
[Serializable]
public class NotificationsConfig : IMareConfiguration
{
public List<StoredNotification> Notifications { get; set; } = new();
public int Version { get; set; } = 1;
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class StoredNotification
{
public string Category { get; set; } = string.Empty; // name of enum NotificationCategory
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,14 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class NotificationsConfigService : ConfigurationServiceBase<NotificationsConfig>
{
public const string ConfigName = "notifications.json";
public NotificationsConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -50,6 +50,8 @@
<PropertyGroup> <PropertyGroup>
<SourceRevisionId>build$([System.DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss:fffZ"))</SourceRevisionId> <SourceRevisionId>build$([System.DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss:fffZ"))</SourceRevisionId>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<DisablePackageVulnerabilityAnalysis>true</DisablePackageVulnerabilityAnalysis>
<NoWarn>$(NoWarn);NU1900</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -167,6 +167,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton((s) => new ServerBlockConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new ServerBlockConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new RemoteConfigCacheService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new RemoteConfigCacheService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new NotificationsConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<MareConfigService>()); collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<MareConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<ServerConfigService>()); collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<ServerConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<NotesConfigService>()); collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<NotesConfigService>());
@@ -178,6 +179,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<ServerBlockConfigService>()); collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<ServerBlockConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>()); collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<RemoteConfigCacheService>()); collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<RemoteConfigCacheService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<NotificationsConfigService>());
collection.AddSingleton<ConfigurationMigrator>(); collection.AddSingleton<ConfigurationMigrator>();
collection.AddSingleton<ConfigurationSaveService>(); collection.AddSingleton<ConfigurationSaveService>();

View File

@@ -73,6 +73,13 @@ public sealed class SyncshellDiscoveryService : IHostedService, IMediatorSubscri
} }
public async Task<bool> SetVisibilityAsync(string gid, bool visible, CancellationToken ct) public async Task<bool> SetVisibilityAsync(string gid, bool visible, CancellationToken ct)
{
return await SetVisibilityAsync(gid, visible, null, null, null, null, null, ct).ConfigureAwait(false);
}
public async Task<bool> SetVisibilityAsync(string gid, bool visible, int? displayDurationHours,
int[]? activeWeekdays, TimeSpan? timeStartLocal, TimeSpan? timeEndLocal, string? timeZone,
CancellationToken ct)
{ {
try try
{ {
@@ -80,6 +87,11 @@ public sealed class SyncshellDiscoveryService : IHostedService, IMediatorSubscri
{ {
GID = gid, GID = gid,
AutoDetectVisible = visible, AutoDetectVisible = visible,
DisplayDurationHours = displayDurationHours,
ActiveWeekdays = activeWeekdays,
TimeStartLocal = timeStartLocal.HasValue ? new DateTime(timeStartLocal.Value.Ticks).ToString("HH:mm") : null,
TimeEndLocal = timeEndLocal.HasValue ? new DateTime(timeEndLocal.Value.Ticks).ToString("HH:mm") : null,
TimeZone = timeZone,
}; };
var success = await _apiController.SyncshellDiscoverySetVisibility(request).ConfigureAwait(false); var success = await _apiController.SyncshellDiscoverySetVisibility(request).ConfigureAwait(false);
if (!success) return false; if (!success) return false;

View File

@@ -8,6 +8,7 @@ using Dalamud.Plugin.Services;
using MareSynchronos.API.Data; using MareSynchronos.API.Data;
using MareSynchronos.Interop; using MareSynchronos.Interop;
using MareSynchronos.MareConfiguration; using MareSynchronos.MareConfiguration;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.MareConfiguration.Models; using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Pairs; using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.Mediator; using MareSynchronos.Services.Mediator;
@@ -38,6 +39,7 @@ public class ChatService : DisposableMediatorSubscriberBase
private CancellationTokenSource? _typingCts; private CancellationTokenSource? _typingCts;
private bool _isTypingAnnounced; private bool _isTypingAnnounced;
private DateTime _lastTypingSent = DateTime.MinValue; private DateTime _lastTypingSent = DateTime.MinValue;
private TypingScope _lastScope = TypingScope.Unknown;
private static readonly TimeSpan TypingIdle = TimeSpan.FromSeconds(2); private static readonly TimeSpan TypingIdle = TimeSpan.FromSeconds(2);
private static readonly TimeSpan TypingResendInterval = TimeSpan.FromMilliseconds(750); private static readonly TimeSpan TypingResendInterval = TimeSpan.FromMilliseconds(750);
@@ -79,7 +81,7 @@ public class ChatService : DisposableMediatorSubscriberBase
if (_gameChatHooks.IsValueCreated) if (_gameChatHooks.IsValueCreated)
_gameChatHooks.Value!.Dispose(); _gameChatHooks.Value!.Dispose();
} }
public void NotifyTypingKeystroke() public void NotifyTypingKeystroke(TypingScope scope)
{ {
lock (_typingLock) lock (_typingLock)
{ {
@@ -88,11 +90,12 @@ public class ChatService : DisposableMediatorSubscriberBase
{ {
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try { await _apiController.UserSetTypingState(true).ConfigureAwait(false); } try { await _apiController.UserSetTypingState(true, scope).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogDebug(ex, "NotifyTypingKeystroke: failed to send typing=true"); } catch (Exception ex) { _logger.LogDebug(ex, "NotifyTypingKeystroke: failed to send typing=true"); }
}); });
_isTypingAnnounced = true; _isTypingAnnounced = true;
_lastTypingSent = now; _lastTypingSent = now;
_lastScope = scope;
} }
_typingCts?.Cancel(); _typingCts?.Cancel();
@@ -105,7 +108,7 @@ public class ChatService : DisposableMediatorSubscriberBase
try try
{ {
await Task.Delay(TypingIdle, token).ConfigureAwait(false); await Task.Delay(TypingIdle, token).ConfigureAwait(false);
await _apiController.UserSetTypingState(false).ConfigureAwait(false); await _apiController.UserSetTypingState(false, _lastScope).ConfigureAwait(false);
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
@@ -140,7 +143,7 @@ public class ChatService : DisposableMediatorSubscriberBase
{ {
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try { await _apiController.UserSetTypingState(false).ConfigureAwait(false); } try { await _apiController.UserSetTypingState(false, _lastScope).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogDebug(ex, "ClearTypingState: failed to send typing=false"); } catch (Exception ex) { _logger.LogDebug(ex, "ClearTypingState: failed to send typing=false"); }
}); });
_isTypingAnnounced = false; _isTypingAnnounced = false;

View File

@@ -10,6 +10,8 @@ using FFXIVClientStructs.FFXIV.Client.UI.Shell;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MareSynchronos.PlayerData.Pairs; using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.WebAPI; using MareSynchronos.WebAPI;
using MareSynchronos.MareConfiguration;
using MareSynchronos.API.Data.Enum;
namespace MareSynchronos.Services; namespace MareSynchronos.Services;
@@ -24,16 +26,18 @@ public sealed class ChatTypingDetectionService : IDisposable
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private readonly IPartyList _partyList; private readonly IPartyList _partyList;
private readonly MareConfigService _configService;
private string _lastChatText = string.Empty; private string _lastChatText = string.Empty;
private bool _isTyping; private bool _isTyping;
private bool _notifyingRemote; private bool _notifyingRemote;
private bool _serverSupportWarnLogged; private bool _serverSupportWarnLogged;
private bool _remoteNotificationsEnabled; private bool _remoteNotificationsEnabled;
private bool _subscribed;
public ChatTypingDetectionService(ILogger<ChatTypingDetectionService> logger, IFramework framework, public ChatTypingDetectionService(ILogger<ChatTypingDetectionService> logger, IFramework framework,
IClientState clientState, IGameGui gameGui, ChatService chatService, PairManager pairManager, IPartyList partyList, IClientState clientState, IGameGui gameGui, ChatService chatService, PairManager pairManager, IPartyList partyList,
TypingIndicatorStateService typingStateService, ApiController apiController) TypingIndicatorStateService typingStateService, ApiController apiController, MareConfigService configService)
{ {
_logger = logger; _logger = logger;
_framework = framework; _framework = framework;
@@ -44,17 +48,50 @@ public sealed class ChatTypingDetectionService : IDisposable
_partyList = partyList; _partyList = partyList;
_typingStateService = typingStateService; _typingStateService = typingStateService;
_apiController = apiController; _apiController = apiController;
_configService = configService;
_framework.Update += OnFrameworkUpdate; Subscribe();
_logger.LogInformation("ChatTypingDetectionService initialized"); _logger.LogInformation("ChatTypingDetectionService initialized");
} }
public void Dispose() public void Dispose()
{ {
_framework.Update -= OnFrameworkUpdate; Unsubscribe();
ResetTypingState(); ResetTypingState();
} }
public void SoftRestart()
{
try
{
_logger.LogInformation("TypingDetection: soft restarting");
Unsubscribe();
ResetTypingState();
_chatService.ClearTypingState();
_typingStateService.ClearAll();
Subscribe();
_logger.LogInformation("TypingDetection: soft restart completed");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "TypingDetection: soft restart failed");
}
}
private void Subscribe()
{
if (_subscribed) return;
_framework.Update += OnFrameworkUpdate;
_subscribed = true;
}
private void Unsubscribe()
{
if (!_subscribed) return;
_framework.Update -= OnFrameworkUpdate;
_subscribed = false;
}
private void OnFrameworkUpdate(IFramework framework) private void OnFrameworkUpdate(IFramework framework)
{ {
try try
@@ -65,6 +102,13 @@ public sealed class ChatTypingDetectionService : IDisposable
return; return;
} }
if (!_configService.Current.TypingIndicatorEnabled)
{
ResetTypingState();
_chatService.ClearTypingState();
return;
}
if (!TryGetChatInput(out var chatText) || string.IsNullOrEmpty(chatText)) if (!TryGetChatInput(out var chatText) || string.IsNullOrEmpty(chatText))
{ {
ResetTypingState(); ResetTypingState();
@@ -89,7 +133,8 @@ public sealed class ChatTypingDetectionService : IDisposable
{ {
if (notifyRemote) if (notifyRemote)
{ {
_chatService.NotifyTypingKeystroke(); var scope = GetCurrentTypingScope();
_chatService.NotifyTypingKeystroke(scope);
_notifyingRemote = true; _notifyingRemote = true;
} }
@@ -120,6 +165,35 @@ public sealed class ChatTypingDetectionService : IDisposable
_typingStateService.SetSelfTypingLocal(false); _typingStateService.SetSelfTypingLocal(false);
} }
private unsafe TypingScope GetCurrentTypingScope()
{
try
{
var shellModule = RaptureShellModule.Instance();
if (shellModule == null)
return TypingScope.Unknown;
var chatType = (XivChatType)shellModule->ChatType;
switch (chatType)
{
case XivChatType.Say:
case XivChatType.Shout:
case XivChatType.Yell:
return TypingScope.Proximity;
case XivChatType.Party:
return TypingScope.Party;
case XivChatType.CrossParty:
return TypingScope.CrossParty;
default:
return TypingScope.Unknown;
}
}
catch
{
return TypingScope.Unknown;
}
}
private static bool IsIgnoredCommand(string chatText) private static bool IsIgnoredCommand(string chatText)
{ {
if (string.IsNullOrWhiteSpace(chatText)) if (string.IsNullOrWhiteSpace(chatText))
@@ -146,6 +220,11 @@ public sealed class ChatTypingDetectionService : IDisposable
{ {
try try
{ {
if (!_configService.Current.TypingIndicatorEnabled)
{
return false;
}
var supportsTypingState = _apiController.SystemInfoDto.SupportsTypingState; var supportsTypingState = _apiController.SystemInfoDto.SupportsTypingState;
var connected = _apiController.IsConnected; var connected = _apiController.IsConnected;
if (!connected || !supportsTypingState) if (!connected || !supportsTypingState)

View File

@@ -2,29 +2,45 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using MareSynchronos.Services.Mediator; using MareSynchronos.Services.Mediator;
using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Configurations;
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.Services.Notifications; namespace MareSynchronos.Services.Notifications;
public enum NotificationCategory public enum NotificationCategory
{ {
AutoDetect, AutoDetect,
Syncshell,
} }
public sealed record NotificationEntry(NotificationCategory Category, string Id, string Title, string? Description, DateTime CreatedAt) public sealed record NotificationEntry(NotificationCategory Category, string Id, string Title, string? Description, DateTime CreatedAt)
{ {
public static NotificationEntry AutoDetect(string uid, string displayName) public static NotificationEntry AutoDetect(string uid, string displayName)
=> new(NotificationCategory.AutoDetect, uid, displayName, "Nouvelle demande d'appairage via AutoDetect.", DateTime.UtcNow); => new(NotificationCategory.AutoDetect, uid, displayName, "Nouvelle demande d'appairage via AutoDetect.", DateTime.UtcNow);
public static NotificationEntry SyncshellPublic(string gid, string aliasOrGid)
=> new(NotificationCategory.Syncshell, gid, $"Syncshell publique: {aliasOrGid}", "La Syncshell est désormais visible via AutoDetect.", DateTime.UtcNow);
public static NotificationEntry SyncshellNotPublic(string gid, string aliasOrGid)
=> new(NotificationCategory.Syncshell, gid, $"Syncshell non publique: {aliasOrGid}", "La Syncshell n'est plus visible via AutoDetect.", DateTime.UtcNow);
} }
public sealed class NotificationTracker public sealed class NotificationTracker
{ {
private const int MaxStored = 100;
private readonly MareMediator _mediator; private readonly MareMediator _mediator;
private readonly NotificationsConfigService _configService;
private readonly Dictionary<(NotificationCategory Category, string Id), NotificationEntry> _entries = new(); private readonly Dictionary<(NotificationCategory Category, string Id), NotificationEntry> _entries = new();
private readonly object _lock = new(); private readonly object _lock = new();
public NotificationTracker(MareMediator mediator) public NotificationTracker(MareMediator mediator, NotificationsConfigService configService)
{ {
_mediator = mediator; _mediator = mediator;
_configService = configService;
LoadPersisted();
PublishState();
} }
public void Upsert(NotificationEntry entry) public void Upsert(NotificationEntry entry)
@@ -32,6 +48,8 @@ public sealed class NotificationTracker
lock (_lock) lock (_lock)
{ {
_entries[(entry.Category, entry.Id)] = entry; _entries[(entry.Category, entry.Id)] = entry;
TrimIfNecessary_NoLock();
Persist_NoLock();
} }
PublishState(); PublishState();
} }
@@ -41,6 +59,7 @@ public sealed class NotificationTracker
lock (_lock) lock (_lock)
{ {
_entries.Remove((category, id)); _entries.Remove((category, id));
Persist_NoLock();
} }
PublishState(); PublishState();
} }
@@ -70,4 +89,56 @@ public sealed class NotificationTracker
{ {
_mediator.Publish(new NotificationStateChanged(Count)); _mediator.Publish(new NotificationStateChanged(Count));
} }
private void LoadPersisted()
{
try
{
var list = _configService.Current.Notifications ?? new List<StoredNotification>();
foreach (var s in list)
{
if (!Enum.TryParse<NotificationCategory>(s.Category, out var cat)) continue;
var entry = new NotificationEntry(cat, s.Id, s.Title, s.Description, s.CreatedAtUtc);
_entries[(entry.Category, entry.Id)] = entry;
}
TrimIfNecessary_NoLock();
}
catch
{
// ignore load errors, start empty
}
}
private void Persist_NoLock()
{
try
{
var stored = _entries.Values
.OrderBy(e => e.CreatedAt)
.Select(e => new StoredNotification
{
Category = e.Category.ToString(),
Id = e.Id,
Title = e.Title,
Description = e.Description,
CreatedAtUtc = e.CreatedAt
})
.ToList();
_configService.Current.Notifications = stored;
_configService.Save();
}
catch
{
// ignore persistence errors
}
}
private void TrimIfNecessary_NoLock()
{
if (_entries.Count <= MaxStored) return;
foreach (var kv in _entries.Values.OrderByDescending(v => v.CreatedAt).Skip(MaxStored).ToList())
{
_entries.Remove((kv.Category, kv.Id));
}
}
} }

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Linq;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
@@ -17,22 +18,29 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private readonly INotificationManager _notificationManager; private readonly INotificationManager _notificationManager;
private readonly IChatGui _chatGui; private readonly IChatGui _chatGui;
private readonly MareConfigService _configurationService; private readonly MareConfigService _configurationService;
private readonly Services.Notifications.NotificationTracker _notificationTracker;
private readonly PlayerData.Pairs.PairManager _pairManager;
public NotificationService(ILogger<NotificationService> logger, MareMediator mediator, public NotificationService(ILogger<NotificationService> logger, MareMediator mediator,
DalamudUtilService dalamudUtilService, DalamudUtilService dalamudUtilService,
INotificationManager notificationManager, INotificationManager notificationManager,
IChatGui chatGui, MareConfigService configurationService) : base(logger, mediator) IChatGui chatGui, MareConfigService configurationService,
Services.Notifications.NotificationTracker notificationTracker,
PlayerData.Pairs.PairManager pairManager) : base(logger, mediator)
{ {
_dalamudUtilService = dalamudUtilService; _dalamudUtilService = dalamudUtilService;
_notificationManager = notificationManager; _notificationManager = notificationManager;
_chatGui = chatGui; _chatGui = chatGui;
_configurationService = configurationService; _configurationService = configurationService;
_notificationTracker = notificationTracker;
_pairManager = pairManager;
} }
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
Mediator.Subscribe<NotificationMessage>(this, ShowNotification); Mediator.Subscribe<NotificationMessage>(this, ShowNotification);
Mediator.Subscribe<DualNotificationMessage>(this, ShowDualNotification); Mediator.Subscribe<DualNotificationMessage>(this, ShowDualNotification);
Mediator.Subscribe<Services.Mediator.SyncshellAutoDetectStateChanged>(this, OnSyncshellAutoDetectStateChanged);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -113,6 +121,31 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
ShowChat(baseMsg); ShowChat(baseMsg);
} }
private void OnSyncshellAutoDetectStateChanged(SyncshellAutoDetectStateChanged msg)
{
try
{
if (msg.Visible) return; // only handle transition to not visible
var gid = msg.Gid;
// Try to resolve alias from PairManager snapshot; fallback to gid
var alias = _pairManager.Groups.Values.FirstOrDefault(g => string.Equals(g.GID, gid, StringComparison.OrdinalIgnoreCase))?.GroupAliasOrGID ?? gid;
var title = $"Syncshell non publique: {alias}";
var message = "La Syncshell n'est plus visible via AutoDetect.";
// Show toast + chat
ShowDualNotification(new DualNotificationMessage(title, message, NotificationType.Info, TimeSpan.FromSeconds(4)));
// Persist into notification center
_notificationTracker.Upsert(Services.Notifications.NotificationEntry.SyncshellNotPublic(gid, alias));
}
catch
{
// ignore failures
}
}
private static bool ShouldForceChat(NotificationMessage msg, out bool appendInstruction) private static bool ShouldForceChat(NotificationMessage msg, out bool appendInstruction)
{ {
appendInstruction = false; appendInstruction = false;

View File

@@ -41,8 +41,8 @@ public class PartyListTypingService : DisposableMediatorSubscriberBase
public void Draw() public void Draw()
{ {
if (!_configService.Current.TypingIndicatorEnabled) return;
if (!_configService.Current.TypingIndicatorShowOnPartyList) return; if (!_configService.Current.TypingIndicatorShowOnPartyList) return;
// Build map of visible users by AliasOrUID -> UID (case-insensitive)
var visibleByAlias = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var visibleByAlias = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try try
{ {

View File

@@ -5,24 +5,27 @@ using MareSynchronos.Services.Mediator;
using MareSynchronos.WebAPI; using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MareSynchronos.API.Data; using MareSynchronos.API.Data;
using MareSynchronos.MareConfiguration;
namespace MareSynchronos.Services; namespace MareSynchronos.Services;
public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposable public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposable
{ {
private sealed record TypingEntry(UserData User, DateTime FirstSeen, DateTime LastUpdate); private sealed record TypingEntry(UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope);
private readonly ConcurrentDictionary<string, TypingEntry> _typingUsers = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, TypingEntry> _typingUsers = new(StringComparer.Ordinal);
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly ILogger<TypingIndicatorStateService> _logger; private readonly ILogger<TypingIndicatorStateService> _logger;
private readonly MareConfigService _configService;
private DateTime _selfTypingLast = DateTime.MinValue; private DateTime _selfTypingLast = DateTime.MinValue;
private DateTime _selfTypingStart = DateTime.MinValue; private DateTime _selfTypingStart = DateTime.MinValue;
private bool _selfTypingActive; private bool _selfTypingActive;
public TypingIndicatorStateService(ILogger<TypingIndicatorStateService> logger, MareMediator mediator, ApiController apiController) public TypingIndicatorStateService(ILogger<TypingIndicatorStateService> logger, MareMediator mediator, ApiController apiController, MareConfigService configService)
{ {
_logger = logger; _logger = logger;
_apiController = apiController; _apiController = apiController;
_configService = configService;
Mediator = mediator; Mediator = mediator;
mediator.Subscribe<UserTypingStateMessage>(this, OnTypingState); mediator.Subscribe<UserTypingStateMessage>(this, OnTypingState);
@@ -51,8 +54,19 @@ public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposab
_selfTypingActive = isTyping; _selfTypingActive = isTyping;
} }
public void ClearAll()
{
_typingUsers.Clear();
_selfTypingActive = false;
_selfTypingStart = DateTime.MinValue;
_selfTypingLast = DateTime.MinValue;
_logger.LogDebug("TypingIndicatorStateService: cleared all typing state");
}
private void OnTypingState(UserTypingStateMessage msg) private void OnTypingState(UserTypingStateMessage msg)
{ {
if (!_configService.Current.TypingIndicatorEnabled)
return;
var uid = msg.Typing.User.UID; var uid = msg.Typing.User.UID;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@@ -74,8 +88,8 @@ public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposab
else if (msg.Typing.IsTyping) else if (msg.Typing.IsTyping)
{ {
_typingUsers.AddOrUpdate(uid, _typingUsers.AddOrUpdate(uid,
_ => new TypingEntry(msg.Typing.User, now, now), _ => new TypingEntry(msg.Typing.User, now, now, msg.Typing.Scope),
(_, existing) => new TypingEntry(msg.Typing.User, existing.FirstSeen, now)); (_, existing) => new TypingEntry(msg.Typing.User, existing.FirstSeen, now, msg.Typing.Scope));
} }
else else
{ {
@@ -101,7 +115,7 @@ public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposab
return true; return true;
} }
public IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate)> GetActiveTypers(TimeSpan maxAge) public IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope)> GetActiveTypers(TimeSpan maxAge)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var kvp in _typingUsers.ToArray()) foreach (var kvp in _typingUsers.ToArray())
@@ -112,6 +126,6 @@ public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposab
} }
} }
return _typingUsers.ToDictionary(k => k.Key, v => (v.Value.User, v.Value.FirstSeen, v.Value.LastUpdate), StringComparer.Ordinal); return _typingUsers.ToDictionary(k => k.Key, v => (v.Value.User, v.Value.FirstSeen, v.Value.LastUpdate, v.Value.Scope), StringComparer.Ordinal);
} }
} }

View File

@@ -3,6 +3,7 @@ using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.AutoDetect; using MareSynchronos.Services.AutoDetect;
using MareSynchronos.Services.Mediator; using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Services.Notifications;
using MareSynchronos.UI; using MareSynchronos.UI;
using MareSynchronos.UI.Components.Popup; using MareSynchronos.UI.Components.Popup;
using MareSynchronos.WebAPI; using MareSynchronos.WebAPI;
@@ -21,10 +22,11 @@ public class UiFactory
private readonly MareProfileManager _mareProfileManager; private readonly MareProfileManager _mareProfileManager;
private readonly PerformanceCollectorService _performanceCollectorService; private readonly PerformanceCollectorService _performanceCollectorService;
private readonly SyncshellDiscoveryService _syncshellDiscoveryService; private readonly SyncshellDiscoveryService _syncshellDiscoveryService;
private readonly NotificationTracker _notificationTracker;
public UiFactory(ILoggerFactory loggerFactory, MareMediator mareMediator, ApiController apiController, public UiFactory(ILoggerFactory loggerFactory, MareMediator mareMediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService, ServerConfigurationManager serverConfigManager, UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService, ServerConfigurationManager serverConfigManager,
MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService) MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService, NotificationTracker notificationTracker)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_mareMediator = mareMediator; _mareMediator = mareMediator;
@@ -35,12 +37,13 @@ public class UiFactory
_serverConfigManager = serverConfigManager; _serverConfigManager = serverConfigManager;
_mareProfileManager = mareProfileManager; _mareProfileManager = mareProfileManager;
_performanceCollectorService = performanceCollectorService; _performanceCollectorService = performanceCollectorService;
_notificationTracker = notificationTracker;
} }
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
{ {
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _mareMediator, return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _mareMediator,
_apiController, _uiSharedService, _pairManager, _syncshellDiscoveryService, dto, _performanceCollectorService); _apiController, _uiSharedService, _pairManager, _syncshellDiscoveryService, dto, _performanceCollectorService, _notificationTracker);
} }
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)

View File

@@ -1114,7 +1114,10 @@ public sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
ImGui.TableSetupColumn("Expire"); ImGui.TableSetupColumn("Expire");
ImGui.TableSetupColumn("Téléchargements"); ImGui.TableSetupColumn("Téléchargements");
ImGui.TableSetupColumn("Accès"); ImGui.TableSetupColumn("Accès");
ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 220f); var style = ImGui.GetStyle();
float BtnWidth(string label) => ImGui.CalcTextSize(label).X + style.FramePadding.X * 2f;
float ownActionsWidth = BtnWidth("Appliquer en GPose") + style.ItemSpacing.X + BtnWidth("Enregistrer") + style.ItemSpacing.X + BtnWidth("Supprimer") + 2f; // small margin
ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, ownActionsWidth);
ImGui.TableHeadersRow(); ImGui.TableHeadersRow();
foreach (var entry in _mcdfShareManager.OwnShares) foreach (var entry in _mcdfShareManager.OwnShares)
@@ -1134,6 +1137,32 @@ public sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
ImGui.TableNextColumn(); ImGui.TableNextColumn();
ImGui.TextUnformatted($"UID : {entry.AllowedIndividuals.Count}, Syncshells : {entry.AllowedSyncshells.Count}"); ImGui.TextUnformatted($"UID : {entry.AllowedIndividuals.Count}, Syncshells : {entry.AllowedSyncshells.Count}");
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
if (entry.AllowedIndividuals.Count > 0)
{
ImGui.TextUnformatted("UID autorisés:");
foreach (var uid in entry.AllowedIndividuals)
ImGui.BulletText(FormatUidWithName(uid));
}
else
{
ImGui.TextDisabled("Aucun UID autorisé");
}
ImGui.Separator();
if (entry.AllowedSyncshells.Count > 0)
{
ImGui.TextUnformatted("Syncshells autorisées:");
foreach (var gid in entry.AllowedSyncshells)
ImGui.BulletText(FormatSyncshellLabel(gid));
}
else
{
ImGui.TextDisabled("Aucune syncshell autorisée");
}
ImGui.EndTooltip();
}
ImGui.TableNextColumn(); ImGui.TableNextColumn();
using (ImRaii.PushId("ownShare" + entry.Id)) using (ImRaii.PushId("ownShare" + entry.Id))
@@ -1177,7 +1206,10 @@ public sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
ImGui.TableSetupColumn("Propriétaire"); ImGui.TableSetupColumn("Propriétaire");
ImGui.TableSetupColumn("Expire"); ImGui.TableSetupColumn("Expire");
ImGui.TableSetupColumn("Téléchargements"); ImGui.TableSetupColumn("Téléchargements");
ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 180f); var style2 = ImGui.GetStyle();
float BtnWidth2(string label) => ImGui.CalcTextSize(label).X + style2.FramePadding.X * 2f;
float sharedActionsWidth = BtnWidth2("Appliquer") + style2.ItemSpacing.X + BtnWidth2("Enregistrer") + 2f;
ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, sharedActionsWidth);
ImGui.TableHeadersRow(); ImGui.TableHeadersRow();
foreach (var entry in _mcdfShareManager.SharedShares) foreach (var entry in _mcdfShareManager.SharedShares)
@@ -1188,6 +1220,17 @@ public sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
ImGui.TableNextColumn(); ImGui.TableNextColumn();
ImGui.TextUnformatted(string.IsNullOrEmpty(entry.OwnerAlias) ? entry.OwnerUid : entry.OwnerAlias); ImGui.TextUnformatted(string.IsNullOrEmpty(entry.OwnerAlias) ? entry.OwnerUid : entry.OwnerAlias);
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
ImGui.TextUnformatted($"UID propriétaire: {entry.OwnerUid}");
if (!string.IsNullOrEmpty(entry.OwnerAlias))
{
ImGui.Separator();
ImGui.TextUnformatted($"Alias: {entry.OwnerAlias}");
}
ImGui.EndTooltip();
}
ImGui.TableNextColumn(); ImGui.TableNextColumn();
ImGui.TextUnformatted(entry.ExpiresAtUtc.HasValue ? entry.ExpiresAtUtc.Value.ToLocalTime().ToString("g") : "Jamais"); ImGui.TextUnformatted(entry.ExpiresAtUtc.HasValue ? entry.ExpiresAtUtc.Value.ToLocalTime().ToString("g") : "Jamais");
@@ -1334,6 +1377,14 @@ public sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
return trimmed.ToUpperInvariant(); return trimmed.ToUpperInvariant();
} }
private string FormatUidWithName(string uid)
{
if (string.IsNullOrEmpty(uid)) return string.Empty;
var note = _serverConfigurationManager.GetNoteForUid(uid);
if (!string.IsNullOrEmpty(note)) return $"{uid} ({note})";
return uid;
}
private string FormatPairLabel(string candidate) private string FormatPairLabel(string candidate)
{ {
if (string.IsNullOrEmpty(candidate)) if (string.IsNullOrEmpty(candidate))

View File

@@ -860,28 +860,34 @@ if (showNearby && pendingInvites > 0)
ImGuiHelpers.ScaledDummy(4f); ImGuiHelpers.ScaledDummy(4f);
var indent = 18f * ImGuiHelpers.GlobalScale; var indent = 18f * ImGuiHelpers.GlobalScale;
ImGui.Indent(indent); ImGui.Indent(indent);
foreach (var e in nearbyEntries)
// Use a table to guarantee right-aligned action within the card content area
var actionButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.UserPlus);
if (ImGui.BeginTable("nearby-table", 2, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.BordersInnerV))
{ {
if (!e.AcceptPairRequests || string.IsNullOrEmpty(e.Token)) ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthStretch, 1f);
{ ImGui.TableSetupColumn("Action", ImGuiTableColumnFlags.WidthFixed, actionButtonSize.X);
continue;
}
var name = e.DisplayName ?? e.Name; foreach (var e in nearbyEntries)
ImGui.AlignTextToFramePadding(); {
ImGui.TextUnformatted(name); if (!e.AcceptPairRequests || string.IsNullOrEmpty(e.Token))
var right = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth(); {
ImGui.SameLine(); continue;
}
ImGui.TableNextRow();
ImGui.TableSetColumnIndex(0);
var name = e.DisplayName ?? e.Name;
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(name);
// Right column: action button, aligned to the right within the column
ImGui.TableSetColumnIndex(1);
var curX = ImGui.GetCursorPosX();
var availX = ImGui.GetContentRegionAvail().X; // width of the action column
ImGui.SetCursorPosX(curX + MathF.Max(0, availX - actionButtonSize.X));
var statusButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.UserPlus);
ImGui.SetCursorPosX(right - statusButtonSize.X);
if (!e.AcceptPairRequests)
{
_uiSharedService.IconText(FontAwesomeIcon.Ban, ImGuiColors.DalamudGrey3);
UiSharedService.AttachToolTip("Les demandes sont désactivées pour ce joueur");
}
else if (!string.IsNullOrEmpty(e.Token))
{
using (ImRaii.PushId(e.Token ?? e.Uid ?? e.Name ?? string.Empty)) using (ImRaii.PushId(e.Token ?? e.Uid ?? e.Name ?? string.Empty))
{ {
if (_uiSharedService.IconButton(FontAwesomeIcon.UserPlus)) if (_uiSharedService.IconButton(FontAwesomeIcon.UserPlus))
@@ -891,12 +897,9 @@ if (showNearby && pendingInvites > 0)
} }
UiSharedService.AttachToolTip("Envoyer une invitation d'apparaige"); UiSharedService.AttachToolTip("Envoyer une invitation d'apparaige");
} }
else ImGui.EndTable();
{
_uiSharedService.IconText(FontAwesomeIcon.QuestionCircle, ImGuiColors.DalamudGrey3);
UiSharedService.AttachToolTip("Impossible d'inviter ce joueur");
}
} }
ImGui.Unindent(indent); ImGui.Unindent(indent);
}, stretchWidth: true); }, stretchWidth: true);
} }
@@ -1173,6 +1176,9 @@ if (showNearby && pendingInvites > 0)
case NotificationCategory.AutoDetect: case NotificationCategory.AutoDetect:
DrawAutoDetectNotification(notification); DrawAutoDetectNotification(notification);
break; break;
case NotificationCategory.Syncshell:
DrawSyncshellNotification(notification);
break;
default: default:
UiSharedService.DrawCard($"notification-{notification.Category}-{notification.Id}", () => UiSharedService.DrawCard($"notification-{notification.Category}-{notification.Id}", () =>
{ {
@@ -1237,6 +1243,30 @@ if (showNearby && pendingInvites > 0)
}, stretchWidth: true); }, stretchWidth: true);
} }
private void DrawSyncshellNotification(NotificationEntry notification)
{
UiSharedService.DrawCard($"notification-syncshell-{notification.Id}", () =>
{
ImGui.TextUnformatted(notification.Title);
if (!string.IsNullOrEmpty(notification.Description))
{
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
ImGui.TextWrapped(notification.Description);
ImGui.PopStyleColor();
}
ImGuiHelpers.ScaledDummy(3f);
using (ImRaii.PushId($"syncshell-{notification.Id}"))
{
if (ImGui.Button("Effacer"))
{
_notificationTracker.Remove(NotificationCategory.Syncshell, notification.Id);
}
}
}, stretchWidth: true);
}
private void TriggerAcceptAutoDetectNotification(string uid) private void TriggerAcceptAutoDetectNotification(string uid)
{ {
_ = Task.Run(async () => _ = Task.Run(async () =>

View File

@@ -79,6 +79,8 @@ public class DrawGroupPair : DrawPairBase
width += _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Plus).X + spacing; width += _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Plus).X + spacing;
} }
width += spacing * 1.2f;
return width; return width;
} }
@@ -215,6 +217,7 @@ public class DrawGroupPair : DrawPairBase
var pauseIcon = _fullInfoDto.GroupUserPermissions.IsPaused() ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause; var pauseIcon = _fullInfoDto.GroupUserPermissions.IsPaused() ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause;
var pauseButtonWidth = _uiSharedService.GetIconButtonSize(pauseIcon).X; var pauseButtonWidth = _uiSharedService.GetIconButtonSize(pauseIcon).X;
var barButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Bars).X; var barButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Bars).X;
var rightEdgeGap = spacing * 1.2f;
float totalWidth = 0f; float totalWidth = 0f;
void Accumulate(bool condition, float width) void Accumulate(bool condition, float width)
@@ -242,7 +245,7 @@ public class DrawGroupPair : DrawPairBase
float cardPaddingX = UiSharedService.GetCardContentPaddingX(); float cardPaddingX = UiSharedService.GetCardContentPaddingX();
float rightMargin = cardPaddingX + 6f * ImGuiHelpers.GlobalScale; float rightMargin = cardPaddingX + 6f * ImGuiHelpers.GlobalScale;
float baseX = MathF.Max(ImGui.GetCursorPosX(), float baseX = MathF.Max(ImGui.GetCursorPosX(),
ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - rightMargin - totalWidth); ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - rightMargin - rightEdgeGap - totalWidth);
float currentX = baseX; float currentX = baseX;
ImGui.SameLine(); ImGui.SameLine();
@@ -266,6 +269,16 @@ public class DrawGroupPair : DrawPairBase
if (showInfo && infoIconWidth > 0f) if (showInfo && infoIconWidth > 0f)
{ {
bool centerWarning = permIcon == FontAwesomeIcon.ExclamationTriangle && showPause && showBars && !showShared && !showPlus;
if (centerWarning)
{
float barsClusterWidth = showBars ? (barButtonWidth + spacing * 0.5f) : 0f;
float leftAreaWidth = MathF.Max(totalWidth - pauseButtonWidth - barsClusterWidth, 0f);
float warningX = baseX + MathF.Max((leftAreaWidth - infoIconWidth) / 2f, 0f);
currentX = warningX;
ImGui.SetCursorPosX(currentX);
}
ImGui.SetCursorPosY(textPosY); ImGui.SetCursorPosY(textPosY);
if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled) if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled)
{ {
@@ -359,7 +372,7 @@ public class DrawGroupPair : DrawPairBase
{ {
ImGui.SetCursorPosY(originalY); ImGui.SetCursorPosY(originalY);
if (_uiSharedService.IconButton(FontAwesomeIcon.Plus)) if (_uiSharedService.IconPlusButtonCentered())
{ {
var targetUid = _pair.UserData.UID; var targetUid = _pair.UserData.UID;
if (!string.IsNullOrEmpty(targetUid)) if (!string.IsNullOrEmpty(targetUid))
@@ -376,7 +389,7 @@ public class DrawGroupPair : DrawPairBase
{ {
float gapToBars = showBars ? spacing * 0.5f : spacing; float gapToBars = showBars ? spacing * 0.5f : spacing;
ImGui.SetCursorPosY(originalY); ImGui.SetCursorPosY(originalY);
if (_uiSharedService.IconButton(pauseIcon)) if (pauseIcon == FontAwesomeIcon.Pause ? _uiSharedService.IconPauseButtonCentered() : _uiSharedService.IconButtonCentered(pauseIcon))
{ {
var newPermissions = _fullInfoDto.GroupUserPermissions ^ GroupUserPermissions.Paused; var newPermissions = _fullInfoDto.GroupUserPermissions ^ GroupUserPermissions.Paused;
_fullInfoDto.GroupUserPermissions = newPermissions; _fullInfoDto.GroupUserPermissions = newPermissions;
@@ -391,7 +404,7 @@ public class DrawGroupPair : DrawPairBase
if (showBars) if (showBars)
{ {
ImGui.SetCursorPosY(originalY); ImGui.SetCursorPosY(originalY);
if (_uiSharedService.IconButton(FontAwesomeIcon.Bars)) if (_uiSharedService.IconButtonCentered(FontAwesomeIcon.Bars))
{ {
ImGui.OpenPopup("Syncshell Flyout Menu"); ImGui.OpenPopup("Syncshell Flyout Menu");
} }

View File

@@ -42,8 +42,8 @@ public abstract class DrawPairBase
var menuButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Bars); var menuButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Bars);
float pauseClusterWidth = Math.Max(pauseButtonSize.X, playButtonSize.X); float pauseClusterWidth = Math.Max(pauseButtonSize.X, playButtonSize.X);
float pauseClusterHeight = Math.Max(pauseButtonSize.Y, playButtonSize.Y); float pauseClusterHeight = Math.Max(Math.Max(pauseButtonSize.Y, playButtonSize.Y), ImGui.GetFrameHeight());
float reservedSpacing = style.ItemSpacing.X * 2.4f; float reservedSpacing = style.ItemSpacing.X * 1.6f;
float rightButtonWidth = float rightButtonWidth =
menuButtonSize.X + menuButtonSize.X +
pauseClusterWidth + pauseClusterWidth +
@@ -84,11 +84,15 @@ public abstract class DrawPairBase
ImGui.SetCursorPos(new Vector2(rowStartCursor.X + padding.X, iconTop)); ImGui.SetCursorPos(new Vector2(rowStartCursor.X + padding.X, iconTop));
DrawLeftSide(iconTop, iconTop); DrawLeftSide(iconTop, iconTop);
ImGui.SameLine();
ImGui.SetCursorPosY(textTop); float leftReserved = GetLeftSideReservedWidth();
var posX = ImGui.GetCursorPosX(); float nameStartX = rowStartCursor.X + padding.X + leftReserved;
var rightSide = DrawRightSide(buttonTop, buttonTop); var rightSide = DrawRightSide(buttonTop, buttonTop);
DrawName(textTop + padding.Y * 0.15f, posX, rightSide);
ImGui.SameLine(nameStartX);
ImGui.SetCursorPosY(textTop);
DrawName(textTop + padding.Y * 0.15f, nameStartX, rightSide);
ImGui.SetCursorPos(new Vector2(rowStartCursor.X, rowStartCursor.Y + totalHeight)); ImGui.SetCursorPos(new Vector2(rowStartCursor.X, rowStartCursor.Y + totalHeight));
ImGui.SetCursorPosX(rowStartCursor.X); ImGui.SetCursorPosX(rowStartCursor.X);
@@ -100,6 +104,8 @@ public abstract class DrawPairBase
protected virtual float GetRightSideExtraWidth() => 0f; protected virtual float GetRightSideExtraWidth() => 0f;
protected virtual float GetLeftSideReservedWidth() => UiSharedService.GetIconSize(FontAwesomeIcon.Moon).X * 2f + ImGui.GetStyle().ItemSpacing.X * 1.5f;
private void DrawName(float originalY, float leftSide, float rightSide) private void DrawName(float originalY, float leftSide, float rightSide)
{ {
_displayHandler.DrawPairText(_id, _pair, leftSide, originalY, () => rightSide - leftSide); _displayHandler.DrawPairText(_id, _pair, leftSide, originalY, () => rightSide - leftSide);

View File

@@ -60,8 +60,28 @@ public class DrawUserPair : DrawPairBase
width += _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Running).X + spacingX * 0.5f; width += _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Running).X + spacingX * 0.5f;
} }
width += spacingX * 1.2f;
return width; return width;
} }
protected override float GetLeftSideReservedWidth()
{
var style = ImGui.GetStyle();
float spacing = style.ItemSpacing.X;
float iconW = UiSharedService.GetIconSize(FontAwesomeIcon.Moon).X;
int icons = 1;
if (!(_pair.UserPair!.OwnPermissions.IsPaired() && _pair.UserPair!.OtherPermissions.IsPaired()))
icons++;
else if (_pair.UserPair!.OwnPermissions.IsPaused() || _pair.UserPair!.OtherPermissions.IsPaused())
icons++;
if (_pair.IsOnline && _pair.IsVisible)
icons++;
float iconsTotal = icons * iconW + Math.Max(0, icons - 1) * spacing;
float cushion = spacing * 0.6f;
return iconsTotal + cushion;
}
protected override void DrawLeftSide(float textPosY, float originalY) protected override void DrawLeftSide(float textPosY, float originalY)
{ {
@@ -133,7 +153,8 @@ public class DrawUserPair : DrawPairBase
var entryUID = _pair.UserData.AliasOrUID; var entryUID = _pair.UserData.AliasOrUID;
var spacingX = ImGui.GetStyle().ItemSpacing.X; var spacingX = ImGui.GetStyle().ItemSpacing.X;
var edgePadding = UiSharedService.GetCardContentPaddingX() + 6f * ImGuiHelpers.GlobalScale; var edgePadding = UiSharedService.GetCardContentPaddingX() + 6f * ImGuiHelpers.GlobalScale;
var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - edgePadding; var rightEdgeGap = spacingX * 1.2f;
var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - edgePadding - rightEdgeGap;
var rightSidePos = windowEndX - barButtonSize.X; var rightSidePos = windowEndX - barButtonSize.X;
// Flyout Menu // Flyout Menu
@@ -150,13 +171,12 @@ public class DrawUserPair : DrawPairBase
ImGui.EndPopup(); ImGui.EndPopup();
} }
// Pause (mutual pairs only)
if (_pair.UserPair!.OwnPermissions.IsPaired() && _pair.UserPair!.OtherPermissions.IsPaired()) if (_pair.UserPair!.OwnPermissions.IsPaired() && _pair.UserPair!.OtherPermissions.IsPaired())
{ {
rightSidePos -= pauseIconSize.X + spacingX; rightSidePos -= pauseIconSize.X + spacingX;
ImGui.SameLine(rightSidePos); ImGui.SameLine(rightSidePos);
ImGui.SetCursorPosY(originalY); ImGui.SetCursorPosY(originalY);
if (_uiSharedService.IconButton(pauseIcon)) if (pauseIcon == FontAwesomeIcon.Pause ? _uiSharedService.IconPauseButtonCentered() : _uiSharedService.IconButtonCentered(pauseIcon))
{ {
var perm = _pair.UserPair!.OwnPermissions; var perm = _pair.UserPair!.OwnPermissions;
perm.SetPaused(!perm.IsPaused()); perm.SetPaused(!perm.IsPaused());

View File

@@ -52,6 +52,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly AccountRegistrationService _registerService; private readonly AccountRegistrationService _registerService;
private readonly ServerConfigurationManager _serverConfigurationManager; private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly UiSharedService _uiShared; private readonly UiSharedService _uiShared;
private readonly TypingIndicatorStateService _typingStateService;
private readonly ChatTypingDetectionService _chatTypingDetectionService;
private static readonly string DtrDefaultPreviewText = DtrEntry.DefaultGlyph + " 123"; private static readonly string DtrDefaultPreviewText = DtrEntry.DefaultGlyph + " 123";
private bool _deleteAccountPopupModalShown = false; private bool _deleteAccountPopupModalShown = false;
private string _lastTab = string.Empty; private string _lastTab = string.Empty;
@@ -80,7 +82,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
FileCompactor fileCompactor, ApiController apiController, FileCompactor fileCompactor, ApiController apiController,
IpcManager ipcManager, IpcProvider ipcProvider, CacheMonitor cacheMonitor, IpcManager ipcManager, IpcProvider ipcProvider, CacheMonitor cacheMonitor,
DalamudUtilService dalamudUtilService, AccountRegistrationService registerService, DalamudUtilService dalamudUtilService, AccountRegistrationService registerService,
AutoDetectSuppressionService autoDetectSuppressionService) : base(logger, mediator, "Umbra Settings", performanceCollector) AutoDetectSuppressionService autoDetectSuppressionService,
TypingIndicatorStateService typingIndicatorStateService,
ChatTypingDetectionService chatTypingDetectionService) : base(logger, mediator, "Umbra Settings", performanceCollector)
{ {
_configService = configService; _configService = configService;
_pairManager = pairManager; _pairManager = pairManager;
@@ -102,6 +106,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
_autoDetectSuppressionService = autoDetectSuppressionService; _autoDetectSuppressionService = autoDetectSuppressionService;
_fileCompactor = fileCompactor; _fileCompactor = fileCompactor;
_uiShared = uiShared; _uiShared = uiShared;
_typingStateService = typingIndicatorStateService;
_chatTypingDetectionService = chatTypingDetectionService;
AllowClickthrough = false; AllowClickthrough = false;
AllowPinning = false; AllowPinning = false;
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
@@ -1123,8 +1129,10 @@ public class SettingsUi : WindowMediatorSubscriberBase
var useNameColors = _configService.Current.UseNameColors; var useNameColors = _configService.Current.UseNameColors;
var nameColors = _configService.Current.NameColors; var nameColors = _configService.Current.NameColors;
var autoPausedNameColors = _configService.Current.BlockedNameColors; var autoPausedNameColors = _configService.Current.BlockedNameColors;
var typingEnabled = _configService.Current.TypingIndicatorEnabled;
var typingIndicatorNameplates = _configService.Current.TypingIndicatorShowOnNameplates; var typingIndicatorNameplates = _configService.Current.TypingIndicatorShowOnNameplates;
var typingIndicatorPartyList = _configService.Current.TypingIndicatorShowOnPartyList; var typingIndicatorPartyList = _configService.Current.TypingIndicatorShowOnPartyList;
var typingShowSelf = _configService.Current.TypingIndicatorShowSelf;
if (ImGui.Checkbox("Coloriser les plaques de nom des paires", ref useNameColors)) if (ImGui.Checkbox("Coloriser les plaques de nom des paires", ref useNameColors))
{ {
_configService.Current.UseNameColors = useNameColors; _configService.Current.UseNameColors = useNameColors;
@@ -1152,42 +1160,60 @@ public class SettingsUi : WindowMediatorSubscriberBase
} }
} }
if (ImGui.Checkbox("Afficher la bulle de frappe sur les plaques", ref typingIndicatorNameplates)) if (ImGui.Checkbox("Activer le système d'indicateur de frappe", ref typingEnabled))
{ {
_configService.Current.TypingIndicatorShowOnNameplates = typingIndicatorNameplates; _configService.Current.TypingIndicatorEnabled = typingEnabled;
_configService.Save(); _configService.Save();
_chatTypingDetectionService.SoftRestart();
} }
_uiShared.DrawHelpText("Ajoute une bulle '...' sur la plaque des paires en train d'écrire."); _uiShared.DrawHelpText("Active ou désactive complètement l'envoi/la réception et l'affichage des bulles de frappe.");
using (ImRaii.Disabled(!typingIndicatorNameplates)) if (typingEnabled)
{ {
using var indentTyping = ImRaii.PushIndent(); if (ImGui.Checkbox("Afficher la bulle de frappe sur les plaques", ref typingIndicatorNameplates))
var bubbleSize = _configService.Current.TypingIndicatorBubbleSize;
TypingIndicatorBubbleSize? selectedBubbleSize = _uiShared.DrawCombo("Taille de la bulle de frappe##typingBubbleSize",
Enum.GetValues<TypingIndicatorBubbleSize>(),
size => size switch
{
TypingIndicatorBubbleSize.Small => "Petite",
TypingIndicatorBubbleSize.Medium => "Moyenne",
TypingIndicatorBubbleSize.Large => "Grande",
_ => size.ToString()
},
null,
bubbleSize);
if (selectedBubbleSize.HasValue && selectedBubbleSize.Value != bubbleSize)
{ {
_configService.Current.TypingIndicatorBubbleSize = selectedBubbleSize.Value; _configService.Current.TypingIndicatorShowOnNameplates = typingIndicatorNameplates;
_configService.Save(); _configService.Save();
} }
} _uiShared.DrawHelpText("Ajoute une bulle '...' sur la plaque des paires en train d'écrire.");
if (ImGui.Checkbox("Tracer la frappe dans la liste de groupe", ref typingIndicatorPartyList)) if (typingIndicatorNameplates)
{ {
_configService.Current.TypingIndicatorShowOnPartyList = typingIndicatorPartyList; using var indentTyping = ImRaii.PushIndent();
_configService.Save(); var bubbleSize = _configService.Current.TypingIndicatorBubbleSize;
TypingIndicatorBubbleSize? selectedBubbleSize = _uiShared.DrawCombo("Taille de la bulle de frappe##typingBubbleSize",
Enum.GetValues<TypingIndicatorBubbleSize>(),
size => size switch
{
TypingIndicatorBubbleSize.Small => "Petite",
TypingIndicatorBubbleSize.Medium => "Moyenne",
TypingIndicatorBubbleSize.Large => "Grande",
_ => size.ToString()
},
null,
bubbleSize);
if (selectedBubbleSize.HasValue && selectedBubbleSize.Value != bubbleSize)
{
_configService.Current.TypingIndicatorBubbleSize = selectedBubbleSize.Value;
_configService.Save();
}
if (ImGui.Checkbox("Tracer la frappe dans la liste de groupe", ref typingIndicatorPartyList))
{
_configService.Current.TypingIndicatorShowOnPartyList = typingIndicatorPartyList;
_configService.Save();
}
_uiShared.DrawHelpText("Consigne dans les journaux quand une paire du groupe est en train d'écrire (bulle visuelle ultérieure).");
if (ImGui.Checkbox("Afficher ma propre bulle", ref typingShowSelf))
{
_configService.Current.TypingIndicatorShowSelf = typingShowSelf;
_configService.Save();
}
_uiShared.DrawHelpText("Affiche votre propre bulle lorsque vous tapez (utile pour test/retour visuel).");
}
} }
_uiShared.DrawHelpText("Consigne dans les journaux quand une paire du groupe est en train d'écrire (bulle visuelle ultérieure).");
if (ImGui.Checkbox("Show separate Visible group", ref showVisibleSeparate)) if (ImGui.Checkbox("Show separate Visible group", ref showVisibleSeparate))
{ {

View File

@@ -11,11 +11,13 @@ using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services; using MareSynchronos.Services;
using MareSynchronos.Services.AutoDetect; using MareSynchronos.Services.AutoDetect;
using MareSynchronos.Services.Mediator; using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.Notifications;
using MareSynchronos.WebAPI; using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Globalization; using System.Globalization;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.UI.Components.Popup; namespace MareSynchronos.UI.Components.Popup;
@@ -28,6 +30,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private readonly UiSharedService _uiSharedService; private readonly UiSharedService _uiSharedService;
private readonly SyncshellDiscoveryService _syncshellDiscoveryService; private readonly SyncshellDiscoveryService _syncshellDiscoveryService;
private readonly NotificationTracker _notificationTracker;
private List<BannedGroupUserDto> _bannedUsers = []; private List<BannedGroupUserDto> _bannedUsers = [];
private int _multiInvites; private int _multiInvites;
private string _newPassword; private string _newPassword;
@@ -41,10 +44,20 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private bool _autoDetectVisible; private bool _autoDetectVisible;
private bool _autoDetectPasswordDisabled; private bool _autoDetectPasswordDisabled;
private string? _autoDetectMessage; private string? _autoDetectMessage;
private bool _autoDetectDesiredVisibility;
private int _adDurationHours = 2;
private bool _adRecurring = false;
private readonly bool[] _adWeekdays = new bool[7];
private int _adStartHour = 21;
private int _adStartMinute = 0;
private int _adEndHour = 23;
private int _adEndMinute = 0;
private const string AutoDetectTimeZone = "Europe/Paris";
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, MareMediator mediator, ApiController apiController, public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, MareMediator mediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService, UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService,
GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService) GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, NotificationTracker notificationTracker)
: base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
{ {
GroupFullInfo = groupFullInfo; GroupFullInfo = groupFullInfo;
@@ -52,12 +65,14 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
_uiSharedService = uiSharedService; _uiSharedService = uiSharedService;
_pairManager = pairManager; _pairManager = pairManager;
_syncshellDiscoveryService = syncshellDiscoveryService; _syncshellDiscoveryService = syncshellDiscoveryService;
_notificationTracker = notificationTracker;
_isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal); _isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal);
_isModerator = GroupFullInfo.GroupUserInfo.IsModerator(); _isModerator = GroupFullInfo.GroupUserInfo.IsModerator();
_newPassword = string.Empty; _newPassword = string.Empty;
_multiInvites = 30; _multiInvites = 30;
_pwChangeSuccess = true; _pwChangeSuccess = true;
_autoDetectVisible = groupFullInfo.AutoDetectVisible; _autoDetectVisible = groupFullInfo.AutoDetectVisible;
_autoDetectDesiredVisibility = _autoDetectVisible;
_autoDetectPasswordDisabled = groupFullInfo.PasswordTemporarilyDisabled; _autoDetectPasswordDisabled = groupFullInfo.PasswordTemporarilyDisabled;
Mediator.Subscribe<SyncshellAutoDetectStateChanged>(this, OnSyncshellAutoDetectStateChanged); Mediator.Subscribe<SyncshellAutoDetectStateChanged>(this, OnSyncshellAutoDetectStateChanged);
IsOpen = true; IsOpen = true;
@@ -89,6 +104,9 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
ImGui.Separator(); ImGui.Separator();
var perm = GroupFullInfo.GroupPermissions; var perm = GroupFullInfo.GroupPermissions;
using var tabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor);
using var tabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor);
using var tabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor);
using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID); using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID);
if (tabbar) if (tabbar)
@@ -498,26 +516,93 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
UiSharedService.ColorTextWrapped(_autoDetectMessage!, ImGuiColors.DalamudYellow); UiSharedService.ColorTextWrapped(_autoDetectMessage!, ImGuiColors.DalamudYellow);
} }
bool desiredVisibility = _autoDetectVisible;
using (ImRaii.Disabled(_autoDetectToggleInFlight || _autoDetectStateLoading)) using (ImRaii.Disabled(_autoDetectToggleInFlight || _autoDetectStateLoading))
{ {
if (ImGui.Checkbox("Afficher cette Syncshell dans l'AutoDetect", ref desiredVisibility)) if (ImGui.Checkbox("Afficher cette Syncshell dans l'AutoDetect", ref _autoDetectDesiredVisibility))
{ {
_ = ToggleAutoDetectAsync(desiredVisibility); // Only change local desired state; sending is done via the validate button
} }
} }
_uiSharedService.DrawHelpText("Quand cette option est activée, le mot de passe devient inactif tant que la visibilité est maintenue."); _uiSharedService.DrawHelpText("Quand cette option est activée, le mot de passe devient inactif tant que la visibilité est maintenue.");
if (_autoDetectDesiredVisibility)
{
ImGuiHelpers.ScaledDummy(4);
ImGui.TextUnformatted("Options d'affichage AutoDetect");
ImGui.Separator();
// Recurring toggle first
ImGui.Checkbox("Affichage récurrent", ref _adRecurring);
_uiSharedService.DrawHelpText("Si activé, vous pouvez choisir les jours et une plage horaire récurrents. Si désactivé, seule la durée sera prise en compte.");
// Duration in hours (only when NOT recurring)
if (!_adRecurring)
{
ImGuiHelpers.ScaledDummy(4);
int duration = _adDurationHours;
ImGui.PushItemWidth(120 * ImGuiHelpers.GlobalScale);
if (ImGui.InputInt("Durée (heures)", ref duration))
{
_adDurationHours = Math.Clamp(duration, 1, 240);
}
ImGui.PopItemWidth();
_uiSharedService.DrawHelpText("Combien de temps la Syncshell doit rester visible, en heures.");
}
ImGuiHelpers.ScaledDummy(4);
if (_adRecurring)
{
ImGuiHelpers.ScaledDummy(4);
ImGui.TextUnformatted("Jours de la semaine actifs :");
string[] daysFr = new[] { "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim" };
for (int i = 0; i < 7; i++)
{
ImGui.SameLine(i == 0 ? 0 : 0);
bool v = _adWeekdays[i];
if (ImGui.Checkbox($"##adwd{i}", ref v)) _adWeekdays[i] = v;
ImGui.SameLine();
ImGui.TextUnformatted(daysFr[i]);
if (i < 6) ImGui.SameLine();
}
ImGui.NewLine();
_uiSharedService.DrawHelpText("Sélectionnez les jours où l'affichage est autorisé (ex: jeudi et dimanche).");
ImGuiHelpers.ScaledDummy(4);
ImGui.TextUnformatted("Plage horaire (heure locale Europe/Paris) :");
ImGui.PushItemWidth(60 * ImGuiHelpers.GlobalScale);
ImGui.InputInt("Début heure", ref _adStartHour); ImGui.SameLine();
ImGui.InputInt("min", ref _adStartMinute);
_adStartHour = Math.Clamp(_adStartHour, 0, 23);
_adStartMinute = Math.Clamp(_adStartMinute, 0, 59);
ImGui.SameLine();
ImGui.TextUnformatted("→"); ImGui.SameLine();
ImGui.InputInt("Fin heure", ref _adEndHour); ImGui.SameLine();
ImGui.InputInt("min ", ref _adEndMinute);
_adEndHour = Math.Clamp(_adEndHour, 0, 23);
_adEndMinute = Math.Clamp(_adEndMinute, 0, 59);
ImGui.PopItemWidth();
_uiSharedService.DrawHelpText("Exemple : de 21h00 à 23h00. Le fuseau utilisé est Europe/Paris (avec changements été/hiver).");
}
}
if (_autoDetectPasswordDisabled && _autoDetectVisible) if (_autoDetectPasswordDisabled && _autoDetectVisible)
{ {
UiSharedService.ColorTextWrapped("Le mot de passe est actuellement désactivé pendant la visibilité AutoDetect.", ImGuiColors.DalamudYellow); UiSharedService.ColorTextWrapped("Le mot de passe est actuellement désactivé pendant la visibilité AutoDetect.", ImGuiColors.DalamudYellow);
} }
ImGuiHelpers.ScaledDummy(6); ImGuiHelpers.ScaledDummy(6);
if (ImGui.Button("Recharger l'état")) using (ImRaii.Disabled(_autoDetectToggleInFlight || _autoDetectStateLoading))
{ {
_autoDetectStateLoading = true; if (ImGui.Button("Valider et envoyer"))
_ = EnsureAutoDetectStateAsync(true); {
_ = SubmitAutoDetectAsync();
}
ImGui.SameLine();
if (ImGui.Button("Recharger l'état"))
{
_autoDetectStateLoading = true;
_ = EnsureAutoDetectStateAsync(true);
}
} }
} }
@@ -569,6 +654,11 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
_autoDetectMessage = desiredVisibility _autoDetectMessage = desiredVisibility
? "La Syncshell est désormais visible dans AutoDetect." ? "La Syncshell est désormais visible dans AutoDetect."
: "La Syncshell n'est plus visible dans AutoDetect."; : "La Syncshell n'est plus visible dans AutoDetect.";
if (desiredVisibility)
{
PublishSyncshellPublicNotification();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -580,6 +670,72 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
} }
} }
private async Task SubmitAutoDetectAsync()
{
if (_autoDetectToggleInFlight)
{
return;
}
_autoDetectToggleInFlight = true;
_autoDetectMessage = null;
try
{
// Duration always used when visible
int? duration = _autoDetectDesiredVisibility ? _adDurationHours : null;
// Scheduling fields only if recurring is enabled
int[]? weekdaysArr = null;
TimeSpan? start = null;
TimeSpan? end = null;
string? tz = null;
if (_autoDetectDesiredVisibility && _adRecurring)
{
List<int> weekdays = new();
for (int i = 0; i < 7; i++) if (_adWeekdays[i]) weekdays.Add(i);
weekdaysArr = weekdays.Count > 0 ? weekdays.ToArray() : Array.Empty<int>();
start = new TimeSpan(_adStartHour, _adStartMinute, 0);
end = new TimeSpan(_adEndHour, _adEndMinute, 0);
tz = AutoDetectTimeZone;
}
var ok = await _syncshellDiscoveryService.SetVisibilityAsync(
GroupFullInfo.GID,
_autoDetectDesiredVisibility,
duration,
weekdaysArr,
start,
end,
tz,
CancellationToken.None).ConfigureAwait(false);
if (!ok)
{
_autoDetectMessage = "Impossible d'envoyer les paramètres AutoDetect.";
return;
}
await EnsureAutoDetectStateAsync(true).ConfigureAwait(false);
_autoDetectMessage = _autoDetectDesiredVisibility
? "Paramètres AutoDetect envoyés. La Syncshell sera visible selon le planning défini."
: "La Syncshell n'est plus visible dans AutoDetect.";
if (_autoDetectDesiredVisibility)
{
PublishSyncshellPublicNotification();
}
}
catch (Exception ex)
{
_autoDetectMessage = $"Erreur lors de l'envoi des paramètres AutoDetect : {ex.Message}";
}
finally
{
_autoDetectToggleInFlight = false;
}
}
private void ApplyAutoDetectState(bool visible, bool passwordDisabled, bool fromServer) private void ApplyAutoDetectState(bool visible, bool passwordDisabled, bool fromServer)
{ {
_autoDetectVisible = visible; _autoDetectVisible = visible;
@@ -602,4 +758,19 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
{ {
Mediator.Publish(new RemoveWindowMessage(this)); Mediator.Publish(new RemoveWindowMessage(this));
} }
private void PublishSyncshellPublicNotification()
{
try
{
var title = $"Syncshell publique: {GroupFullInfo.GroupAliasOrGID}";
var message = "La Syncshell est désormais visible via AutoDetect.";
Mediator.Publish(new DualNotificationMessage(title, message, NotificationType.Info, TimeSpan.FromSeconds(4)));
_notificationTracker.Upsert(NotificationEntry.SyncshellPublic(GroupFullInfo.GID, GroupFullInfo.GroupAliasOrGID));
}
catch
{
// swallow any notification errors to not break UI flow
}
}
} }

View File

@@ -7,6 +7,7 @@ using Dalamud.Interface.Utility;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using MareSynchronos.API.Data; using MareSynchronos.API.Data;
using MareSynchronos.API.Data.Extensions; using MareSynchronos.API.Data.Extensions;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.MareConfiguration; using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Models; using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Pairs; using MareSynchronos.PlayerData.Pairs;
@@ -75,6 +76,10 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
if (!_clientState.IsLoggedIn) if (!_clientState.IsLoggedIn)
return; return;
var typingEnabled = _configService.Current.TypingIndicatorEnabled;
if (!typingEnabled)
return;
var showParty = _configService.Current.TypingIndicatorShowOnPartyList; var showParty = _configService.Current.TypingIndicatorShowOnPartyList;
var showNameplates = _configService.Current.TypingIndicatorShowOnNameplates; var showNameplates = _configService.Current.TypingIndicatorShowOnNameplates;
@@ -97,14 +102,16 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
} }
} }
private unsafe void DrawPartyIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate)> activeTypers, private unsafe void DrawPartyIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope)> activeTypers,
bool selfActive, DateTime now, DateTime selfStart, DateTime selfLast) bool selfActive, DateTime now, DateTime selfStart, DateTime selfLast)
{ {
var partyAddon = (AtkUnitBase*)_gameGui.GetAddonByName("_PartyList", 1).Address; var partyAddon = (AtkUnitBase*)_gameGui.GetAddonByName("_PartyList", 1).Address;
if (partyAddon == null || !partyAddon->IsVisible) if (partyAddon == null || !partyAddon->IsVisible)
return; return;
var showSelf = _configService.Current.TypingIndicatorShowSelf;
if (selfActive if (selfActive
&& showSelf
&& (now - selfStart) >= TypingDisplayDelay && (now - selfStart) >= TypingDisplayDelay
&& (now - selfLast) <= TypingDisplayFade) && (now - selfLast) <= TypingDisplayFade)
{ {
@@ -180,14 +187,16 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.9f))); ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.9f)));
} }
private unsafe void DrawNameplateIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate)> activeTypers, private unsafe void DrawNameplateIndicators(ImDrawListPtr drawList, IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope)> activeTypers,
bool selfActive, DateTime now, DateTime selfStart, DateTime selfLast) bool selfActive, DateTime now, DateTime selfStart, DateTime selfLast)
{ {
var iconWrap = _textureProvider.GetFromGameIcon(NameplateIconId).GetWrapOrEmpty(); var iconWrap = _textureProvider.GetFromGameIcon(NameplateIconId).GetWrapOrEmpty();
if (iconWrap == null || iconWrap.Handle == IntPtr.Zero) if (iconWrap == null || iconWrap.Handle == IntPtr.Zero)
return; return;
var showSelf = _configService.Current.TypingIndicatorShowSelf;
if (selfActive if (selfActive
&& showSelf
&& _clientState.LocalPlayer != null && _clientState.LocalPlayer != null
&& (now - selfStart) >= TypingDisplayDelay && (now - selfStart) >= TypingDisplayDelay
&& (now - selfLast) <= TypingDisplayFade) && (now - selfLast) <= TypingDisplayFade)
@@ -212,11 +221,22 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
var pairName = pair?.PlayerName ?? entry.User.AliasOrUID ?? string.Empty; var pairName = pair?.PlayerName ?? entry.User.AliasOrUID ?? string.Empty;
var pairIdent = pair?.Ident ?? string.Empty; var pairIdent = pair?.Ident ?? string.Empty;
var isPartyMember = IsPartyMember(objectId, pairName); var isPartyMember = IsPartyMember(objectId, pairName);
// Enforce party-only visibility when the scope is Party/CrossParty
if (entry.Scope is TypingScope.Party or TypingScope.CrossParty)
{
if (!isPartyMember)
{
_typedLogger.LogTrace("TypingIndicator: suppressed non-party bubble for {uid} due to scope={scope}", uid, entry.Scope);
continue;
}
}
var isRelevantMember = IsPlayerRelevant(pair, isPartyMember); var isRelevantMember = IsPlayerRelevant(pair, isPartyMember);
if (objectId != uint.MaxValue && objectId != 0 && TryDrawNameplateBubble(drawList, iconWrap, objectId)) if (objectId != uint.MaxValue && objectId != 0 && TryDrawNameplateBubble(drawList, iconWrap, objectId))
{ {
_typedLogger.LogTrace("TypingIndicator: drew nameplate bubble for {uid} (objectId={objectId})", uid, objectId); _typedLogger.LogTrace("TypingIndicator: drew nameplate bubble for {uid} (objectId={objectId}, scope={scope})", uid, objectId, entry.Scope);
continue; continue;
} }
@@ -226,13 +246,20 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
if (!isRelevantMember && !isNearby) if (!isRelevantMember && !isNearby)
continue; continue;
// For Party/CrossParty scope, do not draw fallback world icon for non-party even if nearby
if (entry.Scope is TypingScope.Party or TypingScope.CrossParty)
{
if (!isPartyMember)
continue;
}
if (pair == null) if (pair == null)
{ {
_typedLogger.LogTrace("TypingIndicator: no pair found for {uid}, attempting fallback", uid); _typedLogger.LogTrace("TypingIndicator: no pair found for {uid}, attempting fallback", uid);
} }
_typedLogger.LogTrace("TypingIndicator: fallback draw for {uid} (objectId={objectId}, name={name}, ident={ident})", _typedLogger.LogTrace("TypingIndicator: fallback draw for {uid} (objectId={objectId}, name={name}, ident={ident}, scope={scope})",
uid, objectId, pairName, pairIdent); uid, objectId, pairName, pairIdent, entry.Scope);
if (hasWorldPosition) if (hasWorldPosition)
{ {
@@ -318,26 +345,6 @@ public sealed class TypingIndicatorOverlay : WindowMediatorSubscriberBase
if (!iconVisible) if (!iconVisible)
{ {
sizeScaleFactor = 2.5f;
var anchor = rootPosition + iconLocalPosition + new Vector2(iconDimensions.X * 0.5f, 0f);
var distanceOffset = new Vector2(0f, -16f + distance) * scaleVector;
if (iconNode->Height == 24)
{
distanceOffset.Y += 16f * scaleY;
}
distanceOffset.Y += 64f * scaleY;
var referenceSize = GetConfiguredBubbleSize(scaleX * sizeScaleFactor, scaleY * sizeScaleFactor, false, TypingIndicatorBubbleSize.Small);
var manualOffset = new Vector2(referenceSize.X * 2.00f, referenceSize.Y * 2.00f);
var iconSizeHidden = GetConfiguredBubbleSize(scaleX * sizeScaleFactor, scaleY * sizeScaleFactor, false);
var center = anchor + distanceOffset + manualOffset;
var topLeft = center - (iconSizeHidden / 2f);
drawList.AddImage(textureWrap.Handle, topLeft, topLeft + iconSizeHidden, Vector2.Zero, Vector2.One,
ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 0.95f)));
return true; return true;
} }

View File

@@ -541,6 +541,100 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
return result; return result;
} }
public bool IconButtonCentered(FontAwesomeIcon icon, float? height = null, float xOffset = 0f, float yOffset = 0f, bool square = false)
{
string text = icon.ToIconString();
ImGui.PushID($"centered-{text}");
Vector2 glyphSize;
using (IconFont.Push())
glyphSize = ImGui.CalcTextSize(text);
ImDrawListPtr drawList = ImGui.GetWindowDrawList();
Vector2 cursorScreenPos = ImGui.GetCursorScreenPos();
float frameHeight = height ?? ImGui.GetFrameHeight();
float buttonWidth = square ? frameHeight : glyphSize.X + ImGui.GetStyle().FramePadding.X * 2f;
using var hoverColor = ImRaii.PushColor(ImGuiCol.ButtonHovered, AccentHoverColor);
using var activeColor = ImRaii.PushColor(ImGuiCol.ButtonActive, AccentActiveColor);
bool clicked = ImGui.Button(string.Empty, new Vector2(buttonWidth, frameHeight));
Vector2 pos = new Vector2(
cursorScreenPos.X + (buttonWidth - glyphSize.X) / 2f + xOffset,
cursorScreenPos.Y + frameHeight / 2f - glyphSize.Y / 2f + yOffset);
using (IconFont.Push())
drawList.AddText(pos, ImGui.GetColorU32(ImGuiCol.Text), text);
ImGui.PopID();
return clicked;
}
public bool IconPauseButtonCentered(float? height = null)
{
ImGui.PushID("centered-pause-custom");
Vector2 glyphSize;
using (IconFont.Push())
glyphSize = ImGui.CalcTextSize(FontAwesomeIcon.Pause.ToIconString());
float frameHeight = height ?? ImGui.GetFrameHeight();
float buttonWidth = glyphSize.X + ImGui.GetStyle().FramePadding.X * 2f;
using var hoverColor = ImRaii.PushColor(ImGuiCol.ButtonHovered, AccentHoverColor);
using var activeColor = ImRaii.PushColor(ImGuiCol.ButtonActive, AccentActiveColor);
var drawList = ImGui.GetWindowDrawList();
var buttonTopLeft = ImGui.GetCursorScreenPos();
bool clicked = ImGui.Button(string.Empty, new Vector2(buttonWidth, frameHeight));
var textColor = ImGui.GetColorU32(ImGuiCol.Text);
float h = frameHeight * 0.55f; // bar height
float w = MathF.Max(1f, frameHeight * 0.16f); // bar width
float gap = MathF.Max(1f, w * 0.9f); // gap between bars
float total = 2f * w + gap;
float startX = buttonTopLeft.X + (buttonWidth - total) / 2f;
float startY = buttonTopLeft.Y + (frameHeight - h) / 2f;
float rounding = w * 0.35f;
drawList.AddRectFilled(new Vector2(startX, startY), new Vector2(startX + w, startY + h), textColor, rounding);
float rightX = startX + w + gap;
drawList.AddRectFilled(new Vector2(rightX, startY), new Vector2(rightX + w, startY + h), textColor, rounding);
ImGui.PopID();
return clicked;
}
public bool IconPlusButtonCentered(float? height = null)
{
ImGui.PushID("centered-plus-custom");
Vector2 glyphSize;
using (IconFont.Push())
glyphSize = ImGui.CalcTextSize(FontAwesomeIcon.Plus.ToIconString());
float frameHeight = height ?? ImGui.GetFrameHeight();
float buttonWidth = glyphSize.X + ImGui.GetStyle().FramePadding.X * 2f;
using var hoverColor = ImRaii.PushColor(ImGuiCol.ButtonHovered, AccentHoverColor);
using var activeColor = ImRaii.PushColor(ImGuiCol.ButtonActive, AccentActiveColor);
var drawList = ImGui.GetWindowDrawList();
var buttonTopLeft = ImGui.GetCursorScreenPos();
bool clicked = ImGui.Button(string.Empty, new Vector2(buttonWidth, frameHeight));
var color = ImGui.GetColorU32(ImGuiCol.Text);
float armThickness = MathF.Max(1f, frameHeight * 0.14f);
float crossSize = frameHeight * 0.55f; // total length of vertical/horizontal arms
float startX = buttonTopLeft.X + (buttonWidth - crossSize) / 2f;
float startY = buttonTopLeft.Y + (frameHeight - crossSize) / 2f;
float endX = startX + crossSize;
float endY = startY + crossSize;
float r = armThickness * 0.35f;
float hY1 = startY + (crossSize - armThickness) / 2f;
drawList.AddRectFilled(new Vector2(startX, hY1), new Vector2(endX, hY1 + armThickness), color, r);
float vX1 = startX + (crossSize - armThickness) / 2f;
drawList.AddRectFilled(new Vector2(vX1, startY), new Vector2(vX1 + armThickness, endY), color, r);
ImGui.PopID();
return clicked;
}
private bool IconTextButtonInternal(FontAwesomeIcon icon, string text, Vector4? defaultColor = null, float? width = null, bool useAccentHover = true) private bool IconTextButtonInternal(FontAwesomeIcon icon, string text, Vector4? defaultColor = null, float? width = null, bool useAccentHover = true)
{ {

View File

@@ -103,6 +103,21 @@ public partial class ApiController
await _mareHub!.SendAsync(nameof(UserSetTypingState), isTyping).ConfigureAwait(false); await _mareHub!.SendAsync(nameof(UserSetTypingState), isTyping).ConfigureAwait(false);
} }
public async Task UserSetTypingState(bool isTyping, MareSynchronos.API.Data.Enum.TypingScope scope)
{
CheckConnection();
try
{
await _mareHub!.SendAsync(nameof(UserSetTypingState), isTyping, scope).ConfigureAwait(false);
}
catch (Exception ex)
{
// fallback for older servers without scope support
Logger.LogDebug(ex, "UserSetTypingState(scope) not supported on server, falling back to legacy call");
await _mareHub!.SendAsync(nameof(UserSetTypingState), isTyping).ConfigureAwait(false);
}
}
private async Task PushCharacterDataInternal(CharacterData character, List<UserData> visibleCharacters) private async Task PushCharacterDataInternal(CharacterData character, List<UserData> visibleCharacters)
{ {
Logger.LogInformation("Pushing character data for {hash} to {charas}", character.DataHash.Value, string.Join(", ", visibleCharacters.Select(c => c.AliasOrUID))); Logger.LogInformation("Pushing character data for {hash} to {charas}", character.DataHash.Value, string.Join(", ", visibleCharacters.Select(c => c.AliasOrUID)));

View File

@@ -180,7 +180,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IM
InitializeApiHooks(); InitializeApiHooks();
await _mareHub.StartAsync(token).ConfigureAwait(false); await _mareHub.StartAsync(token).ConfigureAwait(false);
_connectionDto = await GetConnectionDto().ConfigureAwait(false); _connectionDto = await GetConnectionDto().ConfigureAwait(false);
ServerState = ServerState.Connected; ServerState = ServerState.Connected;

View File

@@ -3,8 +3,6 @@ using MareSynchronos.Services;
using MareSynchronos.Services.Mediator; using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.WebAPI.SignalR.Utils; using MareSynchronos.WebAPI.SignalR.Utils;
using MessagePack;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -172,25 +170,7 @@ public class HubFactory : MediatorSubscriberBase
options.SkipNegotiation = hubConfig.SkipNegotiation && (transports == HttpTransportType.WebSockets); options.SkipNegotiation = hubConfig.SkipNegotiation && (transports == HttpTransportType.WebSockets);
options.Transports = transports; options.Transports = transports;
}) })
.AddMessagePackProtocol(opt => .AddJsonProtocol()
{
var resolver = CompositeResolver.Create(StandardResolverAllowPrivate.Instance,
BuiltinResolver.Instance,
AttributeFormatterResolver.Instance,
// replace enum resolver
DynamicEnumAsStringResolver.Instance,
DynamicGenericResolver.Instance,
DynamicUnionResolver.Instance,
DynamicObjectResolver.Instance,
PrimitiveObjectResolver.Instance,
// final fallback(last priority)
StandardResolver.Instance);
opt.SerializerOptions =
MessagePackSerializerOptions.Standard
.WithCompression(MessagePackCompression.Lz4Block)
.WithResolver(resolver);
})
.WithAutomaticReconnect(new ForeverRetryPolicy(Mediator)) .WithAutomaticReconnect(new ForeverRetryPolicy(Mediator))
.ConfigureLogging(a => .ConfigureLogging(a =>
{ {