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:
parent
a0c7bf63f0
commit
66abc6e59e
40 changed files with 3723 additions and 117 deletions
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,10 +22,6 @@
|
|||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -14,3 +14,5 @@ public interface IChatClient
|
|||
Task StopTypingAsync(Guid threadId, string userIdentifier);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
9
LANCommander.SDK/Models/InfiniteResponse.cs
Normal file
9
LANCommander.SDK/Models/InfiniteResponse.cs
Normal 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; }
|
||||
}
|
||||
2992
LANCommander.Server.Data.SQLite/Migrations/20260111073107_ChatReadStatusMessageId.Designer.cs
generated
Normal file
2992
LANCommander.Server.Data.SQLite/Migrations/20260111073107_ChatReadStatusMessageId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
21
LANCommander.UI/Components/BaseComponent.cs
Normal file
21
LANCommander.UI/Components/BaseComponent.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
.chat-message-input {
|
||||
flex: 0 0 auto;
|
||||
|
||||
.EasyMDEContainer .CodeMirror {
|
||||
margin: .5em;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ export class Terminal {
|
|||
}
|
||||
|
||||
constructor() {
|
||||
console.log("Terminal Constructor");
|
||||
(<any>window).XtermBlazor.registerAddons({
|
||||
"readline": new Readline(),
|
||||
"addon-fit": new FitAddon(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ public static class IServiceCollectionExtensions
|
|||
public static IServiceCollection AddLANCommanderUI(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<TimeProvider, LocalTimeProvider>();
|
||||
services.AddScoped<ScriptProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
|
|
|||
63
LANCommander.UI/Providers/ScriptProvider.cs
Normal file
63
LANCommander.UI/Providers/ScriptProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue