From 86adeec6db98cb705bd35bbb3e1c0bae138b9abc Mon Sep 17 00:00:00 2001
From: cashew <70486856+cashew-42@users.noreply.github.com>
Date: Sat, 19 Sep 2026 21:10:03 +0000
Subject: [PATCH] Fix OnAttachedChanged Lag when having a long chat history
(#46050)
* I fucking hate chat code and UI code
* while I'm at it I'll make this prettier
* docs
* typo
* testfail
* reset chat upon joining game
* move code
* remove unused imports and add doc
* doc
* final commit
* oop
* Revert "reset chat upon joining game"
This reverts commit a851319521193d099b6d1571631b63917500bc47.
* review stuff
* testfail
---
.../UserInterface/Controls/TextLinkLabel.cs | 80 ++++++++++++++++++-
.../RichText/TextLinkTag.Entity.cs | 5 +-
.../RichText/TextLinkTag.Plain.cs | 4 +-
.../UserInterface/RichText/TextLinkTag.cs | 72 ++++++-----------
.../Systems/Chat/ChatUIController.cs | 13 ++-
.../Systems/Chat/Widgets/ChatBox.xaml.cs | 15 ++++
6 files changed, 132 insertions(+), 57 deletions(-)
diff --git a/Content.Client/UserInterface/Controls/TextLinkLabel.cs b/Content.Client/UserInterface/Controls/TextLinkLabel.cs
index 1600e979b8..afd4fa48a1 100644
--- a/Content.Client/UserInterface/Controls/TextLinkLabel.cs
+++ b/Content.Client/UserInterface/Controls/TextLinkLabel.cs
@@ -1,13 +1,85 @@
+using Content.Client.UserInterface.ControlExtensions;
+using Content.Client.UserInterface.RichText;
+using Content.Shared.Chat;
+using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
+using Robust.Shared.Input;
+using Robust.Shared.Utility;
+
namespace Content.Client.UserInterface.Controls;
///
/// Carries link data parsed and resolved from a TextLink ,
-/// only one field per link should be populated at a time.
+/// only one LinkType field per link should be populated at a time.
///
-public sealed class TextLinkLabel : Label
+public sealed partial class TextLinkLabel : Label
{
- public string? LinkString { get; init; }
- public NetEntity? LinkEntity { get; init; }
+ public string? LinkString { get; init; } // default links
+ public NetEntity? LinkEntity { get; init; } // entity links
+ public Color LinkColor { get; set; }
+
+ [Dependency] private IEntityManager _entity = default!;
+ private bool canClickLink;
+
+ public TextLinkLabel()
+ {
+ IoCManager.InjectDependencies(this);
+ OnKeyBindDown += OnKeybindDown;
+ OnMouseEntered += _ => OnHoverChanged(true);
+ OnMouseExited += _ => OnHoverChanged(false);
+ }
+
+ ///
+ /// Refreshes the label's properties based on the current link target
+ /// and the viewer's permission to follow it.
Call after construction,
+ /// and whenever that permission could have changed.
+ ///
+ /// Whether the label should be shown at all. Defaults to true.
+ /// Additional override to force the label non-clickable. Defaults to true.
+ public void UpdateLabelProperties(SharedChatSystem chatSystem, bool? visible = null, bool? clickable = null)
+ {
+ visible ??= true;
+ Visible = visible.Value;
+ clickable ??= true;
+
+ canClickLink = (LinkString != null || (LinkEntity is { } netEntity && chatSystem.CanClickMessageSender(netEntity))) && (bool)clickable;
+
+ MouseFilter = canClickLink ? MouseFilterMode.Stop : MouseFilterMode.Ignore;
+ DefaultCursorShape = canClickLink ? CursorShape.Hand : CursorShape.Arrow;
+
+ OnHoverChanged(false);
+ }
+
+ private void OnHoverChanged(bool hovering)
+ {
+ FontColorOverride = (canClickLink, hovering) switch
+ {
+ (true, true) => Color.LightSkyBlue, // clickable and currently hovered
+ _ => LinkColor, // not clickable, or not hovered
+ };
+ }
+
+ ///
+ /// Delegates click to the nearest ancestor ILinkClickHandler or IEntityLinkClickHandler;
+ /// TextLinkLabel has no idea what a click actually does.
+ ///
+ private void OnKeybindDown(GUIBoundKeyEventArgs args)
+ {
+ if (args.Function != EngineKeyFunctions.UIClick)
+ return;
+
+ if (LinkString is null && LinkEntity is null)
+ return;
+
+ if (LinkEntity is { } entity &&
+ this.TryGetParentHandler(out var entityLinkClickHandler))
+ {
+ entityLinkClickHandler.HandleClick(entity);
+ }
+ else if (LinkString != null && this.TryGetParentHandler(out var linkClickHandler))
+ {
+ linkClickHandler.HandleClick(LinkString);
+ }
+ }
}
diff --git a/Content.Client/UserInterface/RichText/TextLinkTag.Entity.cs b/Content.Client/UserInterface/RichText/TextLinkTag.Entity.cs
index c68b375a0e..3b1b94f1e1 100644
--- a/Content.Client/UserInterface/RichText/TextLinkTag.Entity.cs
+++ b/Content.Client/UserInterface/RichText/TextLinkTag.Entity.cs
@@ -24,12 +24,9 @@ public sealed partial class TextLinkTag
if (!NetEntity.TryParse(entStr, out var netEntity))
return false;
- var chat = _entity.System();
- var clickable = chat.CanClickMessageSender(netEntity);
-
var color = GetLinkColor(node, netEntity);
- data = new LinkData(LinkString: null, LinkEntity: netEntity, Color: color, Clickable: clickable);
+ data = new LinkData(LinkString: null, LinkEntity: netEntity, Color: color);
return true;
}
diff --git a/Content.Client/UserInterface/RichText/TextLinkTag.Plain.cs b/Content.Client/UserInterface/RichText/TextLinkTag.Plain.cs
index d18e21116e..9defaf0253 100644
--- a/Content.Client/UserInterface/RichText/TextLinkTag.Plain.cs
+++ b/Content.Client/UserInterface/RichText/TextLinkTag.Plain.cs
@@ -1,4 +1,4 @@
-using Robust.Shared.Utility;
+using Robust.Shared.Utility;
namespace Content.Client.UserInterface.RichText;
@@ -15,7 +15,7 @@ public sealed partial class TextLinkTag
return false;
}
- data = new LinkData(LinkString: linkStr, LinkEntity: null, Color: null, Clickable: true);
+ data = new LinkData(LinkString: linkStr, LinkEntity: null, Color: null);
return true;
}
}
diff --git a/Content.Client/UserInterface/RichText/TextLinkTag.cs b/Content.Client/UserInterface/RichText/TextLinkTag.cs
index a9f78f10f5..98fde7e8ac 100644
--- a/Content.Client/UserInterface/RichText/TextLinkTag.cs
+++ b/Content.Client/UserInterface/RichText/TextLinkTag.cs
@@ -4,20 +4,25 @@ using JetBrains.Annotations;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.RichText;
-using Robust.Shared.Input;
using Robust.Shared.Utility;
-using Content.Client.UserInterface.ControlExtensions;
using Content.Client.UserInterface.Controls;
+using Content.Shared.Chat;
using Robust.Client.ResourceManagement;
namespace Content.Client.UserInterface.RichText;
///
/// Markup tag handler for [textlink="LinkText"] nodes. Renders a link
-/// in rich text, covering two types:
-/// plain links (link=) and entity links (entity=).
-/// optional color= and entitynamecolor= parameters
-/// allow setting a color override and opting into using entity name colors for entity links
+/// in rich text, covering two link types:
+///
+/// - link="GuideEntryPrototypeID" — a plain link.
+/// - entity="NetEntity" — an entity link.
+///
+/// Optional parameters:
+///
+/// - color="HexColor" — color override.
+/// - entitynamecolor="Bool" — entity links only; opt into using the entity's name color.
+///
///
[UsedImplicitly]
public sealed partial class TextLinkTag : IMarkupTagHandler
@@ -25,6 +30,7 @@ public sealed partial class TextLinkTag : IMarkupTagHandler
[Dependency] private IEntityManager _entity = default!;
[Dependency] private IUserInterfaceManager _ui = default!;
[Dependency] private IResourceCache _cache = default!;
+ private SharedChatSystem? _chat;
public string Name => "textlink";
public static Color DefaultLinkColor => Color.CornflowerBlue;
@@ -38,7 +44,7 @@ public sealed partial class TextLinkTag : IMarkupTagHandler
///
/// Resolved Link Data, LinkString and LinkEntity should not be populated at the same time
///
- private readonly record struct LinkData(string? LinkString, NetEntity? LinkEntity, Color? Color, bool Clickable);
+ private readonly record struct LinkData(string? LinkString, NetEntity? LinkEntity, Color? Color);
public TextLinkTag()
{
@@ -67,25 +73,27 @@ public sealed partial class TextLinkTag : IMarkupTagHandler
foreach (var (attrname, resolver) in _resolvers)
{
- if (node.Attributes.ContainsKey(attrname))
+ if (!node.Attributes.ContainsKey(attrname))
+ continue;
+
+ if(!resolver(node, out linkData))
{
- if(!resolver(node, out linkData))
- {
- return false;
- }
- linkTypeResolved = true;
- break;
+ return false;
}
+
+ linkTypeResolved = true;
+ break;
}
+
if (!linkTypeResolved)
{
return false;
}
+
// color= > resolver-supplied color > default
var linkColor = ResolveColorOverride(node) ?? linkData.Color ?? DefaultLinkColor;
- var linkLabel = new TextLinkLabel() { Text = text, LinkString = linkData.LinkString, LinkEntity = linkData.LinkEntity };
- linkLabel.FontColorOverride = linkColor;
+ var linkLabel = new TextLinkLabel() { Text = text, LinkString = linkData.LinkString, LinkEntity = linkData.LinkEntity, LinkColor = linkColor};
// eat my ass about where this magic number comes from
// our UI stack is awful. Finding this magic number was awful.
@@ -96,14 +104,8 @@ public sealed partial class TextLinkTag : IMarkupTagHandler
linkLabel.FontOverride = boldFont;
}
- if (linkData.Clickable)
- {
- linkLabel.MouseFilter = Control.MouseFilterMode.Stop;
- linkLabel.DefaultCursorShape = Control.CursorShape.Hand;
- linkLabel.OnMouseEntered += _ => linkLabel.FontColorOverride = Color.LightSkyBlue;
- linkLabel.OnMouseExited += _ => linkLabel.FontColorOverride = linkColor;
- linkLabel.OnKeyBindDown += args => OnKeybindDown(args, linkLabel);
- }
+ _chat ??= _entity.System();
+ linkLabel.UpdateLabelProperties(_chat);
control = linkLabel;
return true;
@@ -119,28 +121,6 @@ public sealed partial class TextLinkTag : IMarkupTagHandler
return Color.TryFromHex(colorStr, out var color) ? color : null;
}
-
- ///
- /// Delegates to the nearest ancestor ILinkClickHandler or IEntityLinkClickHandler;
- /// TextLinkTag has no idea what a click actually does.
- ///
- private void OnKeybindDown(GUIBoundKeyEventArgs args, TextLinkLabel? control)
- {
- if (args.Function != EngineKeyFunctions.UIClick)
- return;
-
- if (control == null)
- return;
-
- if (control.LinkEntity is { } entity && control.TryGetParentHandler(out var entityLinkClickHandler))
- {
- entityLinkClickHandler.HandleClick(entity);
- }
- else if (control.LinkString != null && control.TryGetParentHandler(out var linkClickHandler))
- {
- linkClickHandler.HandleClick(control.LinkString);
- }
- }
}
///
diff --git a/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs b/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs
index 4f95bb9021..2140879aa9 100644
--- a/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs
+++ b/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs
@@ -67,6 +67,7 @@ public sealed partial class ChatUIController : UIController
[UISystemDependency] private readonly MindSystem? _mindSystem = default!;
[UISystemDependency] private readonly RoleCodewordSystem? _roleCodewordSystem = default!;
+ private SharedChatSystem? _sharedChatSys;
private static readonly ProtoId ChatNamePalette = "ChatNames";
private string[] _chatNameColors = default!;
private bool _chatNameColorsEnabled;
@@ -429,7 +430,7 @@ public sealed partial class ChatUIController : UIController
{
UpdateChannelPermissions();
UpdateAutoFillHighlights();
- Repopulate();
+ UpdateLinkLabels();
}
private void AddSpeechBubble(ChatMessage msg, SpeechBubble.SpeechType speechType)
@@ -942,6 +943,16 @@ public sealed partial class ChatUIController : UIController
}
}
+ private void UpdateLinkLabels()
+ {
+ _sharedChatSys ??= _ent.System();
+
+ foreach (var chat in _chats)
+ {
+ chat.UpdateTextLinkLabelProperties(_sharedChatSys);
+ }
+ }
+
///
/// Returns the chat name color for a mob
///
diff --git a/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml.cs b/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml.cs
index 8a8b6750da..f5545a496f 100644
--- a/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml.cs
+++ b/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml.cs
@@ -1,3 +1,4 @@
+using Content.Client.UserInterface.Controls;
using Content.Client.UserInterface.RichText;
using Content.Client.UserInterface.Systems.Chat.Controls;
using Content.Shared.Chat;
@@ -91,6 +92,20 @@ public partial class ChatBox : UIWidget, IEntityLinkClickHandler
}
}
+ ///
+ /// Calls update function on every child in the ChatBox's OutputPanel "Contents"
+ ///
+ public void UpdateTextLinkLabelProperties(SharedChatSystem _sharedChatSys)
+ {
+ foreach (var contentsChild in Contents.Children)
+ {
+ if (contentsChild is TextLinkLabel textLinkLabel)
+ {
+ textLinkLabel.UpdateLabelProperties(_sharedChatSys);
+ }
+ }
+ }
+
private void OnChannelFilter(ChatChannel channel, bool active)
{
Contents.Clear();