From aae788028eaca52a32ef69c1ed4d7939086cec46 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 08:19:43 +0200 Subject: [PATCH] feat: enhance chat functionality with auto-join, persistent read positions, and theme border customization --- docs/changelog/index.md | 2 +- docs/changelog/v0.2.13.md | 8 +- src/EchoHub.Client/AppOrchestrator.cs | 105 ++++++++++++++++-- src/EchoHub.Client/Config/ClientConfig.cs | 12 ++ src/EchoHub.Client/Config/ConfigManager.cs | 74 +++++++----- .../Services/ConnectionManager.cs | 63 +++++++++-- src/EchoHub.Client/Themes/Theme.cs | 8 ++ src/EchoHub.Client/Themes/ThemeManager.cs | 18 +++ .../UI/Chat/ChatMessageManager.cs | 76 ++++++++++++- src/EchoHub.Client/UI/MainWindow.cs | 10 ++ 10 files changed, 320 insertions(+), 56 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index d67767e..ce4d9fe 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,7 +4,7 @@ Release history for EchoHub. ## Releases -- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul: Aligned Nick Column, Nick Colors, Date Rules & Unread Marker +- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions - [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix - [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata - [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes diff --git a/docs/changelog/v0.2.13.md b/docs/changelog/v0.2.13.md index 0f99cf5..81cbbfa 100644 --- a/docs/changelog/v0.2.13.md +++ b/docs/changelog/v0.2.13.md @@ -1,6 +1,6 @@ # v0.2.13 -A visual overhaul of the chat client: messages now line up in a WeeChat-style column layout with a timestamp gutter, right-aligned nicks, and a vertical rail; every user gets a stable nickname color; day boundaries and unread positions are marked with horizontal rules; the status bar gains an irssi-style activity segment and an animated connecting spinner; and an ASCII-art welcome banner greets you before you join a channel. Modern polish, old IRC soul. +A visual overhaul of the chat client — WeeChat-style column layout, per-user nick colors, date rules, unread markers, an activity status segment, and an ASCII welcome banner — plus reliable cross-channel notifications: the client now joins all your channels at connect so unread counts and @mentions light up everywhere, and read positions persist across restarts so activity that happened while you were offline still shows. Modern polish, old IRC soul. ## New Features @@ -13,8 +13,14 @@ A visual overhaul of the chat client: messages now line up in a WeeChat-style co - **Mention-aware channel list** — a channel where you were @mentioned turns orange in the channel list (name and unread badge), escalating above the cyan plain-unread highlight. The highlight clears when you view the channel. - **Welcome banner** — with no channel selected (fresh start, or after disconnecting) the chat pane shows a gold-gradient ASCII "ECHOHUB" logo with the version and key hints, instead of an empty box. Narrow panes get a compact variant. - **Rounded frame borders** — the channels, chat, input, and users panels draw with rounded corners. +- **Auto-join all channels at connect** — the client now joins every channel it lists for you (public channels and prior memberships) as part of connecting, so message events flow for all of them: unread badges, @mention highlights, and the status-bar activity segment work without having to open each channel first. Protected channels you've never entered are skipped silently (joining them stays a prompted, manual action), and channels you `/leave` are remembered and excluded until you join them again. +- **Read positions survive restarts** — the client persists the last message you read per channel (locally, per server). On the next connect it compares that against fetched history, so messages that arrived while you were offline still produce unread counts, mention highlights, and a correctly placed "new messages" marker. +- **Theme-tinted frame borders** — themes can now color the window frame borders independently of text via a new optional `border` section (hex values supported). The Transparent and TransparentLight themes use it to draw dim gray borders instead of stark white ones, for a subtler, glassier look. Custom theme JSON files without a `border` section keep their base colors. ## Bug Fixes - The @mention highlight no longer drops off the second and later lines of a long mention message — wrapped continuation lines now inherit the highlight. - The unread-position marker is re-anchored after a channel switch reloads history, instead of being wiped by the reload before you could see it. +- Cached room keys for end-to-end encrypted channels no longer disappear from the config on reconnect. Saving the server entry after a successful connect replaced it wholesale, wiping the cached keys (and forcing a passphrase re-entry on the next launch); the entry is now updated in place. +- A failed channel rejoin after a reconnect (e.g. a channel deleted while you were away) no longer aborts rejoining the remaining channels. +- Client config file access is now serialized across threads (token refresh, room-key cache, and read-position checkpoints all write it), preventing rare config corruption. diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index f56a4a1..48b8ec7 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -67,6 +67,8 @@ public sealed class AppOrchestrator : IDisposable public void Dispose() { + // Quit while still connected — capture read positions before tearing down + PersistLastReads(); _conn.DisposeAsync().AsTask().GetAwaiter().GetResult(); _updateService.Dispose(); } @@ -384,6 +386,10 @@ public sealed class AppOrchestrator : IDisposable var history = await JoinChannelWithPasswordPromptAsync(channelName, password); if (history is null) return; // user cancelled the password prompt + // A deliberate join cancels any earlier /leave exclusion + UpdateServerConfig(server => + server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase))); + InvokeUI(() => { _mainWindow.EnsureChannelInList(channelName); @@ -553,6 +559,14 @@ public sealed class AppOrchestrator : IDisposable try { await _conn.LeaveChannelAsync(channel); + + // Remember the leave so the connect-time auto-join doesn't pull us back in + UpdateServerConfig(server => + { + if (!server.LeftChannels.Contains(channel, StringComparer.OrdinalIgnoreCase)) + server.LeftChannels.Add(channel); + }); + InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}")); } catch (Exception ex) @@ -957,13 +971,28 @@ public sealed class AppOrchestrator : IDisposable _session.Username = result.Login.Username; + // Persisted last-read markers for this server — used to seed unread counts, + // mention highlights, and "new messages" markers from the fetched histories. + var lastReads = ConfigManager.Load().SavedServers + .FirstOrDefault(s => string.Equals(s.Url, dialogResult.ServerUrl, StringComparison.OrdinalIgnoreCase)) + ?.LastReadMessages ?? []; + InvokeUI(() => { _mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username); _mainWindow.SetChannels(result.Channels); _mainWindow.SwitchToChannel(HubConstants.DefaultChannel); - if (result.DefaultHistory.Count > 0) - _messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory); + + foreach (var (channel, history) in result.Histories) + { + if (history.Count == 0) + continue; + + Guid? lastRead = lastReads.TryGetValue(channel, out var idText) + && Guid.TryParse(idText, out var id) ? id : null; + _messageManager.LoadHistory(channel, history, lastRead); + } + _mainWindow.FocusInput(); FetchAndUpdateOnlineUsers(); }); @@ -975,6 +1004,7 @@ public sealed class AppOrchestrator : IDisposable { Log.Information("Disconnecting from server"); lock (_channelUsersLock) _channelUsers.Clear(); + PersistLastReads(); RunAsync(async () => { @@ -990,6 +1020,7 @@ public sealed class AppOrchestrator : IDisposable private void HandleLogout() { Log.Information("Logging out from server"); + PersistLastReads(); RunAsync(async () => { @@ -1061,6 +1092,9 @@ public sealed class AppOrchestrator : IDisposable { if (!_conn.IsConnected) return; + // Checkpoint read positions — the previous channel was just marked read + PersistLastReads(); + RunAsync(async () => { if (_conn.TrackChannel(channelName)) @@ -1073,6 +1107,10 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel)); return; } + + // A deliberate join cancels any earlier /leave exclusion + UpdateServerConfig(server => + server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase))); } try @@ -1667,20 +1705,63 @@ public sealed class AppOrchestrator : IDisposable private void SaveServerToConfig(ConnectDialogResult result) { - var savedServer = new SavedServer + // Update the existing entry in place (never replace it) — the per-server entry also + // carries cached room keys, left channels, and last-read markers that must survive. + var config = ConfigManager.Load(); + var server = config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, result.ServerUrl, StringComparison.OrdinalIgnoreCase)); + + if (server is null) { - Name = new Uri(result.ServerUrl).Host, - Url = result.ServerUrl, - Username = result.Username, - RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null, - RememberMe = result.RememberMe, - LastConnected = DateTimeOffset.Now - }; - ConfigManager.SaveServer(savedServer); - _config = ConfigManager.Load(); + server = new SavedServer { Name = new Uri(result.ServerUrl).Host, Url = result.ServerUrl }; + config.SavedServers.Add(server); + } + + server.Username = result.Username; + server.RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null; + server.RememberMe = result.RememberMe; + server.LastConnected = DateTimeOffset.Now; + + ConfigManager.Save(config); + _config = config; Log.Information("Connected successfully to {Url}", result.ServerUrl); } + /// + /// Mutates the current server's config entry and persists it. No-op when not + /// authenticated or the server isn't saved. + /// + private void UpdateServerConfig(Action mutate) + { + var url = _conn.Api?.BaseUrl; + if (url is null) return; + + var config = ConfigManager.Load(); + var server = config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase)); + if (server is null) return; + + mutate(server); + ConfigManager.Save(config); + _config = config; + } + + /// + /// Persists the in-memory last-read message ids to the current server's config entry, + /// so unread/mention state can be reconstructed on the next connect. + /// + private void PersistLastReads() + { + var lastReads = _messageManager.LastReadIds; + if (lastReads.Count == 0) return; + + UpdateServerConfig(server => + { + foreach (var (channel, id) in lastReads) + server.LastReadMessages[channel] = id.ToString(); + }); + } + private void ClearSavedToken(string serverUrl) { var config = ConfigManager.Load(); diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 6ebedd6..3e25650 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -42,6 +42,18 @@ public class SavedServer /// user's machine — the server never sees them. /// public Dictionary ChannelKeys { get; set; } = []; + + /// + /// Channels the user explicitly left with /leave. Excluded from the automatic + /// join-all-channels pass at connect until the user joins them again. + /// + public List LeftChannels { get; set; } = []; + + /// + /// Last message the user has read per channel (message id as string), persisted so + /// unread counts, @mention highlights, and the "new messages" marker survive restarts. + /// + public Dictionary LastReadMessages { get; set; } = []; } public class AccountPreset diff --git a/src/EchoHub.Client/Config/ConfigManager.cs b/src/EchoHub.Client/Config/ConfigManager.cs index 5c40071..7e30db5 100644 --- a/src/EchoHub.Client/Config/ConfigManager.cs +++ b/src/EchoHub.Client/Config/ConfigManager.cs @@ -9,6 +9,10 @@ public static class ConfigManager private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json"); + // Load-mutate-save cycles run from both the UI thread and background tasks + // (token refresh, room keys, last-read checkpoints) — serialize file access. + private static readonly Lock FileLock = new(); + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -17,54 +21,66 @@ public static class ConfigManager public static ClientConfig Load() { - try + lock (FileLock) { - if (!File.Exists(ConfigPath)) - return new ClientConfig(); + try + { + if (!File.Exists(ConfigPath)) + return new ClientConfig(); - var json = File.ReadAllText(ConfigPath); - return JsonSerializer.Deserialize(json, JsonOptions) ?? new ClientConfig(); - } - catch - { - return new ClientConfig(); + var json = File.ReadAllText(ConfigPath); + return JsonSerializer.Deserialize(json, JsonOptions) ?? new ClientConfig(); + } + catch + { + return new ClientConfig(); + } } } public static void Save(ClientConfig config) { - try + lock (FileLock) { - Directory.CreateDirectory(ConfigDir); - var json = JsonSerializer.Serialize(config, JsonOptions); - File.WriteAllText(ConfigPath, json); - } - catch - { - // Silently fail — config save is best-effort + try + { + Directory.CreateDirectory(ConfigDir); + var json = JsonSerializer.Serialize(config, JsonOptions); + File.WriteAllText(ConfigPath, json); + } + catch + { + // Silently fail — config save is best-effort + } } } public static void SaveServer(SavedServer server) { - var config = Load(); - var existing = config.SavedServers.FindIndex(s => - string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase)); + lock (FileLock) + { + var config = Load(); + var existing = config.SavedServers.FindIndex(s => + string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase)); - if (existing >= 0) - config.SavedServers[existing] = server; - else - config.SavedServers.Add(server); + if (existing >= 0) + config.SavedServers[existing] = server; + else + config.SavedServers.Add(server); - Save(config); + Save(config); + } } public static void RemoveServer(string url) { - var config = Load(); - config.SavedServers.RemoveAll(s => - string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase)); + lock (FileLock) + { + var config = Load(); + config.SavedServers.RemoveAll(s => + string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase)); - Save(config); + Save(config); + } } } diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index ec4e83b..dfc5a29 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -9,11 +9,13 @@ namespace EchoHub.Client.Services; /// /// Result of a successful connection, returned to AppOrchestrator for UI updates. +/// holds the initial history of every auto-joined channel +/// (keyed by channel name, always including the default channel). /// internal record ConnectResult( LoginResponse Login, List Channels, - List DefaultHistory); + Dictionary> Histories); /// /// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking. @@ -109,24 +111,54 @@ internal sealed class ConnectionManager : IAsyncDisposable await _connection.ConnectAsync(); var channels = await _apiClient.GetChannelsAsync(); - onStatus("Connected"); // Join default channel + fetch history + onStatus("Joining channels..."); _joinedChannels.Clear(); _joinedChannels.Add(HubConstants.DefaultChannel); await _connection.JoinChannelAsync(HubConstants.DefaultChannel); - List history = []; + var histories = new Dictionary>(StringComparer.OrdinalIgnoreCase); try { - history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel); + histories[HubConstants.DefaultChannel] = await _connection.GetHistoryAsync(HubConstants.DefaultChannel); } catch { // History might not be available } - return new ConnectResult(loginResponse, channels, history); + // Auto-join every other channel the server lists for this user (public + + // prior memberships) so message events — unread counts, @mentions — flow for + // all of them, not just channels opened this session. Channels the user left + // with /leave stay out until rejoined; protected channels we can't enter + // silently (no cached membership) are skipped, never prompted for. + var leftChannels = FindServer(ConfigManager.Load(), info.ServerUrl)?.LeftChannels ?? []; + foreach (var channel in channels) + { + if (channel.Name.Equals(HubConstants.DefaultChannel, StringComparison.OrdinalIgnoreCase)) + continue; + if (leftChannels.Contains(channel.Name, StringComparer.OrdinalIgnoreCase)) + continue; + + try + { + var outcome = await _connection.JoinChannelAsync(channel.Name); + _joinedChannels.Add(channel.Name); + histories[channel.Name] = outcome.History; + } + catch (ChannelPasswordRequiredException) + { + // First-time protected channel — joining stays a manual, prompted action + } + catch (Exception ex) + { + Log.Warning(ex, "Auto-join failed for #{Channel}", channel.Name); + } + } + + onStatus("Connected"); + return new ConnectResult(loginResponse, channels, histories); } catch { @@ -236,11 +268,19 @@ internal sealed class ConnectionManager : IAsyncDisposable foreach (var channel in channels) { - _joinedChannels.Add(channel); - await _connection.JoinChannelAsync(channel); + // One channel gone bad (deleted, membership revoked) must not stop the rest + try + { + await _connection.JoinChannelAsync(channel); + _joinedChannels.Add(channel); + } + catch (Exception ex) + { + Log.Warning(ex, "Rejoin failed for #{Channel}", channel); + } } - Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count); + Log.Information("Rejoined {Count} channel(s) after reconnect", _joinedChannels.Count); } // ── SignalR Event Wiring ────────────────────────────────────────────── @@ -268,8 +308,7 @@ internal sealed class ConnectionManager : IAsyncDisposable { if (_apiClient?.RefreshToken is null) return; var config = ConfigManager.Load(); - var server = config.SavedServers.FirstOrDefault(s => - string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase)); + var server = FindServer(config, _apiClient.BaseUrl); if (server is not null && server.RememberMe) { server.RefreshToken = _apiClient.RefreshToken; @@ -277,6 +316,10 @@ internal sealed class ConnectionManager : IAsyncDisposable } } + private static SavedServer? FindServer(ClientConfig config, string url) => + config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase)); + // ── Dispose ─────────────────────────────────────────────────────────── public async ValueTask DisposeAsync() diff --git a/src/EchoHub.Client/Themes/Theme.cs b/src/EchoHub.Client/Themes/Theme.cs index 75a7965..219ea5f 100644 --- a/src/EchoHub.Client/Themes/Theme.cs +++ b/src/EchoHub.Client/Themes/Theme.cs @@ -7,6 +7,14 @@ public class Theme public ThemeColors Menu { get; set; } = new(); public ThemeColors Dialog { get; set; } = new(); public ThemeColors Status { get; set; } = new(); + + /// + /// Colors for the main-window frame borders (and their titles). Null falls back + /// to . Lets themes tone borders down independently of text — + /// e.g. the transparent themes use a dim gray for a subtler, glassy look. + /// Supports hex values ("#6E6E6E") as well as named colors. + /// + public ThemeColors? Border { get; set; } } public class ThemeColors diff --git a/src/EchoHub.Client/Themes/ThemeManager.cs b/src/EchoHub.Client/Themes/ThemeManager.cs index 130dad4..a3de441 100644 --- a/src/EchoHub.Client/Themes/ThemeManager.cs +++ b/src/EchoHub.Client/Themes/ThemeManager.cs @@ -442,6 +442,14 @@ public static class ThemeManager Background = "None", FocusForeground = "Gray", FocusBackground = "None" + }, + // Dim, grayish borders — bright white frames fight the glassy transparent look + Border = new ThemeColors + { + Foreground = "#6E6E6E", + Background = "None", + FocusForeground = "#8A8A8A", + FocusBackground = "None" } }; @@ -475,6 +483,14 @@ public static class ThemeManager Background = "None", FocusForeground = "DarkGray", FocusBackground = "None" + }, + // Softer gray borders against light terminal backgrounds + Border = new ThemeColors + { + Foreground = "#8F8F8F", + Background = "None", + FocusForeground = "#6E6E6E", + FocusBackground = "None" } }; @@ -546,6 +562,8 @@ public static class ThemeManager SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base)); SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu)); SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog)); + // Frame borders/titles; themes without an explicit Border section keep Base + SchemeManager.AddScheme("Border", BuildColorScheme(theme.Border ?? theme.Base)); } public static void SaveTheme(Theme theme) diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index b3efb59..6f9a66a 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -28,6 +28,8 @@ public sealed class ChatMessageManager private readonly HashSet _markedChannels = []; private readonly Dictionary _markerAnchor = []; private readonly HashSet _mentionChannels = []; + private readonly Dictionary _lastRead = []; + private readonly Dictionary _channelNewestId = []; private string _currentUser = string.Empty; private string _currentChannel = string.Empty; @@ -50,8 +52,10 @@ public sealed class ChatMessageManager return; // Leaving a channel consumes its "new messages" marker so the next - // unread burst gets a fresh one (irssi behavior). + // unread burst gets a fresh one (irssi behavior), and everything + // visible up to now counts as read. RemoveUnreadMarker(_currentChannel); + MarkRead(_currentChannel); _currentChannel = value; } } @@ -78,6 +82,19 @@ public sealed class ChatMessageManager { _channelUnread[channelName] = 0; _mentionChannels.Remove(channelName); + MarkRead(channelName); + } + + /// + /// Last message the user has read per channel — persisted by the orchestrator so + /// unread/mention state can be seeded from history on the next connect. + /// + public IReadOnlyDictionary LastReadIds => _lastRead; + + private void MarkRead(string channelName) + { + if (!string.IsNullOrEmpty(channelName) && _channelNewestId.TryGetValue(channelName, out var newest)) + _lastRead[channelName] = newest; } internal Dictionary GetUnreadCounts() => _channelUnread; @@ -108,6 +125,9 @@ public sealed class ChatMessageManager } var isCurrent = message.ChannelName == _currentChannel; + _channelNewestId[message.ChannelName] = message.Id; + if (isCurrent) + _lastRead[message.ChannelName] = message.Id; // First unread message in an inactive channel → "new messages" marker, // anchored to this message so a history reload can re-place it @@ -213,11 +233,17 @@ public sealed class ChatMessageManager /// /// Load historical messages into a channel, replacing any existing messages. + /// When is given (persisted from a previous session), + /// messages after it seed the unread count, @mention highlight, and the + /// "new messages" marker — so activity that happened while offline still lights up. /// - public void LoadHistory(string channelName, List messages) + public void LoadHistory(string channelName, List messages, Guid? lastReadId = null) { var formatted = FormatWithDateRules(messages, out var lastDate); + if (messages.Count > 0) + _channelNewestId[channelName] = messages[^1].Id; + // Re-place the "new messages" marker at its anchor — channel selection // reloads history, which would otherwise wipe the marker right when the // user switches in to read the unread backlog. @@ -237,6 +263,10 @@ public sealed class ChatMessageManager _markerAnchor.Remove(channelName); } } + else if (lastReadId is { } lastRead && messages.Count > 0) + { + SeedUnreadFromHistory(channelName, messages, formatted, lastRead); + } _channelMessages[channelName] = formatted; @@ -245,8 +275,46 @@ public sealed class ChatMessageManager else _channelLastDate.Remove(channelName); + MessagesChanged?.Invoke(channelName); + } + + /// + /// Reconstructs unread state from a persisted last-read message id: places the + /// "new messages" marker before the first unread message and, for inactive + /// channels, seeds the unread count and @mention highlight. A last-read id that + /// is no longer inside the fetched window treats the whole window as unread. + /// + private void SeedUnreadFromHistory(string channelName, List messages, + List formatted, Guid lastReadId) + { + // FindIndex miss (-1 → 0) means the last-read message is older than the fetched + // window: everything in the window counts as unread. + var firstUnread = messages.FindIndex(m => m.Id == lastReadId) + 1; + if (firstUnread >= messages.Count) + return; // everything read + + var anchor = messages[firstUnread]; + var lineIdx = formatted.FindIndex(l => l.MessageId == anchor.Id); + if (lineIdx < 0) + return; + + formatted.Insert(lineIdx, UnreadMarkerRule()); + _markedChannels.Add(channelName); + _markerAnchor[channelName] = anchor.Id; + + // The active channel shows the marker but is being read right now — + // badges and mention highlights are only for background channels. if (channelName == _currentChannel) - MessagesChanged?.Invoke(channelName); + return; + + _channelUnread[channelName] = messages.Count - firstUnread; + + if (!string.IsNullOrEmpty(_currentUser)) + { + var pattern = $@"@{Regex.Escape(_currentUser)}\b"; + if (messages.Skip(firstUnread).Any(m => Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase))) + _mentionChannels.Add(channelName); + } } /// @@ -302,6 +370,8 @@ public sealed class ChatMessageManager _markedChannels.Clear(); _markerAnchor.Clear(); _mentionChannels.Clear(); + _lastRead.Clear(); + _channelNewestId.Clear(); _currentChannel = string.Empty; _currentUser = string.Empty; } diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index a332de6..b5fc39f 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -366,6 +366,7 @@ public sealed partial class MainWindow : Runnable { var baseScheme = SchemeManager.GetScheme("Base"); var menuScheme = SchemeManager.GetScheme("Menu"); + var borderScheme = SchemeManager.GetScheme("Border") ?? baseScheme; if (baseScheme is not null) { @@ -376,6 +377,12 @@ public sealed partial class MainWindow : Runnable { if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel) sub.SetScheme(baseScheme); + + // Frame borders (and their titles) take the theme's border colors, so + // themes can tone them down independently of text (e.g. transparent + // themes use dim gray instead of eye-catching white) + if (sub is FrameView frame && borderScheme is not null) + frame.Border?.SetScheme(borderScheme); } } @@ -919,6 +926,9 @@ public sealed partial class MainWindow : Runnable RefreshMessages(); else RefreshChannelList(); + + // Background-channel activity feeds the status bar's Act segment + _statusLabel.SetNeedsDraw(); } private void OnHistoryPrepended(string channelName)