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

View File

@@ -8,6 +8,7 @@ using Content.Server.Connection;
using Content.Server.Database;
using Content.Server.Discord.DiscordLink;
using Content.Server.EUI;
using Content.Server.FeedbackSystem;
using Content.Server.GameTicking;
using Content.Server.GhostKick;
using Content.Server.GuideGenerator;
@@ -23,6 +24,7 @@ using Content.Server.ServerInfo;
using Content.Server.ServerUpdates;
using Content.Server.Voting.Managers;
using Content.Shared.CCVar;
using Content.Shared.FeedbackSystem;
using Content.Shared.Kitchen;
using Content.Shared.Localizations;
using Robust.Server;
@@ -76,6 +78,7 @@ namespace Content.Server.Entry
[Dependency] private readonly ServerApi _serverApi = default!;
[Dependency] private readonly ServerInfoManager _serverInfo = default!;
[Dependency] private readonly ServerUpdateManager _updateManager = default!;
[Dependency] private readonly ServerFeedbackManager _feedbackManager = null!;
public override void PreInit()
{
@@ -165,6 +168,7 @@ namespace Content.Server.Entry
_connection.PostInit();
_multiServerKick.Initialize();
_cvarCtrl.Initialize();
_feedbackManager.Initialize();
}
public override void Update(ModUpdateLevel level, FrameEventArgs frameEventArgs)

View File

@@ -0,0 +1,63 @@
using Content.Server.Administration;
using Content.Shared.Administration;
using Content.Shared.FeedbackSystem;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Toolshed;
namespace Content.Server.FeedbackSystem;
/// <summary>
/// Adds, removes, and displays feedback for specified sessions.
/// </summary>
[ToolshedCommand]
[AdminCommand(AdminFlags.Debug)]
public sealed class FeedbackCommand : ToolshedCommand
{
[Dependency] private readonly ISharedFeedbackManager _feedback = null!;
[CommandImplementation("show")]
public void ExecuteShow([CommandArgument] ICommonSession session)
{
_feedback.OpenForSession(session);
}
[CommandImplementation("show")]
public void ExecuteShow([PipedArgument] IEnumerable<ICommonSession> sessions)
{
foreach (var session in sessions)
{
_feedback.OpenForSession(session);
}
}
[CommandImplementation("add")]
public void ExecuteAdd([CommandArgument] ICommonSession session, ProtoId<FeedbackPopupPrototype> protoId)
{
_feedback.SendToSession(session, [protoId]);
}
[CommandImplementation("add")]
public void ExecuteAdd([PipedArgument] IEnumerable<ICommonSession> sessions, ProtoId<FeedbackPopupPrototype> protoId)
{
foreach (var session in sessions)
{
_feedback.SendToSession(session, [protoId]);
}
}
[CommandImplementation("remove")]
public void ExecuteRemove([CommandArgument] ICommonSession session, ProtoId<FeedbackPopupPrototype> protoId)
{
_feedback.SendToSession(session, [protoId], true);
}
[CommandImplementation("remove")]
public void ExecuteRemove([PipedArgument] IEnumerable<ICommonSession> sessions, ProtoId<FeedbackPopupPrototype> protoId)
{
foreach (var session in sessions)
{
_feedback.SendToSession(session, [protoId], true);
}
}
}

View File

@@ -0,0 +1,24 @@
using Content.Shared.Administration;
using Content.Shared.FeedbackSystem;
using Robust.Shared.Toolshed;
namespace Content.Server.FeedbackSystem;
/// <summary>
/// Opens the feedback popup window for the executing session
/// </summary>
[AnyCommand]
[ToolshedCommand]
public sealed class OpenFeedbackPopupCommand : ToolshedCommand
{
[Dependency] private readonly ISharedFeedbackManager _feedback = null!;
[CommandImplementation]
public void Execute(IInvocationContext context)
{
if (context.Session == null)
return;
_feedback.OpenForSession(context.Session);
}
}

View File

@@ -0,0 +1,78 @@
using Content.Shared.FeedbackSystem;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Server.FeedbackSystem;
/// <inheritdoc />
public sealed class ServerFeedbackManager : SharedFeedbackManager
{
[Dependency] private readonly ISharedPlayerManager _player = null!;
public override void Initialize()
{
base.Initialize();
NetManager.RegisterNetMessage<FeedbackPopupMessage>();
NetManager.RegisterNetMessage<OpenFeedbackPopupMessage>();
}
/// <inheritdoc />
public override bool Send(EntityUid uid, List<ProtoId<FeedbackPopupPrototype>> popupPrototypes)
{
if (!_player.TryGetSessionByEntity(uid, out var session))
return false;
SendToSession(session, popupPrototypes);
return true;
}
/// <inheritdoc />
public override void SendToSession(ICommonSession session, List<ProtoId<FeedbackPopupPrototype>> popupPrototypes, bool remove = false)
{
if (!NetManager.IsServer)
return;
var msg = new FeedbackPopupMessage
{
FeedbackPrototypes = popupPrototypes,
Remove = remove,
};
NetManager.ServerSendMessage(msg, session.Channel);
}
/// <inheritdoc />
public override void SendToAllSessions(List<ProtoId<FeedbackPopupPrototype>> popupPrototypes, bool remove = false)
{
if (!NetManager.IsServer)
return;
var msg = new FeedbackPopupMessage
{
FeedbackPrototypes = popupPrototypes,
Remove = remove,
};
NetManager.ServerSendToAll(msg);
}
/// <inheritdoc />
public override void OpenForSession(ICommonSession session)
{
if (!NetManager.IsServer)
return;
var msg = new OpenFeedbackPopupMessage();
NetManager.ServerSendMessage(msg, session.Channel);
}
/// <inheritdoc />
public override void OpenForAllSessions()
{
if (!NetManager.IsServer)
return;
var msg = new OpenFeedbackPopupMessage();
NetManager.ServerSendToAll(msg);
}
}

View File

@@ -10,6 +10,7 @@ using Content.Server.Discord;
using Content.Server.Discord.DiscordLink;
using Content.Server.Discord.WebhookMessages;
using Content.Server.EUI;
using Content.Server.FeedbackSystem;
using Content.Server.GhostKick;
using Content.Server.Info;
using Content.Server.Mapping;
@@ -26,6 +27,7 @@ using Content.Server.Worldgen.Tools;
using Content.Shared.Administration.Logs;
using Content.Shared.Administration.Managers;
using Content.Shared.Chat;
using Content.Shared.FeedbackSystem;
using Content.Shared.IoC;
using Content.Shared.Kitchen;
using Content.Shared.Players.PlayTimeTracking;
@@ -80,5 +82,7 @@ internal static class ServerContentIoC
deps.Register<CVarControlManager>();
deps.Register<DiscordLink>();
deps.Register<DiscordChatLink>();
deps.Register<ServerFeedbackManager>();
deps.Register<ISharedFeedbackManager, ServerFeedbackManager>();
}
}

View File

@@ -0,0 +1,17 @@
using Robust.Shared.Configuration;
namespace Content.Shared.CCVar;
public sealed partial class CCVars
{
/// <summary>
/// Used to set what popups are shown. Can accept multiple origins, just use spaces! See
/// <see cref="Content.Shared.FeedbackSystem.FeedbackPopupPrototype">FeedbackPopupPrototype</see>'s <see cref="Content.Shared.FeedbackSystem.FeedbackPopupPrototype.PopupOrigin">PopupOrigin</see> field.
/// Only prototypes who's PopupOrigin matches one of the FeedbackValidOrigins will be shown to players.
/// </summary>
/// <example>
/// wizden deltav
/// </example>
public static readonly CVarDef<string> FeedbackValidOrigins =
CVarDef.Create("feedback.valid_origins", "", CVar.SERVER | CVar.REPLICATED);
}

View File

@@ -0,0 +1,61 @@
using Lidgren.Network;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.FeedbackSystem;
/// <summary>
/// When clients receive this message a popup will appear with the contents from the given prototypes.
/// </summary>
public sealed class FeedbackPopupMessage : NetMessage
{
public override MsgGroups MsgGroup => MsgGroups.Command;
/// <summary>
/// When true, the popup prototypes specified in this message will be removed from the client's list of feedback popups.
/// If no prototypes are specified, all popups will be removed.
/// </summary>
/// <remarks>If this is false and the list of prototypes is empty, the message will be ignored</remarks>
public bool Remove { get; set; }
public List<ProtoId<FeedbackPopupPrototype>>? FeedbackPrototypes;
public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
{
Remove = buffer.ReadBoolean();
buffer.ReadPadBits();
var count = buffer.ReadVariableInt32();
FeedbackPrototypes = [];
for (var i = 0; i < count; i++)
{
FeedbackPrototypes.Add(new ProtoId<FeedbackPopupPrototype>(buffer.ReadString()));
}
}
public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer)
{
buffer.Write(Remove);
buffer.WritePadBits();
buffer.WriteVariableInt32(FeedbackPrototypes?.Count ?? 0);
if (FeedbackPrototypes == null)
return;
foreach (var proto in FeedbackPrototypes)
{
buffer.Write(proto);
}
}
}
/// <summary>
/// Sent from the server to open the feedback popup.
/// </summary>
public sealed class OpenFeedbackPopupMessage : NetMessage
{
public override MsgGroups MsgGroup => MsgGroups.Command;
public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) { }
public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) { }
}

View File

@@ -0,0 +1,56 @@
using Robust.Shared.Prototypes;
namespace Content.Shared.FeedbackSystem;
/// <summary>
/// Prototype that describes the contents of a feedback popup.
/// </summary>
[Prototype]
public sealed partial class FeedbackPopupPrototype : IPrototype
{
/// <inheritdoc/>
[IdDataField]
public string ID { get; private set; } = null!;
/// <summary>
/// What server the popup is from, you must edit the ccvar to include this for the popup to appear!
/// </summary>
[DataField(required: true)]
public string PopupOrigin = string.Empty;
/// <summary>
/// Title of the popup. This supports rich text so you can use colors and stuff.
/// </summary>
[DataField(required: true)]
public string Title = string.Empty;
/// <summary>
/// List of "paragraphs" that are placed in the middle of the popup. Put any relevant information about what to give
/// feedback on here! [bold]Rich text is allowed[/bold]
/// </summary>
[DataField(required: true)]
public string Description = string.Empty;
/// <summary>
/// The kind of response the player should expect to give; good examples are "Survey", "Discord Channel", "Feedback Thread" etc.
/// Will be listed near the "Open Link" button; rich text is not allowed.
/// </summary>
[DataField]
public string? ResponseType;
/// <summary>
/// A link leading to where you want players to give feedback. Discord channel, form etc...
/// </summary>
[DataField]
public string? ResponseLink;
/// <summary>
/// Should this feedback be shown when the round ends.
/// </summary>
/// <remarks>
/// If this is false popups have to be shown to players by running the <pre>feedback:add</pre> command.<br />
/// This allows admins to show popups to only specific people.
/// </remarks>
[DataField]
public bool ShowRoundEnd = true;
}

View File

@@ -0,0 +1,21 @@
using System.Linq;
using Content.Shared.CCVar;
using Content.Shared.GameTicking;
using Robust.Shared.Configuration;
namespace Content.Shared.FeedbackSystem;
public abstract partial class SharedFeedbackManager : IEntityEventSubscriber
{
[Dependency] private readonly IConfigurationManager _configManager = null!;
private void InitSubscriptions()
{
_configManager.OnValueChanged(CCVars.FeedbackValidOrigins, OnFeedbackOriginsUpdated, true);
}
private void OnFeedbackOriginsUpdated(string newOrigins)
{
_validOrigins = newOrigins.Split(' ').ToList();
}
}

View File

@@ -0,0 +1,150 @@
using System.Linq;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Shared.FeedbackSystem;
/// <summary>
/// SharedFeedbackManager handles feedback popup management and distribution across sessions.
/// It manages the state of displayed popups and provides mechanisms for opening, displaying, removing,
/// and sending popups to specified sessions or all sessions.
/// </summary>
public interface ISharedFeedbackManager
{
/// <summary>
/// An event that is triggered whenever the set of displayed feedback popups changes.<br/>
/// The boolean parameter is true if new popups have been added
/// </summary>
event Action<bool>? DisplayedPopupsChanged;
/// <summary>
/// Adds the specified popup prototypes to the displayed popups on the client..
/// </summary>
/// <param name="prototypes">A list of popup prototype IDs to be added to the displayed prototypes</param>
/// <remarks>
/// This does nothing on the server.
/// <br/>
/// Use this if you want to add a popup from a shared or client-side entity system.
/// </remarks>
void Display(List<ProtoId<FeedbackPopupPrototype>>? prototypes) {}
/// <summary>
/// Removes the specified popup prototypes from the displayed popups on the client.
/// </summary>
/// <param name="prototypes">A list of popup prototype IDs to be removed from the displayed prototypes.
/// If null, all displayed popups will be cleared.</param>
/// <remarks>This does nothing on the server.</remarks>
void Remove(List<ProtoId<FeedbackPopupPrototype>>? prototypes) {}
/// <summary>
/// Sends a list of feedback popup prototypes to a specific entity, identified by its EntityUid.
/// </summary>
/// <param name="uid">The unique identifier of the entity to send the feedback popups to.</param>
/// <param name="popupPrototypes">The list of feedback popup prototypes to send to the entity.</param>
/// <returns>Returns true if the feedback popups were successfully sent, otherwise false.</returns>
/// <remarks>This does nothing on the client.</remarks>
bool Send(EntityUid uid, List<ProtoId<FeedbackPopupPrototype>> popupPrototypes)
{
return false;
}
/// <summary>
/// Sends a list of feedback popup prototypes to the specified session.
/// </summary>
/// <param name="session">The session to which the feedback popups will be sent.</param>
/// <param name="popupPrototypes">A list of feedback popup prototype IDs to send to the session.</param>
/// <param name="remove">When true, removes the specified prototypes instead of adding them</param>
/// <remarks>This does nothing on the client.</remarks>
void SendToSession(ICommonSession session, List<ProtoId<FeedbackPopupPrototype>> popupPrototypes, bool remove = false) {}
/// <summary>
/// Sends the specified feedback popup prototypes to all connected client sessions.
/// </summary>
/// <param name="popupPrototypes">A list of popup prototype IDs to be sent to all connected sessions.</param>
/// <param name="remove">When true, removes the specified prototypes instead of adding them</param>
/// <remarks>This does nothing on the client.</remarks>
void SendToAllSessions(List<ProtoId<FeedbackPopupPrototype>> popupPrototypes, bool remove = false) {}
/// <summary>
/// Opens the feedback popup for a specific session.
/// </summary>
/// <param name="session">The session for which the feedback popup should be opened.</param>
/// <remarks>This does nothing on the client.</remarks>
void OpenForSession(ICommonSession session) {}
/// <summary>
/// Opens the feedback popup for all connected sessions.
/// </summary>
/// <remarks>This does nothing on the client.</remarks>
void OpenForAllSessions() {}
}
/// <inheritdoc cref="ISharedFeedbackManager" />
public abstract partial class SharedFeedbackManager : ISharedFeedbackManager
{
[Dependency] private readonly IPrototypeManager _proto = null!;
[Dependency] protected readonly INetManager NetManager = null!;
public virtual IReadOnlySet<ProtoId<FeedbackPopupPrototype>>? DisplayedPopups => null;
// <inheritdoc />
public event Action<bool>? DisplayedPopupsChanged;
/// <summary>
/// List of valid origns of the feedback popup that is filled from the CCVar. See
/// <see cref="Content.Shared.CCVar.CCVars.FeedbackValidOrigins">FeedbackValidOrigins</see>
/// </summary>
private List<string> _validOrigins = [];
[MustCallBase]
public virtual void Initialize()
{
InitSubscriptions();
}
/// <inheritdoc />
public virtual void Display(List<ProtoId<FeedbackPopupPrototype>>? prototypes) {}
/// <inheritdoc />
public virtual void Remove(List<ProtoId<FeedbackPopupPrototype>>? prototypes) {}
/// <inheritdoc />
public virtual bool Send(EntityUid uid, List<ProtoId<FeedbackPopupPrototype>> popupPrototypes)
{
return false;
}
/// <inheritdoc />
public virtual void SendToSession(ICommonSession session, List<ProtoId<FeedbackPopupPrototype>> popupPrototypes, bool remove = false) {}
/// <inheritdoc />
public virtual void SendToAllSessions(List<ProtoId<FeedbackPopupPrototype>> popupPrototypes, bool remove = false) {}
/// <inheritdoc />
public virtual void OpenForSession(ICommonSession session) {}
/// <inheritdoc />
public virtual void OpenForAllSessions() {}
/// <summary>
/// Get a list of feedback prototypes that match the current valid origins.
/// </summary>
/// <param name="roundEndOnly">If true, only retrieve pop-ups with ShowRoundEnd set to true.</param>
/// <returns>Returns a list of protoIds; possibly empty.</returns>
public List<ProtoId<FeedbackPopupPrototype>> GetOriginFeedbackPrototypes(bool roundEndOnly)
{
var feedbackProtypes = _proto.EnumeratePrototypes<FeedbackPopupPrototype>()
.Where(x => (!roundEndOnly || x.ShowRoundEnd) && _validOrigins.Contains(x.PopupOrigin))
.Select(x => new ProtoId<FeedbackPopupPrototype>(x.ID))
.OrderBy(x => x.Id)
.ToList();
return feedbackProtypes;
}
protected void InvokeDisplayedPopupsChanged(bool show)
{
DisplayedPopupsChanged?.Invoke(show);
}
}

View File

@@ -7,4 +7,4 @@ ui-escape-guidebook = Guidebook
ui-escape-wiki = Wiki
ui-escape-disconnect = Disconnect
ui-escape-quit = Quit
ui-escape-feedback = Feedback

View File

@@ -0,0 +1,28 @@
feedbackpopup-window-name = Request for feedback
feedbackpopup-control-button-text = Open Link
feedbackpopup-control-total-surveys = {$num ->
[one] {$num} entry
*[other] {$num} entries
}
feedbackpopup-control-no-entries= No entries
feedbackpopup-control-ui-footer = Let us know what you think!
# Command strings
command-description-openfeedbackpopup = Opens the feedback popup window.
command-description-feedback-show = Opens the feedback popup window for the given sessions.
command-description-feedback-add = Adds a feedback popup prototype to the given clients and opens the popup window if the client didn't already have the prototype listed.
command-description-feedback-remove = Removes a feedback popup prototype from the given clients.
feedbackpopup-give-command-name = givefeedbackpopup
feedbackpopup-show-command-name = showfeedbackpopup
cmd-givefeedbackpopup-desc = Gives the targeted player a feedback popup.
cmd-givefeedbackpopup-help = Usage: givefeedbackpopup <playerUid> <prototypeId>
cmd-showfeedbackpopup-desc = Open the feedback popup window.
cmd-showfeedbackpopup-help = Usage: showfeedbackpopup
feedbackpopup-command-error-invalid-proto = Invalid feedback popup prototype.
feedbackpopup-command-error-popup-send-fail = Failed to send popup! There probably isn't a mind attached to the given entity.
feedbackpopup-command-success = Sent popup!
feedbackpopup-command-hint-playerUid = <playerUid>
feedbackpopup-command-hint-protoId = <prototypeId>

View File

@@ -0,0 +1,19 @@
- type: feedbackPopup
id: FeedbackPopup
popupOrigin: wizden_master
title: "[bold]Feedback on the new feedback popup system[/bold]"
description: |-
This window you are seeing is a new system to get feedback on features. It will give popups at the end of the round (mostly on our testing server Vulture)!
Please share your thoughts through the forums below! Log in with your Space Station 14 account!
responseType: "Feedback Thread A"
responseLink: "https://forum.spacestation14.com/t/feedback-popup-feedback/22858"
- type: feedbackPopup
id: GeneralFeedback
popupOrigin: wizden_master
title: "[bold]General feedback for the game[/bold]"
description: >-
If you have any feedback on the game, feel free to create a thread in the feedback forum category.
responseType: "General Feedback"
responseLink: "https://forum.spacestation14.com/c/development/feedback/51"
showRoundEnd: false