2026-01-03 15:53:17 -06:00
|
|
|
using System.Linq.Expressions;
|
2026-01-11 14:38:24 -06:00
|
|
|
using LANCommander.SDK.Models;
|
2026-01-03 15:53:17 -06:00
|
|
|
using Microsoft.AspNetCore.Components;
|
|
|
|
|
using Microsoft.JSInterop;
|
|
|
|
|
|
2026-01-03 23:22:35 -06:00
|
|
|
namespace LANCommander.UI.Components;
|
2026-01-03 15:53:17 -06:00
|
|
|
|
2026-01-11 14:38:24 -06:00
|
|
|
public partial class InfiniteLoader<T> : BaseComponent
|
2026-01-03 15:53:17 -06:00
|
|
|
{
|
|
|
|
|
[Parameter]
|
2026-01-11 14:38:24 -06:00
|
|
|
public Func<T, int, Task<InfiniteResponse<T>>>? Loader { get; set; }
|
2026-01-03 15:53:17 -06:00
|
|
|
|
|
|
|
|
[Parameter]
|
|
|
|
|
public int PageSize { get; set; } = 10;
|
|
|
|
|
|
|
|
|
|
[Parameter]
|
|
|
|
|
public Expression<Func<T, string>>? KeySelector { get; set; }
|
|
|
|
|
|
|
|
|
|
[Parameter]
|
|
|
|
|
public RenderFragment<T>? ChildContent { get; set; }
|
|
|
|
|
|
|
|
|
|
private readonly List<T> _items = new();
|
|
|
|
|
private T? _next;
|
|
|
|
|
private bool _isLoadingMore;
|
|
|
|
|
private bool _hasMore = true;
|
|
|
|
|
|
|
|
|
|
private Func<T, string>? _keySelector;
|
|
|
|
|
|
|
|
|
|
private ElementReference _scrollHost;
|
|
|
|
|
private ElementReference _sentinel;
|
|
|
|
|
|
2026-01-07 18:24:24 -06:00
|
|
|
private IJSObjectReference? _scrollInterop;
|
2026-01-03 15:53:17 -06:00
|
|
|
|
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
|
|
|
{
|
|
|
|
|
await base.OnInitializedAsync();
|
|
|
|
|
|
|
|
|
|
await LoadInitialAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
|
|
|
{
|
2026-01-07 18:24:24 -06:00
|
|
|
if (firstRender)
|
2026-01-11 14:38:24 -06:00
|
|
|
{
|
|
|
|
|
_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
|
|
|
|
|
}
|
2026-01-03 15:53:17 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override void OnParametersSet()
|
|
|
|
|
{
|
|
|
|
|
if (KeySelector is null)
|
|
|
|
|
throw new ArgumentNullException(nameof(KeySelector));
|
|
|
|
|
|
|
|
|
|
_keySelector = KeySelector.Compile();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task LoadInitialAsync()
|
|
|
|
|
{
|
|
|
|
|
if (Loader == null)
|
|
|
|
|
throw new ArgumentNullException($"{nameof(Loader)} is null");
|
|
|
|
|
|
|
|
|
|
var response = await Loader(_next, PageSize);
|
|
|
|
|
|
|
|
|
|
_items.Clear();
|
|
|
|
|
|
|
|
|
|
if (response.Items is not null)
|
2026-01-11 14:38:24 -06:00
|
|
|
{
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-03 15:53:17 -06:00
|
|
|
|
|
|
|
|
_hasMore = response.HasMore;
|
2026-01-11 14:38:24 -06:00
|
|
|
|
|
|
|
|
// Set cursor to the oldest message (first in list after reversal) for loading older messages
|
|
|
|
|
if (_items.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
_next = _items[0];
|
|
|
|
|
}
|
2026-01-03 15:53:17 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task LoadMoreAsync(object? anchor)
|
|
|
|
|
{
|
2026-01-11 14:38:24 -06:00
|
|
|
if (_isLoadingMore || !_hasMore || Loader is null || _scrollInterop is null)
|
2026-01-03 15:53:17 -06:00
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
_isLoadingMore = true;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var page = await Loader.Invoke(_next, PageSize);
|
|
|
|
|
|
2026-01-11 14:38:24 -06:00
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-03 15:53:17 -06:00
|
|
|
|
|
|
|
|
_hasMore = page.HasMore;
|
|
|
|
|
|
|
|
|
|
await InvokeAsync(StateHasChanged);
|
2026-01-07 18:24:24 -06:00
|
|
|
|
|
|
|
|
await _scrollInterop.InvokeVoidAsync("RestoreAfterPrepend", anchor);
|
2026-01-11 14:38:24 -06:00
|
|
|
|
|
|
|
|
_isLoadingMore = false;
|
|
|
|
|
|
|
|
|
|
// After loading and rendering, check if we need to load more to fill the viewport
|
|
|
|
|
await Task.Delay(50);
|
|
|
|
|
await CheckAndLoadMoreIfNeededAsync();
|
2026-01-03 15:53:17 -06:00
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
_isLoadingMore = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 14:38:24 -06:00
|
|
|
[JSInvokable("OnSentinelVisible")]
|
2026-01-03 15:53:17 -06:00
|
|
|
public async Task OnSentinelVisible()
|
|
|
|
|
{
|
|
|
|
|
if (_scrollInterop is null)
|
|
|
|
|
return;
|
|
|
|
|
|
2026-01-07 18:24:24 -06:00
|
|
|
var anchor = await _scrollInterop.InvokeAsync<object?>("CaptureAnchor");
|
2026-01-03 15:53:17 -06:00
|
|
|
|
|
|
|
|
await LoadMoreAsync(anchor);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 21:21:12 -06:00
|
|
|
/// <summary>
|
|
|
|
|
/// Adds new items to the end of the list (for real-time updates like new messages)
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task AddItemsAsync(IEnumerable<T> newItems)
|
|
|
|
|
{
|
|
|
|
|
if (newItems == null || !newItems.Any())
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
var itemsList = newItems.ToList();
|
|
|
|
|
|
|
|
|
|
if (_keySelector is not null)
|
|
|
|
|
{
|
|
|
|
|
// Filter out duplicates using the key selector
|
|
|
|
|
var existingKeys = new HashSet<string>(_items.Select(_keySelector));
|
|
|
|
|
var uniqueItems = itemsList.Where(item => !existingKeys.Contains(_keySelector(item))).ToList();
|
|
|
|
|
|
|
|
|
|
if (uniqueItems.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
_items.AddRange(uniqueItems);
|
|
|
|
|
await InvokeAsync(StateHasChanged);
|
|
|
|
|
|
|
|
|
|
// Scroll to bottom to show new messages
|
|
|
|
|
await Task.Delay(50);
|
|
|
|
|
await ScrollToBottomAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
_items.AddRange(itemsList);
|
|
|
|
|
await InvokeAsync(StateHasChanged);
|
|
|
|
|
|
|
|
|
|
// Scroll to bottom to show new messages
|
|
|
|
|
await Task.Delay(50);
|
|
|
|
|
await ScrollToBottomAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the last item in the list (newest item)
|
|
|
|
|
/// </summary>
|
|
|
|
|
public T? GetLastItem()
|
|
|
|
|
{
|
|
|
|
|
return _items.Count > 0 ? _items[_items.Count - 1] : default;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Updates the last item in the list (for merging messages into the last group)
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task UpdateLastItemAsync(T updatedItem)
|
|
|
|
|
{
|
|
|
|
|
if (_items.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
_items[_items.Count - 1] = updatedItem;
|
|
|
|
|
await InvokeAsync(StateHasChanged);
|
|
|
|
|
|
|
|
|
|
// Scroll to bottom to show the updated message
|
|
|
|
|
await Task.Delay(50);
|
|
|
|
|
await ScrollToBottomAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 15:53:17 -06:00
|
|
|
public async ValueTask DisposeAsync()
|
|
|
|
|
{
|
|
|
|
|
if (_scrollInterop is not null)
|
|
|
|
|
await _scrollInterop.DisposeAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|