diff --git a/docs/changelog/v0.2.9.md b/docs/changelog/v0.2.9.md index 3fc6db7..03249ec 100644 --- a/docs/changelog/v0.2.9.md +++ b/docs/changelog/v0.2.9.md @@ -3,3 +3,11 @@ ## Bug Fixes - Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll") + +## New Features + +- More messages now load when scrolling up in channels with long histories (previously only loaded the most recent 100 messages) + +## Refactoring + +- `ChatHub.GetChannelHistory` now accepts an addional `offset` parameter to support loading older messages in batches instead of just the most recent 100 diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 5c1f52d..9753185 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(); @@ -91,6 +92,7 @@ private void WireMainWindowEvents() _mainWindow.OnRollbackRequested += HandleRollbackRequested; _mainWindow.OnUserProfileRequested += HandleViewProfile; _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; + _mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -720,6 +722,31 @@ private void HandleChannelSelected(string channelName) }, "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 @@ public Task SendMessageAsync(string channel, string content) => _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 async Task SendMessageAsync(string channelName, string content) 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 void LoadHistory(string channelName, List messages) 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 f46e5cc..c12c3b8 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; @@ -116,6 +117,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. /// @@ -156,6 +162,7 @@ public MainWindow(IApplication app, ChatMessageManager messageManager) _app = app; _messageManager = messageManager; _messageManager.MessagesChanged += OnMessagesChanged; + _messageManager.HistoryPrepended += OnHistoryPrepended; Arrangement = ViewArrangement.Fixed; // Menu bar at the top @@ -216,6 +223,9 @@ public MainWindow(IApplication app, ChatMessageManager messageManager) }; _messageList.Source = new ChatListSource(); _messageList.Accepting += OnMessageListAccepting; + _messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled; + _messageList.VerticalScrollBar.Visible = true; + _chatFrame.Add(_messageList); Add(_chatFrame); @@ -472,6 +482,12 @@ private void OnMessageListAccepting(object? sender, CommandEventArgs e) } } + 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; @@ -607,6 +623,26 @@ private void OnMessagesChanged(string channelName) 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/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 async Task SendMessage(string channelName, string content) } } - 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 async Task LeaveChannelAsync(string connectionId, string username, string 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 @@ private static string SanitizeNewlines(string content) 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 @@ private async Task> GetChannelHistoryInternalAsync(EchoHubDbCon 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 @@ public Task LeaveChannelAsync(string connectionId, string username, string chann 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)