From e42f1a0965c8c60957b0b856dd0fe7f1974ac2fa Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:30:48 +0100 Subject: [PATCH 1/2] feat: load more message history when scrolling to top --- src/EchoHub.Client/AppOrchestrator.cs | 27 ++++++++++++++ .../Services/ConnectionManager.cs | 4 +-- .../Services/EchoHubConnection.cs | 4 +-- .../UI/Chat/ChatMessageManager.cs | 33 +++++++++++++++++ src/EchoHub.Client/UI/MainWindow.cs | 36 +++++++++++++++++++ .../Constants/ValidationConstants.cs | 2 +- src/EchoHub.Core/Contracts/IChatService.cs | 2 +- src/EchoHub.Server/Hubs/ChatHub.cs | 4 +-- src/EchoHub.Server/Services/ChatService.cs | 8 +++-- src/EchoHub.Tests/Irc/TestHelpers.cs | 2 +- 10 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index fc47c3c..2e8e4c1 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -30,6 +30,7 @@ public sealed class AppOrchestrator : IDisposable private readonly ConnectionManager _conn = new(); private readonly Dictionary> _channelUsers = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _channelUsersLock = new(); + private readonly HashSet _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase); private ClientConfig _config; private readonly UserSession _session = new(); @@ -92,6 +93,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnUserProfileRequested += HandleViewProfile; _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; _mainWindow.OnSearchRequested += HandleSearchRequested; + _mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -721,6 +723,31 @@ public sealed class AppOrchestrator : IDisposable }, "Failed to join channel"); } + private void HandleLoadMoreRequested() + { + if (!_conn.IsConnected) return; + + var channel = _mainWindow.CurrentChannel; + if (string.IsNullOrEmpty(channel)) return; + + if (!_channelsLoadingMore.Add(channel)) return; + + var offset = _messageManager.GetMessages(channel)?.Count ?? 0; + + RunAsync(async () => + { + try + { + var history = await _conn.GetHistoryAsync(channel, HubConstants.DefaultHistoryCount, offset); + InvokeUI(() => _messageManager.PrependHistory(channel, history)); + } + finally + { + _channelsLoadingMore.Remove(channel); + } + }, "Failed to load more messages"); + } + private void HandleChannelJoinFromMessage(string channelName) { if (!_conn.IsConnected) return; diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index ebb4285..381be64 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -196,8 +196,8 @@ internal sealed class ConnectionManager : IAsyncDisposable _connection?.SendMessageAsync(channel, content) ?? throw new InvalidOperationException("Not connected"); - public Task> GetHistoryAsync(string channel) => - _connection?.GetHistoryAsync(channel) + public Task> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) => + _connection?.GetHistoryAsync(channel, count, offset) ?? throw new InvalidOperationException("Not connected"); public Task> GetOnlineUsersAsync(string channel) => diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index c79fccd..5ab24e8 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -154,9 +154,9 @@ public sealed class EchoHubConnection : IAsyncDisposable await _connection.InvokeAsync("SendMessage", channelName, encrypted); } - public async Task> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount) + public async Task> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0) { - var messages = await _connection.InvokeAsync>("GetChannelHistory", channelName, count); + var messages = await _connection.InvokeAsync>("GetChannelHistory", channelName, count, offset); return DecryptMessages(messages); } diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index 440b57e..cccd834 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -186,6 +186,39 @@ public sealed class ChatMessageManager MessagesChanged?.Invoke(channelName); } + /// + /// Prepend older messages at the front of a channel's buffer, skipping any that are already present. + /// Fires when new lines are actually inserted. + /// + public void PrependHistory(string channelName, List olderMessages) + { + if (!_channelMessages.TryGetValue(channelName, out var existing)) + return; + + var existingIds = existing + .Where(l => l.MessageId.HasValue) + .Select(l => l.MessageId!.Value) + .ToHashSet(); + + var newLines = olderMessages + .Where(m => !existingIds.Contains(m.Id)) + .SelectMany(FormatMessage) + .ToList(); + + if (newLines.Count == 0) + return; + + existing.InsertRange(0, newLines); + + if (channelName == _currentChannel) + HistoryPrepended?.Invoke(channelName); + } + + /// + /// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name. + /// + public event Action? HistoryPrepended; + /// /// Reset all message state (used on disconnect). /// diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index a30230b..919a4a0 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.RegularExpressions; using EchoHub.Client.Services; using EchoHub.Client.Themes; @@ -117,6 +118,11 @@ public sealed partial class MainWindow : Runnable /// public event Action? OnSavedServersRequested; + /// + /// Fired when the user scrolls to the top of the message list and older messages should be loaded. + /// + public event Action? OnLoadMoreRequested; + /// /// Fired when the user requests to create a new channel. /// @@ -162,6 +168,7 @@ public sealed partial class MainWindow : Runnable _app = app; _messageManager = messageManager; _messageManager.MessagesChanged += OnMessagesChanged; + _messageManager.HistoryPrepended += OnHistoryPrepended; Arrangement = ViewArrangement.Fixed; // Menu bar at the top @@ -222,6 +229,9 @@ public sealed partial class MainWindow : Runnable }; _messageList.Source = new ChatListSource(); _messageList.Accepting += OnMessageListAccepting; + _messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled; + _messageList.VerticalScrollBar.Visible = true; + _chatFrame.Add(_messageList); Add(_chatFrame); @@ -478,6 +488,12 @@ public sealed partial class MainWindow : Runnable } } + private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs e) + { + if (_messageList.VerticalScrollBar.Value == 0) + OnLoadMoreRequested?.Invoke(); + } + private void OnUsersListAccepting(object? sender, CommandEventArgs e) { var index = _usersList.SelectedItem; @@ -631,6 +647,26 @@ public sealed partial class MainWindow : Runnable RefreshChannelList(); } + private void OnHistoryPrepended(string channelName) + { + if (channelName != _messageManager.CurrentChannel) + return; + + var messages = _messageManager.GetMessages(channelName); + if (messages is null) + return; + + var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0; + + RefreshMessages(); + + // Scroll to the item that was at the top before the prepend so the user + // stays at their previous reading position rather than jumping to the top. + var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount; + if (prependedCount > 0) + _messageList.SelectedItem = prependedCount; + } + /// /// Set the list of available channels, storing topics, and refresh the channel list view. /// diff --git a/src/EchoHub.Core/Constants/ValidationConstants.cs b/src/EchoHub.Core/Constants/ValidationConstants.cs index 266c874..fd8659b 100644 --- a/src/EchoHub.Core/Constants/ValidationConstants.cs +++ b/src/EchoHub.Core/Constants/ValidationConstants.cs @@ -13,7 +13,7 @@ public static partial class ValidationConstants public const int MaxBioLength = 500; public const int MaxStatusMessageLength = 100; public const int MaxChannelTopicLength = 500; - public const int MaxHistoryCount = 100; + public const int MaxHistoryCount = 200; [GeneratedRegex(UsernamePattern)] public static partial Regex UsernameRegex(); diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index 2c1e0cb..4b10f28 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -15,7 +15,7 @@ public interface IChatService // Messaging Task SendMessageAsync(Guid userId, string username, string channelName, string content); - Task> GetChannelHistoryAsync(string channelName, int count); + Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0); // Presence Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage); diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index eb73a7a..723ac07 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -106,11 +106,11 @@ public class ChatHub : Hub } } - public async Task> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount) + public async Task> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0) { try { - return await _chatService.GetChannelHistoryAsync(channelName, count); + return await _chatService.GetChannelHistoryAsync(channelName, count, offset); } catch (Exception ex) { diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index cf7bb17..a7bf032 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -245,15 +245,16 @@ public class ChatService : IChatService return null; } - public async Task> GetChannelHistoryAsync(string channelName, int count) + public async Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0) { channelName = channelName.ToLowerInvariant().Trim(); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); + offset = Math.Max(offset, 0); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - return await GetChannelHistoryInternalAsync(db, channelName, count); + return await GetChannelHistoryInternalAsync(db, channelName, count, offset); } public async Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) @@ -366,7 +367,7 @@ public class ChatService : IChatService return string.Join('\n', result); } - private async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) + private async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0) { var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) @@ -375,6 +376,7 @@ public class ChatService : IChatService var raw = await db.Messages .Where(m => m.ChannelId == channel.Id) .OrderByDescending(m => m.SentAt) + .Skip(offset) .Take(count) .Join(db.Users, m => m.SenderUserId, diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 358dec2..7c5407e 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -190,7 +190,7 @@ internal sealed class FakeChatService : IChatService return Task.FromResult(SendMessageError); } - public Task> GetChannelHistoryAsync(string channelName, int count) => + public Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0) => Task.FromResult(HistoryToReturn); public Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) From 6e7cbf39f02242522949caa02a29c82be25a7082 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:36:44 +0100 Subject: [PATCH 2/2] chore: add channel history loading and refactor GetChannelHistory with offset to changelog --- docs/changelog/v0.2.10.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changelog/v0.2.10.md b/docs/changelog/v0.2.10.md index 1023f5a..20b9f48 100644 --- a/docs/changelog/v0.2.10.md +++ b/docs/changelog/v0.2.10.md @@ -1,10 +1,11 @@ # v0.2.10 -Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, and input polish. +Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, infinite-scroll message history, and input polish. ## New Features - Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries +- Scroll-to-load message history — scrolling to the top of a channel now fetches the next batch of older messages in the background (previously only the most recent 100 messages were available). Duplicate messages are filtered by ID, a per-channel guard prevents concurrent fetches, and the scroll position is preserved after the prepend so your reading position doesn't jump ## Bug Fixes @@ -16,3 +17,5 @@ Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a ## Refactoring - Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator` — `MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing +- `ChatHub.GetChannelHistory` and `IChatService.GetChannelHistoryAsync` gain an additional `offset` parameter for paginated history loading (defaults to `0` — existing callers are unaffected) +- `ValidationConstants.MaxHistoryCount` raised from `100` to `200` so power users and paginated fetches can request larger batches; `DefaultHistoryCount` stays at `100`