using System; using System.Collections.Generic; using System.Linq; using Content.Client.Alerts.UI; using Content.Client.Chat.Managers; using Content.Client.Chat.TypingIndicator; using Content.Client.Resources; using Content.Client.Stylesheets; using Content.Shared.Chat; using Content.Shared.Input; using Robust.Client.AutoGenerated; using Robust.Client.ResourceManagement; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; using Robust.Shared.Input; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Log; using Robust.Shared.Maths; using Robust.Shared.Utility; namespace Content.Client.Chat.UI { [GenerateTypedNameReferences] [Virtual] public partial class ChatBox : Control { [Dependency] protected readonly IChatManager ChatMgr = default!; // order in which the available channel filters show up when available private static readonly ChatChannel[] ChannelFilterOrder = { ChatChannel.Local, ChatChannel.Whisper, ChatChannel.Emotes, ChatChannel.Radio, ChatChannel.OOC, ChatChannel.Dead, ChatChannel.Admin, ChatChannel.Server }; // order in which the channels show up in the channel selector private static readonly ChatSelectChannel[] ChannelSelectorOrder = { ChatSelectChannel.Local, ChatSelectChannel.Whisper, ChatSelectChannel.Emotes, ChatSelectChannel.Radio, ChatSelectChannel.LOOC, ChatSelectChannel.OOC, ChatSelectChannel.Dead, ChatSelectChannel.Admin // NOTE: Console is not in there and it can never be permanently selected. // You can, however, still submit commands as console by prefixing with /. }; public const char AliasLocal = '.'; public const char AliasConsole = '/'; public const char AliasDead = '\\'; public const char AliasOOC = '['; public const char AliasEmotes = '@'; public const char AliasAdmin = ']'; public const char AliasRadio = ';'; public const char AliasWhisper = ','; private static readonly Dictionary PrefixToChannel = new() { {AliasLocal, ChatSelectChannel.Local}, {AliasWhisper, ChatSelectChannel.Whisper}, {AliasConsole, ChatSelectChannel.Console}, {AliasOOC, ChatSelectChannel.OOC}, {AliasEmotes, ChatSelectChannel.Emotes}, {AliasAdmin, ChatSelectChannel.Admin}, {AliasRadio, ChatSelectChannel.Radio}, {AliasDead, ChatSelectChannel.Dead} }; private static readonly Dictionary ChannelPrefixes = PrefixToChannel.ToDictionary(kv => kv.Value, kv => kv.Key); private const float FilterPopupWidth = 110; /// /// The currently default channel that will be used if no prefix is specified. /// public ChatSelectChannel SelectedChannel { get; private set; } = ChatSelectChannel.OOC; /// /// The "preferred" channel. Will be switched to if permissions change and the channel becomes available, /// such as by re-entering body. Gets changed if the user manually selects a channel with the buttons. /// public ChatSelectChannel PreferredChannel { get; set; } = ChatSelectChannel.OOC; public bool ReleaseFocusOnEnter { get; set; } = true; private readonly Popup _channelSelectorPopup; private readonly BoxContainer _channelSelectorHBox; private readonly Popup _filterPopup; private readonly PanelContainer _filterPopupPanel; private readonly BoxContainer _filterVBox; /// /// When lobbyMode is false, will position / add to correct location in StateRoot and /// be resizable. /// wWen true, will leave layout up to parent and not be resizable. /// public ChatBox() { IoCManager.InjectDependencies(this); RobustXamlLoader.Load(this); LayoutContainer.SetMarginLeft(this, 4); LayoutContainer.SetMarginRight(this, 4); _filterPopup = new Popup { Children = { (_filterPopupPanel = new PanelContainer { StyleClasses = {StyleNano.StyleClassBorderedWindowPanel}, Children = { new BoxContainer { Orientation = BoxContainer.LayoutOrientation.Horizontal, Children = { new Control {MinSize = (4, 0)}, (_filterVBox = new BoxContainer { Margin = new Thickness(0, 10), Orientation = BoxContainer.LayoutOrientation.Vertical, SeparationOverride = 4 }) } } } }) } }; _channelSelectorPopup = new Popup { Children = { (_channelSelectorHBox = new BoxContainer { Orientation = BoxContainer.LayoutOrientation.Horizontal, SeparationOverride = 1 }) } }; ChannelSelector.OnToggled += OnChannelSelectorToggled; FilterButton.OnToggled += OnFilterButtonToggled; Input.OnKeyBindDown += InputKeyBindDown; Input.OnTextEntered += Input_OnTextEntered; Input.OnTextChanged += InputOnTextChanged; _channelSelectorPopup.OnPopupHide += OnChannelSelectorPopupHide; _filterPopup.OnPopupHide += OnFilterPopupHide; } protected override void EnteredTree() { base.EnteredTree(); ChatMgr.MessageAdded += WriteChatMessage; ChatMgr.ChatPermissionsUpdated += OnChatPermissionsUpdated; ChatMgr.UnreadMessageCountsUpdated += UpdateUnreadMessageCounts; ChatMgr.FiltersUpdated += Repopulate; // The chat manager may have messages logged from before there was a chat box. // In this case, these messages will be marked as unread despite the filters allowing them through. // Tell chat manager to clear these. ChatMgr.ClearUnfilteredUnreads(); ChatPermissionsUpdated(0); UpdateChannelSelectButton(); Repopulate(); } protected override void ExitedTree() { base.ExitedTree(); ChatMgr.MessageAdded -= WriteChatMessage; ChatMgr.ChatPermissionsUpdated -= OnChatPermissionsUpdated; ChatMgr.UnreadMessageCountsUpdated -= UpdateUnreadMessageCounts; ChatMgr.FiltersUpdated -= Repopulate; } private void OnChatPermissionsUpdated(ChatPermissionsUpdatedEventArgs eventArgs) { ChatPermissionsUpdated(eventArgs.OldSelectableChannels); } private void ChatPermissionsUpdated(ChatSelectChannel oldSelectable) { // update the channel selector _channelSelectorHBox.Children.Clear(); foreach (var selectableChannel in ChannelSelectorOrder) { if ((ChatMgr.SelectableChannels & selectableChannel) == 0) continue; var newButton = new ChannelItemButton(selectableChannel); newButton.OnPressed += OnChannelSelectorItemPressed; _channelSelectorHBox.AddChild(newButton); } // Selected channel no longer available, switch to OOC? if ((ChatMgr.SelectableChannels & SelectedChannel) == 0) { // Handle local -> dead mapping when you e.g. ghost. // Only necessary for admins because they always have deadchat // so the normal preferred check won't see it as newly available and do nothing. var mappedSelect = MapLocalIfGhost(SelectedChannel); if ((ChatMgr.SelectableChannels & mappedSelect) != 0) SafelySelectChannel(mappedSelect); else SafelySelectChannel(ChatSelectChannel.OOC); } // If the preferred channel just became available, switch to it. var pref = MapLocalIfGhost(PreferredChannel); if ((oldSelectable & pref) == 0 && (ChatMgr.SelectableChannels & pref) != 0) SafelySelectChannel(pref); // update the channel filters _filterVBox.Children.Clear(); foreach (var channelFilter in ChannelFilterOrder) { if ((ChatMgr.FilterableChannels & channelFilter) == 0) continue; int? unreadCount = null; if (ChatMgr.UnreadMessages.TryGetValue(channelFilter, out var unread)) unreadCount = unread; var newCheckBox = new ChannelFilterCheckbox(channelFilter, unreadCount) { Pressed = (ChatMgr.ChannelFilters & channelFilter) != 0 }; newCheckBox.OnToggled += OnFilterCheckboxToggled; _filterVBox.AddChild(newCheckBox); } UpdateChannelSelectButton(); } private void UpdateUnreadMessageCounts() { foreach (var channelFilter in _filterVBox.Children) { if (channelFilter is not ChannelFilterCheckbox filterCheckbox) continue; if (ChatMgr.UnreadMessages.TryGetValue(filterCheckbox.Channel, out var unread)) { filterCheckbox.UpdateUnreadCount(unread); } else { filterCheckbox.UpdateUnreadCount(null); } } } private void OnFilterCheckboxToggled(BaseButton.ButtonToggledEventArgs args) { if (args.Button is not ChannelFilterCheckbox checkbox) return; ChatMgr.OnFilterButtonToggled(checkbox.Channel, checkbox.Pressed); } private void OnFilterButtonToggled(BaseButton.ButtonToggledEventArgs args) { if (args.Pressed) { var globalPos = FilterButton.GlobalPosition; var (minX, minY) = _filterPopupPanel.MinSize; var box = UIBox2.FromDimensions(globalPos - (FilterPopupWidth, 0), (Math.Max(minX, FilterPopupWidth), minY)); UserInterfaceManager.ModalRoot.AddChild(_filterPopup); _filterPopup.Open(box); } else { _filterPopup.Close(); } } private void OnChannelSelectorToggled(BaseButton.ButtonToggledEventArgs args) { if (args.Pressed) { var globalLeft = GlobalPosition.X; var globalBot = GlobalPosition.Y + Height; var box = UIBox2.FromDimensions((globalLeft, globalBot), (SizeBox.Width, AlertsUI.ChatSeparation)); UserInterfaceManager.ModalRoot.AddChild(_channelSelectorPopup); _channelSelectorPopup.Open(box); } else { _channelSelectorPopup.Close(); } } private void OnFilterPopupHide() { OnPopupHide(_filterPopup, FilterButton); } private void OnChannelSelectorPopupHide() { OnPopupHide(_channelSelectorPopup, ChannelSelector); } private void OnPopupHide(Control popup, BaseButton button) { UserInterfaceManager.ModalRoot.RemoveChild(popup); // this weird check here is because the hiding of the popup happens prior to the button // receiving the keydown, which would cause it to then become unpressed // and reopen immediately. To avoid this, if the popup was hidden due to clicking on the button, // we will not auto-unpress the button, instead leaving it up to the button toggle logic // (and this requires the button to be set to EnableAllKeybinds = true) if (UserInterfaceManager.CurrentlyHovered != button) { button.Pressed = false; } } private void OnChannelSelectorItemPressed(BaseButton.ButtonEventArgs obj) { if (obj.Button is not ChannelItemButton button) return; PreferredChannel = button.Channel; SafelySelectChannel(button.Channel); _channelSelectorPopup.Close(); } public bool SafelySelectChannel(ChatSelectChannel toSelect) { toSelect = MapLocalIfGhost(toSelect); if ((ChatMgr.SelectableChannels & toSelect) == 0) return false; SelectedChannel = toSelect; UpdateChannelSelectButton(); return true; } private void UpdateChannelSelectButton() { var (prefixChannel, _) = SplitInputContents(); var channel = prefixChannel == 0 ? SelectedChannel : prefixChannel; ChannelSelector.Text = ChannelSelectorName(channel); ChannelSelector.Modulate = ChannelSelectColor(channel); } protected override void KeyBindDown(GUIBoundKeyEventArgs args) { base.KeyBindDown(args); if (args.CanFocus) { Input.GrabKeyboardFocus(); } } public void CycleChatChannel(bool forward) { Input.IgnoreNext = true; var idx = Array.IndexOf(ChannelSelectorOrder, SelectedChannel); do { // go over every channel until we find one we can actually select. idx += forward ? 1 : -1; idx = MathHelper.Mod(idx, ChannelSelectorOrder.Length); } while ((ChatMgr.SelectableChannels & ChannelSelectorOrder[idx]) == 0); SafelySelectChannel(ChannelSelectorOrder[idx]); } private void Repopulate() { Contents.Clear(); foreach (var msg in ChatMgr.History) { WriteChatMessage(msg); } } private void WriteChatMessage(StoredChatMessage message) { var messageText = FormattedMessage.EscapeText(message.Message); if (!string.IsNullOrEmpty(message.MessageWrap)) { messageText = string.Format(message.MessageWrap, messageText); } Logger.DebugS("chat", $"{message.Channel}: {messageText}"); if (IsFilteredOut(message.Channel)) return; // TODO: Can make this "smarter" later by only setting it false when the message has been scrolled to message.Read = true; var color = message.MessageColorOverride != Color.Transparent ? message.MessageColorOverride : ChatHelper.ChatColor(message.Channel); AddLine(messageText, message.Channel, color); } private bool IsFilteredOut(ChatChannel channel) { return (ChatMgr.ChannelFilters & channel) == 0; } private void InputKeyBindDown(GUIBoundKeyEventArgs args) { if (args.Function == EngineKeyFunctions.TextReleaseFocus) { Input.ReleaseKeyboardFocus(); args.Handle(); return; } if (args.Function == ContentKeyFunctions.CycleChatChannelForward) { CycleChatChannel(true); args.Handle(); return; } if (args.Function == ContentKeyFunctions.CycleChatChannelBackward) { CycleChatChannel(false); args.Handle(); } } private (ChatSelectChannel selChannel, ReadOnlyMemory text) SplitInputContents() { var text = Input.Text.AsMemory().Trim(); if (text.Length == 0) return default; var prefixChar = text.Span[0]; var channel = GetChannelFromPrefix(prefixChar); if ((ChatMgr.SelectableChannels & channel) != 0) // Cut off prefix if it's valid and we can use the channel in question. text = text[1..]; else channel = 0; channel = MapLocalIfGhost(channel); // Trim from start again to cut out any whitespace between the prefix and message, if any. return (channel, text.TrimStart()); } private void InputOnTextChanged(LineEdit.LineEditEventArgs obj) { // Update channel select button to correct channel if we have a prefix. UpdateChannelSelectButton(); // Warn typing indicator about change IoCManager.Resolve().GetEntitySystem().ClientChangedChatText(); } private static ChatSelectChannel GetChannelFromPrefix(char prefix) { return PrefixToChannel.GetValueOrDefault(prefix); } public static char GetPrefixFromChannel(ChatSelectChannel channel) { return ChannelPrefixes.GetValueOrDefault(channel); } public static string ChannelSelectorName(ChatSelectChannel channel) { return Loc.GetString($"hud-chatbox-select-channel-{channel}"); } public static Color ChannelSelectColor(ChatSelectChannel channel) { return channel switch { ChatSelectChannel.Radio => Color.LimeGreen, ChatSelectChannel.LOOC => Color.MediumTurquoise, ChatSelectChannel.OOC => Color.LightSkyBlue, ChatSelectChannel.Dead => Color.MediumPurple, ChatSelectChannel.Admin => Color.Red, _ => Color.DarkGray }; } public void AddLine(string message, ChatChannel channel, Color color) { DebugTools.Assert(!Disposed); var formatted = new FormattedMessage(3); formatted.PushColor(color); formatted.AddMarkup(message); formatted.Pop(); Contents.AddMessage(formatted); } private void Input_OnTextEntered(LineEdit.LineEditEventArgs args) { // Warn typing indicator about entered text IoCManager.Resolve().GetEntitySystem().ClientSubmittedChatText(); if (!string.IsNullOrWhiteSpace(args.Text)) { var (prefixChannel, text) = SplitInputContents(); // Check if message is longer than the character limit if (text.Length > ChatMgr.MaxMessageLength) { string locWarning = Loc.GetString( "chat-manager-max-message-length", ("maxMessageLength", ChatMgr.MaxMessageLength)); AddLine(locWarning, ChatChannel.Server, Color.Orange); return; } ChatMgr.OnChatBoxTextSubmitted(this, text, prefixChannel == 0 ? SelectedChannel : prefixChannel); } Input.Clear(); UpdateChannelSelectButton(); if (ReleaseFocusOnEnter) Input.ReleaseKeyboardFocus(); } public void Focus(ChatSelectChannel? channel = null) { var selectStart = Index.End; if (channel != null) { channel = MapLocalIfGhost(channel.Value); // Channel not selectable, just do NOTHING (not even focus). if (!((ChatMgr.SelectableChannels & channel.Value) != 0)) return; var (_, text) = SplitInputContents(); var newPrefix = GetPrefixFromChannel(channel.Value); DebugTools.Assert(newPrefix != default, "Focus channel must have prefix!"); if (channel == SelectedChannel) { // New selected channel is just the selected channel, // just remove prefix (if any) and leave text unchanged. Input.Text = text.ToString(); selectStart = Index.Start; } else { // Change prefix to new focused channel prefix and leave text unchanged. Input.Text = string.Concat(newPrefix.ToString(), " ", text.Span); selectStart = Index.FromStart(2); } UpdateChannelSelectButton(); } Input.IgnoreNext = true; Input.GrabKeyboardFocus(); Input.CursorPosition = Input.Text.Length; Input.SelectionStart = selectStart.GetOffset(Input.Text.Length); } private ChatSelectChannel MapLocalIfGhost(ChatSelectChannel channel) { if (channel == ChatSelectChannel.Local && ChatMgr.IsGhost) return ChatSelectChannel.Dead; return channel; } } /// /// Only needed to avoid the issue where right click on the button closes the popup /// but leaves the button highlighted. /// public sealed class ChannelSelectorButton : Button { public ChannelSelectorButton() { // needed so the popup is untoggled regardless of which key is pressed when hovering this button. // If we don't have this, then right clicking the button while it's toggled on will hide // the popup but keep the button toggled on Mode = ActionMode.Press; EnableAllKeybinds = true; } protected override void KeyBindDown(GUIBoundKeyEventArgs args) { // needed since we need EnableAllKeybinds - don't double-send both UI click and Use if (args.Function == EngineKeyFunctions.Use) return; base.KeyBindDown(args); } } public sealed class FilterButton : ContainerButton { private static readonly Color ColorNormal = Color.FromHex("#7b7e9e"); private static readonly Color ColorHovered = Color.FromHex("#9699bb"); private static readonly Color ColorPressed = Color.FromHex("#789B8C"); private readonly TextureRect _textureRect; public FilterButton() { var filterTexture = IoCManager.Resolve() .GetTexture("/Textures/Interface/Nano/filter.svg.96dpi.png"); // needed for same reason as ChannelSelectorButton Mode = ActionMode.Press; EnableAllKeybinds = true; AddChild( (_textureRect = new TextureRect { Texture = filterTexture, HorizontalAlignment = HAlignment.Center, VerticalAlignment = VAlignment.Center }) ); ToggleMode = true; } protected override void KeyBindDown(GUIBoundKeyEventArgs args) { // needed since we need EnableAllKeybinds - don't double-send both UI click and Use if (args.Function == EngineKeyFunctions.Use) return; base.KeyBindDown(args); } private void UpdateChildColors() { if (_textureRect == null) return; switch (DrawMode) { case DrawModeEnum.Normal: _textureRect.ModulateSelfOverride = ColorNormal; break; case DrawModeEnum.Pressed: _textureRect.ModulateSelfOverride = ColorPressed; break; case DrawModeEnum.Hover: _textureRect.ModulateSelfOverride = ColorHovered; break; case DrawModeEnum.Disabled: break; } } protected override void DrawModeChanged() { base.DrawModeChanged(); UpdateChildColors(); } protected override void StylePropertiesChanged() { base.StylePropertiesChanged(); UpdateChildColors(); } } public sealed class ChannelItemButton : Button { public readonly ChatSelectChannel Channel; public ChannelItemButton(ChatSelectChannel channel) { Channel = channel; AddStyleClass(StyleNano.StyleClassChatChannelSelectorButton); Text = ChatBox.ChannelSelectorName(channel); var prefix = ChatBox.GetPrefixFromChannel(channel); if (prefix != default) Text = Loc.GetString("hud-chatbox-select-name-prefixed", ("name", Text), ("prefix", prefix)); } } public sealed class ChannelFilterCheckbox : CheckBox { public readonly ChatChannel Channel; public ChannelFilterCheckbox(ChatChannel channel, int? unreadCount) { Channel = channel; UpdateText(unreadCount); } private void UpdateText(int? unread) { var name = Loc.GetString($"hud-chatbox-channel-{Channel}"); if (unread > 0) // todo: proper fluent stuff here. name += " (" + (unread > 9 ? "9+" : unread) + ")"; Text = name; } public void UpdateUnreadCount(int? unread) { UpdateText(unread); } } public readonly struct ChatResizedEventArgs { /// new bottom that the chat rect is going to have in virtual pixels /// after the imminent relayout public readonly float NewBottom; public ChatResizedEventArgs(float newBottom) { NewBottom = newBottom; } } }