Add feedback popups (#41352)

* Commit

* add the form post

* dv

* fixes

* Change wording

* Address review

* wording change

* Added some stuff

* New format

* bruh

* thanks perry!

* yes

* More fixes!

* typo

* Add a command to show the list, improve the UI slightly, split up command names

* Fix UI controller

* Add better comment

* Get rid of weird recursive thing

* Cleanup

* Work on moving feedback popups out of simulation

* Move round end screen subscription to feedback ui controller

* Finish moving feedback popups out of simulation

* Fix _ as parameter

* Clean up FeedbackPopupUIController

* Clean up commands

* Fix prototype yaml

* Fix openfeedbackpopup command description

* Update Resources/Locale/en-US/feedbackpopup/feedbackpopup.ftl

Co-authored-by: Simon <63975668+Simyon264@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Simon <63975668+Simyon264@users.noreply.github.com>

* Address reviews

* Address reviews

* Fix FeedbackPopupPrototype.cs using empty string instead of string.empty

* Address some more of the reviews, style nano is still trolling sadly

* Fix feedback popup styling

* Fix PopupPrototype ID field not having a setter

* Address reviews

* Add label when no feedback entries are present

Change link button to not show when no link is set

---------

Co-authored-by: beck-thompson <beck314159@hotmail.com>
Co-authored-by: SlamBamActionman <slambamactionman@gmail.com>
Co-authored-by: Simon <63975668+Simyon264@users.noreply.github.com>
This commit is contained in:
Julian Giebel
2026-01-22 23:19:54 +01:00
committed by GitHub
parent 39e2b8a9a6
commit bcd3612730
26 changed files with 901 additions and 4 deletions

View File

@@ -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) =>
{

View File

@@ -0,0 +1,71 @@
using Content.Shared.FeedbackSystem;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
namespace Content.Client.FeedbackPopup;
/// <inheritdoc />
public sealed class ClientFeedbackManager : SharedFeedbackManager
{
/// <summary>
/// A read-only set representing the currently displayed feedback popups.
/// </summary>
public IReadOnlySet<ProtoId<FeedbackPopupPrototype>> DisplayedPopups => _displayedPopups;
private readonly HashSet<ProtoId<FeedbackPopupPrototype>> _displayedPopups = [];
public override void Initialize()
{
base.Initialize();
NetManager.RegisterNetMessage<FeedbackPopupMessage>(ReceivedPopupMessage);
NetManager.RegisterNetMessage<OpenFeedbackPopupMessage>(_ => Open());
}
/// <summary>
/// Opens the feedback popup window.
/// </summary>
public void Open()
{
InvokeDisplayedPopupsChanged(true);
}
/// <inheritdoc />
public override void Display(List<ProtoId<FeedbackPopupPrototype>>? prototypes)
{
if (prototypes == null || !NetManager.IsClient)
return;
var count = _displayedPopups.Count;
_displayedPopups.UnionWith(prototypes);
InvokeDisplayedPopupsChanged(_displayedPopups.Count > count);
}
/// <inheritdoc />
public override void Remove(List<ProtoId<FeedbackPopupPrototype>>? 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);
}
}

View File

@@ -0,0 +1,24 @@
<Control xmlns="https://spacestation14.io"
MinHeight="100">
<PanelContainer StyleClasses="BackgroundPanel" ModulateSelfOverride="#2b2b31"/>
<BoxContainer Orientation="Vertical">
<!-- Title -->
<PanelContainer StyleIdentifier="FeedbackBorderThinBottom">
<RichTextLabel Name="TitleLabel" Margin="12 6 6 6" />
</PanelContainer>
<!-- Description -->
<RichTextLabel Name="DescriptionLabel" StyleClasses="LabelLight" Margin="12 4 12 8" VerticalExpand="True"/>
<!-- Footer -->
<PanelContainer StyleIdentifier="FeedbackBorderThinTop">
<BoxContainer>
<Label FontColorOverride="#b1b1b2" StyleClasses="LabelSmall" Name="TypeLabel" Margin="14 6 6 6" />
<Button Name="LinkButton" Text="{Loc feedbackpopup-control-button-text}" MinWidth="80"
Margin="8 6 14 6" HorizontalExpand="True" HorizontalAlignment="Right" />
</BoxContainer>
</PanelContainer>
</BoxContainer>
</Control>

View File

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

View File

@@ -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<PalettedStylesheet>
{
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<PanelContainer>()
.Identifier("FeedbackBorderThinTop")
.Prop(PanelContainer.StylePropertyPanel, borderTop),
E<PanelContainer>()
.Identifier("FeedbackBorderThinBottom")
.Prop(PanelContainer.StylePropertyPanel, borderBottom),
];
}
}

View File

@@ -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;
/// <summary>
/// This handles getting feedback popup messages from the server and making a popup in the client.
/// </summary>
[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<PrototypesReloadedEventArgs>(OnPrototypesReloaded);
SubscribeNetworkEvent<RoundEndMessageEvent>(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<ProtoId<FeedbackPopupPrototype>> prototypes)
{
_window.Update(prototypes);
}
}

View File

@@ -0,0 +1,24 @@
<controls:FancyWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
xmlns:graphics="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
Title="{Loc feedbackpopup-window-name}" MinSize="510 460" RectClipContent="True">
<BoxContainer Orientation="Vertical">
<!-- main box area -->
<BoxContainer Margin="12 12 12 5" VerticalExpand="True">
<PanelContainer HorizontalExpand="True" StyleClasses="PanelDark">
<ScrollContainer HorizontalExpand="True" HScrollEnabled="False">
<BoxContainer Name="NotificationContainer" HorizontalExpand="True" Orientation="Vertical" Margin="10" SeparationOverride="10" />
</ScrollContainer>
</PanelContainer>
</BoxContainer>
<!-- Footer -->
<BoxContainer Orientation="Vertical" SetHeight="30" Margin="2 0 0 0">
<BoxContainer SetHeight="33" Margin="10 0 10 5">
<Label Text="{Loc feedbackpopup-control-ui-footer}" Margin="6 0" StyleClasses="PdaContentFooterText"/>
<Label Name="NumNotifications" Margin="6 0" HorizontalExpand="True" HorizontalAlignment="Right"/>
</BoxContainer>
</BoxContainer>
</BoxContainer>
</controls:FancyWindow>

View File

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

View File

@@ -6,7 +6,7 @@ namespace Content.Client.Guidebook.RichText;
/// <summary>
/// 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 <see cref="Shared.Guidebook.GuidebookDataAttribute"/>.
/// </summary>
public sealed class ProtodataTag : IMarkupTagHandler

View File

@@ -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<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
collection.Register<TitleWindowManager>();
collection.Register<ClientsidePlaytimeTrackingManager>();
collection.Register<ClientFeedbackManager>();
collection.Register<ISharedFeedbackManager, ClientFeedbackManager>();
}
}
}

View File

@@ -12,6 +12,7 @@
<Button Access="Public" Name="GuidebookButton" Text="{Loc 'ui-escape-guidebook'}" />
<Button Access="Public" Name="WikiButton" Text="{Loc 'ui-escape-wiki'}" />
<changelog:ChangelogButton Access="Public" Name="ChangelogButton" />
<Button Access="Public" Name="FeedbackButton" Text="{Loc 'ui-escape-feedback'}"/>
<PanelContainer StyleClasses="LowDivider" Margin="0 2.5 0 2.5" />
<Button Access="Public" Name="OptionsButton" Text="{Loc 'ui-escape-options'}" />
<PanelContainer StyleClasses="LowDivider" Margin="0 2.5 0 2.5" />

View File

@@ -1345,7 +1345,6 @@ namespace Content.Client.Stylesheets
Element<Label>().Class(StyleClassLabelSmall)
.Prop(Label.StylePropertyFont, notoSans10),
// ---
// Different Background shapes ---
Element<PanelContainer>().Class(ClassAngleRect)
@@ -1608,6 +1607,29 @@ namespace Content.Client.Stylesheets
BackgroundColor = FancyTreeSelectedRowColor,
}),
// Inset background (News manager, notifications)
Element<PanelContainer>().Class("InsetBackground")
.Prop(PanelContainer.StylePropertyPanel, new StyleBoxFlat
{
BackgroundColor = Color.FromHex("#202023"),
}),
// Default fancy window border styles
Element<PanelContainer>().Class("DefaultBorderBottom")
.Prop(PanelContainer.StylePropertyPanel, new StyleBoxFlat
{
BorderColor= Color.FromHex("#3B3E56"),
BorderThickness= new Thickness(0, 0, 0, 1),
}),
Element<PanelContainer>().Class("DefaultBorderTop")
.Prop(PanelContainer.StylePropertyPanel, new StyleBoxFlat
{
BorderColor= Color.FromHex("#3B3E56"),
BorderThickness= new Thickness(0, 1, 0, 0),
}),
// Silicon law edit ui
Element<Label>().Class(SiliconLawContainer.StyleClassSiliconLawPositionLabel)
.Prop(Label.StylePropertyFontColor, NanoGold),

View File

@@ -1,4 +1,5 @@
using Content.Client.Gameplay;
using Content.Client.FeedbackPopup;
using Content.Client.Gameplay;
using Content.Client.UserInterface.Controls;
using Content.Client.UserInterface.Systems.Guidebook;
using Content.Client.UserInterface.Systems.Info;
@@ -25,6 +26,7 @@ public sealed class EscapeUIController : UIController, IOnStateEntered<GameplayS
[Dependency] private readonly InfoUIController _info = default!;
[Dependency] private readonly OptionsUIController _options = default!;
[Dependency] private readonly GuidebookUIController _guidebook = default!;
[Dependency] private readonly FeedbackPopupUIController _feedback = null!;
private Options.UI.EscapeMenu? _escapeWindow;
@@ -63,6 +65,12 @@ public sealed class EscapeUIController : UIController, IOnStateEntered<GameplayS
_escapeWindow.OnClose += DeactivateButton;
_escapeWindow.OnOpen += ActivateButton;
_escapeWindow.FeedbackButton.OnPressed += _ =>
{
CloseEscapeWindow();
_feedback.ToggleWindow();
};
_escapeWindow.ChangelogButton.OnPressed += _ =>
{
CloseEscapeWindow();