A UI Razor component that displays recent patch notes for the current game package. It fetches the package changelists asynchronously via Package.FetchAsync, builds up to five note lines (Added, Improved, Fixed, Removed), formats an age label, and renders them with simple markup and marker symbols.
@using System
@using System.Collections.Immutable
@using System.Linq
@using System.Threading.Tasks
@using Sandbox
@using Sandbox.UI
@namespace Machines.UI
@inherits Panel
<root class="revision-card">
@if ( _loading )
{
<div class="rev-empty">Loading patch notes…</div>
}
else if ( _notes.Count == 0 )
{
<div class="rev-empty">No recent updates.</div>
}
else
{
<div class="rev-header">
<span class="rev-pill">@HeaderLabel</span>
<span class="rev-age">@_age</span>
</div>
<div class="rev-notes">
@foreach ( var note in _notes )
{
<div class="rev-note">
<span class="rev-bullet @MarkerClass( note.Kind )">@Marker( note.Kind )</span>
<span class="rev-text">@note.Text</span>
</div>
}
</div>
}
</root>
@code
{
private enum NoteKind { Added, Improved, Fixed, Removed }
private struct Note
{
public string Text;
public NoteKind Kind;
}
private const int MaxNotes = 5;
private static string Marker( NoteKind kind ) => kind switch
{
NoteKind.Added => "+",
NoteKind.Improved => "↑",
NoteKind.Fixed => "✓",
NoteKind.Removed => "−",
_ => "•"
};
private static string MarkerClass( NoteKind kind ) => kind.ToString().ToLowerInvariant();
private bool _loading = true;
private string _version;
private string _title;
private string _age = "";
private readonly List<Note> _notes = new();
private string HeaderLabel => string.IsNullOrWhiteSpace( _version )
? (string.IsNullOrWhiteSpace( _title ) ? "LATEST" : _title)
: $"PATCH {_version}";
protected override void OnAfterTreeRender( bool firstTime )
{
base.OnAfterTreeRender( firstTime );
if ( firstTime )
_ = LoadAsync();
}
private async Task LoadAsync()
{
try
{
var package = await Package.FetchAsync( Game.Ident, true );
var changelists = package is null ? default : await package.GetChangeListsAsync();
if ( !changelists.IsDefaultOrEmpty )
{
var changelist = changelists[0];
_version = changelist.Version;
_title = changelist.Title;
_age = FormatAge( changelist.Created );
BuildNotes( changelist );
}
}
catch ( Exception e )
{
Log.Warning( e, "RevisionCard: failed to fetch changelist" );
}
_loading = false;
StateHasChanged();
}
// Aggregate up to MaxNotes lines: added, improved, fixed, removed.
private void BuildNotes( Package.ChangeList changelist )
{
_notes.Clear();
AddFrom( changelist.Added, NoteKind.Added );
AddFrom( changelist.Improved, NoteKind.Improved );
AddFrom( changelist.Fixed, NoteKind.Fixed );
AddFrom( changelist.Removed, NoteKind.Removed );
}
private void AddFrom( ImmutableArray<Package.ChangeListEntry> entries, NoteKind kind )
{
foreach ( var entry in Safe( entries ) )
{
if ( _notes.Count >= MaxNotes )
return;
_notes.Add( new Note { Text = entry.Text, Kind = kind } );
}
}
private static IEnumerable<Package.ChangeListEntry> Safe( ImmutableArray<Package.ChangeListEntry> entries )
=> entries.IsDefaultOrEmpty ? Enumerable.Empty<Package.ChangeListEntry>() : entries;
private static string FormatAge( DateTimeOffset created )
{
var span = DateTimeOffset.UtcNow - created;
if ( span.TotalMinutes < 1 ) return "just now";
if ( span.TotalMinutes < 60 ) return $"{(int)span.TotalMinutes} min ago";
if ( span.TotalHours < 24 ) return $"{(int)span.TotalHours} hours ago";
var days = (int)span.TotalDays;
return days == 1 ? "1 day ago" : $"{days} days ago";
}
protected override int BuildHash()
{
return System.HashCode.Combine( _loading, _version, _age, _notes.Count );
}
}