Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/changelog/v0.2.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 27 additions & 0 deletions src/EchoHub.Client/AppOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -91,6 +92,7 @@ private void WireMainWindowEvents()
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
_mainWindow.OnUserProfileRequested += HandleViewProfile;
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
}

// ── Command Handler Wiring ─────────────────────────────────────────────
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/EchoHub.Client/Services/ConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,8 @@ public Task SendMessageAsync(string channel, string content) =>
_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) =>
Expand Down
4 changes: 2 additions & 2 deletions src/EchoHub.Client/Services/EchoHubConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,9 @@ public async Task SendMessageAsync(string channelName, string content)
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);
}

Expand Down
33 changes: 33 additions & 0 deletions src/EchoHub.Client/UI/Chat/ChatMessageManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,39 @@ public void LoadHistory(string channelName, List<MessageDto> messages)
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>
Expand Down
36 changes: 36 additions & 0 deletions src/EchoHub.Client/UI/MainWindow.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using EchoHub.Client.Services;
using EchoHub.Client.Themes;
Expand Down Expand Up @@ -116,6 +117,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>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -472,6 +482,12 @@ private void OnMessageListAccepting(object? sender, CommandEventArgs e)
}
}

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;
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Set the list of available channels, storing topics, and refresh the channel list view.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion src/EchoHub.Core/Contracts/IChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/EchoHub.Server/Hubs/ChatHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ public async Task SendMessage(string channelName, string content)
}
}

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)
{
Expand Down
8 changes: 5 additions & 3 deletions src/EchoHub.Server/Services/ChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,15 +245,16 @@ public async Task LeaveChannelAsync(string connectionId, string username, string
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)
Expand Down Expand Up @@ -366,7 +367,7 @@ private static string SanitizeNewlines(string content)
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)
Expand All @@ -375,6 +376,7 @@ private async Task<List<MessageDto>> 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,
Expand Down
2 changes: 1 addition & 1 deletion src/EchoHub.Tests/Irc/TestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ public Task LeaveChannelAsync(string connectionId, string username, string chann
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)
Expand Down