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:
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...
|
||||||
|
|||||||
Reference in New Issue
Block a user