Skip to content
Merged
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
5 changes: 4 additions & 1 deletion docs/changelog/v0.2.10.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# v0.2.10

Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, and input polish.
Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, infinite-scroll message history, and input polish.

## New Features

- Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries
- Scroll-to-load message history — scrolling to the top of a channel now fetches the next batch of older messages in the background (previously only the most recent 100 messages were available). Duplicate messages are filtered by ID, a per-channel guard prevents concurrent fetches, and the scroll position is preserved after the prepend so your reading position doesn't jump

## Bug Fixes

Expand All @@ -16,3 +17,5 @@ Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a
## Refactoring

- Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator` — `MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing
- `ChatHub.GetChannelHistory` and `IChatService.GetChannelHistoryAsync` gain an additional `offset` parameter for paginated history loading (defaults to `0` — existing callers are unaffected)
- `ValidationConstants.MaxHistoryCount` raised from `100` to `200` so power users and paginated fetches can request larger batches; `DefaultHistoryCount` stays at `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 @@ -92,6 +93,7 @@ private void WireMainWindowEvents()
_mainWindow.OnUserProfileRequested += HandleViewProfile;
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
_mainWindow.OnSearchRequested += HandleSearchRequested;
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
}

// ── Command Handler Wiring ─────────────────────────────────────────────
Expand Down Expand Up @@ -721,6 +723,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 @@ -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>
Expand Down Expand Up @@ -162,6 +168,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 @@ -222,6 +229,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 @@ -478,6 +488,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 @@ -631,6 +647,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/Constants/ValidationConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
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
Loading