return null;
}
+
+ public AdminData? GetAdminData(bool includeDeAdmin = false)
+ {
+ if (_player.LocalPlayer is { Session: { } session })
+ return GetAdminData(session, includeDeAdmin);
+
+ return null;
+ }
}
}
-using System;
-using Content.Shared.Administration;
+using Content.Shared.Administration;
namespace Content.Client.Administration.Managers
{
/// </summary>
event Action AdminStatusUpdated;
+ /// <summary>
+ /// Gets the admin data for the client, if they are an admin.
+ /// </summary>
+ /// <param name="includeDeAdmin">
+ /// Whether to return admin data for admins that are current de-adminned.
+ /// </param>
+ /// <returns><see langword="null" /> if the player is not an admin.</returns>
+ AdminData? GetAdminData(bool includeDeAdmin = false);
+
/// <summary>
/// Checks whether the local player is an admin.
/// </summary>
bool CanAdminMenu();
void Initialize();
+
+ /// <summary>
+ /// Checks if the client is an admin.
+ /// </summary>
+ /// <param name="includeDeAdmin">
+ /// Whether to return admin data for admins that are current de-adminned.
+ /// </param>
+ /// <returns>true if the player is an admin, false otherwise.</returns>
+ bool IsAdmin(bool includeDeAdmin = false)
+ {
+ return GetAdminData(includeDeAdmin) != null;
+ }
}
}
-using System;
-using System.Collections.Generic;
using System.Globalization;
-using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Content.Shared.CCVar;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
-using Robust.Shared.IoC;
using Robust.Shared.Serialization;
using Robust.Shared.Serialization.Manager;
-using Robust.Shared.Serialization.Manager.Attributes;
using Robust.Shared.Serialization.Markdown;
using Robust.Shared.Serialization.Markdown.Mapping;
using Robust.Shared.Utility;
-
namespace Content.Client.Changelog
{
- public sealed partial class ChangelogManager
+ public sealed partial class ChangelogManager : IPostInjectInit
{
+ [Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IResourceManager _resource = default!;
[Dependency] private readonly ISerializationManager _serialization = default!;
[Dependency] private readonly IConfigurationManager _configManager = default!;
+ private const string SawmillName = "changelog";
+ public const string MainChangelogName = "Changelog";
+
+ private ISawmill _sawmill = default!;
+
public bool NewChangelogEntries { get; private set; }
public int LastReadId { get; private set; }
public int MaxId { get; private set; }
public async void Initialize()
{
// Open changelog purely to compare to the last viewed date.
- var changelog = await LoadChangelog();
+ var changelogs = await LoadChangelog();
+ UpdateChangelogs(changelogs);
+ }
+
+ private void UpdateChangelogs(List<Changelog> changelogs)
+ {
+ if (changelogs.Count == 0)
+ {
+ return;
+ }
+
+ var mainChangelogs = changelogs.Where(c => c.Name == MainChangelogName).ToArray();
+ if (mainChangelogs.Length == 0)
+ {
+ _sawmill.Error($"No changelog file found in Resources/Changelog with name {MainChangelogName}");
+ return;
+ }
- if (changelog.Count == 0)
+ var changelog = changelogs[0];
+ if (mainChangelogs.Length > 1)
+ {
+ _sawmill.Error($"More than one file found in Resource/Changelog with name {MainChangelogName}");
+ }
+
+ if (changelog.Entries.Count == 0)
{
return;
}
- MaxId = changelog.Max(c => c.Id);
+ MaxId = changelog.Entries.Max(c => c.Id);
var path = new ResPath($"/changelog_last_seen_{_configManager.GetCVar(CCVars.ServerId)}");
- if(_resource.UserData.TryReadAllText(path, out var lastReadIdText))
+ if (_resource.UserData.TryReadAllText(path, out var lastReadIdText))
{
LastReadId = int.Parse(lastReadIdText);
}
NewChangelogEntriesChanged?.Invoke();
}
- public Task<List<ChangelogEntry>> LoadChangelog()
+ public Task<List<Changelog>> LoadChangelog()
{
return Task.Run(() =>
{
- var yamlData = _resource.ContentFileReadYaml(new ("/Changelog/Changelog.yml"));
+ var changelogs = new List<Changelog>();
+ var directory = new ResPath("/Changelog");
+ foreach (var file in _resource.ContentFindFiles(new ResPath("/Changelog/")))
+ {
+ if (file.Directory != directory || file.Extension != "yml")
+ continue;
+
+ var yamlData = _resource.ContentFileReadYaml(file);
- if (yamlData.Documents.Count == 0)
- return new List<ChangelogEntry>();
+ if (yamlData.Documents.Count == 0)
+ continue;
- var node = (MappingDataNode)yamlData.Documents[0].RootNode.ToDataNode();
- return _serialization.Read<List<ChangelogEntry>>(node["Entries"], notNullableOverride: true);
+ var node = yamlData.Documents[0].RootNode.ToDataNodeCast<MappingDataNode>();
+ var changelog = _serialization.Read<Changelog>(node, notNullableOverride: true);
+ if (string.IsNullOrWhiteSpace(changelog.Name))
+ changelog.Name = file.FilenameWithoutExtension;
+
+ changelogs.Add(changelog);
+ }
+
+ changelogs.Sort((a, b) => a.Order.CompareTo(b.Order));
+ return changelogs;
});
}
+ public void PostInject()
+ {
+ _sawmill = _logManager.GetSawmill(SawmillName);
+ }
+
+ [DataDefinition]
+ public sealed partial class Changelog
+ {
+ /// <summary>
+ /// The name to use for this changelog.
+ /// If left unspecified, the name of the file is used instead.
+ /// Used during localization to find the user-displayed name of this changelog.
+ /// </summary>
+ [DataField("Name")]
+ public string Name = string.Empty;
+
+ /// <summary>
+ /// The individual entries in this changelog.
+ /// These are not kept around in memory in the changelog manager.
+ /// </summary>
+ [DataField("Entries")]
+ public List<ChangelogEntry> Entries = new();
+
+ /// <summary>
+ /// Whether or not this changelog will be displayed as a tab to non-admins.
+ /// These are still loaded by all clients, but not shown if they aren't an admin,
+ /// as they do not contain sensitive data and are publicly visible on GitHub.
+ /// </summary>
+ [DataField("AdminOnly")]
+ public bool AdminOnly;
+
+ /// <summary>
+ /// Used when ordering the changelog tabs for the user to see.
+ /// Larger numbers are displayed later, smaller numbers are displayed earlier.
+ /// </summary>
+ [DataField("Order")]
+ public int Order;
+ }
+
[DataDefinition]
public sealed partial class ChangelogEntry : ISerializationHooks
{
}
[DataDefinition]
- public sealed partial class ChangelogChange : ISerializationHooks
+ public sealed partial class ChangelogChange
{
[DataField("type")]
public ChangelogLineType Type { get; private set; }
--- /dev/null
+<controls:ChangelogTab
+ xmlns="https://spacestation14.io"
+ xmlns:controls="clr-namespace:Content.Client.Changelog">
+ <BoxContainer Orientation="Vertical">
+ <ScrollContainer Margin="5" VerticalExpand="True" HScrollEnabled="False">
+ <BoxContainer Orientation="Vertical" Name="ChangelogBody" />
+ </ScrollContainer>
+ </BoxContainer>
+</controls:ChangelogTab>
--- /dev/null
+using System.Linq;
+using System.Numerics;
+using Content.Client.Resources;
+using Content.Client.Stylesheets;
+using Robust.Client.AutoGenerated;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Utility;
+using static Content.Client.Changelog.ChangelogManager;
+using static Robust.Client.UserInterface.Controls.BoxContainer;
+
+namespace Content.Client.Changelog;
+
+[GenerateTypedNameReferences]
+public sealed partial class ChangelogTab : Control
+{
+ [Dependency] private readonly ChangelogManager _changelog = default!;
+ [Dependency] private readonly IResourceCache _resourceCache = default!;
+
+ public bool AdminOnly;
+
+ public ChangelogTab()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+ }
+
+ public void PopulateChangelog(ChangelogManager.Changelog changelog)
+ {
+ var byDay = changelog.Entries
+ .GroupBy(e => e.Time.ToLocalTime().Date)
+ .OrderByDescending(c => c.Key);
+
+ var hasRead = changelog.Name != MainChangelogName ||
+ _changelog.MaxId <= _changelog.LastReadId;
+
+ foreach (var dayEntries in byDay)
+ {
+ var day = dayEntries.Key;
+
+ var groupedEntries = dayEntries
+ .GroupBy(c => (c.Author, Read: c.Id <= _changelog.LastReadId))
+ .OrderBy(c => c.Key.Read)
+ .ThenBy(c => c.Key.Author);
+
+ string dayNice;
+ var today = DateTime.Today;
+ if (day == today)
+ dayNice = Loc.GetString("changelog-today");
+ else if (day == today.AddDays(-1))
+ dayNice = Loc.GetString("changelog-yesterday");
+ else
+ dayNice = day.ToShortDateString();
+
+ ChangelogBody.AddChild(new Label
+ {
+ Text = dayNice,
+ StyleClasses = { StyleBase.StyleClassLabelHeading },
+ Margin = new Thickness(4, 6, 0, 0)
+ });
+
+ var first = true;
+
+ foreach (var groupedEntry in groupedEntries)
+ {
+ var (author, read) = groupedEntry.Key;
+
+ if (!first)
+ {
+ ChangelogBody.AddChild(new Control { Margin = new Thickness(4) });
+ }
+
+ if (read && !hasRead)
+ {
+ hasRead = true;
+
+ var upArrow =
+ _resourceCache.GetTexture("/Textures/Interface/Changelog/up_arrow.svg.192dpi.png");
+
+ var readDivider = new BoxContainer
+ {
+ Orientation = LayoutOrientation.Vertical
+ };
+
+ var hBox = new BoxContainer
+ {
+ Orientation = LayoutOrientation.Horizontal,
+ HorizontalAlignment = HAlignment.Center,
+ Children =
+ {
+ new TextureRect
+ {
+ Texture = upArrow,
+ ModulateSelfOverride = Color.FromHex("#888"),
+ TextureScale = new Vector2(0.5f, 0.5f),
+ Margin = new Thickness(4, 3),
+ VerticalAlignment = VAlignment.Bottom
+ },
+ new Label
+ {
+ Align = Label.AlignMode.Center,
+ Text = Loc.GetString("changelog-new-changes"),
+ FontColorOverride = Color.FromHex("#888"),
+ },
+ new TextureRect
+ {
+ Texture = upArrow,
+ ModulateSelfOverride = Color.FromHex("#888"),
+ TextureScale = new Vector2(0.5f, 0.5f),
+ Margin = new Thickness(4, 3),
+ VerticalAlignment = VAlignment.Bottom
+ }
+ }
+ };
+
+ readDivider.AddChild(hBox);
+ readDivider.AddChild(new PanelContainer { StyleClasses = { StyleBase.ClassLowDivider } });
+ ChangelogBody.AddChild(readDivider);
+
+ if (first)
+ readDivider.SetPositionInParent(ChangelogBody.ChildCount - 2);
+ }
+
+ first = false;
+
+ var authorLabel = new RichTextLabel
+ {
+ Margin = new Thickness(6, 0, 0, 0),
+ };
+ authorLabel.SetMessage(
+ FormattedMessage.FromMarkup(Loc.GetString("changelog-author-changed", ("author", author))));
+ ChangelogBody.AddChild(authorLabel);
+
+ foreach (var change in groupedEntry.SelectMany(c => c.Changes))
+ {
+ var text = new RichTextLabel();
+ text.SetMessage(FormattedMessage.FromMarkup(change.Message));
+ ChangelogBody.AddChild(new BoxContainer
+ {
+ Orientation = LayoutOrientation.Horizontal,
+ Margin = new Thickness(14, 1, 10, 2),
+ Children =
+ {
+ GetIcon(change.Type),
+ text
+ }
+ });
+ }
+ }
+ }
+ }
+
+ private TextureRect GetIcon(ChangelogLineType type)
+ {
+ var (file, color) = type switch
+ {
+ ChangelogLineType.Add => ("plus.svg.192dpi.png", "#6ED18D"),
+ ChangelogLineType.Remove => ("minus.svg.192dpi.png", "#D16E6E"),
+ ChangelogLineType.Fix => ("bug.svg.192dpi.png", "#D1BA6E"),
+ ChangelogLineType.Tweak => ("wrench.svg.192dpi.png", "#6E96D1"),
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
+ };
+
+ return new TextureRect
+ {
+ Texture = _resourceCache.GetTexture(new ResPath($"/Textures/Interface/Changelog/{file}")),
+ VerticalAlignment = VAlignment.Top,
+ TextureScale = new Vector2(0.5f, 0.5f),
+ Margin = new Thickness(2, 4, 6, 2),
+ ModulateSelfOverride = Color.FromHex(color)
+ };
+ }
+}
Title="{Loc 'changelog-window-title'}"
MinSize="500 400"
SetSize="500 400">
-
<PanelContainer StyleClasses="AngleRect" />
<BoxContainer Orientation="Vertical">
-
- <ScrollContainer Margin="5" VerticalExpand="True" HScrollEnabled="False">
- <BoxContainer Orientation="Vertical" Name="ChangelogBody" />
- </ScrollContainer>
-
+ <TabContainer Name="Tabs" Access="Public" HorizontalExpand="True" VerticalExpand="True" />
<PanelContainer StyleClasses="LowDivider" />
- <Label Name="VersionLabel" HorizontalAlignment="Right" StyleClasses="LabelSubText" Margin="4 0" />
+ <Label Name="VersionLabel" Access="Public" HorizontalAlignment="Right" StyleClasses="LabelSubText" Margin="4 0" />
</BoxContainer>
</ui:ChangelogWindow>
using System.Linq;
-using System.Numerics;
-using Content.Client.Resources;
+using Content.Client.Administration.Managers;
using Content.Client.Stylesheets;
using Content.Client.UserInterface.Controls;
using Content.Client.UserInterface.Systems.EscapeMenu;
using Content.Shared.Administration;
using JetBrains.Annotations;
using Robust.Client.AutoGenerated;
-using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface;
-using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Console;
-using Robust.Shared.Utility;
-using static Content.Client.Changelog.ChangelogManager;
-using static Robust.Client.UserInterface.Controls.BoxContainer;
namespace Content.Client.Changelog
{
[GenerateTypedNameReferences]
public sealed partial class ChangelogWindow : FancyWindow
{
+ [Dependency] private readonly IClientAdminManager _adminManager = default!;
[Dependency] private readonly ChangelogManager _changelog = default!;
- [Dependency] private readonly IResourceCache _resourceCache = default!;
public ChangelogWindow()
{
PopulateChangelog();
}
+ protected override void EnteredTree()
+ {
+ base.EnteredTree();
+ _adminManager.AdminStatusUpdated += OnAdminStatusUpdated;
+ }
+
+ protected override void ExitedTree()
+ {
+ base.ExitedTree();
+ _adminManager.AdminStatusUpdated -= OnAdminStatusUpdated;
+ }
+
+ private void OnAdminStatusUpdated()
+ {
+ TabsUpdated();
+ }
+
private async void PopulateChangelog()
{
// Changelog is not kept in memory so load it again.
- var changelog = await _changelog.LoadChangelog();
+ var changelogs = await _changelog.LoadChangelog();
- var byDay = changelog
- .GroupBy(e => e.Time.ToLocalTime().Date)
- .OrderByDescending(c => c.Key);
+ Tabs.DisposeAllChildren();
- var hasRead = _changelog.MaxId <= _changelog.LastReadId;
- foreach (var dayEntries in byDay)
+ var i = 0;
+ foreach (var changelog in changelogs)
{
- var day = dayEntries.Key;
-
- var groupedEntries = dayEntries
- .GroupBy(c => (c.Author, Read: c.Id <= _changelog.LastReadId))
- .OrderBy(c => c.Key.Read)
- .ThenBy(c => c.Key.Author);
-
- string dayNice;
- var today = DateTime.Today;
- if (day == today)
- dayNice = Loc.GetString("changelog-today");
- else if (day == today.AddDays(-1))
- dayNice = Loc.GetString("changelog-yesterday");
- else
- dayNice = day.ToShortDateString();
-
- ChangelogBody.AddChild(new Label
- {
- Text = dayNice,
- StyleClasses = { StyleBase.StyleClassLabelHeading },
- Margin = new Thickness(4, 6, 0, 0)
- });
+ var tab = new ChangelogTab { AdminOnly = changelog.AdminOnly };
+ tab.PopulateChangelog(changelog);
- var first = true;
-
- foreach (var groupedEntry in groupedEntries)
- {
- var (author, read) = groupedEntry.Key;
-
- if (!first)
- {
- ChangelogBody.AddChild(new Control { Margin = new Thickness(4) });
- }
-
- if (read && !hasRead)
- {
- hasRead = true;
-
- var upArrow =
- _resourceCache.GetTexture("/Textures/Interface/Changelog/up_arrow.svg.192dpi.png");
-
- var readDivider = new BoxContainer
- {
- Orientation = LayoutOrientation.Vertical
- };
-
- var hBox = new BoxContainer
- {
- Orientation = LayoutOrientation.Horizontal,
- HorizontalAlignment = HAlignment.Center,
- Children =
- {
- new TextureRect
- {
- Texture = upArrow,
- ModulateSelfOverride = Color.FromHex("#888"),
- TextureScale = new Vector2(0.5f, 0.5f),
- Margin = new Thickness(4, 3),
- VerticalAlignment = VAlignment.Bottom
- },
- new Label
- {
- Align = Label.AlignMode.Center,
- Text = Loc.GetString("changelog-new-changes"),
- FontColorOverride = Color.FromHex("#888"),
- },
- new TextureRect
- {
- Texture = upArrow,
- ModulateSelfOverride = Color.FromHex("#888"),
- TextureScale = new Vector2(0.5f, 0.5f),
- Margin = new Thickness(4, 3),
- VerticalAlignment = VAlignment.Bottom
- }
- }
- };
-
- readDivider.AddChild(hBox);
- readDivider.AddChild(new PanelContainer { StyleClasses = { StyleBase.ClassLowDivider } });
- ChangelogBody.AddChild(readDivider);
-
- if (first)
- readDivider.SetPositionInParent(ChangelogBody.ChildCount - 2);
- }
-
- first = false;
-
- var authorLabel = new RichTextLabel
- {
- Margin = new Thickness(6, 0, 0, 0),
- };
- authorLabel.SetMessage(
- FormattedMessage.FromMarkup(Loc.GetString("changelog-author-changed", ("author", author))));
- ChangelogBody.AddChild(authorLabel);
-
- foreach (var change in groupedEntry.SelectMany(c => c.Changes))
- {
- var text = new RichTextLabel();
- text.SetMessage(FormattedMessage.FromMarkup(change.Message));
- ChangelogBody.AddChild(new BoxContainer
- {
- Orientation = LayoutOrientation.Horizontal,
- Margin = new Thickness(14, 1, 10, 2),
- Children =
- {
- GetIcon(change.Type),
- text
- }
- });
- }
- }
+ Tabs.AddChild(tab);
+ Tabs.SetTabTitle(i++, Loc.GetString($"changelog-tab-title-{changelog.Name}"));
}
var version = typeof(ChangelogWindow).Assembly.GetName().Version ?? new Version(1, 0);
VersionLabel.Text = Loc.GetString("changelog-version-tag", ("version", version.ToString()));
+
+ TabsUpdated();
}
- private TextureRect GetIcon(ChangelogLineType type)
+ private void TabsUpdated()
{
- var (file, color) = type switch
+ var tabs = Tabs.Children.OfType<ChangelogTab>().ToArray();
+ var isAdmin = _adminManager.IsAdmin(true);
+
+ var visibleTabs = 0;
+ int? firstVisible = null;
+ for (var i = 0; i < tabs.Length; i++)
{
- ChangelogLineType.Add => ("plus.svg.192dpi.png", "#6ED18D"),
- ChangelogLineType.Remove => ("minus.svg.192dpi.png", "#D16E6E"),
- ChangelogLineType.Fix => ("bug.svg.192dpi.png", "#D1BA6E"),
- ChangelogLineType.Tweak => ("wrench.svg.192dpi.png", "#6E96D1"),
- _ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
- };
-
- return new TextureRect
+ var tab = tabs[i];
+
+ if (!tab.AdminOnly || isAdmin)
+ {
+ Tabs.SetTabVisible(i, true);
+ tab.Visible = true;
+ visibleTabs++;
+ firstVisible ??= i;
+ }
+ else
+ {
+ Tabs.SetTabVisible(i, false);
+ tab.Visible = false;
+ }
+ }
+
+ Tabs.TabsVisible = visibleTabs > 1;
+
+ // Current tab became invisible, select the first one that is visible
+ if (!Tabs.GetTabVisible(Tabs.CurrentTab) && firstVisible != null)
{
- Texture = _resourceCache.GetTexture(new ResPath($"/Textures/Interface/Changelog/{file}")),
- VerticalAlignment = VAlignment.Top,
- TextureScale = new Vector2(0.5f, 0.5f),
- Margin = new Thickness(2, 4, 6, 2),
- ModulateSelfOverride = Color.FromHex(color)
- };
+ Tabs.CurrentTab = firstVisible.Value;
+ }
+
+ // We are only displaying one tab, hide its header
+ if (!Tabs.TabsVisible && firstVisible != null)
+ {
+ Tabs.SetTabVisible(firstVisible.Value, false);
+ }
}
}
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
-using Robust.Shared.Maths;
using static Robust.Client.UserInterface.StylesheetHelpers;
namespace Content.Client.Stylesheets
var textureInvertedTriangle = resCache.GetTexture("/Textures/Interface/Nano/inverted_triangle.svg.png");
+ var tabContainerPanel = new StyleBoxTexture();
+ tabContainerPanel.SetPatchMargin(StyleBox.Margin.All, 2);
+
+ var tabContainerBoxActive = new StyleBoxFlat {BackgroundColor = new Color(64, 64, 64)};
+ tabContainerBoxActive.SetContentMarginOverride(StyleBox.Margin.Horizontal, 5);
+ var tabContainerBoxInactive = new StyleBoxFlat {BackgroundColor = new Color(32, 32, 32)};
+ tabContainerBoxInactive.SetContentMarginOverride(StyleBox.Margin.Horizontal, 5);
+
Stylesheet = new Stylesheet(BaseRules.Concat(new StyleRule[]
{
Element<Label>().Class(StyleClassLabelHeading)
Element<Label>().Class(OptionButton.StyleClassOptionButton)
.Prop(Label.StylePropertyAlignMode, Label.AlignMode.Center),
+ // TabContainer
+ new StyleRule(new SelectorElement(typeof(TabContainer), null, null, null),
+ new[]
+ {
+ new StyleProperty(TabContainer.StylePropertyPanelStyleBox, tabContainerPanel),
+ new StyleProperty(TabContainer.StylePropertyTabStyleBox, tabContainerBoxActive),
+ new StyleProperty(TabContainer.StylePropertyTabStyleBoxInactive, tabContainerBoxInactive),
+ }),
}).ToList());
}
--- /dev/null
+Name: Admin
+AdminOnly: true
+Order: 1
+Entries:
+- author: DrSmugleaf
+ changes:
+ - {message: 'Created the admin changelog.', type: Add}
+ id: 1
+ time: '2023-10-08T04:26:00.0000000+00:00'
changelog-button = Changelog
changelog-button-new-entries = Changelog (new!)
+
+changelog-tab-title-Changelog = Changelog
+changelog-tab-title-Admin = Admin