Implement infinite loading with chat thread

- Chat messages are now loaded based on scroll position
- Read status is now updated and set by last message read
- Reworked script importing by using ScriptProvider
This commit is contained in:
Pat Hartl 2026-01-11 14:38:24 -06:00
parent a0c7bf63f0
commit 66abc6e59e
40 changed files with 3723 additions and 117 deletions

View file

@ -11,6 +11,7 @@ using Microsoft.Extensions.Options;
using Photino.Blazor;
using Photino.Blazor.CustomWindow.Extensions;
using System.Web;
using LANCommander.UI.Extensions;
namespace LANCommander.Launcher.Services;
@ -42,6 +43,7 @@ internal static class WindowService
builder.Services.AddCustomWindow();
builder.Services.AddAntDesign();
builder.Services.AddSingleton<LocalizationService>();
builder.Services.AddLANCommanderUI();
builder.Services.AddLANCommanderClient<Settings.Settings>();
builder.Services.AddLANCommanderLauncher(options =>
{

View file

@ -22,10 +22,6 @@
background-color: rgba(255, 255, 255, 1);
}
* {
user-select: none;
}
.hidden {
display: none;
}

View file

@ -195,7 +195,7 @@ public class ChatClient : IChatClient, HubChatClient
await _hub.UpdateReadStatusAsync(thread.Id);
}
public async Task<IEnumerable<ChatMessage>> GetMessagesAsync(Guid threadId, ChatMessage cursor, int count)
public async Task<InfiniteResponse<ChatMessage>> GetMessagesAsync(Guid threadId, Guid? cursor, int count)
{
await EnsureConnectedAsync();

View file

@ -199,7 +199,7 @@ public class ChatHubClient : IChatClient, IChatHubClient
await _hub.UpdateReadStatusAsync(thread.Id);
}
public async Task<IEnumerable<ChatMessage>> GetMessagesAsync(Guid threadId, ChatMessage cursor, int count)
public async Task<InfiniteResponse<ChatMessage>> GetMessagesAsync(Guid threadId, Guid? cursor, int count)
{
await EnsureConnectedAsync();

View file

@ -17,6 +17,6 @@ public interface IChatClient
Task StopTypingAsync(Guid threadId, string userId);
Task SendMessageAsync(Guid threadId, string message);
Task UpdatedReadStatus(Guid threadId);
Task<IEnumerable<ChatMessage>> GetMessagesAsync(Guid threadId, ChatMessage cursor, int count);
Task<InfiniteResponse<ChatMessage>> GetMessagesAsync(Guid threadId, Guid? cursor, int count);
Task<int> GetUnreadMessageCountAsync(Guid threadId);
}

View file

@ -14,3 +14,5 @@ public interface IChatClient
Task StopTypingAsync(Guid threadId, string userIdentifier);
}

View file

@ -12,7 +12,7 @@ public interface IChatHub
Task<ChatThread> GetThreadAsync(Guid threadId);
Task<IEnumerable<ChatThread>> GetThreadsAsync();
Task SendMessageAsync(Guid threadId, string message);
Task<IEnumerable<ChatMessage>> GetMessagesAsync(Guid threadId, ChatMessage? cursor, int? count);
Task<InfiniteResponse<ChatMessage>> GetMessagesAsync(Guid threadId, Guid? cursor, int? count);
Task StartTypingAsync(Guid threadId);
Task StopTypingAsync(Guid threadId);
Task UpdateReadStatusAsync(Guid threadId);

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace LANCommander.SDK.Models;
public class InfiniteResponse<T>
{
public IEnumerable<T> Items { get; set; }
public bool HasMore { get; set; }
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Migrations
{
/// <inheritdoc />
public partial class ChatReadStatusMessageId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ChatThreadReadStatuses_ThreadId",
table: "ChatThreadReadStatuses");
migrationBuilder.RenameColumn(
name: "LastReadOn",
table: "ChatThreadReadStatuses",
newName: "LastReadMessageId");
migrationBuilder.AddPrimaryKey(
name: "PK_ChatThreadReadStatuses",
table: "ChatThreadReadStatuses",
columns: new[] { "ThreadId", "UserId" });
migrationBuilder.CreateIndex(
name: "IX_ChatThreadReadStatuses_LastReadMessageId",
table: "ChatThreadReadStatuses",
column: "LastReadMessageId");
migrationBuilder.AddForeignKey(
name: "FK_ChatThreadReadStatuses_ChatMessages_LastReadMessageId",
table: "ChatThreadReadStatuses",
column: "LastReadMessageId",
principalTable: "ChatMessages",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ChatThreadReadStatuses_ChatMessages_LastReadMessageId",
table: "ChatThreadReadStatuses");
migrationBuilder.DropPrimaryKey(
name: "PK_ChatThreadReadStatuses",
table: "ChatThreadReadStatuses");
migrationBuilder.DropIndex(
name: "IX_ChatThreadReadStatuses_LastReadMessageId",
table: "ChatThreadReadStatuses");
migrationBuilder.RenameColumn(
name: "LastReadMessageId",
table: "ChatThreadReadStatuses",
newName: "LastReadOn");
migrationBuilder.CreateIndex(
name: "IX_ChatThreadReadStatuses_ThreadId",
table: "ChatThreadReadStatuses",
column: "ThreadId");
}
}
}

View file

@ -378,16 +378,18 @@ namespace LANCommander.Migrations
modelBuilder.Entity("LANCommander.Server.Data.Models.ChatThreadReadStatus", b =>
{
b.Property<DateTime?>("LastReadOn")
.HasColumnType("TEXT");
b.Property<Guid>("ThreadId")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasIndex("ThreadId");
b.Property<Guid?>("LastReadMessageId")
.HasColumnType("TEXT");
b.HasKey("ThreadId", "UserId");
b.HasIndex("LastReadMessageId");
b.HasIndex("UserId");
@ -2093,6 +2095,11 @@ namespace LANCommander.Migrations
modelBuilder.Entity("LANCommander.Server.Data.Models.ChatThreadReadStatus", b =>
{
b.HasOne("LANCommander.Server.Data.Models.ChatMessage", "LastReadMessage")
.WithMany()
.HasForeignKey("LastReadMessageId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("LANCommander.Server.Data.Models.ChatThread", "Thread")
.WithMany()
.HasForeignKey("ThreadId")
@ -2105,6 +2112,8 @@ namespace LANCommander.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LastReadMessage");
b.Navigation("Thread");
b.Navigation("User");

View file

@ -459,15 +459,26 @@ namespace LANCommander.Server.Data
#region Chat Relationships
builder.Entity<ChatThreadReadStatus>()
.HasNoKey();
.HasKey(rs => new { rs.ThreadId, rs.UserId });
builder.Entity<ChatThreadReadStatus>()
.HasOne(rs => rs.Thread)
.WithMany();
.WithMany()
.HasForeignKey(rs => rs.ThreadId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<ChatThreadReadStatus>()
.HasOne(rs => rs.User)
.WithMany();
.WithMany()
.HasForeignKey(rs => rs.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<ChatThreadReadStatus>()
.HasOne(rs => rs.LastReadMessage)
.WithMany()
.HasForeignKey(rs => rs.LastReadMessageId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
#endregion
}

View file

@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace LANCommander.Server.Data.Models;
[PrimaryKey(nameof(ThreadId), nameof(UserId))]
public class ChatThreadReadStatus
{
public Guid ThreadId { get; set; }
@ -10,5 +12,7 @@ public class ChatThreadReadStatus
public Guid UserId { get; set; }
[ForeignKey(nameof(UserId))]
public User? User { get; set; }
public DateTime? LastReadOn { get; set; }
public Guid? LastReadMessageId { get; set; }
[ForeignKey(nameof(LastReadMessageId))]
public ChatMessage? LastReadMessage { get; set; }
}

View file

@ -12,6 +12,7 @@ namespace LANCommander.Server.Services.Abstractions
IBaseDatabaseService<T> SortBy(Expression<Func<T, object>> expression, SortDirection direction = SortDirection.Ascending);
IBaseDatabaseService<T> AsNoTracking();
IBaseDatabaseService<T> AsSplitQuery();
Task<bool> AnyAsync();
Task<ICollection<T>> GetAsync();

View file

@ -1,10 +1,13 @@
using System.Collections.Concurrent;
using AutoMapper;
using LANCommander.Server.Data.Models;
using LANCommander.SDK.Models;
using LANCommander.Server.Services.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;
using ChatMessage = LANCommander.Server.Data.Models.ChatMessage;
using ChatThread = LANCommander.Server.Data.Models.ChatThread;
using User = LANCommander.Server.Data.Models.User;
namespace LANCommander.Server.Services
{
@ -78,22 +81,82 @@ namespace LANCommander.Server.Services
return message;
}
public async Task<List<ChatMessage>> GetMessagesAsync(Guid threadId, int? count = 10, DateTime? createdBefore = null)
public async Task<InfiniteResponse<SDK.Models.ChatMessage>> GetMessagesAsync(Guid threadId, int? count = 10, Guid? cursor = null)
{
if (createdBefore == null)
createdBefore = DateTime.UtcNow;
var pageSize = Math.Max(1, count.GetValueOrDefault(10));
DateTime? createdBefore = null;
// If cursor is provided, use it to find the boundary for loading older messages
if (cursor.HasValue && cursor.Value != Guid.Empty)
{
var cursorMessage = await chatMessageService.GetAsync(cursor.Value);
// Validate cursor message exists and belongs to this thread
if (cursorMessage != null && cursorMessage.ThreadId == threadId)
{
createdBefore = cursorMessage.CreatedOn;
}
else
{
// Invalid cursor - return empty result
logger.LogWarning("Invalid cursor message {CursorId} for thread {ThreadId}", cursor.Value, threadId);
return new InfiniteResponse<SDK.Models.ChatMessage>
{
Items = [],
HasMore = false,
};
}
}
// Load messages older than the cursor (or newest messages if no cursor)
var messages = await chatMessageService.Query(q =>
{
return q
var query = q
.Include(m => m.CreatedBy)
.Where(m => m.ThreadId == threadId);
// Apply cursor filter if provided
if (createdBefore.HasValue)
{
query = query.Where(m => m.CreatedOn < createdBefore.Value);
}
return query
.OrderByDescending(m => m.CreatedOn)
.Where(m => m.CreatedOn < createdBefore)
.Where(m => m.ThreadId == threadId)
.Take(count.GetValueOrDefault());
.Take(pageSize);
}).GetAsync();
return mapper.Map<List<ChatMessage>>(messages);
var messageList = messages.ToList();
bool hasMore = false;
// Check if there are more older messages beyond what we just loaded
if (messageList.Count > 0)
{
// Use the oldest message in the batch as the boundary for checking if more exist
var oldestMessage = messageList[messageList.Count - 1];
hasMore = await chatMessageService.Query(q =>
{
return q
.Where(m => m.ThreadId == threadId)
.Where(m => m.CreatedOn < oldestMessage.CreatedOn);
}).AnyAsync();
}
else if (!createdBefore.HasValue)
{
// No cursor and no messages returned - check if any messages exist at all
hasMore = await chatMessageService.Query(q =>
{
return q.Where(m => m.ThreadId == threadId);
}).AnyAsync();
}
// If we had a cursor but got no results, there are no more older messages
return new InfiniteResponse<SDK.Models.ChatMessage>
{
Items = mapper.Map<IEnumerable<SDK.Models.ChatMessage>>(messageList),
HasMore = hasMore,
};
}
public async Task<ChatThread> GetThreadAsync(Guid threadId)
@ -151,12 +214,9 @@ namespace LANCommander.Server.Services
public async Task<int> GetUnreadMessageCountAsync(Guid threadId, Guid userId)
{
var lastRead = await chatThreadReadStatusService.GetLastReadAsync(threadId, userId);
var lastReadMessageId = await chatThreadReadStatusService.GetLastReadMessageIdAsync(threadId, userId);
if (lastRead == null)
return await chatThreadReadStatusService.GetUnreadCountAsync(threadId, lastRead.GetValueOrDefault());
return 0;
return await chatThreadReadStatusService.GetUnreadCountAsync(threadId, lastReadMessageId);
}
}
}

View file

@ -11,32 +11,40 @@ namespace LANCommander.Server.Services
{
using (var context = await contextFactory.CreateDbContextAsync())
{
if (context.ChatThreadReadStatuses != null)
if (context.ChatThreadReadStatuses != null && context.ChatMessages != null)
{
// Get the latest message in the thread
var latestMessage = await context.ChatMessages
.Where(m => m.ThreadId == threadId)
.OrderByDescending(m => m.CreatedOn)
.FirstOrDefaultAsync();
if (latestMessage == null)
return; // No messages in thread, nothing to mark as read
var status = await context.ChatThreadReadStatuses.FirstOrDefaultAsync(rs => rs.ThreadId == threadId && rs.UserId == userId);
if (status == null)
{
var result = await context.ChatThreadReadStatuses.AddAsync(new ChatThreadReadStatus
await context.ChatThreadReadStatuses.AddAsync(new ChatThreadReadStatus
{
ThreadId = threadId,
UserId = userId,
LastReadOn = DateTime.Now,
LastReadMessageId = latestMessage.Id,
});
}
else
{
status.LastReadOn = DateTime.Now;
status.LastReadMessageId = latestMessage.Id;
context.ChatThreadReadStatuses.Update(status);
}
}
await context.SaveChangesAsync();
await context.SaveChangesAsync();
}
}
}
public async Task<DateTime?> GetLastReadAsync(Guid threadId, Guid userId)
public async Task<Guid?> GetLastReadMessageIdAsync(Guid threadId, Guid userId)
{
using (var context = await contextFactory.CreateDbContextAsync())
{
@ -45,21 +53,42 @@ namespace LANCommander.Server.Services
var result = await context.ChatThreadReadStatuses.FirstOrDefaultAsync(rs => rs.ThreadId == threadId && rs.UserId == userId);
if (result != null)
return result.LastReadOn;
return result.LastReadMessageId;
}
}
return null;
}
public async Task<int> GetUnreadCountAsync(Guid threadId, DateTime? lastReadOn)
public async Task<int> GetUnreadCountAsync(Guid threadId, Guid? lastReadMessageId)
{
using (var context = await contextFactory.CreateDbContextAsync())
{
if (context.ChatMessages != null)
{
if (lastReadMessageId == null)
{
// No read status, count all messages in thread
return await context.ChatMessages
.Where(m => m.ThreadId == threadId)
.CountAsync();
}
// Get the last read message to find its CreatedOn timestamp
var lastReadMessage = await context.ChatMessages
.FirstOrDefaultAsync(m => m.Id == lastReadMessageId.Value);
if (lastReadMessage == null)
{
// Last read message doesn't exist, count all messages
return await context.ChatMessages
.Where(m => m.ThreadId == threadId)
.CountAsync();
}
// Count messages created after the last read message
return await context.ChatMessages
.Where(m => m.ThreadId == threadId && m.CreatedOn > lastReadOn)
.Where(m => m.ThreadId == threadId && m.CreatedOn > lastReadMessage.CreatedOn)
.CountAsync();
}
}

View file

@ -258,6 +258,29 @@ namespace LANCommander.Server.Services
}
public virtual async Task<bool> AnyAsync()
{
try
{
using var context = await ContextFactory.CreateDbContextAsync();
var queryable = context.Set<User>().AsQueryable();
foreach (var modifier in _modifiers)
queryable = modifier.Invoke(queryable);
return await queryable.AnyAsync();
}
catch
{
return false;
}
finally
{
Reset();
}
}
public async Task<ICollection<User>> GetAsync()
{
try

View file

@ -96,6 +96,29 @@ namespace LANCommander.Server.Services
}
}
public virtual async Task<bool> AnyAsync()
{
try
{
using var context = await dbContextFactory.CreateDbContextAsync();
var queryable = context.Set<T>().AsQueryable();
foreach (var modifier in _modifiers)
queryable = modifier.Invoke(queryable);
return await queryable.AnyAsync();
}
catch
{
return false;
}
finally
{
Reset();
}
}
public virtual async Task<ICollection<T>> GetAsync()
{
return await GetAsync(x => true);

View file

@ -143,6 +143,11 @@ public class ServerChatClient(
await chatService.UpdateReadStatus(threadId, user.Id);
}
public async Task<InfiniteResponse<ChatMessage>> GetMessagesAsync(Guid threadId, Guid? cursor, int count)
{
throw new NotImplementedException();
}
public async Task GetMessagesAsync(Guid threadId)
{
// Ensure thread is loaded in cache

View file

@ -123,12 +123,8 @@ public class ChatHub(
await Clients.Users(participants).ReceiveMessageAsync(threadId, mapper.Map<ChatMessage>(message));
}
public async Task<IEnumerable<ChatMessage>> GetMessagesAsync(Guid threadId, ChatMessage? cursor, int? count)
{
var messages = await chatService.GetMessagesAsync(threadId, count, cursor?.SentOn.UtcDateTime ?? DateTime.UtcNow);
return mapper.Map<IEnumerable<ChatMessage>>(messages);
}
public async Task<InfiniteResponse<ChatMessage>> GetMessagesAsync(Guid threadId, Guid? cursor, int? count)
=> await chatService.GetMessagesAsync(threadId, count, cursor);
public async Task StartTypingAsync(Guid threadId)
{

View file

@ -0,0 +1,21 @@
using AntDesign;
using LANCommander.UI.Providers;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace LANCommander.UI.Components;
public class BaseComponent : ComponentBase
{
[Inject]
protected IJSRuntime? JS { get; set; }
[Inject]
protected ScriptProvider ScriptProvider { get; set; }
[Inject]
protected NavigationManager NavigationManager { get; set; }
[Inject]
protected IMessageService MessageService { get; set; }
}

View file

@ -1,6 +1,8 @@
@using LANCommander.SDK.Services
@using LANCommander.SDK.Models
@namespace LANCommander.UI.Components
@inject IChatClient ChatClient
@implements IAsyncDisposable
<MouseArea Cursor="Cursors.Pointer" OnClick="OnClick" Class="@_cssClass">
<Flex Align="FlexAlign.Center" Gap="FlexGap.Small">
@ -38,20 +40,85 @@
int _unreadCount = 0;
string _cssClass = string.Empty;
bool _isSubscribed = false;
bool _previousActive = false;
protected override async Task OnParametersSetAsync()
{
_unreadCount = await ChatClient.GetUnreadMessageCountAsync(Thread.Id);
await RefreshUnreadCountAsync();
// Refresh unread count when thread becomes active (read status may have been updated)
if (Active && !_previousActive)
{
await RefreshUnreadCountAsync();
}
_previousActive = Active;
_cssClass = new ClassBuilder()
.Add("chat-list-thread")
.If(() => Active, "chat-list-thread-active")
.Build();
// Subscribe to message events if not already subscribed
if (!_isSubscribed)
{
Thread.OnMessageReceivedAsync = OnMessageReceivedAsync;
Thread.OnMessagesReceivedAsync = OnMessagesReceivedAsync;
_isSubscribed = true;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Refresh unread count when component becomes active (read status may have changed)
if (Active && firstRender)
{
await RefreshUnreadCountAsync();
}
}
async Task OnMessageReceivedAsync(SDK.Models.ChatMessage message)
{
// Update unread count when a new message is received
await RefreshUnreadCountAsync();
}
async Task OnMessagesReceivedAsync(IEnumerable<SDK.Models.ChatMessage> messages)
{
// Update unread count when messages are received
await RefreshUnreadCountAsync();
}
async Task RefreshUnreadCountAsync()
{
var newCount = await ChatClient.GetUnreadMessageCountAsync(Thread.Id);
if (newCount != _unreadCount)
{
_unreadCount = newCount;
await InvokeAsync(StateHasChanged);
}
}
async Task OnClick(MouseEventArgs args)
{
if (OnSelected.HasDelegate)
await OnSelected.InvokeAsync(Thread);
// Refresh unread count when thread is selected (read status may have been updated)
await RefreshUnreadCountAsync();
}
public async ValueTask DisposeAsync()
{
if (_isSubscribed)
{
Thread.OnMessageReceivedAsync -= OnMessageReceivedAsync;
Thread.OnMessagesReceivedAsync -= OnMessagesReceivedAsync;
_isSubscribed = false;
}
await Task.CompletedTask;
}
}

View file

@ -1,4 +1,6 @@
.chat-message-input {
flex: 0 0 auto;
.EasyMDEContainer .CodeMirror {
margin: .5em;
border: none;

View file

@ -1,6 +1,6 @@
@using System.Collections.Specialized
@using LANCommander.SDK.Models
@using LANCommander.SDK.Services
@using Microsoft.AspNetCore.Components.Web.Virtualization
@inject IChatClient ChatClient
@namespace LANCommander.UI.Components
@ -9,11 +9,19 @@
@if (_thread != null)
{
<InfiniteLoader
T="SDK.Models.ChatMessage"
T="SDK.Models.ChatMessageGroup"
KeySelector="g => g.Id.ToString()"
Loader="LoadMessages"
PageSize="10">
<ChatMessage Id="context.Id" Content="@context.Content" SentOn="context.SentOn" />
<ChatMessageGroup
UserId="context.UserId"
UserName="@context.UserName"
StartedOn="context.StartedOn.DateTime">
@foreach (var message in context.Messages)
{
<ChatMessage Id="message.Id" Content="@message.Content" SentOn="message.SentOn" />
}
</ChatMessageGroup>
</InfiniteLoader>
}
</Flex>
@ -25,6 +33,7 @@
[Parameter] public Guid? Id { get; set; }
SDK.Models.ChatThread? _thread;
private SDK.Models.ChatMessage? _lastCursor;
protected override async Task OnParametersSetAsync()
{
@ -41,15 +50,105 @@
void MessageGroupsOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
=> InvokeAsync(StateHasChanged);
async Task<InfiniteLoadResponse<SDK.Models.ChatMessage>> LoadMessages(SDK.Models.ChatMessage cursor, int pageSize)
async Task<InfiniteResponse<SDK.Models.ChatMessageGroup>> LoadMessages(SDK.Models.ChatMessageGroup? cursor, int pageSize)
{
var messages = await ChatClient.GetMessagesAsync(Id.Value, cursor, pageSize);
return new InfiniteLoadResponse<SDK.Models.ChatMessage>()
// Use the oldest message from the cursor group, or the last cursor message
SDK.Models.ChatMessage? messageCursor = null;
if (cursor != null && cursor.Messages.Count > 0)
{
Items = messages,
HasMore = true,
// Get the oldest message from the cursor group (first message in chronological order)
messageCursor = cursor.Messages.OrderBy(m => m.SentOn).First();
}
else if (_lastCursor != null)
{
messageCursor = _lastCursor;
}
var response = await ChatClient.GetMessagesAsync(Id.Value, messageCursor?.Id ?? null, pageSize);
// Filter out blank/whitespace messages
var validMessages = (response.Items ?? [])
.Where(m => !string.IsNullOrWhiteSpace(m.Content))
.ToList();
// Mark messages as read when they are loaded
if (Id.HasValue && validMessages.Count > 0)
{
await ChatClient.UpdatedReadStatus(Id.Value);
}
// Group messages by sender (messages are already in chronological order from the service)
var groups = GroupMessagesBySender(validMessages);
// Track the oldest message for next cursor (from the oldest group)
// Groups are in newest-first order (will be reversed by InfiniteLoader)
// So the oldest group is the last one
if (groups.Count > 0)
{
var oldestGroup = groups[groups.Count - 1];
if (oldestGroup.Messages.Count > 0)
{
// Get the oldest message from the oldest group
_lastCursor = oldestGroup.Messages.OrderBy(m => m.SentOn).First();
}
}
return new InfiniteResponse<SDK.Models.ChatMessageGroup>
{
Items = groups,
HasMore = response.HasMore
};
}
private List<SDK.Models.ChatMessageGroup> GroupMessagesBySender(IEnumerable<SDK.Models.ChatMessage> messages)
{
var groups = new List<SDK.Models.ChatMessageGroup>();
SDK.Models.ChatMessageGroup? currentGroup = null;
// Messages come from service in newest-first order (OrderByDescending)
// We'll process them in reverse to get oldest-first for grouping
var messagesList = messages.ToList();
// Reverse to get oldest-first order for proper grouping
messagesList.Reverse();
foreach (var message in messagesList)
{
// Start a new group if this is the first message, or if the sender changed
if (currentGroup == null || message.UserId != currentGroup.UserId)
{
if (currentGroup != null)
{
groups.Add(currentGroup);
}
currentGroup = new SDK.Models.ChatMessageGroup
{
Id = Guid.NewGuid(),
UserId = message.UserId,
UserName = message.UserName,
Messages = new List<SDK.Models.ChatMessage> { message }
};
}
else
{
// Add to current group (messages within group are in chronological order)
currentGroup.Messages.Add(message);
}
}
// Add the last group
if (currentGroup != null)
{
groups.Add(currentGroup);
}
// Groups are now in oldest-first order (first group is oldest)
// InfiniteLoader will reverse them, so we need to reverse again to maintain oldest-first
// Actually, wait - InfiniteLoader reverses, so if we return oldest-first, after reversal it's newest-first (wrong)
// So we should return newest-first, then InfiniteLoader reverses to oldest-first
groups.Reverse();
return groups;
}
}

View file

@ -2,9 +2,23 @@
height: 100%;
flex-grow: 1;
background: #141414;
display: flex;
flex-direction: column;
min-height: 0;
}
.chat-thread-messages {
flex-grow: 1;
flex: 1 1 0;
min-height: 0;
padding: 1em;
display: flex;
flex-direction: column;
overflow: hidden;
.infinite-scroll {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
}

View file

@ -1,9 +1,9 @@
@using LANCommander.UI.Extensions
@using LANCommander.UI.Providers
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.JSInterop
@inject HttpClient HttpClient
@inject NavigationManager Navigator
@inject IJSRuntime JS
@inject ScriptProvider ScriptProvider
@inject IMessageService MessageService
@implements IAsyncDisposable
@namespace LANCommander.UI.Components
@ -104,7 +104,7 @@
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
_uploaderInterop ??= await JS.ImportModuleAsync<ChunkUploader>();
_uploaderInterop ??= await ScriptProvider.ImportModuleAsync<ChunkUploader>();
}
public async Task Start()

View file

@ -1,4 +1,5 @@
@namespace LANCommander.UI.Components
@inherits BaseComponent
@typeparam T
<div class="infinite-scroll" @ref="_scrollHost">
@ -6,7 +7,7 @@
@foreach (var i in _items)
{
<div @key="@_keySelector?.Invoke(i)" data-index="@_keySelector?.Invoke(i)">
<div @key="@_keySelector?.Invoke(i)" data-id="@_keySelector?.Invoke(i)">
@ChildContent?.Invoke(i)
</div>
}

View file

@ -1,14 +1,14 @@
using System.Linq.Expressions;
using LANCommander.UI.Extensions;
using LANCommander.SDK.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace LANCommander.UI.Components;
public partial class InfiniteLoader<T> : ComponentBase
public partial class InfiniteLoader<T> : BaseComponent
{
[Parameter]
public Func<T, int, Task<InfiniteLoadResponse<T>>>? Loader { get; set; }
public Func<T, int, Task<InfiniteResponse<T>>>? Loader { get; set; }
[Parameter]
public int PageSize { get; set; } = 10;
@ -19,9 +19,6 @@ public partial class InfiniteLoader<T> : ComponentBase
[Parameter]
public RenderFragment<T>? ChildContent { get; set; }
[Inject]
private IJSRuntime JS { get; set; }
private readonly List<T> _items = new();
private T? _next;
private bool _isLoadingMore;
@ -44,7 +41,58 @@ public partial class InfiniteLoader<T> : ComponentBase
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
_scrollInterop ??= await JS.ImportModuleAsync("InfiniteScroll", _scrollHost, _sentinel);
{
_scrollInterop ??= await ScriptProvider.ImportModuleAsync(this, "InfiniteScroll", _scrollHost, _sentinel);
await _scrollInterop.InvokeVoidAsync("ObserveSentinel");
// Scroll to bottom to show newest messages
await Task.Delay(50);
await ScrollToBottomAsync();
// Check if sentinel is visible (content doesn't fill container) and load more if needed
// Use a small delay to ensure DOM is fully rendered
await Task.Delay(100);
await CheckAndLoadMoreIfNeededAsync();
}
}
private async Task ScrollToBottomAsync()
{
if (_scrollInterop == null)
return;
try
{
await _scrollInterop.InvokeVoidAsync("ScrollToBottom");
}
catch
{
// Ignore errors - method might not exist yet
}
}
private async Task CheckAndLoadMoreIfNeededAsync()
{
if (_scrollInterop == null || _isLoadingMore || !_hasMore || Loader == null)
return;
try
{
// Check if sentinel is visible (content doesn't fill viewport)
var isSentinelVisible = await _scrollInterop.InvokeAsync<bool>("IsSentinelVisible");
if (isSentinelVisible)
{
// Content doesn't fill container, load more messages
var anchor = await _scrollInterop.InvokeAsync<object?>("CaptureAnchor");
await LoadMoreAsync(anchor);
}
}
catch
{
// Ignore errors - method might not exist yet or sentinel check failed
}
}
protected override void OnParametersSet()
@ -65,15 +113,42 @@ public partial class InfiniteLoader<T> : ComponentBase
_items.Clear();
if (response.Items is not null)
_items.AddRange(response.Items);
{
// Filter out duplicates using the key selector (in case SignalR already added some)
if (_keySelector is not null)
{
var seenKeys = new HashSet<string>();
var uniqueItems = response.Items.Where(item =>
{
var key = _keySelector(item);
return seenKeys.Add(key);
}).ToList();
// Reverse the list so oldest messages are at the top and newest at the bottom
uniqueItems.Reverse();
_items.AddRange(uniqueItems);
}
else
{
var itemsList = response.Items.ToList();
// Reverse the list so oldest messages are at the top and newest at the bottom
itemsList.Reverse();
_items.AddRange(itemsList);
}
}
_hasMore = response.HasMore;
_next = response.Next;
// Set cursor to the oldest message (first in list after reversal) for loading older messages
if (_items.Count > 0)
{
_next = _items[0];
}
}
private async Task LoadMoreAsync(object? anchor)
{
if (_isLoadingMore || !_hasMore || Loader is null || _next is null || _scrollInterop is null)
if (_isLoadingMore || !_hasMore || Loader is null || _scrollInterop is null)
return;
_isLoadingMore = true;
@ -82,15 +157,47 @@ public partial class InfiniteLoader<T> : ComponentBase
{
var page = await Loader.Invoke(_next, PageSize);
if (page.Items is not null)
_items.InsertRange(0, page.Items);
if (page.Items is not null && _keySelector is not null)
{
// Filter out duplicates using the key selector
var existingKeys = new HashSet<string>(_items.Select(_keySelector));
var newItems = page.Items.Where(item => !existingKeys.Contains(_keySelector(item))).ToList();
if (newItems.Count > 0)
{
// Reverse new items so oldest is first, then insert at the beginning
newItems.Reverse();
_items.InsertRange(0, newItems);
// Update cursor to the oldest message (first in list) for next load
_next = _items[0];
}
}
else if (page.Items is not null)
{
var itemsList = page.Items.ToList();
// Reverse new items so oldest is first, then insert at the beginning
itemsList.Reverse();
_items.InsertRange(0, itemsList);
// Update cursor to the oldest message (first in list) for next load
if (_items.Count > 0)
{
_next = _items[0];
}
}
_hasMore = page.HasMore;
_next = page.Next;
await InvokeAsync(StateHasChanged);
await _scrollInterop.InvokeVoidAsync("RestoreAfterPrepend", anchor);
_isLoadingMore = false;
// After loading and rendering, check if we need to load more to fill the viewport
await Task.Delay(50);
await CheckAndLoadMoreIfNeededAsync();
}
finally
{
@ -98,7 +205,7 @@ public partial class InfiniteLoader<T> : ComponentBase
}
}
[JSInvokable]
[JSInvokable("OnSentinelVisible")]
public async Task OnSentinelVisible()
{
if (_scrollInterop is null)

View file

@ -0,0 +1,14 @@
.infinite-scroll {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: end;
overflow-y: auto;
overflow-x: hidden;
}
.sentinel {
height: 1px;
width: 100%;
flex-shrink: 0;
}

View file

@ -1,15 +1,16 @@
import { InfiniteScrollAnchor } from "./InfiniteScrollAnchor";
declare const DotNet: typeof import("@microsoft/dotnet-js-interop").DotNet;
export class InfiniteScroll {
public scrollHost: Element;
public sentinel: Element;
public dotnet: any;
public static Create(scrollHostSelector: string, sentinelSelector: string): InfiniteScroll {
return new InfiniteScroll(document.querySelector(scrollHostSelector), document.querySelector(sentinelSelector));
public static Create(dotnet: any, scrollHost: Element, sentinel: Element): InfiniteScroll {
return new InfiniteScroll(dotnet, scrollHost, sentinel);
}
constructor(scrollHost: Element, sentinel: Element) {
constructor(dotnet: any, scrollHost: Element, sentinel: Element) {
this.dotnet = dotnet;
this.scrollHost = scrollHost;
this.sentinel = sentinel;
}
@ -18,7 +19,7 @@ export class InfiniteScroll {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
DotNet.invokeMethodAsync("LANCommander.UI", "OnSentinelVisible");
this.dotnet.invokeMethodAsync("OnSentinelVisible");
}
}
}, {
@ -61,4 +62,16 @@ export class InfiniteScroll {
this.scrollHost.scrollTop += (newOffset - anchor.OffsetTop);
}
IsSentinelVisible(): boolean {
const rect = this.sentinel.getBoundingClientRect();
const hostRect = this.scrollHost.getBoundingClientRect();
// Check if sentinel is within the visible area of the scroll host
return rect.top >= hostRect.top && rect.top <= hostRect.bottom;
}
ScrollToBottom() {
this.scrollHost.scrollTop = this.scrollHost.scrollHeight;
}
}

View file

@ -2,6 +2,7 @@
@using LANCommander.UI.Providers
@using Microsoft.JSInterop
@inject TimeProvider TimeProvider
@inject ScriptProvider ScriptProvider
@inject IJSRuntime JS
@namespace LANCommander.UI.Components
@ -14,7 +15,7 @@
{
try
{
_timeProvider ??= await JS.ImportModuleAsync("TimeProvider");
_timeProvider ??= await ScriptProvider.ImportModuleAsync("TimeProvider");
var timeZone = await _timeProvider.InvokeAsync<string>("GetLocalTimeZone");

View file

@ -1,6 +1,7 @@
@using LANCommander.UI.Extensions
@using LANCommander.UI.Providers
@using Microsoft.JSInterop
@inject IJSRuntime JS
@inject ScriptProvider ScriptProvider
@namespace LANCommander.UI.Components
<CascadingValue Value="this" IsFixed="@true">
@ -20,6 +21,6 @@
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
_splitPane ??= await JS.ImportModuleAsync<SplitPane>(_paneId);
_splitPane ??= await ScriptProvider.ImportModuleAsync<SplitPane>(_paneId);
}
}

View file

@ -1,4 +1,5 @@
using LANCommander.UI.Extensions;
using LANCommander.UI.Providers;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using XtermBlazor;
@ -9,7 +10,7 @@ namespace LANCommander.UI.Components;
public partial class Terminal : Xterm
{
[Inject]
IJSRuntime JS { get; set; }
ScriptProvider ScriptProvider { get; set; }
IJSObjectReference? _interop;
@ -17,7 +18,7 @@ public partial class Terminal : Xterm
{
if (firstRender)
{
_interop ??= await JS.ImportModuleAsync<Terminal>();
_interop ??= await ScriptProvider.ImportModuleAsync<Terminal>();
if (Addons == null)
Addons = new HashSet<string>();

View file

@ -7,7 +7,6 @@ export class Terminal {
}
constructor() {
console.log("Terminal Constructor");
(<any>window).XtermBlazor.registerAddons({
"readline": new Readline(),
"addon-fit": new FitAddon(),

View file

@ -1,27 +0,0 @@
using Microsoft.JSInterop;
namespace LANCommander.UI.Extensions;
public static class IJSRuntimeExtensions
{
private const string ScriptAsset = "./_content/LANCommander.UI/bundle.js";
private static IJSObjectReference? _module;
public static async Task InitializeAsync(this IJSRuntime js)
=> _module ??= await js.InvokeAsync<IJSObjectReference>("import", ScriptAsset);
public static async Task<IJSObjectReference> ImportModuleAsync<T>(this IJSRuntime js, params object[] args)
{
var type = typeof(T);
return await js.ImportModuleAsync(type.Name, args);
}
public static async Task<IJSObjectReference> ImportModuleAsync(this IJSRuntime js, string type, params object[] args)
{
await js.InitializeAsync();
return await _module!.InvokeAsync<IJSObjectReference>($"{type}.Create", args);
}
}

View file

@ -8,6 +8,7 @@ public static class IServiceCollectionExtensions
public static IServiceCollection AddLANCommanderUI(this IServiceCollection services)
{
services.AddSingleton<TimeProvider, LocalTimeProvider>();
services.AddScoped<ScriptProvider>();
return services;
}

View file

@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace LANCommander.UI.Providers;
public class ScriptProvider(IJSRuntime js)
{
private const string ScriptAsset = "./_content/LANCommander.UI/bundle.js";
private IJSObjectReference? _module;
public async Task InitializeAsync()
=> _module ??= await js.InvokeAsync<IJSObjectReference>("import", ScriptAsset);
public async Task<IJSObjectReference> ImportModuleAsync<T, TComponent>(TComponent component, params object[] args) where TComponent : class, IComponent
{
var mergedArgs = new object[args.Length + 1];
mergedArgs[0] = DotNetObjectReference.Create(component);
Array.Copy(args, 0, mergedArgs, 1, args.Length);
return await ImportModuleAsync<T>(args);
}
public async Task<IJSObjectReference> ImportModuleAsync<TComponent>(TComponent component, string moduleName,
params object[] args) where TComponent : class, IComponent
{
var mergedArgs = new object[args.Length + 1];
mergedArgs[0] = DotNetObjectReference.Create(component);
Array.Copy(args, 0, mergedArgs, 1, args.Length);
return await ImportModuleAsync(moduleName, mergedArgs);
}
public async Task<IJSObjectReference> ImportModuleAsync<T>(params object[] args)
{
var type = typeof(T);
return await ImportModuleAsync(type.Name, args);
}
public async Task<IJSObjectReference> ImportModuleAsync(string moduleName, params object[] args)
{
await InitializeAsync();
return await _module!.InvokeAsync<IJSObjectReference>($"{moduleName}.Create", args);
}
public async Task InvokeVoidAsync(string method, params object[] args)
{
await InitializeAsync();
await _module!.InvokeVoidAsync(method, args);
}
public async Task<T> InvokeAsync<T>(string method, params object[] args)
{
await InitializeAsync();
return await _module!.InvokeAsync<T>(method, args);
}
}

View file

@ -3,7 +3,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
entry: ['./Main.ts'], // Adjust the path if your index.js is located elsewhere
entry: ['./_Imports.razor.ts'], // Adjust the path if your index.js is located elsewhere
module: {
rules: [
{