[frontend] New Virtual Scroller and Note Store

This commit is contained in:
Lilian 2024-11-21 17:28:14 +01:00
parent beb0044d7c
commit 45e0e058c9
No known key found for this signature in database
33 changed files with 1601 additions and 263 deletions

View file

@ -42,6 +42,7 @@
</div>
<script src="~/_framework/blazor.webassembly.js"></script>
<script src="~/Utility.js"></script>
<script>navigator.serviceWorker.register('service-worker.js');</script>
</body>

View file

@ -0,0 +1,102 @@
using Ljbc1994.Blazor.IntersectionObserver;
using Ljbc1994.Blazor.IntersectionObserver.API;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.JSInterop;
namespace Iceshrimp.Frontend.Components;
public class LazyComponent : ComponentBase, IAsyncDisposable
{
[Inject] private IIntersectionObserverService ObserverService { get; set; } = null!;
[Inject] private IJSInProcessRuntime Js { get; set; } = null!;
[Inject] private ILogger<LazyComponent> Logger { get; set; } = null!;
[Parameter] [EditorRequired] public required RenderFragment ChildContent { get; set; } = default!;
[Parameter] public long? InitialHeight { get; set; }
private IntersectionObserver? Observer { get; set; }
public ElementReference Target { get; private set; }
public bool Visible { get; private set; }
private long? Height { get; set; }
protected override void OnInitialized()
{
if (InitialHeight is not null)
{
Height = InitialHeight;
Console.WriteLine($"Set Initial height to {InitialHeight}");
}
}
public long? GetHeight()
{
if (Height != null) return Height.Value;
if (Visible) return Js.Invoke<long>("getHeight", Target);
else
{
Logger.LogError("Invisible, no height available");
return null;
}
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(1, "div");
builder.AddAttribute(2, "class", "target lazy-component-target-internal");
builder.AddElementReferenceCapture(3, elementReference => Target = elementReference);
builder.OpenRegion(10);
if (Visible)
{
builder.AddContent(1, ChildContent);
}
else
{
builder.OpenElement(2, "div");
builder.AddAttribute(3, "class", "placeholder");
if (Height is not null)
{
builder.AddAttribute(4, "style", $"height: {Height}px");
}
else
{
builder.AddAttribute(5, "style", "height: 5rem");
}
builder.CloseElement();
}
builder.CloseRegion();
builder.CloseElement();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
Observer = await ObserverService.Create(OnIntersect);
await Observer.Observe(Target);
}
}
private void OnIntersect(IList<IntersectionObserverEntry> entries)
{
var entry = entries.First();
if (Visible && entry.IsIntersecting is false)
{
Height = Js.Invoke<long>("getHeight", Target);
Visible = false;
StateHasChanged();
}
if (Visible is false && entry.IsIntersecting)
{
Visible = true;
StateHasChanged();
}
}
public async ValueTask DisposeAsync()
{
if (Observer != null) await Observer.Dispose();
}
}

View file

@ -0,0 +1,271 @@
using AngleSharp.Dom;
using Iceshrimp.Frontend.Core.Services;
using Iceshrimp.Frontend.Core.Services.StateServicePatterns;
using Iceshrimp.Frontend.Enums;
using Iceshrimp.Shared.Helpers;
using Ljbc1994.Blazor.IntersectionObserver;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.JSInterop;
namespace Iceshrimp.Frontend.Components;
public class NewVirtualScroller<T> : ComponentBase, IDisposable where T : IIdentifiable
{
[Inject] private IIntersectionObserverService ObserverService { get; set; } = null!;
[Inject] private StateService State { get; set; } = null!;
[Inject] private NavigationManager Navigation { get; set; } = null!;
[Inject] private ILogger<NewVirtualScroller> Logger { get; set; } = null!;
[Inject] private IJSRuntime Js { get; set; } = null!;
[Parameter] [EditorRequired] public required RenderFragment<T> ItemTemplate { get; set; } = default!;
[Parameter] [EditorRequired] public required IReadOnlyList<T> InitialItems { get; set; } = default!;
[Parameter] [EditorRequired] public required Func<DirectionEnum, T, Task<List<T>?>> ItemProvider { get; set; }
[Parameter] [EditorRequired] public required string StateKey { get; set; }
[Parameter] [EditorRequired] public required Func<List<string>, List<T>> ItemProviderById { get; set; }
private ScrollEnd Before { get; set; } = null!;
private ScrollEnd After { get; set; } = null!;
private Dictionary<string, LazyComponent> Children { get; set; } = new();
private SortedDictionary<string, T> Items { get; init; }
= new(Comparer<string>.Create((x, y) => y.CompareTo(x)));
private IJSInProcessObjectReference _module = null!;
private SortedDictionary<string, Child>? _stateItems;
private float _scrollY;
private bool _setScroll = false;
private bool _shouldRender = false;
private bool _initialized = false;
private IDisposable? _locationChangeHandlerDisposable;
protected override bool ShouldRender()
{
return _shouldRender;
}
private void ReRender()
{
_shouldRender = true;
var start = DateTime.Now;
StateHasChanged();
var end = DateTime.Now;
_shouldRender = false;
var diff = end - start;
Console.WriteLine($"Rendering took {diff.TotalMilliseconds}ms");
}
protected override async Task OnInitializedAsync()
{
_module = await Js.InvokeAsync<IJSInProcessObjectReference>("import", "./Components/NewVirtualScroller.cs.js");
_locationChangeHandlerDisposable = Navigation.RegisterLocationChangingHandler(LocationChangeHandlerAsync);
State.NewVirtualScroller.States.TryGetValue(StateKey, out var value);
if (value is null)
{
foreach (var el in InitialItems)
{
var x = Items.TryAdd(el.Id, el);
if (x is false) Logger.LogWarning($"Dropped duplicate element with ID: {el.Id}");
}
}
else
{
_stateItems = value.Items;
var items = ItemProviderById(value.Items.Select(p => p.Value.Id).ToList());
foreach (var el in items)
{
var x = Items.TryAdd(el.Id, el);
if (x is false) Logger.LogWarning($"Dropped duplicate element with ID: {el.Id}");
}
_scrollY = value.ScrollY;
_setScroll = true;
}
ReRender();
_initialized = true;
}
protected override void OnAfterRender(bool firstRender)
{
if (_setScroll)
{
RestoreOffset(_scrollY);
_setScroll = false;
}
}
private ValueTask LocationChangeHandlerAsync(LocationChangingContext arg)
{
Save();
return ValueTask.CompletedTask;
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
var start = DateTime.Now;
builder.OpenRegion(1);
builder.OpenComponent<ScrollEnd>(1);
builder.AddComponentParameter(2, "IntersectionChange", new EventCallback(this, CallbackBeforeAsync));
builder.AddComponentParameter(3, "ManualLoad", new EventCallback(this, CallbackBeforeAsync));
builder.AddComponentParameter(4, "RequireReset", true);
builder.AddComponentReferenceCapture(6,
reference =>
Before = reference as ScrollEnd
?? throw new InvalidOperationException());
builder.CloseComponent();
builder.CloseRegion();
builder.OpenRegion(2);
foreach (var item in Items)
{
builder.OpenElement(2, "div");
builder.AddAttribute(3, "class", "target");
builder.SetKey(item.Key);
builder.OpenComponent<LazyComponent>(5);
if (_stateItems != null)
{
var res = _stateItems.TryGetValue(item.Key, out var value);
if (res)
{
builder.AddComponentParameter(6, "InitialHeight", value!.Height);
}
}
builder.AddAttribute(8, "ChildContent",
(RenderFragment)(builder2 => { builder2.AddContent(9, ItemTemplate(item.Value)); }));
builder.AddComponentReferenceCapture(10,
o => Children[item.Key] = o as LazyComponent
?? throw new InvalidOperationException());
builder.CloseComponent();
builder.CloseElement();
}
builder.CloseRegion();
builder.OpenRegion(3);
builder.OpenComponent<ScrollEnd>(1);
builder.AddComponentParameter(2, "IntersectionChange", new EventCallback(this, CallbackAfterAsync));
builder.AddComponentParameter(3, "ManualLoad", new EventCallback(this, CallbackAfterAsync));
builder.AddComponentParameter(4, "RequireReset", true);
builder.AddComponentReferenceCapture(5,
reference =>
After = reference as ScrollEnd
?? throw new InvalidOperationException());
builder.CloseElement();
builder.CloseRegion();
var end = DateTime.Now;
var difference = end - start;
Console.WriteLine($"Building render tree took {difference.TotalMilliseconds}ms");
}
private async Task CallbackBeforeAsync()
{
if (!_initialized)
{
Console.WriteLine("callback before rejected");
Before.Reset();
return;
}
Console.WriteLine("callback before running");
var heightBefore = _module.Invoke<float>("GetDocumentHeight");
var res = await ItemProvider(DirectionEnum.Newer, Items.First().Value);
if (res is not null && res.Count > 0)
{
foreach (var el in res)
{
var x = Items.TryAdd(el.Id, el);
if (x is false) Logger.LogWarning($"Dropped duplicate element with ID: {el.Id}");
}
ReRender();
var heightAfter = _module.Invoke<float>("GetDocumentHeight");
var diff = heightAfter - heightBefore;
var scroll = _module.Invoke<float>("GetScrollY");
_module.InvokeVoid("SetScrollY", scroll + diff);
}
Before.Reset();
}
private async Task CallbackAfterAsync()
{
if (!_initialized)
{
Console.WriteLine("callback after rejected");
After.Reset();
return;
}
Console.WriteLine("callback after running");
var res = await ItemProvider(DirectionEnum.Older, Items.Last().Value);
if (res is not null && res.Count > 0)
{
foreach (var el in res)
{
var x = Items.TryAdd(el.Id, el);
if (x is false) Logger.LogWarning($"Dropped duplicate element with ID: {el.Id}");
}
}
After.Reset();
ReRender();
}
private float GetScrollY()
{
var js = (IJSInProcessRuntime)Js;
var scrollY = js.Invoke<float>("GetScrollY");
return scrollY;
}
private void RestoreOffset(float scrollY)
{
Console.WriteLine($"Restoring offeset to {scrollY}");
_module.InvokeVoid("SetScrollY", scrollY);
}
private void SaveVisible()
{
var scrollY = GetScrollY();
var visible = Children.Where(p => p.Value.Visible);
var before = Children.TakeWhile(p => p.Value.Visible == false);
var after = Children.Skip(before.Count() + visible.Count());
var childrenVisible =
new SortedDictionary<string, Child>(visible.ToDictionary(p => p.Key,
p => new Child
{
Id = p.Key, Height = p.Value.GetHeight()
}));
}
private void Save()
{
var scrollY = GetScrollY();
var r =
new SortedDictionary<string, Child>(Children.ToDictionary(p => p.Key,
p => new Child
{
Id = p.Key, Height = p.Value.GetHeight()
}));
var x = State.NewVirtualScroller.States.TryAdd(StateKey,
new NewVirtualScrollerState { Items = r, ScrollY = scrollY });
if (!x)
{
State.NewVirtualScroller.States.Remove(StateKey);
State.NewVirtualScroller.States.Add(StateKey,
new NewVirtualScrollerState { Items = r, ScrollY = scrollY });
}
}
public void Dispose()
{
Console.WriteLine("Disposing of virtual scroller");
_locationChangeHandlerDisposable?.Dispose();
}
}

View file

@ -1,5 +1,4 @@
@using Iceshrimp.Assets.PhosphorIcons
<CascadingValue Value="this">
@if (NoteResponse.Renote != null)
{
@ -42,4 +41,3 @@
<NoteComponent Note="NoteResponse" Quote="NoteResponse.Quote" Indented="Indented" ReplyInaccessible="NoteResponse.ReplyInaccessible"/>
}
}
</CascadingValue>

View file

@ -16,108 +16,108 @@ public partial class Note : IDisposable
[Parameter] [EditorRequired] public required NoteResponse NoteResponse { get; set; }
[Parameter] public bool Indented { get; set; }
private bool _shouldRender = false;
// private bool _shouldRender = false;
private IDisposable _noteChangedHandler = null!;
private bool _overrideHide = false;
public void React(EmojiResponse emoji)
{
var target = NoteResponse.Renote ?? NoteResponse;
var x = target.Reactions.FirstOrDefault(p => p.Name == emoji.Name);
if (x is null || x.Reacted == false) _ = AddReact(emoji.Name, emoji.Sensitive, emoji.PublicUrl);
}
// public void React(EmojiResponse emoji)
// {
// var target = NoteResponse.Renote ?? NoteResponse;
// var x = target.Reactions.FirstOrDefault(p => p.Name == emoji.Name);
// if (x is null || x.Reacted == false) _ = AddReact(emoji.Name, emoji.Sensitive, emoji.PublicUrl);
// }
//
// public async Task AddReact(string name, bool sensitive, string? url = null)
// {
// var target = NoteResponse.Renote ?? NoteResponse;
// var x = target.Reactions.FirstOrDefault(p => p.Name == name);
// if (x == null)
// {
// target.Reactions.Add(new NoteReactionSchema
// {
// NoteId = target.Id,
// Name = name,
// Count = 1,
// Reacted = true,
// Url = url,
// Sensitive = sensitive
// });
// }
// else x.Count++;
//
// Broadcast();
// try
// {
// await ApiService.Notes.ReactToNoteAsync(target.Id, name);
// }
// catch (ApiException)
// {
// if (x!.Count > 1) x.Count--;
// else target.Reactions.Remove(x);
// Broadcast();
// }
// }
//
// public async Task RemoveReact(string name)
// {
// var target = NoteResponse.Renote ?? NoteResponse;
// var rollback = target.Reactions.First(p => p.Name == name);
// if (rollback.Count > 1) rollback.Count--;
// else target.Reactions.Remove(rollback);
// Broadcast();
// try
// {
// await ApiService.Notes.RemoveReactionFromNoteAsync(target.Id, name);
// }
// catch (ApiException)
// {
// if (rollback.Count >= 1) rollback.Count++;
// else target.Reactions.Add(rollback);
// Broadcast();
// }
// }
public async Task AddReact(string name, bool sensitive, string? url = null)
{
var target = NoteResponse.Renote ?? NoteResponse;
var x = target.Reactions.FirstOrDefault(p => p.Name == name);
if (x == null)
{
target.Reactions.Add(new NoteReactionSchema
{
NoteId = target.Id,
Name = name,
Count = 1,
Reacted = true,
Url = url,
Sensitive = sensitive
});
}
else x.Count++;
// private void Broadcast()
// {
// MessageSvc.UpdateNoteAsync(NoteResponse);
// }
Broadcast();
try
{
await ApiService.Notes.ReactToNoteAsync(target.Id, name);
}
catch (ApiException)
{
if (x!.Count > 1) x.Count--;
else target.Reactions.Remove(x);
Broadcast();
}
}
public async Task RemoveReact(string name)
{
var target = NoteResponse.Renote ?? NoteResponse;
var rollback = target.Reactions.First(p => p.Name == name);
if (rollback.Count > 1) rollback.Count--;
else target.Reactions.Remove(rollback);
Broadcast();
try
{
await ApiService.Notes.RemoveReactionFromNoteAsync(target.Id, name);
}
catch (ApiException)
{
if (rollback.Count >= 1) rollback.Count++;
else target.Reactions.Add(rollback);
Broadcast();
}
}
private void Broadcast()
{
MessageSvc.UpdateNoteAsync(NoteResponse);
}
public async Task ToggleLike()
{
var target = NoteResponse.Renote ?? NoteResponse;
if (target.Liked)
{
try
{
target.Liked = false;
target.Likes--;
Broadcast();
await ApiService.Notes.UnlikeNoteAsync(target.Id);
}
catch (ApiException)
{
target.Liked = true;
target.Likes++;
Broadcast();
}
}
else
{
try
{
target.Liked = true;
target.Likes++;
Broadcast();
await ApiService.Notes.LikeNoteAsync(target.Id);
}
catch (ApiException)
{
target.Liked = false;
target.Likes--;
Broadcast();
}
}
}
// public async Task ToggleLike()
// {
// var target = NoteResponse.Renote ?? NoteResponse;
// if (target.Liked)
// {
// try
// {
// target.Liked = false;
// target.Likes--;
// Broadcast();
// await ApiService.Notes.UnlikeNoteAsync(target.Id);
// }
// catch (ApiException)
// {
// target.Liked = true;
// target.Likes++;
// Broadcast();
// }
// }
// else
// {
// try
// {
// target.Liked = true;
// target.Likes++;
// Broadcast();
// await ApiService.Notes.LikeNoteAsync(target.Id);
// }
// catch (ApiException)
// {
// target.Liked = false;
// target.Likes--;
// Broadcast();
// }
// }
// }
private void OnNoteChanged(object? _, NoteResponse note)
{
@ -130,69 +130,69 @@ public partial class Note : IDisposable
_noteChangedHandler = MessageSvc.Register(NoteResponse.Id, OnNoteChanged, MessageService.Type.Updated);
}
public void Reply()
{
var target = NoteResponse.Renote ?? NoteResponse;
ComposeService.ComposeDialog?.OpenDialog(target);
}
// public void Reply()
// {
// var target = NoteResponse.Renote ?? NoteResponse;
// ComposeService.ComposeDialog?.OpenDialog(target);
// }
public async Task Renote(NoteVisibility visibility)
{
var target = NoteResponse.Renote ?? NoteResponse;
target.Renotes++;
Broadcast();
try
{
await ApiService.Notes.RenoteNoteAsync(target.Id, visibility);
}
catch (ApiException)
{
target.Renotes--;
Broadcast();
}
// public async Task Renote(NoteVisibility visibility)
// {
// var target = NoteResponse.Renote ?? NoteResponse;
// target.Renotes++;
// Broadcast();
// try
// {
// await ApiService.Notes.RenoteNoteAsync(target.Id, visibility);
// }
// catch (ApiException)
// {
// target.Renotes--;
// Broadcast();
// }
//
// Rerender();
// }
Rerender();
}
// public void DoQuote()
// {
// var target = NoteResponse.Renote ?? NoteResponse;
// ComposeService.ComposeDialog?.OpenDialog(null, target);
// }
public void DoQuote()
{
var target = NoteResponse.Renote ?? NoteResponse;
ComposeService.ComposeDialog?.OpenDialog(null, target);
}
public async Task Redraft()
{
await ApiService.Notes.DeleteNoteAsync(NoteResponse.Id);
ComposeService.ComposeDialog?.OpenDialogRedraft(NoteResponse);
}
// public async Task Redraft()
// {
// await ApiService.Notes.DeleteNoteAsync(NoteResponse.Id);
// ComposeService.ComposeDialog?.OpenDialogRedraft(NoteResponse);
// }
public async Task Bite()
{
await ApiService.Notes.BiteNoteAsync(NoteResponse.Id);
}
// public async Task Bite()
// {
// await ApiService.Notes.BiteNoteAsync(NoteResponse.Id);
// }
public async Task Mute()
{
await ApiService.Notes.MuteNoteAsync(NoteResponse.Id);
}
// public async Task Mute()
// {
// await ApiService.Notes.MuteNoteAsync(NoteResponse.Id);
// }
private void Rerender()
{
_shouldRender = true;
// _shouldRender = true;
StateHasChanged();
_shouldRender = false;
// _shouldRender = false;
}
protected override bool ShouldRender()
{
return _shouldRender;
return true;
}
public async Task Delete()
{
await ApiService.Notes.DeleteNoteAsync(NoteResponse.Id);
await MessageSvc.DeleteNoteAsync(NoteResponse);
}
// public async Task Delete()
// {
// await ApiService.Notes.DeleteNoteAsync(NoteResponse.Id);
// await MessageSvc.DeleteNoteAsync(NoteResponse);
// }
private void ShowNote()
{

View file

@ -1,42 +1,63 @@
@using Iceshrimp.Frontend.Core.Miscellaneous
@using Iceshrimp.Frontend.Core.Services
@using Iceshrimp.Shared.Schemas.Web
@inject ApiService ApiService;
@inject NavigationManager NavigationManager
@inject ComposeService ComposeService
@inject SessionService Session;
<div class="note-header">
<NoteUserInfo User="@Note.User" Indented="Indented"/>
<NoteMetadata
Visibility="@Note.Visibility"
InstanceName="@Note.User.InstanceName"
CreatedAt="DateTime.Parse(Note.CreatedAt)">
</NoteMetadata>
</div>
<NoteBody NoteBase="Note" OverLength="@CheckLen()" Indented="Indented" ReplyInaccessible="ReplyInaccessible"/>
@if (Quote != null)
{
<div @onclick="OpenQuote" @onclick:stopPropagation="true" class="quote">
<NoteComponent Note="Quote" AsQuote="true"></NoteComponent>
@implements IDisposable
@inject ApiService ApiService;
@inject NavigationManager NavigationManager
@inject ComposeService ComposeService
@inject SessionService Session;
@inject ILogger<NoteComponent> Logger;
<CascadingValue Value="Note">
<div class="note-header">
<NoteUserInfo User="@Note.User" Indented="Indented"/>
<NoteMetadata
Visibility="@Note.Visibility"
InstanceName="@Note.User.InstanceName"
CreatedAt="DateTime.Parse(Note.CreatedAt)">
</NoteMetadata>
</div>
}
@if (!AsQuote)
{
<NoteFooter
Reactions="Note.Reactions"
Likes="Note.Likes"
IsLiked="Note.Liked"
Renotes="Note.Renotes"
Replies="Note.Replies"
RenotePossible=
"@(Note.Visibility == NoteVisibility.Public || Note.Visibility == NoteVisibility.Home || Session.Current?.Id == Note.User.Id)"/>
}
<NoteBody NoteBase="Note" OverLength="@CheckLen()" Indented="Indented" ReplyInaccessible="ReplyInaccessible"/>
@if (Quote != null)
{
<div @onclick="OpenQuote" @onclick:stopPropagation="true" class="quote">
<NoteComponent Note="Quote" AsQuote="true"></NoteComponent>
</div>
}
@if (!AsQuote)
{
<NoteFooter
Reactions="Note.Reactions"
Likes="Note.Likes"
IsLiked="Note.Liked"
Renotes="Note.Renotes"
Replies="Note.Replies"
RenotePossible=
"@(Note.Visibility == NoteVisibility.Public || Note.Visibility == NoteVisibility.Home || Session.Current?.Id == Note.User.Id)"/>
}
</CascadingValue>
@code {
[Parameter] [EditorRequired] public required NoteBase Note { get; set; }
[Parameter] public bool Indented { get; set; }
[Parameter] public NoteBase? Quote { get; set; }
[Parameter] public bool AsQuote { get; set; }
[Parameter] public bool ReplyInaccessible { get; set; }
[Parameter] [EditorRequired] public required NoteBase Note { get; set; }
[Parameter] public bool Indented { get; set; }
[Parameter] public NoteBase? Quote { get; set; }
[Parameter] public bool AsQuote { get; set; }
[Parameter] public bool ReplyInaccessible { get; set; }
private IDisposable? _noteChangedHandler;
[CascadingParameter(Name="Provider")] NoteMessageProvider? Provider { get; set; }
protected override void OnInitialized()
{
_noteChangedHandler = Provider?.Register(Note.Id, HandleNoteChanged);
}
private void HandleNoteChanged(object? _, NoteBase note)
{
Logger.LogInformation($"{note.Id} updated");
StateHasChanged();
}
private bool CheckLen()
{
@ -47,4 +68,9 @@
{
NavigationManager.NavigateTo($"/notes/{Quote!.Id}");
}
public void Dispose()
{
_noteChangedHandler?.Dispose();
}
}

View file

@ -1,5 +1,6 @@
@using Iceshrimp.Assets.PhosphorIcons
@using Iceshrimp.Frontend.Core.Services
@using Iceshrimp.Frontend.Core.Services.NoteStore
@using Iceshrimp.Frontend.Localization
@using Iceshrimp.Shared.Schemas.Web
@using Microsoft.Extensions.Localization
@ -7,6 +8,7 @@
@inject IJSRuntime Js;
@inject SessionService Session;
@inject GlobalComponentSvc GlobalComponentSvc
@inject NoteActions NoteActions;
<div class="note-footer">
@if (Reactions.Count > 0)
@ -25,12 +27,14 @@
<span class="reply-count">@Replies</span>
}
</button>
<button @ref="RenoteButton" class="btn @(RenotePossible ? "" : "disabled")" @onclick="@(RenotePossible ? ToggleRenoteMenu : () => { })" @onclick:stopPropagation="true" aria-label="renote">
<button @ref="RenoteButton" class="btn @(RenotePossible ? "" : "disabled")"
@onclick="@(RenotePossible ? ToggleRenoteMenu : () => { })" @onclick:stopPropagation="true"
aria-label="renote">
@if (RenotePossible)
{
<Icon Name="Icons.Repeat" Size="1.3em"/>
<Menu @ref="RenoteMenu">
@if (Note.NoteResponse.Visibility == NoteVisibility.Public)
@if (Note.Visibility == NoteVisibility.Public)
{
<MenuElement Icon="Icons.Repeat" OnSelect="() => Renote(NoteVisibility.Public)">
<Text>@Loc["Renote"]</Text>
@ -68,7 +72,8 @@
<span class="like-count">@Likes</span>
}
</button>
<button @ref="EmojiButton" class="btn" @onclick="ToggleEmojiPicker" @onclick:stopPropagation="true" aria-label="emoji picker" >
<button @ref="EmojiButton" class="btn" @onclick="ToggleEmojiPicker" @onclick:stopPropagation="true"
aria-label="emoji picker">
<Icon Name="Icons.Smiley" Size="1.3em"/>
</button>
<button class="btn" @onclick="Quote" @onclick:stopPropagation="true" aria-label="quote">
@ -77,18 +82,18 @@
<button @ref="MenuButton" class="btn" @onclick="ToggleMenu" @onclick:stopPropagation="true" aria-label="more">
<Icon Name="Icons.DotsThreeOutline" Size="1.3em"/>
<Menu @ref="ContextMenu">
@if (Note.NoteResponse.User.Id == Session.Current?.Id)
@if (Note.User.Id == Session.Current?.Id)
{
<MenuElement Icon="Icons.Trash" OnSelect="Note.Delete">
<MenuElement Icon="Icons.Trash" OnSelect="Delete">
<Text>@Loc["Delete"]</Text>
</MenuElement>
<MenuElement Icon="Icons.Eraser" OnSelect="Note.Redraft">
<MenuElement Icon="Icons.Eraser" OnSelect="Redraft">
<Text>@Loc["Redraft"]</Text>
</MenuElement>
}
else
{
<MenuElement Icon="Icons.Tooth" OnSelect="Note.Bite">
<MenuElement Icon="Icons.Tooth" OnSelect="Bite">
<Text>@Loc["Bite"]</Text>
</MenuElement>
}
@ -98,7 +103,7 @@
<MenuElement Icon="Icons.Share" OnSelect="CopyLink">
<Text>@Loc["Copy link"]</Text>
</MenuElement>
<MenuElement Icon="Icons.SpeakerX" OnSelect="Note.Mute">
<MenuElement Icon="Icons.SpeakerX" OnSelect="Mute">
<Text>@Loc["Mute Note"]</Text>
</MenuElement>
<ClosingBackdrop OnClose="ContextMenu.Close"></ClosingBackdrop>
@ -119,24 +124,24 @@
private ElementReference EmojiButton { get; set; }
private ElementReference MenuButton { get; set; }
[CascadingParameter] Note Note { get; set; } = null!;
[CascadingParameter] NoteBase Note { get; set; } = null!;
private void ToggleMenu() => ContextMenu.Toggle(MenuButton);
private void Delete() => _ = Note.Delete();
private void Delete() => _ = NoteActions.DeleteAsync(Note);
private void OpenOriginal() => Js.InvokeVoidAsync("open", Note.NoteResponse.Url, "_blank");
private void OpenOriginal() => Js.InvokeVoidAsync("open", Note.Url, "_blank");
private void CopyLink() => Js.InvokeVoidAsync("navigator.clipboard.writeText", Note.NoteResponse.Url);
private void CopyLink() => Js.InvokeVoidAsync("navigator.clipboard.writeText", Note.Url);
private void Like()
{
_ = Note.ToggleLike();
_ = NoteActions.ToggleLikeAsync(Note);
}
private void Reply()
{
Note.Reply();
NoteActions.Reply(Note);
}
private void ToggleRenoteMenu()
@ -146,12 +151,12 @@
private void Renote(NoteVisibility visibility)
{
_ = Note.Renote(visibility);
_ = NoteActions.RenoteAsync(Note);
}
private void Quote()
{
Note.DoQuote();
NoteActions.DoQuote(Note);
}
private void ToggleEmojiPicker()
@ -161,6 +166,21 @@
private void React(EmojiResponse emoji)
{
Note.React(emoji);
NoteActions.React(Note, emoji);
}
private void Redraft()
{
_ = NoteActions.RedraftAsync(Note);
}
private void Bite()
{
_ = NoteActions.BiteAsync(Note);
}
private void Mute()
{
_ = NoteActions.MuteAsync(Note);
}
}

View file

@ -1,4 +1,6 @@
@using Iceshrimp.Frontend.Core.Services.NoteStore
@using Iceshrimp.Shared.Schemas.Web
@inject NoteActions NoteActions;
<button @onclick="React" class="reaction @(Reaction.Reacted ? "reacted" : "")" title="@Reaction.Name">
<InlineEmoji Name="@Reaction.Name" Url="@Reaction.Url" Wide="true"/>
<span class="count">
@ -8,11 +10,11 @@
@code {
[Parameter] [EditorRequired] public required NoteReactionSchema Reaction { get; set; }
[CascadingParameter] Note Note { get; set; } = null!;
[CascadingParameter] NoteBase Note { get; set; } = null!;
private void React()
{
if (Reaction.Reacted) _ = Note.RemoveReact(Reaction.Name);
else _ = Note.AddReact(Reaction.Name, Reaction.Sensitive, Reaction.Url);
if (Reaction.Reacted) _ = NoteActions.RemoveReactAsync(Note, Reaction.Name);
else _ = NoteActions.AddReactAsync(Note, Reaction.Name, Reaction.Sensitive, Reaction.Url);
}
}

View file

@ -1,10 +1,14 @@
@using Iceshrimp.Frontend.Core.Miscellaneous
@using Iceshrimp.Shared.Schemas.Web
@if (_state == State.Init)
{
<div class="scroller">
@foreach (var el in Notifications)
{
<div class="wrapper">
<NotificationComponent NotificationResponse="el" @key="el.Id"/>
<CascadingValue Value="NotificationStore" TValue="NoteMessageProvider" Name="Provider">
<NotificationComponent NotificationResponse="el" @key="el.Id"/>
</CascadingValue>
</div>
}
<ScrollEnd ManualLoad="LoadMore" IntersectionChange="LoadMore"></ScrollEnd>

View file

@ -1,31 +1,34 @@
using Iceshrimp.Frontend.Core.Miscellaneous;
using Iceshrimp.Frontend.Core.Services;
using Iceshrimp.Frontend.Core.Services.NoteStore;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Components;
namespace Iceshrimp.Frontend.Components;
public partial class NotificationList : IAsyncDisposable
public partial class NotificationList : IDisposable
{
private string? _minId;
private State _state = State.Loading;
[Inject] private StreamingService StreamingService { get; set; } = null!;
[Inject] private NotificationStore NotificationStore { get; set; } = null!;
[Inject] private ApiService Api { get; set; } = null!;
private List<NotificationResponse> Notifications { get; set; } = [];
public async ValueTask DisposeAsync()
public void Dispose()
{
StreamingService.Notification -= OnNotification;
await StreamingService.DisposeAsync();
GC.SuppressFinalize(this);
NotificationStore.Notification -= OnNotification;
}
private async Task GetNotifications()
{
try
{
var res = await Api.Notifications.GetNotificationsAsync(new PaginationQuery());
var res =await NotificationStore.FetchNotificationsAsync(new PaginationQuery());
if (res is null)
{
_state = State.Error;
return;
}
if (res.Count > 0)
{
Notifications = res;
@ -43,7 +46,8 @@ public partial class NotificationList : IAsyncDisposable
private async Task LoadMore()
{
var pq = new PaginationQuery { MaxId = _minId, Limit = 20 };
var res = await Api.Notifications.GetNotificationsAsync(pq);
var res = await NotificationStore.FetchNotificationsAsync(pq);
if (res is null) return;
if (res.Count > 0)
{
Notifications.AddRange(res);
@ -54,8 +58,8 @@ public partial class NotificationList : IAsyncDisposable
protected override async Task OnInitializedAsync()
{
StreamingService.Notification += OnNotification;
await StreamingService.ConnectAsync();
NotificationStore.Notification += OnNotification;
await NotificationStore.InitializeAsync();
await GetNotifications();
StateHasChanged();
}

View file

@ -1,8 +1,16 @@
@using Ljbc1994.Blazor.IntersectionObserver.API
@using Ljbc1994.Blazor.IntersectionObserver.Components
@using Iceshrimp.Assets.PhosphorIcons
<IntersectionObserve OnChange="entry => OnChange(entry)">
<div @ref="context.Ref.Current" class="@Class">
<button class="button" @onclick="ManualLoad">Load more</button>
<div @ref="context.Ref.Current" class="@Class" style="@Style">
@if (_state is State.Loading or State.Init)
{
<Icon Name="Icons.Spinner"/>
}
else
{
<button class="button" @onclick="Manual">Load more</button>
}
</div>
</IntersectionObserve>
@ -10,12 +18,45 @@
[Parameter] [EditorRequired] public EventCallback IntersectionChange { get; set; }
[Parameter] [EditorRequired] public EventCallback ManualLoad { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter] public bool RequireReset { get; init; }
[Parameter] public string? Style { get; set; }
private State _state;
private async Task OnChange(IntersectionObserverEntry entry)
{
if (entry.IsIntersecting)
{
await IntersectionChange.InvokeAsync();
switch (_state)
{
case State.Loading:
return;
case State.Waiting or State.Init:
_state = State.Loading;
await IntersectionChange.InvokeAsync();
break;
}
if (!RequireReset)
{
_state = State.Waiting;
}
}
}
private async Task Manual()
{
await ManualLoad.InvokeAsync();
}
private enum State
{
Init,
Loading,
Waiting
}
public void Reset()
{
_state = State.Waiting;
}
}

View file

@ -1,9 +1,12 @@
using Iceshrimp.Frontend.Core.Miscellaneous;
using Iceshrimp.Frontend.Core.Services;
using Iceshrimp.Frontend.Core.Services.NoteStore;
using Iceshrimp.Frontend.Core.Services.StateServicePatterns;
using Iceshrimp.Frontend.Enums;
using Iceshrimp.Shared.Schemas.SignalR;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Components;
using TimelineState = Iceshrimp.Frontend.Core.Services.StateServicePatterns.TimelineState;
namespace Iceshrimp.Frontend.Components;
@ -13,6 +16,7 @@ public partial class TimelineComponent : IAsyncDisposable
[Inject] private StreamingService StreamingService { get; set; } = null!;
[Inject] private StateService StateService { get; set; } = null!;
[Inject] private ILogger<TimelineComponent> Logger { get; set; } = null!;
[Inject] private TimelineStore Store { get; set; } = null!;
private TimelineState State { get; set; } = null!;
private State ComponentState { get; set; } = Core.Miscellaneous.State.Loading;
@ -28,8 +32,17 @@ public partial class TimelineComponent : IAsyncDisposable
private async Task<bool> Initialize()
{
var pq = new PaginationQuery { Limit = 30 };
var res = await ApiService.Timelines.GetHomeTimelineAsync(pq);
var cs = new TimelineStore.Cursor
{
Direction = DirectionEnum.Older,
Count = 30,
Id = null
};
var res = await Store.GetHomeTimelineAsync("home", cs);
if (res is null)
{
return false;
}
if (res.Count < 1)
{
return false;
@ -48,8 +61,12 @@ public partial class TimelineComponent : IAsyncDisposable
{
if (LockFetch) return true;
LockFetch = true;
var pq = new PaginationQuery { Limit = 15, MaxId = State.MinId };
var res = await ApiService.Timelines.GetHomeTimelineAsync(pq);
var cs = new TimelineStore.Cursor
{
Direction = DirectionEnum.Older, Count = 15, Id = State.MinId
};
var res = await Store.GetHomeTimelineAsync("home", cs);
if (res is null) return false;
switch (res.Count)
{
case > 0:

View file

@ -14,7 +14,7 @@ else
<Line></Line>
</div>
<div class="note">
<NoteComponent Note="Note.Reply" Indented="true" AsQuote="true"/>
<NoteComponent Note="Note.Reply" Indented="true"/>
</div>
</div>
}

View file

@ -0,0 +1,41 @@
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Frontend.Core.Miscellaneous;
public abstract class NoteMessageProvider
{
internal readonly Dictionary<string, EventHandler<NoteBase>> NoteChangedHandlers = new();
public class NoteMessageHandler(EventHandler<NoteBase> handler, string id, NoteMessageProvider noteState)
: IDisposable
{
public void Dispose()
{
noteState.Unregister(id, handler);
}
}
private void Unregister(string id, EventHandler<NoteBase> func)
{
if (NoteChangedHandlers.ContainsKey(id))
{
#pragma warning disable CS8601
NoteChangedHandlers[id] -= func;
#pragma warning restore CS8601
}
else
{
throw new ArgumentException("Tried to unregister from callback that doesn't exist");
}
}
public NoteMessageHandler Register(string id, EventHandler<NoteBase> func)
{
if (!NoteChangedHandlers.TryAdd(id, func))
{
NoteChangedHandlers[id] += func;
}
return new NoteMessageHandler(func, id, this);
}
}

View file

@ -0,0 +1,170 @@
using Iceshrimp.Frontend.Core.Miscellaneous;
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Frontend.Core.Services.NoteStore;
internal class NoteActions(
ApiService api,
ILogger<NoteActions> logger,
StateSynchronizer stateSynchronizer,
ComposeService composeService
)
{
private void Broadcast(NoteBase note)
{
stateSynchronizer.Broadcast(note);
}
public async Task ToggleLikeAsync(NoteBase note)
{
if (note.Liked)
{
try
{
note.Likes -= 1;
note.Liked = false;
Broadcast(note);
var res = await api.Notes.UnlikeNoteAsync(note.Id);
if (res is not null) { note.Likes = (int)res.Value;}
Broadcast(note);
}
catch (ApiException e)
{
logger.LogError(e, "Failed to like note");
note.Likes += 1;
note.Liked = true;
Broadcast(note);
}
}
else
{
try
{
note.Likes += 1;
note.Liked = true;
Broadcast(note);
var res = await api.Notes.LikeNoteAsync(note.Id);
if (res is not null) note.Likes = (int)res.Value;
Broadcast(note);
}
catch (ApiException e)
{
logger.LogError(e, "Failed to like note");
note.Likes -= 1;
note.Liked = false;
Broadcast(note);
}
}
}
public void Reply(NoteBase note)
{
composeService.ComposeDialog?.OpenDialog(note);
}
public void DoQuote(NoteBase note)
{
composeService.ComposeDialog?.OpenDialog(null, note);
}
public async Task BiteAsync(NoteBase note)
{
await api.Notes.BiteNoteAsync(note.Id);
}
public async Task MuteAsync(NoteBase note)
{
await api.Notes.MuteNoteAsync(note.Id);
}
public async Task RedraftAsync(NoteBase note)
{
try
{
var original = await api.Notes.GetNoteAsync(note.Id);
if (original is not null)
{
await api.Notes.DeleteNoteAsync(note.Id);
composeService.ComposeDialog?.OpenDialogRedraft(original);
}
}
catch (ApiException e)
{
logger.LogError(e, "Failed to redraft");
}
}
public async Task RenoteAsync(NoteBase note)
{
note.Renotes++;
Broadcast(note);
try
{
await api.Notes.RenoteNoteAsync(note.Id, note.Visibility);
}
catch (ApiException)
{
note.Renotes--;
Broadcast(note);
}
}
public void React(NoteBase target, EmojiResponse emoji)
{
var x = target.Reactions.FirstOrDefault(p => p.Name == emoji.Name);
if (x is null || x.Reacted == false) _ = AddReactAsync(target, emoji.Name, emoji.Sensitive, emoji.PublicUrl);
}
public async Task AddReactAsync(NoteBase target, string name, bool sensitive, string? url = null)
{
var x = target.Reactions.FirstOrDefault(p => p.Name == name);
if (x == null)
{
target.Reactions.Add(new NoteReactionSchema
{
NoteId = target.Id,
Name = name,
Count = 1,
Reacted = true,
Url = url,
Sensitive = sensitive
});
}
else x.Count++;
Broadcast(target);
try
{
await api.Notes.ReactToNoteAsync(target.Id, name);
}
catch (ApiException)
{
if (x!.Count > 1) x.Count--;
else target.Reactions.Remove(x);
Broadcast(target);
}
}
public async Task RemoveReactAsync(NoteBase target, string name)
{
var rollback = target.Reactions.First(p => p.Name == name);
if (rollback.Count > 1) rollback.Count--;
else target.Reactions.Remove(rollback);
Broadcast(target);
try
{
await api.Notes.RemoveReactionFromNoteAsync(target.Id, name);
}
catch (ApiException)
{
if (rollback.Count >= 1) rollback.Count++;
else target.Reactions.Add(rollback);
Broadcast(target);
}
}
public async Task DeleteAsync(NoteBase note)
{
await api.Notes.DeleteNoteAsync(note.Id);
}
}

View file

@ -0,0 +1,80 @@
using Iceshrimp.Frontend.Core.Miscellaneous;
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Frontend.Core.Services.NoteStore;
internal class NoteStore : NoteMessageProvider, IDisposable
{
public event EventHandler<NoteResponse>? AnyNoteChanged;
public event EventHandler<NoteResponse>? AnyNoteDeleted;
private Dictionary<string, NoteResponse> Notes { get; } = [];
private readonly StateSynchronizer _stateSynchronizer;
private readonly ApiService _api;
private readonly ILogger<NoteStore> _logger;
public NoteStore(ApiService api, ILogger<NoteStore> logger, StateSynchronizer stateSynchronizer)
{
_api = api;
_logger = logger;
_stateSynchronizer = stateSynchronizer;
_stateSynchronizer.NoteChanged += OnNoteChanged;
}
private void OnNoteChanged(object? _, NoteBase noteResponse)
{
if (Notes.TryGetValue(noteResponse.Id, out var note))
{
note.Text = noteResponse.Text;
note.Cw = noteResponse.Cw;
note.Emoji = noteResponse.Emoji;
note.Liked = noteResponse.Liked;
note.Likes = noteResponse.Likes;
note.Renotes = noteResponse.Renotes;
note.Replies = noteResponse.Replies;
note.Attachments = noteResponse.Attachments;
note.Reactions = noteResponse.Reactions;
AnyNoteChanged?.Invoke(this, note);
NoteChangedHandlers.First(p => p.Key == note.Id).Value.Invoke(this, note);
}
}
public void Delete(string id)
{
if (Notes.TryGetValue(id, out var note))
{
AnyNoteDeleted?.Invoke(this, note);
}
}
public void Dispose()
{
_stateSynchronizer.NoteChanged -= OnNoteChanged;
}
private async Task<NoteResponse?> FetchNoteAsync(string id)
{
try
{
var res = await _api.Notes.GetNoteAsync(id);
if (res is null) return null;
var success = Notes.TryAdd(res.Id, res);
if (success) return res;
Notes.Remove(res.Id);
Notes.Add(res.Id, res);
return res;
}
catch (ApiException e)
{
_logger.LogError(e, "Failed to fetch note.");
throw;
}
}
public async Task<NoteResponse?> GetNoteAsync(string id)
{
var res = Notes.TryGetValue(id, out var value);
if (res) return value ?? throw new InvalidOperationException("Key somehow had no associated value.");
return await FetchNoteAsync(id);
}
}

View file

@ -0,0 +1,87 @@
using Iceshrimp.Frontend.Core.Miscellaneous;
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Frontend.Core.Services.NoteStore;
// This is presently a very thin shim to get Notification working, notifications should be transitioned to the virtual scroller eventually.
internal class NotificationStore : NoteMessageProvider, IAsyncDisposable
{
public event EventHandler<NotificationResponse>? Notification;
private readonly StateSynchronizer _stateSynchronizer;
private readonly ApiService _api;
private readonly ILogger<NotificationStore> _logger;
private StreamingService _streamingService;
private bool _initialized = false;
private SortedList<string, NotificationResponse> Notifications { get; set; } = new();
public NotificationStore(
ApiService api, ILogger<NotificationStore> logger, StateSynchronizer stateSynchronizer,
StreamingService streamingService
)
{
_api = api;
_logger = logger;
_stateSynchronizer = stateSynchronizer;
_streamingService = streamingService;
_stateSynchronizer.NoteChanged += OnNoteChanged;
_streamingService.Notification -= OnNotification;
}
public async Task InitializeAsync()
{
if (_initialized) return;
await _streamingService.ConnectAsync();
}
public async ValueTask DisposeAsync()
{
_stateSynchronizer.NoteChanged -= OnNoteChanged;
await _streamingService.DisposeAsync();
}
private void OnNotification(object? _, NotificationResponse notificationResponse)
{
var add = Notifications.TryAdd(notificationResponse.Id, notificationResponse);
if (add is false) _logger.LogError($"Duplicate notification: {notificationResponse.Id}");
Notification?.Invoke(this, notificationResponse);
}
public async Task<List<NotificationResponse>?> FetchNotificationsAsync(PaginationQuery pq)
{
try
{
var res = await _api.Notifications.GetNotificationsAsync(pq);
foreach (var notification in res)
{
var add = Notifications.TryAdd(notification.Id, notification);
if (add is false) _logger.LogError($"Duplicate notification: {notification.Id}");
}
return res;
}
catch (ApiException e)
{
_logger.LogError(e, "Failed to fetch notifications");
return null;
}
}
public void OnNoteChanged(object? _, NoteBase noteResponse)
{
var elements = Notifications.Where(p => p.Value.Note?.Id == noteResponse.Id);
foreach (var el in elements)
{
if (el.Value.Note is null) throw new Exception("Reply in note to be modified was null");
el.Value.Note.Cw = noteResponse.Cw;
el.Value.Note.Text = noteResponse.Text;
el.Value.Note.Emoji = noteResponse.Emoji;
el.Value.Note.Liked = noteResponse.Liked;
el.Value.Note.Likes = noteResponse.Likes;
el.Value.Note.Renotes = noteResponse.Renotes;
el.Value.Note.Replies = noteResponse.Replies;
el.Value.Note.Attachments = noteResponse.Attachments;
el.Value.Note.Reactions = noteResponse.Reactions;
NoteChangedHandlers.First(p => p.Key == noteResponse.Id).Value.Invoke(this, el.Value.Note);
}
}
}

View file

@ -0,0 +1,137 @@
using Iceshrimp.Frontend.Core.Miscellaneous;
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Frontend.Core.Services.NoteStore;
internal class RelatedStore : NoteMessageProvider, IDisposable
{
private Dictionary<string, NoteContainer> Ascendants { get; set; } = new();
public event EventHandler<NoteResponse>? NoteChanged;
private Dictionary<string, NoteContainer> Descendants { get; set; } = new();
private readonly StateSynchronizer _stateSynchronizer;
private readonly ApiService _api;
private readonly ILogger<NoteStore> _logger;
public RelatedStore(StateSynchronizer stateSynchronizer, ApiService api, ILogger<NoteStore> logger)
{
_stateSynchronizer = stateSynchronizer;
_api = api;
_logger = logger;
_stateSynchronizer.NoteChanged += OnNoteChanged;
}
private void OnNoteChanged(object? _, NoteBase noteResponse)
{
foreach (var container in Ascendants)
{
if (container.Value.Notes.TryGetValue(noteResponse.Id, out var note))
{
note.Text = noteResponse.Text;
note.Cw = noteResponse.Cw;
note.Emoji = noteResponse.Emoji;
note.Liked = noteResponse.Liked;
note.Likes = noteResponse.Likes;
note.Renotes = noteResponse.Renotes;
note.Replies = noteResponse.Replies;
note.Attachments = noteResponse.Attachments;
note.Reactions = noteResponse.Reactions;
// container.Value.Notes[noteResponse.Id] = note;
NoteChangedHandlers.First(p => p.Key == note.Id).Value.Invoke(this, note);
NoteChanged?.Invoke(this, note);
}
}
foreach (var container in Descendants)
{
if (container.Value.Notes.TryGetValue(noteResponse.Id, out var note))
{
note.Text = noteResponse.Text;
note.Cw = noteResponse.Cw;
note.Emoji = noteResponse.Emoji;
note.Liked = noteResponse.Liked;
note.Likes = noteResponse.Likes;
note.Renotes = noteResponse.Renotes;
note.Replies = noteResponse.Replies;
note.Attachments = noteResponse.Attachments;
note.Reactions = noteResponse.Reactions;
// container.Value.Notes[noteResponse.Id] = note;
NoteChangedHandlers.First(p => p.Key == note.Id).Value.Invoke(this, note);
NoteChanged?.Invoke(this, note);
}
}
}
public async Task<SortedList<string, NoteResponse>?> GetAscendantsAsync(string id, int? limit)
{
var success = Ascendants.TryGetValue(id, out var value);
if (success) return value?.Notes ?? throw new InvalidOperationException("Key somehow had no associated value.");
return await FetchAscendantsAsync(id, limit);
}
public async Task<SortedList<string, NoteResponse>?> GetDescendantsAsync(string id, int? limit)
{
var success = Descendants.TryGetValue(id, out var value);
if (success) return value?.Notes ?? throw new InvalidOperationException("Key somehow had no associated value.");
return await FetchDescendantsAsync(id, limit);
}
private async Task<SortedList<string, NoteResponse>?> FetchAscendantsAsync(string id, int? limit)
{
try
{
var res = await _api.Notes.GetNoteAscendantsAsync(id, limit);
if (res is null) return null;
Ascendants.Remove(id);
var container = new NoteContainer();
foreach (var note in res)
{
container.Notes.Add(note.Id, note);
}
Ascendants.Add(id, container);
return container.Notes;
}
catch (ApiException e)
{
_logger.LogError(e, "Failed to fetch note.");
throw;
}
}
private async Task<SortedList<string, NoteResponse>?> FetchDescendantsAsync(string id, int? limit)
{
try
{
var res = await _api.Notes.GetNoteDescendantsAsync(id, limit);
if (res is null) return null;
Descendants.Remove(id);
var container = new NoteContainer();
foreach (var note in res)
{
container.Notes.Add(note.Id, note);
}
Descendants.Add(id, container);
return container.Notes;
}
catch (ApiException e)
{
_logger.LogError(e, "Failed to fetch note.");
throw;
}
}
private class NoteContainer
{
public SortedList<string, NoteResponse> Notes { get; } = new(Comparer<string>.Create((x, y) => y.CompareTo(x)));
}
public void Dispose()
{
_stateSynchronizer.NoteChanged -= OnNoteChanged;
}
}

View file

@ -0,0 +1,19 @@
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Frontend.Core.Services.NoteStore;
public class StateSynchronizer
{
public event EventHandler<NoteBase>? NoteChanged;
public event EventHandler<NoteBase>? NoteDeleted;
public void Broadcast(NoteBase note)
{
NoteChanged?.Invoke(this, note);
}
public void Delete(NoteBase note)
{
NoteDeleted?.Invoke(this, note);
}
}

View file

@ -0,0 +1,10 @@
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Frontend.Core.Services.NoteStore;
internal class TimelineState
{
public SortedList<string, NoteResponse> Timeline { get; } = new(Comparer<string>.Create((x, y) => y.CompareTo(x)));
public string? MaxId { get; set; }
public string? MinId { get; set; }
}

View file

@ -0,0 +1,166 @@
using Iceshrimp.Frontend.Core.Miscellaneous;
using Iceshrimp.Frontend.Enums;
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Frontend.Core.Services.NoteStore;
internal class TimelineStore : NoteMessageProvider, IDisposable
{
private Dictionary<string, TimelineState> Timelines { get; set; } = new();
private readonly ApiService _api;
private readonly ILogger<TimelineStore> _logger;
private readonly StateSynchronizer _stateSynchronizer;
public TimelineStore(ApiService api, ILogger<TimelineStore> logger, StateSynchronizer stateSynchronizer)
{
_api = api;
_logger = logger;
_stateSynchronizer = stateSynchronizer;
_stateSynchronizer.NoteChanged += OnNoteChanged;
}
private void OnNoteChanged(object? _, NoteBase changedNote)
{
foreach (var timeline in Timelines)
{
var replies = timeline.Value.Timeline.Where(p => p.Value.Reply?.Id == changedNote.Id);
foreach (var el in replies)
{
if (el.Value.Reply is null) throw new Exception("Reply in note to be modified was null");
el.Value.Reply.Cw = changedNote.Cw;
el.Value.Reply.Text = changedNote.Text;
el.Value.Reply.Emoji = changedNote.Emoji;
el.Value.Reply.Liked = changedNote.Liked;
el.Value.Reply.Likes = changedNote.Likes;
el.Value.Reply.Renotes = changedNote.Renotes;
el.Value.Reply.Replies = changedNote.Replies;
el.Value.Reply.Attachments = changedNote.Attachments;
el.Value.Reply.Reactions = changedNote.Reactions;
}
if (timeline.Value.Timeline.TryGetValue(changedNote.Id, out var note))
{
note.Cw = changedNote.Cw;
note.Text = changedNote.Text;
note.Emoji = changedNote.Emoji;
note.Liked = changedNote.Liked;
note.Likes = changedNote.Likes;
note.Renotes = changedNote.Renotes;
note.Replies = changedNote.Replies;
note.Attachments = changedNote.Attachments;
note.Reactions = changedNote.Reactions;
NoteChangedHandlers.First(p => p.Key == note.Id).Value.Invoke(this, note);
}
}
}
private async Task<List<NoteResponse>?> FetchTimelineAsync(string timeline, PaginationQuery pq)
{
try
{
var res = await _api.Timelines.GetHomeTimelineAsync(pq);
if (Timelines.ContainsKey(timeline) is false)
{
Timelines.Add(timeline, new TimelineState());
}
foreach (var note in res)
{
var add = Timelines[timeline].Timeline.TryAdd(note.Id, note);
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;
}
catch (ApiException e)
{
_logger.LogError(e, "Failed to fetch timeline");
return null;
}
}
public async Task<List<NoteResponse>?> GetHomeTimelineAsync(string timeline, Cursor cs)
{
if (cs.Id is null)
{
return await FetchTimelineAsync(timeline,
new PaginationQuery { MaxId = null, MinId = null, Limit = cs.Count });
}
switch (cs.Direction)
{
case DirectionEnum.Newer:
{
var indexStart = Timelines[timeline].Timeline.IndexOfKey(cs.Id);
if (indexStart != -1 && indexStart - cs.Count > 0)
{
var res = Timelines[timeline]
.Timeline.Take(new Range(indexStart - cs.Count, indexStart));
return res.Select(p => p.Value).ToList();
}
else
{
var res = await FetchTimelineAsync(timeline,
new PaginationQuery
{
MaxId = null, MinId = cs.Id, Limit = cs.Count
});
res?.Reverse();
return res;
}
}
case DirectionEnum.Older:
{
if (!Timelines.ContainsKey(timeline))
{
return await FetchTimelineAsync(timeline,
new PaginationQuery
{
MaxId = cs.Id, MinId = null, Limit = cs.Count
});
}
var indexStart = Timelines[timeline].Timeline.IndexOfKey(cs.Id);
if (indexStart != -1 && indexStart + cs.Count < Timelines[timeline].Timeline.Count)
{
var res = Timelines[timeline]
.Timeline.Take(new Range(indexStart, indexStart + cs.Count));
return res.Select(p => p.Value).ToList();
}
else
{
return await FetchTimelineAsync(timeline,
new PaginationQuery
{
MaxId = cs.Id, MinId = null, Limit = cs.Count
});
}
}
}
throw new InvalidOperationException();
}
public List<NoteResponse> GetIdsFromTimeline(string timeline, List<string> ids)
{
List<NoteResponse> list = [];
list.AddRange(ids.Select(id => Timelines[timeline].Timeline[id]));
return list;
}
public class Cursor
{
public required DirectionEnum Direction { get; set; }
public required int Count { get; set; }
public string? Id { get; set; }
}
public void Dispose()
{
_stateSynchronizer.NoteChanged -= OnNoteChanged;
}
}

View file

@ -4,8 +4,9 @@ namespace Iceshrimp.Frontend.Core.Services;
internal class StateService(MessageService messageService)
{
public VirtualScroller VirtualScroller { get; } = new(messageService);
public Timeline Timeline { get; } = new(messageService);
public SingleNote SingleNote { get; } = new();
public Search Search { get; } = new();
public VirtualScroller VirtualScroller { get; } = new(messageService);
public Timeline Timeline { get; } = new(messageService);
public SingleNote SingleNote { get; } = new();
public Search Search { get; } = new();
public NewVirtualScroller NewVirtualScroller { get; } = new();
}

View file

@ -0,0 +1,22 @@
using System.Collections;
namespace Iceshrimp.Frontend.Core.Services.StateServicePatterns;
internal class NewVirtualScroller
{
public Dictionary<string, NewVirtualScrollerState> States = new();
}
internal class NewVirtualScrollerState
{
public required SortedDictionary<string, Child> Items { get; set; }
public required float ScrollY { get; set; }
}
public class Child
{
public required string Id { get; set; }
public required long? Height { get; set; }
}

View file

@ -0,0 +1,7 @@
namespace Iceshrimp.Frontend.Enums;
public enum DirectionEnum
{
Newer,
Older
}

View file

@ -70,4 +70,8 @@
</Compile>
</ItemGroup>
<ItemGroup>
<Folder Include="Core\Services\TimelineStore\" />
</ItemGroup>
</Project>

View file

@ -1,18 +1,20 @@
@page "/notes/{NoteId}"
@attribute [Authorize]
@inject ApiService ApiService
@inject NoteStore NoteStore;
@inject RelatedStore RelatedStore;
@inject IJSRuntime Js
@inject MessageService MessageService
@inject StateService StateSvc
@inject NavigationManager Navigation
@inject IStringLocalizer<Localization> Loc
@inject ILogger<SingleNote> Logger;
@inject StateSynchronizer StateSynchronizer;
@using Iceshrimp.Assets.PhosphorIcons
@using Iceshrimp.Frontend.Components
@using Iceshrimp.Frontend.Components.Note
@using Iceshrimp.Frontend.Core.Miscellaneous
@using Iceshrimp.Frontend.Core.Services
@using Iceshrimp.Frontend.Core.Services.NoteStore
@using Iceshrimp.Frontend.Core.Services.StateServicePatterns
@using Iceshrimp.Frontend.Localization
@using Iceshrimp.Shared.Schemas.Web
@ -27,7 +29,8 @@
<Icon Name="Icons.Signpost"></Icon>
@if (RootNote is { User.DisplayName: not null } && RootNote.User.Emojis.Count != 0)
{
<MfmText Text="@Loc["Note by {0}", RootNote.User.DisplayName]" Emoji="@RootNote.User.Emojis" Simple="@true"/>
<MfmText Text="@Loc["Note by {0}", RootNote.User.DisplayName]" Emoji="@RootNote.User.Emojis"
Simple="@true"/>
}
else
{
@ -42,12 +45,16 @@
<div class="ascendants">
@foreach (var note in Ascendants)
{
<AscendedNote Note="note"/>
<CascadingValue Value="RelatedStore" TValue="NoteMessageProvider" Name="Provider">
<AscendedNote Note="note.Value"/>
</CascadingValue>
}
</div>
}
<div @ref="RootNoteRef" class="root-note">
<Note NoteResponse="RootNote"></Note>
<CascadingValue Value="NoteStore" TValue="NoteMessageProvider" Name="Provider">
<Note NoteResponse="RootNote"></Note>
</CascadingValue>
</div>
<div class="details">
<TabbedMenu>
@ -61,7 +68,9 @@
<div class="descendants">
@foreach (var element in Descendants)
{
<RecursiveNote Note="element" Depth="0" MaxDepth="_depth"/>
<CascadingValue Value="RelatedStore" TValue="NoteMessageProvider" Name="Provider">
<RecursiveNote Note="element.Value" Depth="0" MaxDepth="_depth"/>
</CascadingValue>
}
</div>
}
@ -125,17 +134,16 @@
}
@code {
[Parameter] public string? NoteId { get; set; }
public NoteResponse? RootNote { get; set; }
private IList<NoteResponse>? Descendants { get; set; }
private IList<NoteResponse>? Ascendants { get; set; }
private IJSInProcessObjectReference? Module { get; set; }
private ElementReference RootNoteRef { get; set; }
private int _depth = 20;
private IDisposable? _locationChangingHandlerDisposable;
private IDisposable? _noteChangedHandler;
private State _componentState;
private bool _firstLoad = true;
[Parameter] public string? NoteId { get; set; }
public NoteResponse? RootNote { get; set; }
private SortedList<string, NoteResponse>? Descendants { get; set; }
private SortedList<string, NoteResponse>? Ascendants { get; set; }
private IJSInProcessObjectReference? Module { get; set; }
private ElementReference RootNoteRef { get; set; }
private int _depth = 20;
private IDisposable? _locationChangingHandlerDisposable;
private State _componentState;
private bool _firstLoad = true;
private async Task Load()
{
@ -149,9 +157,9 @@
try
{
var rootNoteTask = ApiService.Notes.GetNoteAsync(NoteId);
var descendantsTask = ApiService.Notes.GetNoteDescendantsAsync(NoteId, _depth);
var ascendantsTask = ApiService.Notes.GetNoteAscendantsAsync(NoteId, default);
var rootNoteTask = NoteStore.GetNoteAsync(NoteId);
var descendantsTask = RelatedStore.GetDescendantsAsync(NoteId, _depth);
var ascendantsTask = RelatedStore.GetAscendantsAsync(NoteId, default);
RootNote = await rootNoteTask;
Descendants = await descendantsTask;
Ascendants = await ascendantsTask;
@ -191,17 +199,11 @@
protected override void OnInitialized()
{
if (NoteId != null) _noteChangedHandler = MessageService.Register(NoteId, OnNoteChanged, MessageService.Type.Updated);
_locationChangingHandlerDisposable = Navigation.RegisterLocationChangingHandler(LocationChangeHandler);
MessageService.AnyNoteDeleted += OnNoteDeleted;
StateSynchronizer.NoteDeleted += OnNoteDeleted;
}
private void OnNoteChanged(object? _, NoteResponse note)
{
var __ = Refresh();
}
private void OnNoteDeleted(object? _, NoteResponse note)
private void OnNoteDeleted(object? _, NoteBase note)
{
if (NoteId == note.Id)
{
@ -209,8 +211,8 @@
}
else
{
Ascendants?.Remove(note);
Descendants?.Remove(note);
Ascendants?.Remove(note.Id);
Descendants?.Remove(note.Id);
}
StateHasChanged();
@ -219,12 +221,12 @@
private async Task Refresh()
{
if (NoteId == null) throw new InvalidOperationException("RefreshNote called under impossible circumstances");
Descendants = await ApiService.Notes.GetNoteDescendantsAsync(NoteId, default);
Ascendants = await ApiService.Notes.GetNoteAscendantsAsync(NoteId, default);
Descendants = await RelatedStore.GetDescendantsAsync(NoteId, default);
Ascendants = await RelatedStore.GetAscendantsAsync(NoteId, default);
StateHasChanged();
}
// ReSharper disable once InconsistentNaming
// ReSharper disable once InconsistentNaming
private ValueTask LocationChangeHandler(LocationChangingContext arg)
{
SaveState();
@ -235,7 +237,8 @@
{
if (firstRender)
{
Module = (IJSInProcessObjectReference)await Js.InvokeAsync<IJSObjectReference>("import", "/Pages/SingleNote.razor.js");
Module = (IJSInProcessObjectReference)await Js.InvokeAsync
<IJSObjectReference>("import", "/Pages/SingleNote.razor.js");
}
if (_componentState == State.Loaded)
@ -262,16 +265,16 @@
{
if (NoteId == null || _componentState != State.Loaded) return;
var scrollTop = (Module ?? throw new Exception("JS Interop used before init"))
.Invoke<float>("GetScrollY");
.Invoke
<float>("GetScrollY");
var state = new SingleNoteState { ScrollTop = scrollTop };
StateSvc.SingleNote.SetState(NoteId, state);
}
public void Dispose()
{
_noteChangedHandler?.Dispose();
SaveState();
_locationChangingHandlerDisposable?.Dispose();
MessageService.AnyNoteDeleted -= OnNoteDeleted;
StateSynchronizer.NoteDeleted -= OnNoteDeleted;
}
}

View file

@ -1,4 +1,4 @@
@page "/"
@page "/OldScroller"
@using Iceshrimp.Assets.PhosphorIcons
@using Iceshrimp.Frontend.Components
@using Iceshrimp.Frontend.Localization

View file

@ -0,0 +1,42 @@
@page "/"
@inject TimelineStore Store;
@using Iceshrimp.Shared.Schemas.Web
@using Iceshrimp.Frontend.Components
@using Iceshrimp.Frontend.Core.Miscellaneous
@using Iceshrimp.Frontend.Core.Services.NoteStore
@using Iceshrimp.Frontend.Enums
<div class="thing">
@if (NoteResponses is not null)
{
<NewVirtualScroller InitialItems="NoteResponses" ItemProvider="Provider" StateKey="1234567890" ItemProviderById="ItemProviderById">
<ItemTemplate Context="note">
<CascadingValue Value="Store" TValue="NoteMessageProvider" Name="Provider">
<TimelineNote Note="note"></TimelineNote>
</CascadingValue>
</ItemTemplate>
</NewVirtualScroller>
}
</div>
@code {
private List<NoteResponse>? NoteResponses { get; set; }
protected override async Task OnInitializedAsync()
{
NoteResponses = await Store.GetHomeTimelineAsync("home", new TimelineStore.Cursor { Direction = DirectionEnum.Older, Count = 20, Id = null });
StateHasChanged();
}
private async Task<List<NoteResponse>?> Provider(DirectionEnum direction, NoteResponse start)
{
var res = await Store.GetHomeTimelineAsync("home", new TimelineStore.Cursor { Direction = direction, Count = 10, Id = start.Id });
return res;
}
private List<NoteResponse> ItemProviderById(List<string> arg)
{
return Store.GetIdsFromTimeline("home", arg);
}
}

View file

@ -0,0 +1,3 @@
.thing {
overflow-anchor: none;
}

View file

@ -4,10 +4,12 @@ using Iceshrimp.Frontend;
using Iceshrimp.Frontend.Core.InMemoryLogger;
using Iceshrimp.Frontend.Core.Miscellaneous;
using Iceshrimp.Frontend.Core.Services;
using Iceshrimp.Frontend.Core.Services.NoteStore;
using Ljbc1994.Blazor.IntersectionObserver;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.JSInterop;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
@ -31,6 +33,13 @@ builder.Services.AddSingleton<MessageService>();
builder.Services.AddSingleton<GlobalComponentSvc>();
builder.Services.AddSingleton<UpdateService>();
builder.Services.AddSingleton<SettingsService>();
builder.Services.AddSingleton<NoteStore>();
builder.Services.AddSingleton<TimelineStore>();
builder.Services.AddSingleton<StateSynchronizer>();
builder.Services.AddSingleton<RelatedStore>();
builder.Services.AddSingleton<NoteActions>();
builder.Services.AddSingleton<NotificationStore>();
builder.Services.AddSingleton<IJSInProcessRuntime>(services => (IJSInProcessRuntime)services.GetRequiredService<IJSRuntime>());
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddBlazoredLocalStorageAsSingleton();

View file

@ -0,0 +1,20 @@
export function scrollTo(element) {
element.scrollIntoView()
}
export function getBoundingClientRectangle(element){
if (element == null) return ;
return element.getBoundingClientRect().top
}
export function SetScrollY(number){
window.scroll(window.scrollX, number);
}
export function GetScrollY(){
return window.scrollY;
}
export function GetDocumentHeight(){
return document.body.scrollHeight
}

View file

@ -0,0 +1,25 @@
function getHeight(ref) {
if (ref != null) {
return ref.scrollHeight;
} else {
console.log("invalid ref")
return 0;
}
}
function getBoundingClientRectangle(element){
if (element == null) return ;
return element.getBoundingClientRect().top
}
function SetScrollY(number){
window.scroll(window.scrollX, number);
}
function GetScrollY(){
return window.scrollY;
}
function GetDocumentHeight(){
return document.body.scrollHeight
}

View file

@ -284,3 +284,9 @@ pre:has(code){
padding: 0.5rem;
border-radius: 0.5rem;
}
.lazy-component-target-internal {
display: flex;
flex-direction: column;
align-items: center;
}