mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: load more message history when scrolling to top
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user