feat: load more message history when scrolling to top

This commit is contained in:
Stone_Red
2026-04-20 17:42:05 +02:00
committed by HueByte
parent fe6dfd3d4d
commit e42f1a0965
10 changed files with 110 additions and 12 deletions
+27
View File
@@ -30,6 +30,7 @@ public sealed class AppOrchestrator : IDisposable
private readonly ConnectionManager _conn = new();
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _channelUsersLock = new();
private readonly HashSet<string> _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;
@@ -196,8 +196,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
_connection?.SendMessageAsync(channel, content)
?? throw new InvalidOperationException("Not connected");
public Task<List<MessageDto>> GetHistoryAsync(string channel) =>
_connection?.GetHistoryAsync(channel)
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
_connection?.GetHistoryAsync(channel, count, offset)
?? throw new InvalidOperationException("Not connected");
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
@@ -154,9 +154,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
}
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
{
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count, offset);
return DecryptMessages(messages);
}
@@ -186,6 +186,39 @@ public sealed class ChatMessageManager
MessagesChanged?.Invoke(channelName);
}
/// <summary>
/// Prepend older messages at the front of a channel's buffer, skipping any that are already present.
/// Fires <see cref="HistoryPrepended"/> when new lines are actually inserted.
/// </summary>
public void PrependHistory(string channelName, List<MessageDto> 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);
}
/// <summary>
/// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name.
/// </summary>
public event Action<string>? HistoryPrepended;
/// <summary>
/// Reset all message state (used on disconnect).
/// </summary>
+36
View File
@@ -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
/// </summary>
public event Action? OnSavedServersRequested;
/// <summary>
/// Fired when the user scrolls to the top of the message list and older messages should be loaded.
/// </summary>
public event Action? OnLoadMoreRequested;
/// <summary>
/// Fired when the user requests to create a new channel.
/// </summary>
@@ -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<int> 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;
}
/// <summary>
/// Set the list of available channels, storing topics, and refresh the channel list view.
/// </summary>
@@ -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();
+1 -1
View File
@@ -15,7 +15,7 @@ public interface IChatService
// Messaging
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count);
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
// Presence
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
+2 -2
View File
@@ -106,11 +106,11 @@ public class ChatHub : Hub<IEchoHubClient>
}
}
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
public async Task<List<MessageDto>> 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)
{
+5 -3
View File
@@ -245,15 +245,16 @@ public class ChatService : IChatService
return null;
}
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count)
public async Task<List<MessageDto>> 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<EchoHubDbContext>();
return await GetChannelHistoryInternalAsync(db, channelName, count);
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
}
public async Task<string?> 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<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
private async Task<List<MessageDto>> 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,
+1 -1
View File
@@ -190,7 +190,7 @@ internal sealed class FakeChatService : IChatService
return Task.FromResult(SendMessageError);
}
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) =>
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0) =>
Task.FromResult(HistoryToReturn);
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)