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 ConnectionManager _conn = new();
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _channelUsersLock = new(); private readonly Lock _channelUsersLock = new();
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
private ClientConfig _config; private ClientConfig _config;
private readonly UserSession _session = new(); private readonly UserSession _session = new();
@@ -92,6 +93,7 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnUserProfileRequested += HandleViewProfile; _mainWindow.OnUserProfileRequested += HandleViewProfile;
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
_mainWindow.OnSearchRequested += HandleSearchRequested; _mainWindow.OnSearchRequested += HandleSearchRequested;
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
} }
// ── Command Handler Wiring ───────────────────────────────────────────── // ── Command Handler Wiring ─────────────────────────────────────────────
@@ -721,6 +723,31 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to join channel"); }, "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) private void HandleChannelJoinFromMessage(string channelName)
{ {
if (!_conn.IsConnected) return; if (!_conn.IsConnected) return;
@@ -196,8 +196,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
_connection?.SendMessageAsync(channel, content) _connection?.SendMessageAsync(channel, content)
?? throw new InvalidOperationException("Not connected"); ?? throw new InvalidOperationException("Not connected");
public Task<List<MessageDto>> GetHistoryAsync(string channel) => public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
_connection?.GetHistoryAsync(channel) _connection?.GetHistoryAsync(channel, count, offset)
?? throw new InvalidOperationException("Not connected"); ?? throw new InvalidOperationException("Not connected");
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) => public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
@@ -154,9 +154,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
await _connection.InvokeAsync("SendMessage", channelName, encrypted); 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); return DecryptMessages(messages);
} }
@@ -186,6 +186,39 @@ public sealed class ChatMessageManager
MessagesChanged?.Invoke(channelName); 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> /// <summary>
/// Reset all message state (used on disconnect). /// Reset all message state (used on disconnect).
/// </summary> /// </summary>
+36
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using EchoHub.Client.Services; using EchoHub.Client.Services;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
@@ -117,6 +118,11 @@ public sealed partial class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnSavedServersRequested; 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> /// <summary>
/// Fired when the user requests to create a new channel. /// Fired when the user requests to create a new channel.
/// </summary> /// </summary>
@@ -162,6 +168,7 @@ public sealed partial class MainWindow : Runnable
_app = app; _app = app;
_messageManager = messageManager; _messageManager = messageManager;
_messageManager.MessagesChanged += OnMessagesChanged; _messageManager.MessagesChanged += OnMessagesChanged;
_messageManager.HistoryPrepended += OnHistoryPrepended;
Arrangement = ViewArrangement.Fixed; Arrangement = ViewArrangement.Fixed;
// Menu bar at the top // Menu bar at the top
@@ -222,6 +229,9 @@ public sealed partial class MainWindow : Runnable
}; };
_messageList.Source = new ChatListSource(); _messageList.Source = new ChatListSource();
_messageList.Accepting += OnMessageListAccepting; _messageList.Accepting += OnMessageListAccepting;
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
_messageList.VerticalScrollBar.Visible = true;
_chatFrame.Add(_messageList); _chatFrame.Add(_messageList);
Add(_chatFrame); 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) private void OnUsersListAccepting(object? sender, CommandEventArgs e)
{ {
var index = _usersList.SelectedItem; var index = _usersList.SelectedItem;
@@ -631,6 +647,26 @@ public sealed partial class MainWindow : Runnable
RefreshChannelList(); 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> /// <summary>
/// Set the list of available channels, storing topics, and refresh the channel list view. /// Set the list of available channels, storing topics, and refresh the channel list view.
/// </summary> /// </summary>
@@ -13,7 +13,7 @@ public static partial class ValidationConstants
public const int MaxBioLength = 500; public const int MaxBioLength = 500;
public const int MaxStatusMessageLength = 100; public const int MaxStatusMessageLength = 100;
public const int MaxChannelTopicLength = 500; public const int MaxChannelTopicLength = 500;
public const int MaxHistoryCount = 100; public const int MaxHistoryCount = 200;
[GeneratedRegex(UsernamePattern)] [GeneratedRegex(UsernamePattern)]
public static partial Regex UsernameRegex(); public static partial Regex UsernameRegex();
+1 -1
View File
@@ -15,7 +15,7 @@ public interface IChatService
// Messaging // Messaging
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content); 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 // Presence
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage); 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 try
{ {
return await _chatService.GetChannelHistoryAsync(channelName, count); return await _chatService.GetChannelHistoryAsync(channelName, count, offset);
} }
catch (Exception ex) catch (Exception ex)
{ {
+5 -3
View File
@@ -245,15 +245,16 @@ public class ChatService : IChatService
return null; 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(); channelName = channelName.ToLowerInvariant().Trim();
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
offset = Math.Max(offset, 0);
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); 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) 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); 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); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) if (channel is null)
@@ -375,6 +376,7 @@ public class ChatService : IChatService
var raw = await db.Messages var raw = await db.Messages
.Where(m => m.ChannelId == channel.Id) .Where(m => m.ChannelId == channel.Id)
.OrderByDescending(m => m.SentAt) .OrderByDescending(m => m.SentAt)
.Skip(offset)
.Take(count) .Take(count)
.Join(db.Users, .Join(db.Users,
m => m.SenderUserId, m => m.SenderUserId,
+1 -1
View File
@@ -190,7 +190,7 @@ internal sealed class FakeChatService : IChatService
return Task.FromResult(SendMessageError); 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); Task.FromResult(HistoryToReturn);
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)