diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs
index 2266b30c515..e0358d54e75 100644
--- a/Content.Client/Entry/EntryPoint.cs
+++ b/Content.Client/Entry/EntryPoint.cs
@@ -3,6 +3,7 @@ using Content.Client.Changelog;
using Content.Client.Chat.Managers;
using Content.Client.DebugMon;
using Content.Client.Eui;
+using Content.Client.FeedbackPopup;
using Content.Client.Fullscreen;
using Content.Client.GameTicking.Managers;
using Content.Client.GhostKick;
@@ -24,6 +25,7 @@ using Content.Client.UserInterface;
using Content.Client.Viewport;
using Content.Client.Voting;
using Content.Shared.Ame.Components;
+using Content.Shared.FeedbackSystem;
using Content.Shared.Gravity;
using Content.Shared.Localizations;
using Robust.Client;
@@ -76,6 +78,7 @@ namespace Content.Client.Entry
[Dependency] private readonly TitleWindowManager _titleWindowManager = default!;
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
[Dependency] private readonly ClientsidePlaytimeTrackingManager _clientsidePlaytimeManager = default!;
+ [Dependency] private readonly ClientFeedbackManager _feedbackManager = null!;
public override void PreInit()
{
@@ -170,6 +173,7 @@ namespace Content.Client.Entry
_userInterfaceManager.SetActiveTheme(_configManager.GetCVar(CVars.InterfaceTheme));
_documentParsingManager.Initialize();
_titleWindowManager.Initialize();
+ _feedbackManager.Initialize();
_baseClient.RunLevelChanged += (_, args) =>
{
diff --git a/Content.Client/FeedbackPopup/ClientFeedbackManager.cs b/Content.Client/FeedbackPopup/ClientFeedbackManager.cs
new file mode 100644
index 00000000000..a4cdf6a6172
--- /dev/null
+++ b/Content.Client/FeedbackPopup/ClientFeedbackManager.cs
@@ -0,0 +1,71 @@
+using Content.Shared.FeedbackSystem;
+using Robust.Shared.Network;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.FeedbackPopup;
+
+///
+public sealed class ClientFeedbackManager : SharedFeedbackManager
+{
+ ///
+ /// A read-only set representing the currently displayed feedback popups.
+ ///
+ public IReadOnlySet> DisplayedPopups => _displayedPopups;
+
+ private readonly HashSet> _displayedPopups = [];
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ NetManager.RegisterNetMessage(ReceivedPopupMessage);
+ NetManager.RegisterNetMessage(_ => Open());
+ }
+
+ ///
+ /// Opens the feedback popup window.
+ ///
+ public void Open()
+ {
+ InvokeDisplayedPopupsChanged(true);
+ }
+
+ ///
+ public override void Display(List>? prototypes)
+ {
+ if (prototypes == null || !NetManager.IsClient)
+ return;
+
+ var count = _displayedPopups.Count;
+ _displayedPopups.UnionWith(prototypes);
+ InvokeDisplayedPopupsChanged(_displayedPopups.Count > count);
+ }
+
+ ///
+ public override void Remove(List>? prototypes)
+ {
+ if (!NetManager.IsClient)
+ return;
+
+ if (prototypes == null)
+ {
+ _displayedPopups.Clear();
+ }
+ else
+ {
+ _displayedPopups.ExceptWith(prototypes);
+ }
+
+ InvokeDisplayedPopupsChanged(false);
+ }
+
+ private void ReceivedPopupMessage(FeedbackPopupMessage message)
+ {
+ if (message.Remove)
+ {
+ Remove(message.FeedbackPrototypes);
+ return;
+ }
+
+ Display(message.FeedbackPrototypes);
+ }
+}
diff --git a/Content.Client/FeedbackPopup/FeedbackEntry.xaml b/Content.Client/FeedbackPopup/FeedbackEntry.xaml
new file mode 100644
index 00000000000..9b7e6ceb17d
--- /dev/null
+++ b/Content.Client/FeedbackPopup/FeedbackEntry.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/FeedbackPopup/FeedbackEntry.xaml.cs b/Content.Client/FeedbackPopup/FeedbackEntry.xaml.cs
new file mode 100644
index 00000000000..a3a6cd9b4dd
--- /dev/null
+++ b/Content.Client/FeedbackPopup/FeedbackEntry.xaml.cs
@@ -0,0 +1,54 @@
+using Content.Shared.FeedbackSystem;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.FeedbackPopup;
+
+[GenerateTypedNameReferences]
+public sealed partial class FeedbackEntry : Control
+{
+ private readonly IUriOpener _uri;
+
+ private readonly FeedbackPopupPrototype? _prototype;
+
+ public FeedbackEntry(ProtoId popupProto, IPrototypeManager proto, IUriOpener uri)
+ {
+ RobustXamlLoader.Load(this);
+ _uri = uri;
+
+ _prototype = proto.Index(popupProto);
+
+ // Title
+ TitleLabel.Text = _prototype.Title;
+ DescriptionLabel.Text = _prototype.Description;
+ TypeLabel.Text = _prototype.ResponseType;
+
+ LinkButton.Visible = !string.IsNullOrEmpty(_prototype.ResponseLink);
+
+ // link button
+ if (!string.IsNullOrEmpty(_prototype.ResponseLink))
+ {
+ LinkButton.OnPressed += OnButtonPressed;
+ }
+ }
+
+ private void OnButtonPressed(BaseButton.ButtonEventArgs args)
+ {
+ if (!string.IsNullOrWhiteSpace(_prototype?.ResponseLink))
+ _uri.OpenUri(_prototype.ResponseLink);
+ }
+
+ protected override void Resized()
+ {
+ base.Resized();
+ // magic
+ TitleLabel.SetWidth = Width - TitleLabel.Margin.SumHorizontal;
+ TitleLabel.InvalidateArrange();
+ DescriptionLabel.SetWidth = Width - DescriptionLabel.Margin.SumHorizontal;
+ DescriptionLabel.InvalidateArrange();
+ }
+}
+
diff --git a/Content.Client/FeedbackPopup/FeedbackPopupSheetlet.cs b/Content.Client/FeedbackPopup/FeedbackPopupSheetlet.cs
new file mode 100644
index 00000000000..7f6d5136547
--- /dev/null
+++ b/Content.Client/FeedbackPopup/FeedbackPopupSheetlet.cs
@@ -0,0 +1,36 @@
+using Content.Client.Stylesheets;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using static Content.Client.Stylesheets.StylesheetHelpers;
+
+namespace Content.Client.FeedbackPopup;
+
+[CommonSheetlet]
+public sealed class FeedbackPopupSheetlet : Sheetlet
+{
+ public override StyleRule[] GetRules(PalettedStylesheet sheet, object config)
+ {
+ var borderTop = new StyleBoxFlat()
+ {
+ BorderColor = sheet.SecondaryPalette.Base,
+ BorderThickness = new Thickness(0, 1, 0, 0),
+ };
+
+ var borderBottom = new StyleBoxFlat()
+ {
+ BorderColor = sheet.SecondaryPalette.Base,
+ BorderThickness = new Thickness(0, 0, 0, 1),
+ };
+
+ return
+ [
+ E()
+ .Identifier("FeedbackBorderThinTop")
+ .Prop(PanelContainer.StylePropertyPanel, borderTop),
+ E()
+ .Identifier("FeedbackBorderThinBottom")
+ .Prop(PanelContainer.StylePropertyPanel, borderBottom),
+ ];
+ }
+}
diff --git a/Content.Client/FeedbackPopup/FeedbackPopupUIController.cs b/Content.Client/FeedbackPopup/FeedbackPopupUIController.cs
new file mode 100644
index 00000000000..19ec95a7001
--- /dev/null
+++ b/Content.Client/FeedbackPopup/FeedbackPopupUIController.cs
@@ -0,0 +1,75 @@
+using Content.Shared.FeedbackSystem;
+using Content.Shared.GameTicking;
+using Robust.Client.UserInterface.Controllers;
+using JetBrains.Annotations;
+using Robust.Client.UserInterface;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.FeedbackPopup;
+
+///
+/// This handles getting feedback popup messages from the server and making a popup in the client.
+///
+[UsedImplicitly]
+public sealed class FeedbackPopupUIController : UIController
+{
+ [Dependency] private readonly ClientFeedbackManager _feedbackManager = null!;
+ [Dependency] private readonly IPrototypeManager _proto = null!;
+ [Dependency] private readonly IUriOpener _uri = null!;
+
+ private FeedbackPopupWindow _window = null!;
+
+ public override void Initialize()
+ {
+ _window = new FeedbackPopupWindow(_proto, _uri);
+
+ SubscribeLocalEvent(OnPrototypesReloaded);
+ SubscribeNetworkEvent(OnRoundEnd);
+
+ _feedbackManager.DisplayedPopupsChanged += OnPopupsChanged;
+ }
+
+ public void ToggleWindow()
+ {
+ if (_window.IsOpen)
+ {
+ _window.Close();
+ }
+ else
+ {
+ _window.OpenCentered();
+ }
+ }
+
+ private void OnRoundEnd(RoundEndMessageEvent ev, EntitySessionEventArgs args)
+ {
+ // Add round end prototypes.
+ var roundEndPrototypes = _feedbackManager.GetOriginFeedbackPrototypes(true);
+ if (roundEndPrototypes.Count == 0)
+ return;
+
+ _feedbackManager.Display(roundEndPrototypes);
+
+ // Even if no new prototypes were added, we still want to open the window.
+ if (!_window.IsOpen)
+ _window.OpenCentered();
+ }
+
+ private void OnPopupsChanged(bool newPopups)
+ {
+ UpdateWindow(_feedbackManager.DisplayedPopups);
+
+ if (newPopups && !_window.IsOpen)
+ _window.OpenCentered();
+ }
+
+ private void OnPrototypesReloaded(PrototypesReloadedEventArgs ev)
+ {
+ UpdateWindow(_feedbackManager.DisplayedPopups);
+ }
+
+ private void UpdateWindow(IReadOnlyCollection> prototypes)
+ {
+ _window.Update(prototypes);
+ }
+}
diff --git a/Content.Client/FeedbackPopup/FeedbackPopupWindow.xaml b/Content.Client/FeedbackPopup/FeedbackPopupWindow.xaml
new file mode 100644
index 00000000000..0d17926419d
--- /dev/null
+++ b/Content.Client/FeedbackPopup/FeedbackPopupWindow.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/FeedbackPopup/FeedbackPopupWindow.xaml.cs b/Content.Client/FeedbackPopup/FeedbackPopupWindow.xaml.cs
new file mode 100644
index 00000000000..fba32eb0d8d
--- /dev/null
+++ b/Content.Client/FeedbackPopup/FeedbackPopupWindow.xaml.cs
@@ -0,0 +1,49 @@
+using Content.Client.UserInterface.Controls;
+using Content.Shared.FeedbackSystem;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.FeedbackPopup;
+
+[GenerateTypedNameReferences]
+public sealed partial class FeedbackPopupWindow : FancyWindow
+{
+ private readonly IPrototypeManager _proto;
+ private readonly IUriOpener _uri;
+
+ public FeedbackPopupWindow(IPrototypeManager proto, IUriOpener uri)
+ {
+ _proto = proto;
+ _uri = uri;
+ RobustXamlLoader.Load(this);
+ DisplayNoEntryLabel();
+ }
+
+ public void Update(IReadOnlyCollection> prototypes)
+ {
+ NotificationContainer.RemoveAllChildren();
+
+ if (prototypes.Count == 0)
+ DisplayNoEntryLabel();
+
+ foreach (var proto in prototypes)
+ {
+ NotificationContainer.AddChild(new FeedbackEntry(proto, _proto, _uri));
+ }
+
+ NumNotifications.Text = Loc.GetString("feedbackpopup-control-total-surveys", ("num", prototypes.Count));
+ }
+
+ private void DisplayNoEntryLabel()
+ {
+ NotificationContainer.AddChild(new Label()
+ {
+ Text = Loc.GetString("feedbackpopup-control-no-entries"),
+ HorizontalAlignment = HAlignment.Center,
+ VerticalAlignment = VAlignment.Center,
+ });
+ }
+}
diff --git a/Content.Client/Guidebook/Richtext/ProtodataTag.cs b/Content.Client/Guidebook/Richtext/ProtodataTag.cs
index 2a6eca4e485..9d7d46e2467 100644
--- a/Content.Client/Guidebook/Richtext/ProtodataTag.cs
+++ b/Content.Client/Guidebook/Richtext/ProtodataTag.cs
@@ -6,7 +6,7 @@ namespace Content.Client.Guidebook.RichText;
///
/// RichText tag that can display values extracted from entity prototypes.
-/// In order to be accessed by this tag, the desired field/property must
+/// To be accessed by this tag, the desired field/property must
/// be tagged with .
///
public sealed class ProtodataTag : IMarkupTagHandler
diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs
index 8f81f90311d..efaf88b0522 100644
--- a/Content.Client/IoC/ClientContentIoC.cs
+++ b/Content.Client/IoC/ClientContentIoC.cs
@@ -4,6 +4,7 @@ using Content.Client.Chat.Managers;
using Content.Client.Clickable;
using Content.Client.DebugMon;
using Content.Client.Eui;
+using Content.Client.FeedbackPopup;
using Content.Client.Fullscreen;
using Content.Client.GameTicking.Managers;
using Content.Client.GhostKick;
@@ -23,6 +24,7 @@ using Content.Client.Lobby;
using Content.Client.Players.RateLimiting;
using Content.Shared.Administration.Managers;
using Content.Shared.Chat;
+using Content.Shared.FeedbackSystem;
using Content.Shared.IoC;
using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Players.RateLimiting;
@@ -62,6 +64,8 @@ namespace Content.Client.IoC
collection.Register();
collection.Register();
collection.Register();
+ collection.Register();
+ collection.Register();
}
}
}
diff --git a/Content.Client/Options/UI/EscapeMenu.xaml b/Content.Client/Options/UI/EscapeMenu.xaml
index 6aa14f27e70..f46d65ac736 100644
--- a/Content.Client/Options/UI/EscapeMenu.xaml
+++ b/Content.Client/Options/UI/EscapeMenu.xaml
@@ -12,6 +12,7 @@
+
diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs
index 34195d3a7c5..901b92f2c67 100644
--- a/Content.Client/Stylesheets/StyleNano.cs
+++ b/Content.Client/Stylesheets/StyleNano.cs
@@ -1345,7 +1345,6 @@ namespace Content.Client.Stylesheets
Element