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
This commit is contained in:
DrSmugleaf
2023-08-13 16:03:17 -07:00
committed by GitHub
parent c7bff10300
commit 35d7656784
7 changed files with 168 additions and 0 deletions

View File

@@ -2,13 +2,17 @@
using Content.Shared.Administration; using Content.Shared.Administration;
using JetBrains.Annotations; using JetBrains.Annotations;
using Robust.Shared.Network; using Robust.Shared.Network;
using Robust.Shared.Timing;
namespace Content.Client.Administration.Systems namespace Content.Client.Administration.Systems
{ {
[UsedImplicitly] [UsedImplicitly]
public sealed class BwoinkSystem : SharedBwoinkSystem public sealed class BwoinkSystem : SharedBwoinkSystem
{ {
[Dependency] private readonly IGameTiming _timing = default!;
public event EventHandler<BwoinkTextMessage>? OnBwoinkTextMessageRecieved; public event EventHandler<BwoinkTextMessage>? OnBwoinkTextMessageRecieved;
private (TimeSpan Timestamp, bool Typing) _lastTypingUpdateSent;
protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs) 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'. // 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. // 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)); 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));
} }
} }
} }

View File

@@ -4,6 +4,7 @@
Orientation="Vertical" Orientation="Vertical"
HorizontalExpand="True"> HorizontalExpand="True">
<OutputPanel Name="TextOutput" VerticalExpand="true" /> <OutputPanel Name="TextOutput" VerticalExpand="true" />
<RichTextLabel Name="TypingIndicator" Access="Public" />
<HistoryLineEdit Name="SenderLineEdit" /> <HistoryLineEdit Name="SenderLineEdit" />
<RichTextLabel Name="RelayedToDiscordLabel" Access="Public" Visible="False" /> <RichTextLabel Name="RelayedToDiscordLabel" Access="Public" Visible="False" />
</BoxContainer> </BoxContainer>

View File

@@ -2,6 +2,7 @@ using Content.Shared.Administration;
using Robust.Client.AutoGenerated; using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML; using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing;
using Robust.Shared.Utility; using Robust.Shared.Utility;
namespace Content.Client.Administration.UI.Bwoink namespace Content.Client.Administration.UI.Bwoink
@@ -13,6 +14,8 @@ namespace Content.Client.Administration.UI.Bwoink
public int Unread { get; private set; } = 0; public int Unread { get; private set; } = 0;
public DateTime LastMessage { get; private set; } = DateTime.MinValue; public DateTime LastMessage { get; private set; } = DateTime.MinValue;
private List<string> PeopleTyping { get; set; } = new();
public event Action<string>? InputTextChanged;
public BwoinkPanel(Action<string> messageSender) public BwoinkPanel(Action<string> messageSender)
{ {
@@ -32,6 +35,8 @@ namespace Content.Client.Administration.UI.Bwoink
Unread = 0; Unread = 0;
}; };
SenderLineEdit.OnTextEntered += Input_OnTextEntered; SenderLineEdit.OnTextEntered += Input_OnTextEntered;
SenderLineEdit.OnTextChanged += Input_OnTextChanged;
UpdateTypingIndicator();
} }
private void Input_OnTextEntered(LineEdit.LineEditEventArgs args) private void Input_OnTextEntered(LineEdit.LineEditEventArgs args)
@@ -43,6 +48,11 @@ namespace Content.Client.Administration.UI.Bwoink
SenderLineEdit.Clear(); SenderLineEdit.Clear();
} }
private void Input_OnTextChanged(LineEdit.LineEditEventArgs args)
{
InputTextChanged?.Invoke(args.Text);
}
public void ReceiveLine(SharedBwoinkSystem.BwoinkTextMessage message) public void ReceiveLine(SharedBwoinkSystem.BwoinkTextMessage message)
{ {
if (!Visible) if (!Visible)
@@ -53,5 +63,54 @@ namespace Content.Client.Administration.UI.Bwoink
TextOutput.AddMessage(formatted); TextOutput.AddMessage(formatted);
LastMessage = message.SentAt; 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;
}
} }
} }

View File

@@ -41,6 +41,7 @@ public sealed class AHelpUIController: UIController, IOnStateChanged<GameplaySta
base.Initialize(); base.Initialize();
SubscribeNetworkEvent<BwoinkDiscordRelayUpdated>(DiscordRelayUpdated); SubscribeNetworkEvent<BwoinkDiscordRelayUpdated>(DiscordRelayUpdated);
SubscribeNetworkEvent<BwoinkPlayerTypingUpdated>(PeopleTypingUpdated);
} }
public void OnStateEntered(GameplayState state) public void OnStateEntered(GameplayState state)
@@ -144,6 +145,11 @@ public sealed class AHelpUIController: UIController, IOnStateChanged<GameplaySta
UIHelper?.DiscordRelayChanged(_discordRelayActive); UIHelper?.DiscordRelayChanged(_discordRelayActive);
} }
private void PeopleTypingUpdated(BwoinkPlayerTypingUpdated args, EntitySessionEventArgs session)
{
UIHelper?.PeopleTypingUpdated(args);
}
public void EnsureUIHelper() public void EnsureUIHelper()
{ {
var isAdmin = _adminManager.HasFlag(AdminFlags.Adminhelp); var isAdmin = _adminManager.HasFlag(AdminFlags.Adminhelp);
@@ -157,6 +163,7 @@ public sealed class AHelpUIController: UIController, IOnStateChanged<GameplaySta
UIHelper.DiscordRelayChanged(_discordRelayActive); UIHelper.DiscordRelayChanged(_discordRelayActive);
UIHelper.SendMessageAction = (userId, textMessage) => _bwoinkSystem?.Send(userId, textMessage); UIHelper.SendMessageAction = (userId, textMessage) => _bwoinkSystem?.Send(userId, textMessage);
UIHelper.InputTextChanged += (channel, text) => _bwoinkSystem?.SendInputTextUpdated(channel, text.Length > 0);
UIHelper.OnClose += () => { SetAHelpPressed(false); }; UIHelper.OnClose += () => { SetAHelpPressed(false); };
UIHelper.OnOpen += () => { SetAHelpPressed(true); }; UIHelper.OnOpen += () => { SetAHelpPressed(true); };
SetAHelpPressed(UIHelper.IsOpen); SetAHelpPressed(UIHelper.IsOpen);
@@ -242,9 +249,11 @@ public interface IAHelpUIHandler : IDisposable
public void Open(NetUserId netUserId, bool relayActive); public void Open(NetUserId netUserId, bool relayActive);
public void ToggleWindow(); public void ToggleWindow();
public void DiscordRelayChanged(bool active); public void DiscordRelayChanged(bool active);
public void PeopleTypingUpdated(BwoinkPlayerTypingUpdated args);
public event Action OnClose; public event Action OnClose;
public event Action OnOpen; public event Action OnOpen;
public Action<NetUserId, string>? SendMessageAction { get; set; } public Action<NetUserId, string>? SendMessageAction { get; set; }
public event Action<NetUserId, string>? InputTextChanged;
} }
public sealed class AdminAHelpUIHandler : IAHelpUIHandler 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? OnClose;
public event Action? OnOpen; public event Action? OnOpen;
public Action<NetUserId, string>? SendMessageAction { get; set; } public Action<NetUserId, string>? SendMessageAction { get; set; }
public event Action<NetUserId, string>? InputTextChanged;
public void Open(NetUserId channelId, bool relayActive) public void Open(NetUserId channelId, bool relayActive)
{ {
@@ -367,6 +383,7 @@ public sealed class AdminAHelpUIHandler : IAHelpUIHandler
return existingPanel; return existingPanel;
_activePanelMap[channelId] = existingPanel = new BwoinkPanel(text => SendMessageAction?.Invoke(channelId, text)); _activePanelMap[channelId] = existingPanel = new BwoinkPanel(text => SendMessageAction?.Invoke(channelId, text));
existingPanel.InputTextChanged += text => InputTextChanged?.Invoke(channelId, text);
existingPanel.Visible = false; existingPanel.Visible = false;
if (!Control!.BwoinkArea.Children.Contains(existingPanel)) if (!Control!.BwoinkArea.Children.Contains(existingPanel))
Control.BwoinkArea.AddChild(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? OnClose;
public event Action? OnOpen; public event Action? OnOpen;
public Action<NetUserId, string>? SendMessageAction { get; set; } public Action<NetUserId, string>? SendMessageAction { get; set; }
public event Action<NetUserId, string>? InputTextChanged;
public void Open(NetUserId channelId, bool relayActive) public void Open(NetUserId channelId, bool relayActive)
{ {
@@ -460,6 +482,7 @@ public sealed class UserAHelpUIHandler : IAHelpUIHandler
if (_window is { Disposed: false }) if (_window is { Disposed: false })
return; return;
_chatPanel = new BwoinkPanel(text => SendMessageAction?.Invoke(_ownerId, text)); _chatPanel = new BwoinkPanel(text => SendMessageAction?.Invoke(_ownerId, text));
_chatPanel.InputTextChanged += text => InputTextChanged?.Invoke(_ownerId, text);
_chatPanel.RelayedToDiscordLabel.Visible = relayActive; _chatPanel.RelayedToDiscordLabel.Visible = relayActive;
_window = new DefaultWindow() _window = new DefaultWindow()
{ {

View File

@@ -17,6 +17,7 @@ using Robust.Shared;
using Robust.Shared.Configuration; using Robust.Shared.Configuration;
using Robust.Shared.Enums; using Robust.Shared.Enums;
using Robust.Shared.Network; using Robust.Shared.Network;
using Robust.Shared.Timing;
using Robust.Shared.Utility; using Robust.Shared.Utility;
namespace Content.Server.Administration.Systems namespace Content.Server.Administration.Systems
@@ -27,6 +28,7 @@ namespace Content.Server.Administration.Systems
[Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IAdminManager _adminManager = default!; [Dependency] private readonly IAdminManager _adminManager = default!;
[Dependency] private readonly IConfigurationManager _config = default!; [Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IPlayerLocator _playerLocator = default!; [Dependency] private readonly IPlayerLocator _playerLocator = default!;
[Dependency] private readonly GameTicker _gameTicker = default!; [Dependency] private readonly GameTicker _gameTicker = default!;
@@ -41,6 +43,7 @@ namespace Content.Server.Administration.Systems
private Dictionary<NetUserId, string> _oldMessageIds = new(); private Dictionary<NetUserId, string> _oldMessageIds = new();
private readonly Dictionary<NetUserId, Queue<string>> _messageQueues = new(); private readonly Dictionary<NetUserId, Queue<string>> _messageQueues = new();
private readonly HashSet<NetUserId> _processingChannels = new(); private readonly HashSet<NetUserId> _processingChannels = new();
private readonly Dictionary<NetUserId, (TimeSpan Timestamp, bool Typing)> _typingUpdateTimestamps = new();
// Max embed description length is 4096, according to https://discord.com/developers/docs/resources/channel#embed-object-embed-limits // 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 // Keep small margin, just to be safe
@@ -67,6 +70,7 @@ namespace Content.Server.Administration.Systems
_playerManager.PlayerStatusChanged += OnPlayerStatusChanged; _playerManager.PlayerStatusChanged += OnPlayerStatusChanged;
SubscribeLocalEvent<GameRunLevelChangedEvent>(OnGameRunLevelChanged); SubscribeLocalEvent<GameRunLevelChangedEvent>(OnGameRunLevelChanged);
SubscribeNetworkEvent<BwoinkClientTypingUpdated>(OnClientTypingUpdated);
} }
private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
@@ -102,6 +106,31 @@ namespace Content.Server.Administration.Systems
_relayMessages.Clear(); _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) private void OnServerNameChanged(string obj)
{ {
_serverName = obj; _serverName = obj;

View File

@@ -62,4 +62,38 @@ namespace Content.Shared.Administration
DiscordRelayEnabled = enabled; DiscordRelayEnabled = enabled;
} }
} }
/// <summary>
/// Sent by the client to notify the server when it begins or stops typing.
/// </summary>
[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;
}
}
/// <summary>
/// Sent by server to notify admins when a player begins or stops typing.
/// </summary>
[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;
}
}
} }

View File

@@ -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-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-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...