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;
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;
[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()
{
_userInterfaceManager.SetActiveTheme(_configManager.GetCVar(CVars.InterfaceTheme));
_documentParsingManager.Initialize();
_titleWindowManager.Initialize();
+ _feedbackManager.Initialize();
_baseClient.RunLevelChanged += (_, args) =>
{
--- /dev/null
+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);
+ }
+}
--- /dev/null
+<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>
--- /dev/null
+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();
+ }
+}
+
--- /dev/null
+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),
+ ];
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+<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>
--- /dev/null
+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,
+ });
+ }
+}
/// <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
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;
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;
collection.Register<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
collection.Register<TitleWindowManager>();
collection.Register<ClientsidePlaytimeTrackingManager>();
+ collection.Register<ClientFeedbackManager>();
+ collection.Register<ISharedFeedbackManager, ClientFeedbackManager>();
}
}
}
<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" />
Element<Label>().Class(StyleClassLabelSmall)
.Prop(Label.StylePropertyFont, notoSans10),
- // ---
// Different Background shapes ---
Element<PanelContainer>().Class(ClassAngleRect)
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),
-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;
[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;
_escapeWindow.OnClose += DeactivateButton;
_escapeWindow.OnOpen += ActivateButton;
+ _escapeWindow.FeedbackButton.OnPressed += _ =>
+ {
+ CloseEscapeWindow();
+ _feedback.ToggleWindow();
+ };
+
_escapeWindow.ChangelogButton.OnPressed += _ =>
{
CloseEscapeWindow();
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;
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;
[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()
{
_connection.PostInit();
_multiServerKick.Initialize();
_cvarCtrl.Initialize();
+ _feedbackManager.Initialize();
}
public override void Update(ModUpdateLevel level, FrameEventArgs frameEventArgs)
--- /dev/null
+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);
+ }
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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);
+ }
+}
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;
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;
deps.Register<CVarControlManager>();
deps.Register<DiscordLink>();
deps.Register<DiscordChatLink>();
+ deps.Register<ServerFeedbackManager>();
+ deps.Register<ISharedFeedbackManager, ServerFeedbackManager>();
}
}
--- /dev/null
+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);
+}
--- /dev/null
+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) { }
+}
--- /dev/null
+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;
+}
--- /dev/null
+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();
+ }
+}
--- /dev/null
+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);
+ }
+}
ui-escape-wiki = Wiki
ui-escape-disconnect = Disconnect
ui-escape-quit = Quit
-
+ui-escape-feedback = Feedback
--- /dev/null
+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>
--- /dev/null
+- 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