From 35d7656784e774f0f6920e387099e3b9a94e5a10 Mon Sep 17 00:00:00 2001 From: DrSmugleaf Date: Sun, 13 Aug 2023 16:03:17 -0700 Subject: [PATCH] Add ahelp typing indicator for admins (#19060) * Add ahelp typing indicator for admins * Lower typing updates throttle from 3 seconds to 1 * Add stopping typing when sending a message * Lower typing indicator timeout from 15 to 10 seconds --- .../Administration/Systems/BwoinkSystem.cs | 17 ++++++ .../Administration/UI/Bwoink/BwoinkPanel.xaml | 1 + .../UI/Bwoink/BwoinkPanel.xaml.cs | 59 +++++++++++++++++++ .../Systems/Bwoink/AHelpUIController.cs | 23 ++++++++ .../Administration/Systems/BwoinkSystem.cs | 29 +++++++++ .../Administration/SharedBwoinkSystem.cs | 34 +++++++++++ .../Locale/en-US/administration/bwoink.ftl | 5 ++ 7 files changed, 168 insertions(+) diff --git a/Content.Client/Administration/Systems/BwoinkSystem.cs b/Content.Client/Administration/Systems/BwoinkSystem.cs index a1d7a17dc9..eafd40cc9c 100644 --- a/Content.Client/Administration/Systems/BwoinkSystem.cs +++ b/Content.Client/Administration/Systems/BwoinkSystem.cs @@ -2,13 +2,17 @@ using Content.Shared.Administration; using JetBrains.Annotations; using Robust.Shared.Network; +using Robust.Shared.Timing; namespace Content.Client.Administration.Systems { [UsedImplicitly] public sealed class BwoinkSystem : SharedBwoinkSystem { + [Dependency] private readonly IGameTiming _timing = default!; + public event EventHandler? OnBwoinkTextMessageRecieved; + private (TimeSpan Timestamp, bool Typing) _lastTypingUpdateSent; protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs) { @@ -20,6 +24,19 @@ namespace Content.Client.Administration.Systems // Reuse the channel ID as the 'true sender'. // Server will ignore this and if someone makes it not ignore this (which is bad, allows impersonation!!!), that will help. RaiseNetworkEvent(new BwoinkTextMessage(channelId, channelId, text)); + SendInputTextUpdated(channelId, false); + } + + public void SendInputTextUpdated(NetUserId channel, bool typing) + { + if (_lastTypingUpdateSent.Typing == typing && + _lastTypingUpdateSent.Timestamp + TimeSpan.FromSeconds(1) > _timing.RealTime) + { + return; + } + + _lastTypingUpdateSent = (_timing.RealTime, typing); + RaiseNetworkEvent(new BwoinkClientTypingUpdated(channel, typing)); } } } diff --git a/Content.Client/Administration/UI/Bwoink/BwoinkPanel.xaml b/Content.Client/Administration/UI/Bwoink/BwoinkPanel.xaml index 5c3e7f667d..9213768ddc 100644 --- a/Content.Client/Administration/UI/Bwoink/BwoinkPanel.xaml +++ b/Content.Client/Administration/UI/Bwoink/BwoinkPanel.xaml @@ -4,6 +4,7 @@ Orientation="Vertical" HorizontalExpand="True"> + diff --git a/Content.Client/Administration/UI/Bwoink/BwoinkPanel.xaml.cs b/Content.Client/Administration/UI/Bwoink/BwoinkPanel.xaml.cs index ee345cb51c..7a032d0bbd 100644 --- a/Content.Client/Administration/UI/Bwoink/BwoinkPanel.xaml.cs +++ b/Content.Client/Administration/UI/Bwoink/BwoinkPanel.xaml.cs @@ -2,6 +2,7 @@ using Content.Shared.Administration; using Robust.Client.AutoGenerated; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; +using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Client.Administration.UI.Bwoink @@ -13,6 +14,8 @@ namespace Content.Client.Administration.UI.Bwoink public int Unread { get; private set; } = 0; public DateTime LastMessage { get; private set; } = DateTime.MinValue; + private List PeopleTyping { get; set; } = new(); + public event Action? InputTextChanged; public BwoinkPanel(Action messageSender) { @@ -32,6 +35,8 @@ namespace Content.Client.Administration.UI.Bwoink Unread = 0; }; SenderLineEdit.OnTextEntered += Input_OnTextEntered; + SenderLineEdit.OnTextChanged += Input_OnTextChanged; + UpdateTypingIndicator(); } private void Input_OnTextEntered(LineEdit.LineEditEventArgs args) @@ -43,6 +48,11 @@ namespace Content.Client.Administration.UI.Bwoink SenderLineEdit.Clear(); } + private void Input_OnTextChanged(LineEdit.LineEditEventArgs args) + { + InputTextChanged?.Invoke(args.Text); + } + public void ReceiveLine(SharedBwoinkSystem.BwoinkTextMessage message) { if (!Visible) @@ -53,5 +63,54 @@ namespace Content.Client.Administration.UI.Bwoink TextOutput.AddMessage(formatted); LastMessage = message.SentAt; } + + private void UpdateTypingIndicator() + { + var msg = new FormattedMessage(); + msg.PushColor(Color.LightGray); + + var text = PeopleTyping.Count == 0 + ? string.Empty + : Loc.GetString("bwoink-system-typing-indicator", + ("players", string.Join(", ", PeopleTyping)), + ("count", PeopleTyping.Count)); + + msg.AddText(text); + msg.Pop(); + + TypingIndicator.SetMessage(msg); + } + + public void UpdatePlayerTyping(string name, bool typing) + { + if (typing) + { + if (PeopleTyping.Contains(name)) + return; + + PeopleTyping.Add(name); + Timer.Spawn(TimeSpan.FromSeconds(10), () => + { + if (Disposed) + return; + + PeopleTyping.Remove(name); + UpdateTypingIndicator(); + }); + } + else + { + PeopleTyping.Remove(name); + } + + UpdateTypingIndicator(); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + InputTextChanged = null; + } } } diff --git a/Content.Client/UserInterface/Systems/Bwoink/AHelpUIController.cs b/Content.Client/UserInterface/Systems/Bwoink/AHelpUIController.cs index cf05fd6115..6097df20c5 100644 --- a/Content.Client/UserInterface/Systems/Bwoink/AHelpUIController.cs +++ b/Content.Client/UserInterface/Systems/Bwoink/AHelpUIController.cs @@ -41,6 +41,7 @@ public sealed class AHelpUIController: UIController, IOnStateChanged(DiscordRelayUpdated); + SubscribeNetworkEvent(PeopleTypingUpdated); } public void OnStateEntered(GameplayState state) @@ -144,6 +145,11 @@ public sealed class AHelpUIController: UIController, IOnStateChanged _bwoinkSystem?.Send(userId, textMessage); + UIHelper.InputTextChanged += (channel, text) => _bwoinkSystem?.SendInputTextUpdated(channel, text.Length > 0); UIHelper.OnClose += () => { SetAHelpPressed(false); }; UIHelper.OnOpen += () => { SetAHelpPressed(true); }; SetAHelpPressed(UIHelper.IsOpen); @@ -242,9 +249,11 @@ public interface IAHelpUIHandler : IDisposable public void Open(NetUserId netUserId, bool relayActive); public void ToggleWindow(); public void DiscordRelayChanged(bool active); + public void PeopleTypingUpdated(BwoinkPlayerTypingUpdated args); public event Action OnClose; public event Action OnOpen; public Action? SendMessageAction { get; set; } + public event Action? InputTextChanged; } public sealed class AdminAHelpUIHandler : IAHelpUIHandler { @@ -319,9 +328,16 @@ public sealed class AdminAHelpUIHandler : IAHelpUIHandler { } + public void PeopleTypingUpdated(BwoinkPlayerTypingUpdated args) + { + if (_activePanelMap.TryGetValue(args.Channel, out var panel)) + panel.UpdatePlayerTyping(args.PlayerName, args.Typing); + } + public event Action? OnClose; public event Action? OnOpen; public Action? SendMessageAction { get; set; } + public event Action? InputTextChanged; public void Open(NetUserId channelId, bool relayActive) { @@ -367,6 +383,7 @@ public sealed class AdminAHelpUIHandler : IAHelpUIHandler return existingPanel; _activePanelMap[channelId] = existingPanel = new BwoinkPanel(text => SendMessageAction?.Invoke(channelId, text)); + existingPanel.InputTextChanged += text => InputTextChanged?.Invoke(channelId, text); existingPanel.Visible = false; if (!Control!.BwoinkArea.Children.Contains(existingPanel)) Control.BwoinkArea.AddChild(existingPanel); @@ -445,9 +462,14 @@ public sealed class UserAHelpUIHandler : IAHelpUIHandler } } + public void PeopleTypingUpdated(BwoinkPlayerTypingUpdated args) + { + } + public event Action? OnClose; public event Action? OnOpen; public Action? SendMessageAction { get; set; } + public event Action? InputTextChanged; public void Open(NetUserId channelId, bool relayActive) { @@ -460,6 +482,7 @@ public sealed class UserAHelpUIHandler : IAHelpUIHandler if (_window is { Disposed: false }) return; _chatPanel = new BwoinkPanel(text => SendMessageAction?.Invoke(_ownerId, text)); + _chatPanel.InputTextChanged += text => InputTextChanged?.Invoke(_ownerId, text); _chatPanel.RelayedToDiscordLabel.Visible = relayActive; _window = new DefaultWindow() { diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 3f602beed7..107f299dbf 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -17,6 +17,7 @@ using Robust.Shared; using Robust.Shared.Configuration; using Robust.Shared.Enums; using Robust.Shared.Network; +using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Server.Administration.Systems @@ -27,6 +28,7 @@ namespace Content.Server.Administration.Systems [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IAdminManager _adminManager = default!; [Dependency] private readonly IConfigurationManager _config = default!; + [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IPlayerLocator _playerLocator = default!; [Dependency] private readonly GameTicker _gameTicker = default!; @@ -41,6 +43,7 @@ namespace Content.Server.Administration.Systems private Dictionary _oldMessageIds = new(); private readonly Dictionary> _messageQueues = new(); private readonly HashSet _processingChannels = new(); + private readonly Dictionary _typingUpdateTimestamps = new(); // Max embed description length is 4096, according to https://discord.com/developers/docs/resources/channel#embed-object-embed-limits // Keep small margin, just to be safe @@ -67,6 +70,7 @@ namespace Content.Server.Administration.Systems _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; SubscribeLocalEvent(OnGameRunLevelChanged); + SubscribeNetworkEvent(OnClientTypingUpdated); } private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) @@ -102,6 +106,31 @@ namespace Content.Server.Administration.Systems _relayMessages.Clear(); } + private void OnClientTypingUpdated(BwoinkClientTypingUpdated msg, EntitySessionEventArgs args) + { + if (_typingUpdateTimestamps.TryGetValue(args.SenderSession.UserId, out var tuple) && + tuple.Typing == msg.Typing && + tuple.Timestamp + TimeSpan.FromSeconds(1) > _timing.RealTime) + { + return; + } + + _typingUpdateTimestamps[args.SenderSession.UserId] = (_timing.RealTime, msg.Typing); + + // Non-admins can only ever type on their own ahelp, guard against fake messages + var isAdmin = _adminManager.GetAdminData((IPlayerSession) args.SenderSession)?.HasFlag(AdminFlags.Adminhelp) ?? false; + var channel = isAdmin ? msg.Channel : args.SenderSession.UserId; + var update = new BwoinkPlayerTypingUpdated(channel, args.SenderSession.Name, msg.Typing); + + foreach (var admin in GetTargetAdmins()) + { + if (admin.UserId == args.SenderSession.UserId) + continue; + + RaiseNetworkEvent(update, admin); + } + } + private void OnServerNameChanged(string obj) { _serverName = obj; diff --git a/Content.Shared/Administration/SharedBwoinkSystem.cs b/Content.Shared/Administration/SharedBwoinkSystem.cs index d8d719857a..944a1ecdd8 100644 --- a/Content.Shared/Administration/SharedBwoinkSystem.cs +++ b/Content.Shared/Administration/SharedBwoinkSystem.cs @@ -62,4 +62,38 @@ namespace Content.Shared.Administration DiscordRelayEnabled = enabled; } } + + /// + /// Sent by the client to notify the server when it begins or stops typing. + /// + [Serializable, NetSerializable] + public sealed class BwoinkClientTypingUpdated : EntityEventArgs + { + public NetUserId Channel { get; } + public bool Typing { get; } + + public BwoinkClientTypingUpdated(NetUserId channel, bool typing) + { + Channel = channel; + Typing = typing; + } + } + + /// + /// Sent by server to notify admins when a player begins or stops typing. + /// + [Serializable, NetSerializable] + public sealed class BwoinkPlayerTypingUpdated : EntityEventArgs + { + public NetUserId Channel { get; } + public string PlayerName { get; } + public bool Typing { get; } + + public BwoinkPlayerTypingUpdated(NetUserId channel, string playerName, bool typing) + { + Channel = channel; + PlayerName = playerName; + Typing = typing; + } + } } diff --git a/Resources/Locale/en-US/administration/bwoink.ftl b/Resources/Locale/en-US/administration/bwoink.ftl index dd96f8a3b7..aafa5c58ea 100644 --- a/Resources/Locale/en-US/administration/bwoink.ftl +++ b/Resources/Locale/en-US/administration/bwoink.ftl @@ -3,3 +3,8 @@ bwoink-user-title = Admin Message bwoink-system-starmute-message-no-other-users = *System: Nobody is available to receive your message. Try pinging Game Admins on Discord. bwoink-system-messages-being-relayed-to-discord = Your messages are being relayed to the admins via Discord. + +bwoink-system-typing-indicator = {$players} {$count -> +[one] is +*[other] are +} typing...