diff --git a/Content.Client/Options/UI/Tabs/MiscTab.xaml b/Content.Client/Options/UI/Tabs/MiscTab.xaml
index 8a73aa9aec4..1c6f82b5581 100644
--- a/Content.Client/Options/UI/Tabs/MiscTab.xaml
+++ b/Content.Client/Options/UI/Tabs/MiscTab.xaml
@@ -20,6 +20,7 @@
+
diff --git a/Content.Client/Options/UI/Tabs/MiscTab.xaml.cs b/Content.Client/Options/UI/Tabs/MiscTab.xaml.cs
index b254dd401b3..1c4f4fd07f5 100644
--- a/Content.Client/Options/UI/Tabs/MiscTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/MiscTab.xaml.cs
@@ -63,6 +63,7 @@ public sealed partial class MiscTab : Control
Control.AddOptionCheckBox(CCVars.ChatEnableFancyBubbles, FancySpeechBubblesCheckBox);
Control.AddOptionCheckBox(CCVars.ChatFancyNameBackground, FancyNameBackgroundsCheckBox);
Control.AddOptionCheckBox(CCVars.StaticStorageUI, StaticStorageUI);
+ Control.AddOptionCheckBox(CCVars.InterfaceChatFollowButton, ChatFollowButton);
Control.Initialize();
}
diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs
index 1e79426f88c..f882ff70f37 100644
--- a/Content.Server/Chat/Managers/ChatManager.cs
+++ b/Content.Server/Chat/Managers/ChatManager.cs
@@ -5,6 +5,7 @@ using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
using Content.Server.Administration.Systems;
using Content.Server.Discord.DiscordLink;
+using Content.Server.Ghost;
using Content.Server.Players.RateLimiting;
using Content.Server.Preferences.Managers;
using Content.Shared.Administration;
@@ -15,6 +16,7 @@ using Content.Shared.Mind;
using Content.Shared.Players; // DeltaV - OOC muting
using Content.Shared.Players.RateLimiting;
using Robust.Shared.Configuration;
+using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Replays;
@@ -47,6 +49,7 @@ internal sealed partial class ChatManager : IChatManager
[Dependency] private readonly ISharedPlayerManager _player = default!;
[Dependency] private readonly DiscordChatLink _discordLink = default!;
[Dependency] private readonly ILogManager _logManager = default!;
+ [Dependency] private readonly ILocalizationManager _localizationManager = default!;
private ISawmill _sawmill = default!;
@@ -342,12 +345,35 @@ internal sealed partial class ChatManager : IChatManager
#region Utility
+ private bool IsValidWarpDestination(EntityUid source)
+ {
+ if (!source.Valid)
+ return false;
+
+ if (!_entityManager.TryGetComponent(source, out TransformComponent? transform))
+ return false;
+
+ return transform.MapID != MapId.Nullspace;
+ }
+
+ public string PrependFollowButtonIfAppropriate(string wrappedMessage, EntityUid source, INetChannel recipient)
+ {
+ if (IsValidWarpDestination(source) && ShouldShowFollowButton(recipient))
+ {
+ var btnText = _localizationManager.GetString("chat-manager-follow-button");
+ return $"[cmdlink=\"{btnText}\" command=\"{GhostFollowEntityCommand.CommandName} {_entityManager.GetNetEntity(source)}\" /] " + wrappedMessage;
+ }
+
+ return wrappedMessage;
+ }
+
public void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, NetUserId? author = null)
{
var user = author == null ? null : EnsurePlayer(author);
var netSource = _entityManager.GetNetEntity(source);
user?.AddEntity(netSource);
+ wrappedMessage = PrependFollowButtonIfAppropriate(wrappedMessage, source, client);
var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume);
_netManager.ServerSendMessage(new MsgChatMessage() { Message = msg }, client);
@@ -370,8 +396,12 @@ internal sealed partial class ChatManager : IChatManager
var netSource = _entityManager.GetNetEntity(source);
user?.AddEntity(netSource);
- var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume);
- _netManager.ServerSendToMany(new MsgChatMessage() { Message = msg }, clients);
+ foreach (var client in clients)
+ {
+ var customWrapMessage = PrependFollowButtonIfAppropriate(wrappedMessage, source, client);
+ var msg = new ChatMessage(channel, message, customWrapMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume);
+ _netManager.ServerSendMessage(new MsgChatMessage { Message = msg }, client);
+ }
if (!recordReplay)
return;
@@ -379,6 +409,7 @@ internal sealed partial class ChatManager : IChatManager
if ((channel & ChatChannel.AdminRelated) == 0 ||
_configurationManager.GetCVar(CCVars.ReplayRecordAdminChat))
{
+ var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume);
_replay.RecordServerMessage(msg);
}
}
@@ -439,6 +470,22 @@ internal sealed partial class ChatManager : IChatManager
}
#endregion
+
+ private bool ShouldShowFollowButton(INetChannel recipient)
+ {
+ if (!_player.TryGetSessionByChannel(recipient, out var session))
+ return false;
+
+ if (_entityManager.TrySystem(out GhostSystem? ghost))
+ {
+ if (!ghost.CanGhostWarp(session, out _))
+ {
+ return false;
+ }
+ }
+
+ return _netConfigManager.GetClientCVar(recipient, CCVars.InterfaceChatFollowButton);
+ }
}
public enum OOCChatType : byte
diff --git a/Content.Server/Chat/Managers/IChatManager.cs b/Content.Server/Chat/Managers/IChatManager.cs
index 9ac2a27c4ec..8cc97ac8c69 100644
--- a/Content.Server/Chat/Managers/IChatManager.cs
+++ b/Content.Server/Chat/Managers/IChatManager.cs
@@ -49,5 +49,7 @@ namespace Content.Server.Chat.Managers
/// The player sending a chat message.
/// False if the player has violated rate limits and should be blocked from sending further messages.
RateLimitStatus HandleRateLimit(ICommonSession player);
+
+ string PrependFollowButtonIfAppropriate(string wrappedMessage, EntityUid source, INetChannel recipient);
}
}
diff --git a/Content.Server/Ghost/GhostFollowEntityCommand.cs b/Content.Server/Ghost/GhostFollowEntityCommand.cs
new file mode 100644
index 00000000000..c86aa5c41dc
--- /dev/null
+++ b/Content.Server/Ghost/GhostFollowEntityCommand.cs
@@ -0,0 +1,26 @@
+using Content.Shared.Administration;
+using Robust.Shared.Console;
+
+namespace Content.Server.Ghost;
+
+[AnyCommand]
+internal sealed partial class GhostFollowEntityCommand : LocalizedEntityCommands
+{
+ public const string CommandName = "ghost_follow_entity";
+
+ [Dependency] private GhostSystem _ghost = null!;
+
+ public override string Command => CommandName;
+
+ public override void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (args.Length != 1 || shell.Player is not { } player)
+ return;
+
+ var target = args[0];
+ if (!NetEntity.TryParse(target, out var targetEnt))
+ return;
+
+ _ghost.GhostWarpRequest(player, targetEnt);
+ }
+}
diff --git a/Content.Server/Ghost/GhostSystem.cs b/Content.Server/Ghost/GhostSystem.cs
index eb869b23aab..5c2c5237446 100644
--- a/Content.Server/Ghost/GhostSystem.cs
+++ b/Content.Server/Ghost/GhostSystem.cs
@@ -299,10 +299,22 @@ namespace Content.Server.Ghost
#region Warp
+ public bool CanGhostWarp(ICommonSession session, out EntityUid entity)
+ {
+ if (session.AttachedEntity is not { Valid: true } sessionEntity
+ || !_ghostQuery.HasComp(sessionEntity))
+ {
+ entity = default;
+ return false;
+ }
+
+ entity = sessionEntity;
+ return true;
+ }
+
private void OnGhostWarpsRequest(GhostWarpsRequestEvent msg, EntitySessionEventArgs args)
{
- if (args.SenderSession.AttachedEntity is not {Valid: true} entity
- || !_ghostQuery.HasComp(entity))
+ if (!CanGhostWarp(args.SenderSession, out var entity))
{
Log.Warning($"User {args.SenderSession.Name} sent a {nameof(GhostWarpsRequestEvent)} without being a ghost.");
return;
@@ -312,30 +324,33 @@ namespace Content.Server.Ghost
RaiseNetworkEvent(response, args.SenderSession.Channel);
}
+ public void GhostWarpRequest(ICommonSession player, NetEntity target)
+ {
+ if (!CanGhostWarp(player, out var attached))
+ {
+ Log.Warning($"User {player.Name} tried to warp to {target} without being a ghost.");
+ return;
+ }
+
+ var realTarget = GetEntity(target);
+
+ if (!Exists(realTarget))
+ {
+ Log.Warning($"User {player.Name} tried to warp to an invalid entity id: {target}");
+ return;
+ }
+
+ WarpTo(attached, realTarget);
+ }
+
private void OnGhostWarpToTargetRequest(GhostWarpToTargetRequestEvent msg, EntitySessionEventArgs args)
{
- if (args.SenderSession.AttachedEntity is not {Valid: true} attached
- || !_ghostQuery.HasComp(attached))
- {
- Log.Warning($"User {args.SenderSession.Name} tried to warp to {msg.Target} without being a ghost.");
- return;
- }
-
- var target = GetEntity(msg.Target);
-
- if (!Exists(target))
- {
- Log.Warning($"User {args.SenderSession.Name} tried to warp to an invalid entity id: {msg.Target}");
- return;
- }
-
- WarpTo(attached, target);
+ GhostWarpRequest(args.SenderSession, msg.Target);
}
private void OnGhostnadoRequest(GhostnadoRequestEvent msg, EntitySessionEventArgs args)
{
- if (args.SenderSession.AttachedEntity is not {} uid
- || !_ghostQuery.HasComp(uid))
+ if (CanGhostWarp(args.SenderSession, out var uid))
{
Log.Warning($"User {args.SenderSession.Name} tried to ghostnado without being a ghost.");
return;
diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs
index 6889bd2951f..65348ba955c 100644
--- a/Content.Server/Radio/EntitySystems/RadioSystem.cs
+++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs
@@ -1,5 +1,7 @@
using Content.Server.Administration.Logs;
+using Content.Server.Chat.Managers;
using Content.Server.Chat.Systems;
+using Content.Server.Ghost;
using Content.Server.Power.Components;
using Content.Shared._DV.Chat;
using Content.Shared.Chat;
@@ -28,6 +30,8 @@ public sealed class RadioSystem : EntitySystem
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly ChatSystem _chat = default!;
+ [Dependency] private readonly IChatManager _chatManager = default!;
+ [Dependency] private readonly GhostSystem _ghost = default!;
// set used to prevent radio feedback loops.
private readonly HashSet _messages = new();
@@ -55,8 +59,25 @@ public sealed class RadioSystem : EntitySystem
private void OnIntrinsicReceive(EntityUid uid, IntrinsicRadioReceiverComponent component, ref RadioReceiveEvent args)
{
- if (TryComp(uid, out ActorComponent? actor))
- _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel);
+ if (!TryComp(uid, out ActorComponent? actor))
+ return;
+
+ var msg = args.ChatMsg;
+ if (_ghost.CanGhostWarp(actor.PlayerSession, out _))
+ {
+ msg = new MsgChatMessage
+ {
+ Message = new ChatMessage(args.ChatMsg.Message)
+ {
+ WrappedMessage = _chatManager.PrependFollowButtonIfAppropriate(
+ args.ChatMsg.Message.WrappedMessage,
+ args.MessageSource,
+ actor.PlayerSession.Channel),
+ },
+ };
+ }
+
+ _netMan.ServerSendMessage(msg, actor.PlayerSession.Channel);
}
// DeltaV
diff --git a/Content.Shared/CCVar/CCVars.Interface.cs b/Content.Shared/CCVar/CCVars.Interface.cs
index fb651570154..752f73f982a 100644
--- a/Content.Shared/CCVar/CCVars.Interface.cs
+++ b/Content.Shared/CCVar/CCVars.Interface.cs
@@ -135,4 +135,10 @@ public sealed partial class CCVars
///
public static readonly CVarDef AdminOverlayStackMax =
CVarDef.Create("ui.admin_overlay_stack_max", 3, CVar.CLIENTONLY | CVar.ARCHIVE);
+
+ ///
+ /// If true, ghosts will see an "(F)" button next to chat messages, which can be used to follow the sender.
+ ///
+ public static readonly CVarDef InterfaceChatFollowButton =
+ CVarDef.Create("ui.chat_follow_button", true, CVar.CLIENT | CVar.REPLICATED | CVar.ARCHIVE);
}
diff --git a/Content.Shared/Chat/MsgChatMessage.cs b/Content.Shared/Chat/MsgChatMessage.cs
index 85367ffb739..4666648624d 100644
--- a/Content.Shared/Chat/MsgChatMessage.cs
+++ b/Content.Shared/Chat/MsgChatMessage.cs
@@ -53,6 +53,20 @@ namespace Content.Shared.Chat
AudioPath = audioPath;
AudioVolume = audioVolume;
}
+
+ public ChatMessage(ChatMessage copyFrom)
+ {
+ Channel = copyFrom.Channel;
+ Message = copyFrom.Message;
+ WrappedMessage = copyFrom.WrappedMessage;
+ SenderEntity = copyFrom.SenderEntity;
+ SenderKey = copyFrom.SenderKey;
+ HideChat = copyFrom.HideChat;
+ MessageColorOverride = copyFrom.MessageColorOverride;
+ AudioPath = copyFrom.AudioPath;
+ AudioVolume = copyFrom.AudioVolume;
+ Read = copyFrom.Read;
+ }
}
///
diff --git a/Resources/Locale/en-US/chat/managers/chat-manager.ftl b/Resources/Locale/en-US/chat/managers/chat-manager.ftl
index 77090065875..54680fa35df 100644
--- a/Resources/Locale/en-US/chat/managers/chat-manager.ftl
+++ b/Resources/Locale/en-US/chat/managers/chat-manager.ftl
@@ -52,6 +52,8 @@ chat-manager-admin-channel-name = ADMIN
chat-manager-rate-limited = You are sending messages too quickly!
chat-manager-rate-limit-admin-announcement = Rate limit warning: { $player }
+chat-manager-follow-button = (F)
+
## Speech verbs for chat
chat-speech-verb-suffix-exclamation = !
diff --git a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl
index 4aad6b684d7..75c8a032efc 100644
--- a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl
+++ b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl
@@ -59,6 +59,7 @@ ui-options-show-ooc-patron-color = Show OOC Patreon color
ui-options-show-looc-on-head = Show LOOC chat above characters head
ui-options-fancy-speech = Show names in speech bubbles
ui-options-fancy-name-background = Add background to speech bubble names
+ui-options-chat-follow-button = As ghost, show a follow button next to chat messages
ui-options-vsync = VSync
ui-options-fullscreen = Fullscreen
ui-options-lighting-label = Lighting Quality: