diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs
index 3c9c23d..a0fd696 100644
--- a/src/EchoHub.Client/AppOrchestrator.cs
+++ b/src/EchoHub.Client/AppOrchestrator.cs
@@ -414,6 +414,18 @@ public sealed class AppOrchestrator : IDisposable
FetchAndUpdateOnlineUsers();
SaveServerToConfig(result);
+
+ // Check for newer version in the background
+ _ = Task.Run(async () =>
+ {
+ var newVersion = await UpdateChecker.CheckForUpdateAsync();
+ if (newVersion is not null)
+ {
+ InvokeUI(() => _mainWindow.AddSystemMessage(
+ HubConstants.DefaultChannel,
+ $"A new version of EchoHub is available: v{newVersion} (current: v{MainWindow.AppVersion}). Visit https://github.com/HueByte/EchoHub/releases"));
+ }
+ });
}, "Connection failed", "Connect");
}
diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs
new file mode 100644
index 0000000..d73c9df
--- /dev/null
+++ b/src/EchoHub.Client/Services/UpdateChecker.cs
@@ -0,0 +1,47 @@
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+
+namespace EchoHub.Client.Services;
+
+public static class UpdateChecker
+{
+ private static readonly Uri ReleaseUrl =
+ new("https://api.github.com/repos/HueByte/EchoHub/releases/latest");
+
+ ///
+ /// Checks GitHub for a newer release. Returns the new version string if one exists, or null.
+ /// Never throws — all errors are silently swallowed.
+ ///
+ public static async Task CheckForUpdateAsync()
+ {
+ try
+ {
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client");
+
+ var release = await http.GetFromJsonAsync(ReleaseUrl);
+ if (release?.TagName is null)
+ return null;
+
+ var tag = release.TagName.TrimStart('v', 'V');
+ if (!Version.TryParse(tag, out var latest))
+ return null;
+
+ var currentStr = typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3);
+ if (currentStr is null || !Version.TryParse(currentStr, out var current))
+ return null;
+
+ return latest > current ? tag : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private sealed class GitHubRelease
+ {
+ [JsonPropertyName("tag_name")]
+ public string? TagName { get; set; }
+ }
+}
diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs
index 8f7d5c9..9fc8b14 100644
--- a/src/EchoHub.Client/UI/MainWindow.cs
+++ b/src/EchoHub.Client/UI/MainWindow.cs
@@ -34,7 +34,7 @@ public sealed class MainWindow : Runnable
private const int UsersPanelWidth = 22;
private static readonly Key F2Key = Key.F2;
- private static readonly string AppVersion =
+ internal static readonly string AppVersion =
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
@@ -479,19 +479,33 @@ public sealed class MainWindow : Runnable
///
public void AddSystemMessage(string channelName, string text)
{
- var time = DateTimeOffset.Now.ToString("HH:mm");
- var segments = new List
- {
- new($"[{time}] ", ChatColors.TimestampAttr),
- new($"** {text}", ChatColors.SystemAttr)
- };
-
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
- messages.Add(new ChatLine(segments));
+
+ var time = DateTimeOffset.Now.ToString("HH:mm");
+ var textLines = text.Split('\n');
+
+ // First line gets timestamp prefix
+ messages.Add(new ChatLine(
+ [
+ new($"[{time}] ", ChatColors.TimestampAttr),
+ new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
+ ]));
+
+ // Continuation lines are indented to align
+ var indent = new string(' ', $"[{time}] ** ".Length);
+ for (int i = 1; i < textLines.Length; i++)
+ {
+ var line = textLines[i].TrimEnd('\r');
+ if (string.IsNullOrWhiteSpace(line)) continue;
+ messages.Add(new ChatLine(
+ [
+ new($"{indent}{line}", ChatColors.SystemAttr)
+ ]));
+ }
if (channelName == _currentChannel)
{