feat: chat follow links for ghosts (upstream port) (#6063)

* Ghosts have follow buttons in chat (#44284)

* Fix ghost follow buttons for station entity (#44289)

---------

Co-authored-by: Pieter-Jan Briers <pieterjan.briers+git@gmail.com>
This commit is contained in:
DisposableCrewmember42 2026-06-19 15:52:36 +00:00 committed by GitHub
parent 72f3edd378
commit e47a0bf455
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 160 additions and 24 deletions

View File

@ -20,6 +20,7 @@
<CheckBox Name="ShowLoocAboveHeadCheckBox" Text="{Loc 'ui-options-show-looc-on-head'}" />
<CheckBox Name="FancySpeechBubblesCheckBox" Text="{Loc 'ui-options-fancy-speech'}" />
<CheckBox Name="FancyNameBackgroundsCheckBox" Text="{Loc 'ui-options-fancy-name-background'}" />
<CheckBox Name="ChatFollowButton" Text="{Loc 'ui-options-chat-follow-button'}" />
<Label Text="{Loc 'ui-options-general-cursor'}"
StyleClasses="LabelKeyText"/>
<CheckBox Name="ShowHeldItemCheckBox" Text="{Loc 'ui-options-show-held-item'}" />

View File

@ -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();
}

View File

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

View File

@ -49,5 +49,7 @@ namespace Content.Server.Chat.Managers
/// <param name="player">The player sending a chat message.</param>
/// <returns>False if the player has violated rate limits and should be blocked from sending further messages.</returns>
RateLimitStatus HandleRateLimit(ICommonSession player);
string PrependFollowButtonIfAppropriate(string wrappedMessage, EntityUid source, INetChannel recipient);
}
}

View File

@ -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);
}
}

View File

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

View File

@ -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<string> _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

View File

@ -135,4 +135,10 @@ public sealed partial class CCVars
/// </summary>
public static readonly CVarDef<int> AdminOverlayStackMax =
CVarDef.Create("ui.admin_overlay_stack_max", 3, CVar.CLIENTONLY | CVar.ARCHIVE);
/// <summary>
/// If true, ghosts will see an "(F)" button next to chat messages, which can be used to follow the sender.
/// </summary>
public static readonly CVarDef<bool> InterfaceChatFollowButton =
CVarDef.Create("ui.chat_follow_button", true, CVar.CLIENT | CVar.REPLICATED | CVar.ARCHIVE);
}

View File

@ -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;
}
}
/// <summary>

View File

@ -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 = !

View File

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