diff --git a/Content.Client/Changelog/ChangelogButton.cs b/Content.Client/Changelog/ChangelogButton.cs
new file mode 100644
index 0000000000..d5d0a3526b
--- /dev/null
+++ b/Content.Client/Changelog/ChangelogButton.cs
@@ -0,0 +1,53 @@
+using Content.Client.UserInterface.Stylesheets;
+using Robust.Client.UserInterface.Controls;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+
+namespace Content.Client.Changelog
+{
+ public sealed class ChangelogButton : Button
+ {
+ [Dependency] private readonly ChangelogManager _changelogManager = default!;
+
+ public ChangelogButton()
+ {
+ IoCManager.InjectDependencies(this);
+
+ OnPressed += OnOnPressed;
+ }
+
+ protected override void EnteredTree()
+ {
+ base.EnteredTree();
+
+ _changelogManager.NewChangelogEntriesChanged += UpdateStuff;
+ UpdateStuff();
+ }
+
+ protected override void ExitedTree()
+ {
+ base.ExitedTree();
+
+ _changelogManager.NewChangelogEntriesChanged -= UpdateStuff;
+ }
+
+ private void OnOnPressed(ButtonEventArgs obj)
+ {
+ new ChangelogWindow().OpenCentered();
+ }
+
+ private void UpdateStuff()
+ {
+ if (_changelogManager.NewChangelogEntries)
+ {
+ Text = Loc.GetString("changelog-button-new-entries");
+ StyleClasses.Add(StyleBase.ButtonCaution);
+ }
+ else
+ {
+ Text = Loc.GetString("changelog-button");
+ StyleClasses.Remove(StyleBase.ButtonCaution);
+ }
+ }
+ }
+}
diff --git a/Content.Client/Changelog/ChangelogManager.cs b/Content.Client/Changelog/ChangelogManager.cs
new file mode 100644
index 0000000000..b6c5b7b04d
--- /dev/null
+++ b/Content.Client/Changelog/ChangelogManager.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Robust.Shared.ContentPack;
+using Robust.Shared.IoC;
+using Robust.Shared.Serialization;
+using Robust.Shared.Utility;
+using YamlDotNet.RepresentationModel;
+
+#nullable enable
+
+namespace Content.Client.Changelog
+{
+ public sealed class ChangelogManager
+ {
+ // If you fork SS14, change this to have the changelog "last seen" date stored separately.
+ public const string ForkId = "Wizards";
+
+ [Dependency] private readonly IResourceManager _resource = default!;
+
+ public bool NewChangelogEntries { get; private set; }
+ public int LastReadId { get; private set; }
+ public int MaxId { get; private set; }
+
+ public event Action? NewChangelogEntriesChanged;
+
+ ///
+ /// Ran when the user opens ("read") the changelog,
+ /// stores the new ID to disk and clears .
+ ///
+ ///
+ /// is NOT cleared
+ /// since that's used in the changelog menu to show the "since you last read" bar.
+ ///
+ public void SaveNewReadId()
+ {
+ NewChangelogEntries = false;
+ NewChangelogEntriesChanged?.Invoke();
+
+ using var file = _resource.UserData.Create(new ResourcePath($"/changelog_last_seen_{ForkId}"));
+ using var sw = new StreamWriter(file);
+
+ sw.Write(MaxId.ToString());
+ }
+
+ public async void Initialize()
+ {
+ // Open changelog purely to compare to the last viewed date.
+ var changelog = await LoadChangelog();
+
+ if (changelog.Count == 0)
+ {
+ return;
+ }
+
+ MaxId = changelog.Max(c => c.Id);
+
+ var path = new ResourcePath($"/changelog_last_seen_{ForkId}");
+ if (_resource.UserData.Exists(path))
+ {
+ LastReadId = int.Parse(_resource.UserData.ReadAllText(path));
+ }
+
+ NewChangelogEntries = LastReadId < MaxId;
+
+ NewChangelogEntriesChanged?.Invoke();
+ }
+
+ public Task> LoadChangelog()
+ {
+ return Task.Run(() =>
+ {
+ var yamlData = _resource.ContentFileReadYaml(new ResourcePath("/Changelog/Changelog.yml"));
+
+ if (yamlData.Documents.Count == 0)
+ return new List();
+
+ var serializer = YamlObjectSerializer.NewReader((YamlMappingNode) yamlData.Documents[0].RootNode);
+
+ return serializer.ReadDataField>("Entries");
+ });
+ }
+
+
+ public sealed class ChangelogEntry : IExposeData
+ {
+ public int Id { get; private set; }
+ public string Author { get; private set; } = "";
+ public DateTime Time { get; private set; }
+ public List Changes { get; private set; } = default!;
+
+ void IExposeData.ExposeData(ObjectSerializer serializer)
+ {
+ Id = serializer.ReadDataField("id");
+ Author = serializer.ReadDataField("author");
+ Time = DateTime.Parse(serializer.ReadDataField("time"), null, DateTimeStyles.RoundtripKind);
+ Changes = serializer.ReadDataField>("changes");
+ }
+ }
+
+ public sealed class ChangelogChange : IExposeData
+ {
+ public ChangelogLineType Type { get; private set; }
+ public string Message { get; private set; } = "";
+
+ void IExposeData.ExposeData(ObjectSerializer serializer)
+ {
+ Type = serializer.ReadDataField("type");
+ Message = serializer.ReadDataField("message");
+ }
+ }
+
+ public enum ChangelogLineType
+ {
+ Add,
+ Remove,
+ Fix,
+ Tweak,
+ }
+ }
+}
diff --git a/Content.Client/Changelog/ChangelogWindow.xaml b/Content.Client/Changelog/ChangelogWindow.xaml
new file mode 100644
index 0000000000..047b4ce809
--- /dev/null
+++ b/Content.Client/Changelog/ChangelogWindow.xaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Changelog/ChangelogWindow.xaml.cs b/Content.Client/Changelog/ChangelogWindow.xaml.cs
new file mode 100644
index 0000000000..dce9dca690
--- /dev/null
+++ b/Content.Client/Changelog/ChangelogWindow.xaml.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Linq;
+using Content.Client.UserInterface.Stylesheets;
+using Content.Client.Utility;
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Console;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+using Robust.Shared.Maths;
+using Robust.Shared.Utility;
+using static Content.Client.Changelog.ChangelogManager;
+
+namespace Content.Client.Changelog
+{
+ [GenerateTypedNameReferences]
+ public sealed partial class ChangelogWindow : BaseWindow
+ {
+ [Dependency] private readonly ChangelogManager _changelog = default!;
+ [Dependency] private readonly IResourceCache _resourceCache = default!;
+
+ public ChangelogWindow()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ Stylesheet = IoCManager.Resolve().SheetSpace;
+ CloseButton.OnPressed += _ => Close();
+ }
+
+ protected override void Opened()
+ {
+ base.Opened();
+
+ _changelog.SaveNewReadId();
+ PopulateChangelog();
+ }
+
+ private async void PopulateChangelog()
+ {
+ // Changelog is not kept in memory so load it again.
+ var changelog = await _changelog.LoadChangelog();
+
+ var byDay = changelog
+ .GroupBy(e => e.Time.ToLocalTime().Date)
+ .OrderByDescending(c => c.Key);
+
+ var hasRead = _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 = {"LabelHeading"},
+ 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 VBoxContainer();
+
+ var hBox = new HBoxContainer
+ {
+ HorizontalAlignment = HAlignment.Center,
+ Children =
+ {
+ new TextureRect
+ {
+ Texture = upArrow,
+ ModulateSelfOverride = Color.FromHex("#888"),
+ TextureScale = (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 = (0.5f, 0.5f),
+ Margin = new Thickness(4, 3),
+ VerticalAlignment = VAlignment.Bottom
+ }
+ }
+ };
+
+ readDivider.AddChild(hBox);
+ readDivider.AddChild(new PanelContainer {StyleClasses = {"LowDivider"}});
+ 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 HBoxContainer
+ {
+ Margin = new Thickness(14, 1, 10, 2),
+ Children =
+ {
+ GetIcon(change.Type),
+ text
+ }
+ });
+ }
+ }
+ }
+
+ var version = typeof(ChangelogWindow).Assembly.GetName().Version ?? new Version(1, 0);
+ VersionLabel.Text = Loc.GetString("changelog-version-tag", ("version", version.ToString()));
+ }
+
+ 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 ResourcePath($"/Textures/Interface/Changelog/{file}")),
+ VerticalAlignment = VAlignment.Top,
+ TextureScale = (0.5f, 0.5f),
+ Margin = new Thickness(2, 4, 6, 2),
+ ModulateSelfOverride = Color.FromHex(color)
+ };
+ }
+
+ protected override DragMode GetDragModeFor(Vector2 relativeMousePos)
+ {
+ return DragMode.Move;
+ }
+ }
+
+ [UsedImplicitly]
+ public sealed class ChangelogCommand : IConsoleCommand
+ {
+ public string Command => "changelog";
+ public string Description => "Opens the changelog";
+ public string Help => "Usage: changelog";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ new ChangelogWindow().OpenCentered();
+ }
+ }
+}
diff --git a/Content.Client/ClientContentIoC.cs b/Content.Client/ClientContentIoC.cs
index f80e61b2e3..1828775f23 100644
--- a/Content.Client/ClientContentIoC.cs
+++ b/Content.Client/ClientContentIoC.cs
@@ -1,4 +1,5 @@
using Content.Client.Administration;
+using Content.Client.Changelog;
using Content.Client.Chat;
using Content.Client.Eui;
using Content.Client.GameTicking;
@@ -45,6 +46,7 @@ namespace Content.Client
IoCManager.Register();
IoCManager.Register();
IoCManager.Register();
+ IoCManager.Register();
}
}
}
diff --git a/Content.Client/EntryPoint.cs b/Content.Client/EntryPoint.cs
index 965efc8798..d77639501b 100644
--- a/Content.Client/EntryPoint.cs
+++ b/Content.Client/EntryPoint.cs
@@ -1,5 +1,6 @@
using System;
using Content.Client.Administration;
+using Content.Client.Changelog;
using Content.Client.Eui;
using Content.Client.GameObjects.Components.Actor;
using Content.Client.Input;
@@ -98,6 +99,7 @@ namespace Content.Client
IoCManager.Resolve().PlayerJoinedServer += SubscribePlayerAttachmentEvents;
IoCManager.Resolve().Initialize();
IoCManager.Resolve().Initialize();
+ IoCManager.Resolve().Initialize();
IoCManager.InjectDependencies(this);
diff --git a/Content.Client/State/MainMenu.cs b/Content.Client/State/MainMenu.cs
index cc55b96f24..34b3c93647 100644
--- a/Content.Client/State/MainMenu.cs
+++ b/Content.Client/State/MainMenu.cs
@@ -1,5 +1,6 @@
using System;
using System.Text.RegularExpressions;
+using Content.Client.Changelog;
using Content.Client.UserInterface;
using Robust.Client;
using Robust.Client.ResourceManagement;
@@ -309,6 +310,8 @@ namespace Content.Client.State
vBox.AddChild(QuitButton);
+ vBox.AddChild(new ChangelogButton());
+
VersionLabel = new Label
{
Text = "v0.1"
diff --git a/Content.Client/UserInterface/ServerInfo.cs b/Content.Client/UserInterface/ServerInfo.cs
index bb29cd6ded..81eb7f5da5 100644
--- a/Content.Client/UserInterface/ServerInfo.cs
+++ b/Content.Client/UserInterface/ServerInfo.cs
@@ -1,4 +1,5 @@
-using Robust.Client.UserInterface;
+using Content.Client.Changelog;
+using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
@@ -35,10 +36,17 @@ namespace Content.Client.UserInterface
var creditsButton = new Button { Text = Loc.GetString("Credits") };
creditsButton.OnPressed += args => new CreditsWindow().Open();
+ var changelogButton = new ChangelogButton
+ {
+ HorizontalExpand = true,
+ HorizontalAlignment = HAlignment.Right
+ };
+
buttons.AddChild(discordButton);
buttons.AddChild(websiteButton);
buttons.AddChild(reportButton);
buttons.AddChild(creditsButton);
+ buttons.AddChild(changelogButton);
}
public void SetInfoBlob(string markup)
diff --git a/Content.Client/UserInterface/Stylesheets/StyleBase.cs b/Content.Client/UserInterface/Stylesheets/StyleBase.cs
index 2da28b599e..3b827cf753 100644
--- a/Content.Client/UserInterface/Stylesheets/StyleBase.cs
+++ b/Content.Client/UserInterface/Stylesheets/StyleBase.cs
@@ -94,6 +94,36 @@ namespace Content.Client.UserInterface.Stylesheets
};
BaseAngleRect.SetPatchMargin(StyleBox.Margin.All, 10);
+ var vScrollBarGrabberNormal = new StyleBoxFlat
+ {
+ BackgroundColor = Color.Gray.WithAlpha(0.35f), ContentMarginLeftOverride = 10,
+ ContentMarginTopOverride = 10
+ };
+ var vScrollBarGrabberHover = new StyleBoxFlat
+ {
+ BackgroundColor = new Color(140, 140, 140).WithAlpha(0.35f), ContentMarginLeftOverride = 10,
+ ContentMarginTopOverride = 10
+ };
+ var vScrollBarGrabberGrabbed = new StyleBoxFlat
+ {
+ BackgroundColor = new Color(160, 160, 160).WithAlpha(0.35f), ContentMarginLeftOverride = 10,
+ ContentMarginTopOverride = 10
+ };
+
+ var hScrollBarGrabberNormal = new StyleBoxFlat
+ {
+ BackgroundColor = Color.Gray.WithAlpha(0.35f), ContentMarginTopOverride = 10
+ };
+ var hScrollBarGrabberHover = new StyleBoxFlat
+ {
+ BackgroundColor = new Color(140, 140, 140).WithAlpha(0.35f), ContentMarginTopOverride = 10
+ };
+ var hScrollBarGrabberGrabbed = new StyleBoxFlat
+ {
+ BackgroundColor = new Color(160, 160, 160).WithAlpha(0.35f), ContentMarginTopOverride = 10
+ };
+
+
BaseRules = new[]
{
// Default font.
@@ -138,6 +168,52 @@ namespace Content.Client.UserInterface.Stylesheets
new StyleProperty(Control.StylePropertyModulateSelf, Color.FromHex("#753131")),
}),
+ // Scroll bars
+ new StyleRule(new SelectorElement(typeof(VScrollBar), null, null, null),
+ new[]
+ {
+ new StyleProperty(ScrollBar.StylePropertyGrabber,
+ vScrollBarGrabberNormal),
+ }),
+
+ new StyleRule(
+ new SelectorElement(typeof(VScrollBar), null, null, new[] {ScrollBar.StylePseudoClassHover}),
+ new[]
+ {
+ new StyleProperty(ScrollBar.StylePropertyGrabber,
+ vScrollBarGrabberHover),
+ }),
+
+ new StyleRule(
+ new SelectorElement(typeof(VScrollBar), null, null, new[] {ScrollBar.StylePseudoClassGrabbed}),
+ new[]
+ {
+ new StyleProperty(ScrollBar.StylePropertyGrabber,
+ vScrollBarGrabberGrabbed),
+ }),
+
+ new StyleRule(new SelectorElement(typeof(HScrollBar), null, null, null),
+ new[]
+ {
+ new StyleProperty(ScrollBar.StylePropertyGrabber,
+ hScrollBarGrabberNormal),
+ }),
+
+ new StyleRule(
+ new SelectorElement(typeof(HScrollBar), null, null, new[] {ScrollBar.StylePseudoClassHover}),
+ new[]
+ {
+ new StyleProperty(ScrollBar.StylePropertyGrabber,
+ hScrollBarGrabberHover),
+ }),
+
+ new StyleRule(
+ new SelectorElement(typeof(HScrollBar), null, null, new[] {ScrollBar.StylePseudoClassGrabbed}),
+ new[]
+ {
+ new StyleProperty(ScrollBar.StylePropertyGrabber,
+ hScrollBarGrabberGrabbed),
+ }),
};
}
}
diff --git a/Content.Client/UserInterface/Stylesheets/StyleNano.cs b/Content.Client/UserInterface/Stylesheets/StyleNano.cs
index 69b0b49a17..83186507b7 100644
--- a/Content.Client/UserInterface/Stylesheets/StyleNano.cs
+++ b/Content.Client/UserInterface/Stylesheets/StyleNano.cs
@@ -31,7 +31,6 @@ namespace Content.Client.UserInterface.Stylesheets
public const string StyleClassActionSearchBox = "actionSearchBox";
public const string StyleClassActionMenuItemRevoked = "actionMenuItemRevoked";
-
public const string StyleClassSliderRed = "Red";
public const string StyleClassSliderGreen = "Green";
public const string StyleClassSliderBlue = "Blue";
@@ -237,35 +236,6 @@ namespace Content.Client.UserInterface.Stylesheets
var tabContainerBoxInactive = new StyleBoxFlat {BackgroundColor = new Color(32, 32, 32)};
tabContainerBoxInactive.SetContentMarginOverride(StyleBox.Margin.Horizontal, 5);
- var vScrollBarGrabberNormal = new StyleBoxFlat
- {
- BackgroundColor = Color.Gray.WithAlpha(0.35f), ContentMarginLeftOverride = 10,
- ContentMarginTopOverride = 10
- };
- var vScrollBarGrabberHover = new StyleBoxFlat
- {
- BackgroundColor = new Color(140, 140, 140).WithAlpha(0.35f), ContentMarginLeftOverride = 10,
- ContentMarginTopOverride = 10
- };
- var vScrollBarGrabberGrabbed = new StyleBoxFlat
- {
- BackgroundColor = new Color(160, 160, 160).WithAlpha(0.35f), ContentMarginLeftOverride = 10,
- ContentMarginTopOverride = 10
- };
-
- var hScrollBarGrabberNormal = new StyleBoxFlat
- {
- BackgroundColor = Color.Gray.WithAlpha(0.35f), ContentMarginTopOverride = 10
- };
- var hScrollBarGrabberHover = new StyleBoxFlat
- {
- BackgroundColor = new Color(140, 140, 140).WithAlpha(0.35f), ContentMarginTopOverride = 10
- };
- var hScrollBarGrabberGrabbed = new StyleBoxFlat
- {
- BackgroundColor = new Color(160, 160, 160).WithAlpha(0.35f), ContentMarginTopOverride = 10
- };
-
var progressBarBackground = new StyleBoxFlat
{
BackgroundColor = new Color(0.25f, 0.25f, 0.25f)
@@ -585,53 +555,6 @@ namespace Content.Client.UserInterface.Stylesheets
new StyleProperty(TabContainer.StylePropertyTabStyleBoxInactive, tabContainerBoxInactive),
}),
- // Scroll bars
- new StyleRule(new SelectorElement(typeof(VScrollBar), null, null, null),
- new[]
- {
- new StyleProperty(ScrollBar.StylePropertyGrabber,
- vScrollBarGrabberNormal),
- }),
-
- new StyleRule(
- new SelectorElement(typeof(VScrollBar), null, null, new[] {ScrollBar.StylePseudoClassHover}),
- new[]
- {
- new StyleProperty(ScrollBar.StylePropertyGrabber,
- vScrollBarGrabberHover),
- }),
-
- new StyleRule(
- new SelectorElement(typeof(VScrollBar), null, null, new[] {ScrollBar.StylePseudoClassGrabbed}),
- new[]
- {
- new StyleProperty(ScrollBar.StylePropertyGrabber,
- vScrollBarGrabberGrabbed),
- }),
-
- new StyleRule(new SelectorElement(typeof(HScrollBar), null, null, null),
- new[]
- {
- new StyleProperty(ScrollBar.StylePropertyGrabber,
- hScrollBarGrabberNormal),
- }),
-
- new StyleRule(
- new SelectorElement(typeof(HScrollBar), null, null, new[] {ScrollBar.StylePseudoClassHover}),
- new[]
- {
- new StyleProperty(ScrollBar.StylePropertyGrabber,
- hScrollBarGrabberHover),
- }),
-
- new StyleRule(
- new SelectorElement(typeof(HScrollBar), null, null, new[] {ScrollBar.StylePseudoClassGrabbed}),
- new[]
- {
- new StyleProperty(ScrollBar.StylePropertyGrabber,
- hScrollBarGrabberGrabbed),
- }),
-
// ProgressBar
new StyleRule(new SelectorElement(typeof(ProgressBar), null, null, null),
new[]
diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/Resources/Changelog/Parts/parts_here.txt b/Resources/Changelog/Parts/parts_here.txt
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/Resources/Locale/en-US/ui/changelog.ftl b/Resources/Locale/en-US/ui/changelog.ftl
new file mode 100644
index 0000000000..620cc361f1
--- /dev/null
+++ b/Resources/Locale/en-US/ui/changelog.ftl
@@ -0,0 +1,11 @@
+### Stuff for the changelog window.
+
+changelog-window-title = Changelog
+changelog-author-changed = [color=#EEE]{ $author }[/color] changed:
+changelog-today = Today
+changelog-yesterday = Yesterday
+changelog-new-changes = new changes
+changelog-version-tag = version v{ $version }
+
+changelog-button = Changelog
+changelog-button-new-entries = Changelog (new!)
diff --git a/Resources/Textures/Interface/Changelog/bug.svg b/Resources/Textures/Interface/Changelog/bug.svg
new file mode 100644
index 0000000000..04a0a14288
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/bug.svg
@@ -0,0 +1,109 @@
+
+
diff --git a/Resources/Textures/Interface/Changelog/bug.svg.192dpi.png b/Resources/Textures/Interface/Changelog/bug.svg.192dpi.png
new file mode 100644
index 0000000000..6ff60d45d7
Binary files /dev/null and b/Resources/Textures/Interface/Changelog/bug.svg.192dpi.png differ
diff --git a/Resources/Textures/Interface/Changelog/bug.svg.192dpi.png.yml b/Resources/Textures/Interface/Changelog/bug.svg.192dpi.png.yml
new file mode 100644
index 0000000000..5c43e23305
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/bug.svg.192dpi.png.yml
@@ -0,0 +1,2 @@
+sample:
+ filter: true
diff --git a/Resources/Textures/Interface/Changelog/minus.svg b/Resources/Textures/Interface/Changelog/minus.svg
new file mode 100644
index 0000000000..e29e613ee2
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/minus.svg
@@ -0,0 +1,73 @@
+
+
diff --git a/Resources/Textures/Interface/Changelog/minus.svg.192dpi.png b/Resources/Textures/Interface/Changelog/minus.svg.192dpi.png
new file mode 100644
index 0000000000..b4637590db
Binary files /dev/null and b/Resources/Textures/Interface/Changelog/minus.svg.192dpi.png differ
diff --git a/Resources/Textures/Interface/Changelog/minus.svg.192dpi.png.yml b/Resources/Textures/Interface/Changelog/minus.svg.192dpi.png.yml
new file mode 100644
index 0000000000..5c43e23305
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/minus.svg.192dpi.png.yml
@@ -0,0 +1,2 @@
+sample:
+ filter: true
diff --git a/Resources/Textures/Interface/Changelog/plus.svg b/Resources/Textures/Interface/Changelog/plus.svg
new file mode 100644
index 0000000000..3721c9a091
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/plus.svg
@@ -0,0 +1,83 @@
+
+
diff --git a/Resources/Textures/Interface/Changelog/plus.svg.192dpi.png b/Resources/Textures/Interface/Changelog/plus.svg.192dpi.png
new file mode 100644
index 0000000000..b6c8ae4908
Binary files /dev/null and b/Resources/Textures/Interface/Changelog/plus.svg.192dpi.png differ
diff --git a/Resources/Textures/Interface/Changelog/plus.svg.192dpi.png.yml b/Resources/Textures/Interface/Changelog/plus.svg.192dpi.png.yml
new file mode 100644
index 0000000000..5c43e23305
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/plus.svg.192dpi.png.yml
@@ -0,0 +1,2 @@
+sample:
+ filter: true
diff --git a/Resources/Textures/Interface/Changelog/up_arrow.svg b/Resources/Textures/Interface/Changelog/up_arrow.svg
new file mode 100644
index 0000000000..fe9df9d332
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/up_arrow.svg
@@ -0,0 +1,80 @@
+
+
diff --git a/Resources/Textures/Interface/Changelog/up_arrow.svg.192dpi.png b/Resources/Textures/Interface/Changelog/up_arrow.svg.192dpi.png
new file mode 100644
index 0000000000..2ffc58a793
Binary files /dev/null and b/Resources/Textures/Interface/Changelog/up_arrow.svg.192dpi.png differ
diff --git a/Resources/Textures/Interface/Changelog/up_arrow.svg.192dpi.png.yml b/Resources/Textures/Interface/Changelog/up_arrow.svg.192dpi.png.yml
new file mode 100644
index 0000000000..5c43e23305
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/up_arrow.svg.192dpi.png.yml
@@ -0,0 +1,2 @@
+sample:
+ filter: true
diff --git a/Resources/Textures/Interface/Changelog/wrench.svg b/Resources/Textures/Interface/Changelog/wrench.svg
new file mode 100644
index 0000000000..853630849d
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/wrench.svg
@@ -0,0 +1,147 @@
+
+
diff --git a/Resources/Textures/Interface/Changelog/wrench.svg.192dpi.png b/Resources/Textures/Interface/Changelog/wrench.svg.192dpi.png
new file mode 100644
index 0000000000..d6e617a42e
Binary files /dev/null and b/Resources/Textures/Interface/Changelog/wrench.svg.192dpi.png differ
diff --git a/Resources/Textures/Interface/Changelog/wrench.svg.192dpi.png.yml b/Resources/Textures/Interface/Changelog/wrench.svg.192dpi.png.yml
new file mode 100644
index 0000000000..5c43e23305
--- /dev/null
+++ b/Resources/Textures/Interface/Changelog/wrench.svg.192dpi.png.yml
@@ -0,0 +1,2 @@
+sample:
+ filter: true
diff --git a/Tools/update_changelog.py b/Tools/update_changelog.py
new file mode 100755
index 0000000000..f46d2c7618
--- /dev/null
+++ b/Tools/update_changelog.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+
+import sys
+import os
+from typing import List, Any
+import yaml
+import argparse
+import datetime
+
+MAX_ENTRIES = 500
+
+HEADER_RE = r"(?::cl:|🆑) *\r?\n(.+)$"
+ENTRY_RE = r"^ *[*-]? *(\S[^\n\r]+)\r?$"
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("changelog_file")
+ parser.add_argument("parts_dir")
+
+ args = parser.parse_args()
+
+ with open(args.changelog_file, "r", encoding="utf-8-sig") as f:
+ current_data = yaml.safe_load(f)
+
+ entries_list: List[Any]
+ if current_data is None:
+ entries_list = []
+ else:
+ entries_list = current_data["Entries"]
+
+ max_id = max(map(lambda e: e["id"], entries_list), default=0)
+
+ for partname in os.listdir(args.parts_dir):
+ if not partname.endswith(".yml"):
+ continue
+
+ partpath = os.path.join(args.parts_dir, partname)
+ print(partpath)
+
+ partyaml = yaml.safe_load(open(partpath, "r", encoding="utf-8-sig"))
+
+ author = partyaml["author"]
+ time = partyaml.get(
+ "time", datetime.datetime.now(datetime.timezone.utc).isoformat()
+ )
+ changes = partyaml["changes"]
+ max_id += 1
+ new_id = max_id
+
+ entries_list.append(
+ {"author": author, "time": time, "changes": changes, "id": new_id}
+ )
+
+ os.remove(partpath)
+
+ print(f"Have {len(entries_list)} changelog entries")
+
+ entries_list.sort(key=lambda e: e["id"])
+
+ overflow = len(entries_list) - MAX_ENTRIES
+ if overflow > 0:
+ print(f"Removing {overflow} old entries.")
+ entries_list = entries_list[overflow:]
+
+ with open(args.changelog_file, "w") as f:
+ yaml.safe_dump({"Entries": entries_list}, f)
+
+
+main()