[frontend] New Virtual Scroller and Note Store
This commit is contained in:
parent
beb0044d7c
commit
45e0e058c9
33 changed files with 1601 additions and 263 deletions
|
@ -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>
|
||||
|
||||
|
|
102
Iceshrimp.Frontend/Components/LazyComponent.cs
Normal file
102
Iceshrimp.Frontend/Components/LazyComponent.cs
Normal 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();
|
||||
}
|
||||
}
|
271
Iceshrimp.Frontend/Components/NewVirtualScroller.cs
Normal file
271
Iceshrimp.Frontend/Components/NewVirtualScroller.cs
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
{
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
41
Iceshrimp.Frontend/Core/Miscellaneous/NoteMessageProvider.cs
Normal file
41
Iceshrimp.Frontend/Core/Miscellaneous/NoteMessageProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
170
Iceshrimp.Frontend/Core/Services/NoteStore/NoteActions.cs
Normal file
170
Iceshrimp.Frontend/Core/Services/NoteStore/NoteActions.cs
Normal 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);
|
||||
}
|
||||
}
|
80
Iceshrimp.Frontend/Core/Services/NoteStore/NoteStore.cs
Normal file
80
Iceshrimp.Frontend/Core/Services/NoteStore/NoteStore.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
137
Iceshrimp.Frontend/Core/Services/NoteStore/RelatedStore.cs
Normal file
137
Iceshrimp.Frontend/Core/Services/NoteStore/RelatedStore.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
10
Iceshrimp.Frontend/Core/Services/NoteStore/TimelineState.cs
Normal file
10
Iceshrimp.Frontend/Core/Services/NoteStore/TimelineState.cs
Normal 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; }
|
||||
}
|
166
Iceshrimp.Frontend/Core/Services/NoteStore/TimelineStore.cs
Normal file
166
Iceshrimp.Frontend/Core/Services/NoteStore/TimelineStore.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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; }
|
||||
}
|
7
Iceshrimp.Frontend/Enums/DirectionEnum.cs
Normal file
7
Iceshrimp.Frontend/Enums/DirectionEnum.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace Iceshrimp.Frontend.Enums;
|
||||
|
||||
public enum DirectionEnum
|
||||
{
|
||||
Newer,
|
||||
Older
|
||||
}
|
|
@ -70,4 +70,8 @@
|
|||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Core\Services\TimelineStore\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
@page "/"
|
||||
@page "/OldScroller"
|
||||
@using Iceshrimp.Assets.PhosphorIcons
|
||||
@using Iceshrimp.Frontend.Components
|
||||
@using Iceshrimp.Frontend.Localization
|
||||
|
|
42
Iceshrimp.Frontend/Pages/VirtualScrollerTest.razor
Normal file
42
Iceshrimp.Frontend/Pages/VirtualScrollerTest.razor
Normal 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);
|
||||
}
|
||||
}
|
3
Iceshrimp.Frontend/Pages/VirtualScrollerTest.razor.css
Normal file
3
Iceshrimp.Frontend/Pages/VirtualScrollerTest.razor.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.thing {
|
||||
overflow-anchor: none;
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
}
|
25
Iceshrimp.Frontend/wwwroot/Utility.js
Normal file
25
Iceshrimp.Frontend/wwwroot/Utility.js
Normal 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
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue