[frontend/components] Make virtual scroller gracefully handle no content

This commit is contained in:
Lilian 2025-02-09 23:02:48 +01:00
parent 13495a692a
commit 652df49326
No known key found for this signature in database
4 changed files with 49 additions and 19 deletions

View file

@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Routing;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using Microsoft.Extensions.Localization;
namespace Iceshrimp.Frontend.Components; namespace Iceshrimp.Frontend.Components;
@ -19,6 +20,7 @@ public class VirtualScroller<T> : ComponentBase, IDisposable where T : IIdentifi
[Inject] private NavigationManager Navigation { get; set; } = null!; [Inject] private NavigationManager Navigation { get; set; } = null!;
[Inject] private ILogger<NewVirtualScroller> Logger { get; set; } = null!; [Inject] private ILogger<NewVirtualScroller> Logger { get; set; } = null!;
[Inject] private IJSRuntime Js { get; set; } = null!; [Inject] private IJSRuntime Js { get; set; } = null!;
[Inject] private IStringLocalizer<Iceshrimp.Frontend.Localization.Localization> Loc { get; set; } = null!;
[Parameter] [EditorRequired] public required RenderFragment<T> ItemTemplate { get; set; } = default!; [Parameter] [EditorRequired] public required RenderFragment<T> ItemTemplate { get; set; } = default!;
[Parameter] [EditorRequired] public required IReadOnlyList<T> InitialItems { get; set; } = default!; [Parameter] [EditorRequired] public required IReadOnlyList<T> InitialItems { get; set; } = default!;
@ -41,6 +43,7 @@ public class VirtualScroller<T> : ComponentBase, IDisposable where T : IIdentifi
private bool _shouldRender = false; private bool _shouldRender = false;
private bool _initialized = false; private bool _initialized = false;
private bool _hideBefore = true; private bool _hideBefore = true;
private bool _hideAfter = true;
private IDisposable? _locationChangeHandlerDisposable; private IDisposable? _locationChangeHandlerDisposable;
@ -120,7 +123,17 @@ public class VirtualScroller<T> : ComponentBase, IDisposable where T : IIdentifi
builder.CloseComponent(); builder.CloseComponent();
builder.CloseRegion(); builder.CloseRegion();
if (Items.Count == 0)
{
builder.OpenRegion(2); builder.OpenRegion(2);
builder.OpenElement(1, "div");
builder.AddAttribute(2, "class", "placeholder");
builder.AddContent(3,@Loc["Nothing here, yet!"]);
builder.CloseElement();
builder.CloseRegion();
}
builder.OpenRegion(3);
foreach (var item in Items) foreach (var item in Items)
{ {
builder.OpenElement(2, "div"); builder.OpenElement(2, "div");
@ -147,13 +160,14 @@ public class VirtualScroller<T> : ComponentBase, IDisposable where T : IIdentifi
builder.CloseRegion(); builder.CloseRegion();
builder.OpenRegion(3); builder.OpenRegion(4);
builder.OpenComponent<ScrollEnd>(1); builder.OpenComponent<ScrollEnd>(1);
builder.AddComponentParameter(2, "IntersectionChange", new EventCallback(this, CallbackAfterAsync)); builder.AddComponentParameter(2, "IntersectionChange", new EventCallback(this, CallbackAfterAsync));
builder.AddComponentParameter(3, "ManualLoad", new EventCallback(this, CallbackAfterAsync)); builder.AddComponentParameter(3, "ManualLoad", new EventCallback(this, CallbackAfterAsync));
builder.AddComponentParameter(4, "RequireReset", true); builder.AddComponentParameter(4, "RequireReset", true);
builder.AddComponentParameter(5, "Class", "virtual-scroller-button"); builder.AddComponentParameter(5, "Class", "virtual-scroller-button");
builder.AddComponentReferenceCapture(6, builder.AddComponentParameter(6, "Hide", _hideAfter);
builder.AddComponentReferenceCapture(7,
reference => reference =>
After = reference as ScrollEnd After = reference as ScrollEnd
?? throw new InvalidOperationException()); ?? throw new InvalidOperationException());
@ -163,7 +177,8 @@ public class VirtualScroller<T> : ComponentBase, IDisposable where T : IIdentifi
private async Task CallbackBeforeAsync() private async Task CallbackBeforeAsync()
{ {
if (!_initialized) // Cannot call item provider when there are no existing items.
if (Items.Count == 0 || !_initialized)
{ {
Before.Reset(); Before.Reset();
return; return;
@ -181,6 +196,7 @@ public class VirtualScroller<T> : ComponentBase, IDisposable where T : IIdentifi
ReRender(); ReRender();
} }
} }
var res = await resTask; var res = await resTask;
if (res is not null && res.Count > 0) if (res is not null && res.Count > 0)
{ {
@ -220,13 +236,26 @@ public class VirtualScroller<T> : ComponentBase, IDisposable where T : IIdentifi
private async Task CallbackAfterAsync() private async Task CallbackAfterAsync()
{ {
if (!_initialized) // Cannot call item provider when there are no existing items.
if (Items.Count == 0 || !_initialized)
{ {
After.Reset(); After.Reset();
return; return;
} }
var res = await ItemProvider(DirectionEnum.Older, Items.Last().Value); var resTask = ItemProvider(DirectionEnum.Older, Items.Last().Value);
// Only show the spinner if ItemProvider takes more than 500ms to load, and the spinner is actually visible on screen.
await Task.WhenAny(Task.Delay(TimeSpan.FromMilliseconds(500)), resTask);
if (resTask.IsCompleted == false)
{
if (After.Visible)
{
_hideAfter = false;
ReRender();
}
}
var res = await resTask;
if (res is not null && res.Count > 0) if (res is not null && res.Count > 0)
{ {
foreach (var el in res) foreach (var el in res)

View file

@ -5,6 +5,4 @@ namespace Iceshrimp.Frontend.Core.Services.NoteStore;
internal class TimelineState internal class TimelineState
{ {
public SortedList<string, NoteResponse> Timeline { get; } = new(Comparer<string>.Create((x, y) => String.Compare(y, x, StringComparison.Ordinal))); public SortedList<string, NoteResponse> Timeline { get; } = new(Comparer<string>.Create((x, y) => String.Compare(y, x, StringComparison.Ordinal)));
public string? MaxId { get; set; }
public string? MinId { get; set; }
} }

View file

@ -81,8 +81,6 @@ internal class TimelineStore : NoteMessageProvider, IAsyncDisposable, IStreaming
if (add is false) _logger.LogError($"Duplicate note: {note.Id}"); if (add is false) _logger.LogError($"Duplicate note: {note.Id}");
} }
Timelines[timeline].MaxId = Timelines[timeline].Timeline.First().Value.Id;
Timelines[timeline].MinId = Timelines[timeline].Timeline.Last().Value.Id;
return res; return res;
} }
catch (ApiException e) catch (ApiException e)

View file

@ -309,9 +309,9 @@ pre:has(code) {
} }
.virtual-scroller-button.hidden { .virtual-scroller-button.hidden {
height: 0; height: 1px;
overflow: clip; overflow: clip;
/*transition: height ease-out 250ms;*/ transition: height ease-out 250ms;
opacity: 0; opacity: 0;
} }
@ -319,10 +319,15 @@ pre:has(code) {
/*noinspection CssInvalidAtRule*/ /*noinspection CssInvalidAtRule*/
@starting-style { @starting-style {
.virtual-scroller-button { .virtual-scroller-button {
height: 0; height: 1px;
} }
} }
.placeholder {
text-align: center;
padding-top: 5rem;
}
@keyframes spinner { @keyframes spinner {
0% { 0% {