using System; using System.Collections.Generic; using Content.Client.Administration.Managers; using Content.Client.Chat.UI; using Content.Client.Ghost; using Content.Shared.Administration; using Content.Shared.Chat; using Robust.Client.Console; using Robust.Client.Graphics; using Robust.Client.Player; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Log; using Robust.Shared.Maths; using Robust.Shared.Network; using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Client.Chat.Managers { internal sealed class ChatManager : IChatManager, IPostInjectInit { private struct SpeechBubbleData { public string Message; public SpeechBubble.SpeechType Type; } /// /// The max amount of chars allowed to fit in a single speech bubble. /// private const int SingleBubbleCharLimit = 100; /// /// Base queue delay each speech bubble has. /// private const float BubbleDelayBase = 0.2f; /// /// Factor multiplied by speech bubble char length to add to delay. /// private const float BubbleDelayFactor = 0.8f / SingleBubbleCharLimit; /// /// The max amount of speech bubbles over a single entity at once. /// private const int SpeechBubbleCap = 4; /// /// The max amount of characters an entity can send in one message /// private int _maxMessageLength = 1000; public const char ConCmdSlash = '/'; public const char OOCAlias = '['; public const char MeAlias = '@'; public const char AdminChatAlias = ']'; public const char RadioAlias = ';'; private readonly List _filteredHistory = new(); // currently enabled channel filters set by the user. If an entry is not in this // list it has not been explicitly set yet, thus will default to enabled when it first // becomes filterable (added to _filterableChannels) // Note that these are persisted here, at the manager, // rather than the chatbox so that these settings persist between instances of different // chatboxes. public readonly Dictionary _channelFilters = new(); // Maintains which channels a client should be able to filter (for showing in the chatbox) // and select (for attempting to send on). // This may not always actually match with what the server will actually allow them to // send / receive on, it is only what the user can select in the UI. For example, // if a user is silenced from speaking for some reason this may still contain ChatChannel.Local, it is left up // to the server to handle invalid attempts to use particular channels and not send messages for // channels the user shouldn't be able to hear. // // Note that Command is an available selection in the chatbox channel selector, // which is not actually a chat channel but is always available. private readonly HashSet _filterableChannels = new(); private readonly List _selectableChannels = new(); // Flag Enums for holding filtered channels private ChatChannel _filteredChannels; /// /// For currently disabled chat filters, /// unread messages (messages received since the channel has been filtered /// out). Never goes above 10 (9+ should be shown when at 10) /// private readonly Dictionary _unreadMessages = new(); [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IClientNetManager _netManager = default!; [Dependency] private readonly IClientConsoleHost _consoleHost = default!; [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; [Dependency] private readonly IClientAdminManager _adminMgr = default!; /// /// Current chat box control. This can be modified, so do not depend on saving a reference to this. /// public ChatBox? CurrentChatBox { get; private set; } /// /// Invoked when CurrentChatBox is resized (including after setting initial default size) /// public event Action? OnChatBoxResized; private Control _speechBubbleRoot = null!; /// /// Speech bubbles that are currently visible on screen. /// We track them to push them up when new ones get added. /// private readonly Dictionary> _activeSpeechBubbles = new(); /// /// Speech bubbles that are to-be-sent because of the "rate limit" they have. /// private readonly Dictionary _queuedSpeechBubbles = new(); public void Initialize() { _netManager.RegisterNetMessage(MsgChatMessage.NAME, OnChatMessage); _netManager.RegisterNetMessage(ChatMaxMsgLengthMessage.NAME, OnMaxLengthReceived); _speechBubbleRoot = new LayoutContainer(); LayoutContainer.SetAnchorPreset(_speechBubbleRoot, LayoutContainer.LayoutPreset.Wide); _userInterfaceManager.StateRoot.AddChild(_speechBubbleRoot); _speechBubbleRoot.SetPositionFirst(); // When connexion is achieved, request the max chat message length _netManager.Connected += RequestMaxLength; } public void PostInject() { _adminMgr.AdminStatusUpdated += UpdateChannelPermissions; _playerManager.LocalPlayerChanged += OnLocalPlayerChanged; OnLocalPlayerChanged(new LocalPlayerChangedEventArgs(null, _playerManager.LocalPlayer)); } private void OnLocalPlayerChanged(LocalPlayerChangedEventArgs obj) { if (obj.OldPlayer != null) { obj.OldPlayer.EntityAttached -= OnLocalPlayerEntityAttached; obj.OldPlayer.EntityDetached -= OnLocalPlayerEntityDetached; } if (obj.NewPlayer != null) { obj.NewPlayer.EntityAttached += OnLocalPlayerEntityAttached; obj.NewPlayer.EntityDetached += OnLocalPlayerEntityDetached; } UpdateChannelPermissions(); } private void OnLocalPlayerEntityAttached(EntityAttachedEventArgs obj) { UpdateChannelPermissions(); } private void OnLocalPlayerEntityDetached(EntityDetachedEventArgs obj) { UpdateChannelPermissions(); } // go through all of the various channels and update filter / select permissions // appropriately, also enabling them if our enabledChannels dict doesn't have an entry // for any newly-granted channels private void UpdateChannelPermissions() { // can always send/recieve OOC if (!_selectableChannels.Contains(ChatChannel.OOC)) { _selectableChannels.Add(ChatChannel.OOC); } AddFilterableChannel(ChatChannel.OOC); // can always hear server (nobody can actually send server messages). AddFilterableChannel(ChatChannel.Server); // can always hear local / radio / emote AddFilterableChannel(ChatChannel.Local); AddFilterableChannel(ChatChannel.Radio); AddFilterableChannel(ChatChannel.Emotes); // Can only send local / radio / emote when attached to a non-ghost entity. // TODO: this logic is iffy (checking if controlling something that's NOT a ghost), is there a better way to check this? if (!_playerManager.LocalPlayer?.ControlledEntity?.HasComponent() ?? false) { _selectableChannels.Add(ChatChannel.Local); _selectableChannels.Add(ChatChannel.Radio); _selectableChannels.Add(ChatChannel.Emotes); } else { _selectableChannels.Remove(ChatChannel.Local); _selectableChannels.Remove(ChatChannel.Radio); _selectableChannels.Remove(ChatChannel.Emotes); } // Only ghosts and admins can send / see deadchat. // TODO: Should spectators also be able to see deadchat? if (_adminMgr.HasFlag(AdminFlags.Admin) || (_playerManager?.LocalPlayer?.ControlledEntity?.HasComponent() ?? false)) { AddFilterableChannel(ChatChannel.Dead); if (!_selectableChannels.Contains(ChatChannel.Dead)) { _selectableChannels.Add(ChatChannel.Dead); } } else { _filterableChannels.Remove(ChatChannel.Dead); _selectableChannels.Remove(ChatChannel.Dead); } // only admins can see / filter asay if (_adminMgr.HasFlag(AdminFlags.Admin)) { AddFilterableChannel(ChatChannel.AdminChat); if (!_selectableChannels.Contains(ChatChannel.AdminChat)) { _selectableChannels.Add(ChatChannel.AdminChat); } } else { _selectableChannels.Remove(ChatChannel.AdminChat); _filterableChannels.Remove(ChatChannel.AdminChat); } // let our chatbox know all the new settings CurrentChatBox?.SetChannelPermissions(_selectableChannels, _filterableChannels, _channelFilters, _unreadMessages, true); } /// /// Adds the channel to the set of filterable channels, defaulting it as enabled /// if it doesn't currently have an explicit enable/disable setting /// private void AddFilterableChannel(ChatChannel channel) { if (!_channelFilters.ContainsKey(channel)) _channelFilters[channel] = true; _filterableChannels.Add(channel); } public void FrameUpdate(FrameEventArgs delta) { // Update queued speech bubbles. if (_queuedSpeechBubbles.Count == 0) { return; } foreach (var (entityUid, queueData) in _queuedSpeechBubbles.ShallowClone()) { if (!_entityManager.TryGetEntity(entityUid, out var entity)) { _queuedSpeechBubbles.Remove(entityUid); continue; } queueData.TimeLeft -= delta.DeltaSeconds; if (queueData.TimeLeft > 0) { continue; } if (queueData.MessageQueue.Count == 0) { _queuedSpeechBubbles.Remove(entityUid); continue; } var msg = queueData.MessageQueue.Dequeue(); queueData.TimeLeft += BubbleDelayBase + msg.Message.Length * BubbleDelayFactor; // We keep the queue around while it has 0 items. This allows us to keep the timer. // When the timer hits 0 and there's no messages left, THEN we can clear it up. CreateSpeechBubble(entity, msg); } } public void SetChatBox(ChatBox chatBox) { if (CurrentChatBox != null) { CurrentChatBox.TextSubmitted -= OnChatBoxTextSubmitted; CurrentChatBox.FilterToggled -= OnFilterButtonToggled; CurrentChatBox.OnResized -= ChatBoxOnResized; } CurrentChatBox = chatBox; if (CurrentChatBox != null) { CurrentChatBox.TextSubmitted += OnChatBoxTextSubmitted; CurrentChatBox.FilterToggled += OnFilterButtonToggled; CurrentChatBox.OnResized += ChatBoxOnResized; CurrentChatBox.SetChannelPermissions(_selectableChannels, _filterableChannels, _channelFilters, _unreadMessages, false); } RepopulateChat(_filteredHistory); } private void ChatBoxOnResized(ChatResizedEventArgs chatResizedEventArgs) { OnChatBoxResized?.Invoke(chatResizedEventArgs); } public void RemoveSpeechBubble(EntityUid entityUid, SpeechBubble bubble) { bubble.Dispose(); var list = _activeSpeechBubbles[entityUid]; list.Remove(bubble); if (list.Count == 0) { _activeSpeechBubbles.Remove(entityUid); } } private void WriteChatMessage(StoredChatMessage message) { Logger.Debug($"{message.Channel}: {message.Message}"); if (IsFiltered(message.Channel)) { Logger.Debug($"Message filtered: {message.Channel}: {message.Message}"); // accumulate unread if (message.Read) return; if (!_unreadMessages.TryGetValue(message.Channel, out var count)) { count = 0; } count = (byte) Math.Min(count + 1, 10); _unreadMessages[message.Channel] = count; CurrentChatBox?.UpdateUnreadMessageCounts(_unreadMessages); return; } var color = Color.DarkGray; var messageText = FormattedMessage.EscapeText(message.Message); if (!string.IsNullOrEmpty(message.MessageWrap)) { messageText = string.Format(message.MessageWrap, messageText); } if (message.MessageColorOverride != Color.Transparent) { color = message.MessageColorOverride; } else { color = ChatHelper.ChatColor(message.Channel); } if (CurrentChatBox == null) return; CurrentChatBox.AddLine(messageText, message.Channel, color); // TODO: Can make this "smarter" later by only setting it false when the message has been scrolled to message.Read = true; } private void OnChatBoxTextSubmitted(ChatBox chatBox, string text) { DebugTools.Assert(chatBox == CurrentChatBox); if (string.IsNullOrWhiteSpace(text)) return; // Check if message is longer than the character limit if (text.Length > _maxMessageLength) { if (CurrentChatBox != null) { string locWarning = Loc.GetString("chat-manager-max-message-length", ("maxMessageLength", _maxMessageLength)); CurrentChatBox.AddLine(locWarning, ChatChannel.Server, Color.Orange); CurrentChatBox.ClearOnEnter = false; // The text shouldn't be cleared if it hasn't been sent } return; } switch (text[0]) { case ConCmdSlash: { // run locally var conInput = text.Substring(1); _consoleHost.ExecuteCommand(conInput); break; } case OOCAlias: { var conInput = text.Substring(1); if (string.IsNullOrWhiteSpace(conInput)) return; _consoleHost.ExecuteCommand($"ooc \"{CommandParsing.Escape(conInput)}\""); break; } case AdminChatAlias: { var conInput = text.Substring(1); if (string.IsNullOrWhiteSpace(conInput)) return; if (_adminMgr.HasFlag(AdminFlags.Admin)) { _consoleHost.ExecuteCommand($"asay \"{CommandParsing.Escape(conInput)}\""); } else { _consoleHost.ExecuteCommand($"ooc \"{CommandParsing.Escape(conInput)}\""); } break; } case MeAlias: { var conInput = text.Substring(1); if (string.IsNullOrWhiteSpace(conInput)) return; _consoleHost.ExecuteCommand($"me \"{CommandParsing.Escape(conInput)}\""); break; } default: { var conInput = CurrentChatBox?.DefaultChatFormat != null ? string.Format(CurrentChatBox.DefaultChatFormat, CommandParsing.Escape(text)) : text; _consoleHost.ExecuteCommand(conInput); break; } } } private void OnFilterButtonToggled(ChatChannel channel, bool enabled) { if (enabled) { _channelFilters[channel] = true; _filteredChannels &= ~channel; _unreadMessages.Remove(channel); CurrentChatBox?.UpdateUnreadMessageCounts(_unreadMessages); } else { _channelFilters[channel] = false; _filteredChannels |= channel; } RepopulateChat(_filteredHistory); } private void RepopulateChat(IEnumerable filteredMessages) { if (CurrentChatBox == null) { return; } CurrentChatBox.Contents.Clear(); foreach (var msg in filteredMessages) { WriteChatMessage(msg); } } private void OnChatMessage(MsgChatMessage msg) { // Log all incoming chat to repopulate when filter is un-toggled var storedMessage = new StoredChatMessage(msg); _filteredHistory.Add(storedMessage); WriteChatMessage(storedMessage); // Local messages that have an entity attached get a speech bubble. if (msg.SenderEntity == default) return; switch (msg.Channel) { case ChatChannel.Local: AddSpeechBubble(msg, SpeechBubble.SpeechType.Say); break; case ChatChannel.Dead: if (!_playerManager.LocalPlayer?.ControlledEntity?.HasComponent() ?? true) break; AddSpeechBubble(msg, SpeechBubble.SpeechType.Say); break; case ChatChannel.Emotes: AddSpeechBubble(msg, SpeechBubble.SpeechType.Emote); break; } } private void OnMaxLengthReceived(ChatMaxMsgLengthMessage msg) { _maxMessageLength = msg.MaxMessageLength; } private void RequestMaxLength(object? sender, NetChannelArgs args) { ChatMaxMsgLengthMessage msg = _netManager.CreateNetMessage(); _netManager.ClientSendMessage(msg); } private void AddSpeechBubble(MsgChatMessage msg, SpeechBubble.SpeechType speechType) { if (!_entityManager.TryGetEntity(msg.SenderEntity, out var entity)) { Logger.WarningS("chat", "Got local chat message with invalid sender entity: {0}", msg.SenderEntity); return; } var messages = SplitMessage(FormattedMessage.RemoveMarkup(msg.Message)); foreach (var message in messages) { EnqueueSpeechBubble(entity, message, speechType); } } private List SplitMessage(string msg) { // Split message into words separated by spaces. var words = msg.Split(' '); var messages = new List(); var currentBuffer = new List(); // Really shoddy way to approximate word length. // Yes, I am aware of all the crimes here. // TODO: Improve this to use actual glyph width etc.. var currentWordLength = 0; foreach (var word in words) { // +1 for the space. currentWordLength += word.Length + 1; if (currentWordLength > SingleBubbleCharLimit) { // Too long for the current speech bubble, flush it. messages.Add(string.Join(" ", currentBuffer)); currentBuffer.Clear(); currentWordLength = word.Length; if (currentWordLength > SingleBubbleCharLimit) { // Word is STILL too long. // Truncate it with an ellipse. messages.Add($"{word.Substring(0, SingleBubbleCharLimit - 3)}..."); currentWordLength = 0; continue; } } currentBuffer.Add(word); } if (currentBuffer.Count != 0) { // Don't forget the last bubble. messages.Add(string.Join(" ", currentBuffer)); } return messages; } private void EnqueueSpeechBubble(IEntity entity, string contents, SpeechBubble.SpeechType speechType) { if (!_queuedSpeechBubbles.TryGetValue(entity.Uid, out var queueData)) { queueData = new SpeechBubbleQueueData(); _queuedSpeechBubbles.Add(entity.Uid, queueData); } queueData.MessageQueue.Enqueue(new SpeechBubbleData { Message = contents, Type = speechType, }); } private void CreateSpeechBubble(IEntity entity, SpeechBubbleData speechData) { var bubble = SpeechBubble.CreateSpeechBubble(speechData.Type, speechData.Message, entity, _eyeManager, this); if (_activeSpeechBubbles.TryGetValue(entity.Uid, out var existing)) { // Push up existing bubbles above the mob's head. foreach (var existingBubble in existing) { existingBubble.VerticalOffset += bubble.ContentHeight; } } else { existing = new List(); _activeSpeechBubbles.Add(entity.Uid, existing); } existing.Add(bubble); _speechBubbleRoot.AddChild(bubble); if (existing.Count > SpeechBubbleCap) { // Get the oldest to start fading fast. var last = existing[0]; last.FadeNow(); } } private bool IsFiltered(ChatChannel channel) { return _filteredChannels.HasFlag(channel); } private sealed class SpeechBubbleQueueData { /// /// Time left until the next speech bubble can appear. /// public float TimeLeft { get; set; } public Queue MessageQueue { get; } = new(); } } }