A Perf test (tests/e2e/Perf/FeedPerfTests, Category=Perf) scrolls load0's home, thousands of posts from SocialPub's load runs, to 2000 posts. Before: 100 cards took 12 s to reach, 1000 took 437 s, 2000 took 29 minutes; the main thread was busy almost all of that time, and the page held 224 000 DOM nodes and 920 MB of .NET heap. Every card re-rendered whenever the list did. - A card re-renders only when its own events run or its render key changes (Content.RenderKey: the post, its status, notification, conversation, place and grouping, where it is shown, the persona and the connection). 2000 cards then took 122 s. - The list is a <Virtualize>: only the cards near the view are rendered. The page now holds about 3 000 DOM nodes and 145 MB however long the feed is, and each page of older posts costs the same (about 1.9 s in the test's scroll loop) at the 100th post as at the 1700th. The feed tells how many posts it holds in data-feed-count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
541 lines
19 KiB
Plaintext
541 lines
19 KiB
Plaintext
@page "/"
|
|
@page "/home"
|
|
@page "/index"
|
|
@attribute [Authorize]
|
|
@inherits LocalizableComponentBase
|
|
<Title>@Localizer[Filter.TimelineType is TimelineType.Federation ? "Federated" : Filter.TimelineType.ToString()]</Title>
|
|
|
|
@* The feed page, the middle and right of three columns (NavMenu is the left): the feed with a tab per kind of feed,
|
|
and the globe, which follows the posts in view (GlobePanel). Below lg the globe is a banner above the feed, closed by
|
|
default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through
|
|
PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page
|
|
is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit
|
|
the stream tells is applied at once. *@
|
|
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
|
|
|
|
<aside class="@(Filter.ShowGlobe ? "block" : "hidden") lg:block lg:order-last relative h-56 sm:h-64 lg:h-full flex-none min-h-0 rounded-xl neo-raised neo-xs overflow-hidden">
|
|
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" />
|
|
</aside>
|
|
|
|
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
|
|
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
|
|
OnChanged="ChangeFeed" />
|
|
|
|
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
|
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4">
|
|
<div class="flex flex-col gap-4" data-feed-count="@posts.Count">
|
|
|
|
@if (isComposing)
|
|
{
|
|
<div class="p-3 md:p-4 rounded-xl neo-raised neo-xs">
|
|
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
|
</div>
|
|
}
|
|
|
|
@if (pending.Count > 0)
|
|
{
|
|
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-center sticky top-0 z-10" OnClick="ShowPending">
|
|
<i class="ion-md-arrow-up"></i>
|
|
<span>@Localizer["{0} new", pending.Count]</span>
|
|
</DButton>
|
|
}
|
|
|
|
@if (Filter.TimelineType is TimelineType.Nearby && position is null)
|
|
{
|
|
<div class="flex flex-col items-center gap-2 text-sm text-center">
|
|
<p>@Localizer["Nearby shows located posts written within their range of you. It needs this device's position, which goes to PrivaPub only."]</p>
|
|
@if (positionError is { Length: > 0 })
|
|
{
|
|
<p class="text-error">@positionError</p>
|
|
}
|
|
<DButton Size="DSize.Sm" OnClick="AskPosition" Disabled="IsDefaultDisabled">
|
|
<i class="ion-md-locate"></i><span>@Localizer["Use my position"]</span>
|
|
</DButton>
|
|
</div>
|
|
}
|
|
else if (IsLoading)
|
|
{
|
|
<p class="flex w-full items-center justify-center gap-2">
|
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
|
@Localizer["loading"]
|
|
</p>
|
|
}
|
|
else if (loadError is { Length: > 0 })
|
|
{
|
|
<DAlert Color="DAlertColor.Error" Variant="DAlertVariant.Soft" class="text-sm">
|
|
<span>@loadError</span>
|
|
<DButton Size="DSize.Sm" OnClick="Reload">@Localizer["Retry"]</DButton>
|
|
</DAlert>
|
|
}
|
|
else if (posts.Count == 0)
|
|
{
|
|
<p class="w-full text-center text-lg" data-feed-empty>
|
|
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
|
|
</p>
|
|
}
|
|
|
|
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
|
|
(tests/e2e/Perf/FeedPerfTests) *@
|
|
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
|
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
|
</Virtualize>
|
|
|
|
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
|
@if (isLoadingMore)
|
|
{
|
|
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
|
}
|
|
else if (!IsLoading && posts.Count > 0 && maxId is null)
|
|
{
|
|
<span>@Localizer["That's all."]</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
@code {
|
|
const string ScrollId = "feed-scroll";
|
|
const string SentinelId = "feed-end";
|
|
static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(45);
|
|
|
|
[Inject] ILocalStorageService LocalStorage { get; set; }
|
|
[Inject] IJSRuntime JS { get; set; }
|
|
[Inject] TimelineService TimelineService { get; set; }
|
|
[Inject] GroupService GroupService { get; set; }
|
|
[Inject] ListService ListService { get; set; }
|
|
[Inject] StreamingService Streaming { get; set; }
|
|
[Inject] InstanceService InstanceService { get; set; }
|
|
[Inject] RequestStats RequestStats { get; set; }
|
|
|
|
FeedFilter Filter { get; set; } = new();
|
|
List<FeedPost> posts = [];
|
|
List<FeedPost> pending = [];
|
|
List<ViewGroup> groups = [];
|
|
List<MastodonList> lists = [];
|
|
int unreadNotifications;
|
|
int unreadConversations;
|
|
string maxId;
|
|
string minId;
|
|
string loadError;
|
|
bool isLoadingMore;
|
|
bool isComposing;
|
|
(double Latitude, double Longitude)? position;
|
|
string positionError;
|
|
string loadedPersonaId;
|
|
FeedQuery lastQuery;
|
|
DateTime? lastPollAt;
|
|
TimeSpan? lastLoadTook;
|
|
|
|
GlobePanel globe;
|
|
IJSObjectReference feedModule;
|
|
DotNetObjectReference<Index> reference;
|
|
PeriodicTimer pollTimer;
|
|
CancellationTokenSource pageCancellation = new();
|
|
CancellationTokenSource loadCancellation;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
Streaming.Received += OnStream;
|
|
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
|
|
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
|
|
Filter.TimelineType = TimelineType.Home;
|
|
|
|
if (CascadingState.IsOffline)
|
|
{
|
|
WhenOnline(Reload);
|
|
StartOnlinePolling();
|
|
IsLoading = false;
|
|
return;
|
|
}
|
|
|
|
if (CascadingState.PersonaId is null)
|
|
{
|
|
IsLoading = false;
|
|
return;
|
|
}
|
|
|
|
await Reload();
|
|
}
|
|
|
|
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null)
|
|
return;
|
|
await Reload();
|
|
}
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
await base.OnAfterRenderAsync(firstRender);
|
|
if (!firstRender)
|
|
return;
|
|
|
|
reference = DotNetObjectReference.Create(this);
|
|
feedModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/feed.js");
|
|
await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId);
|
|
pollTimer = new(PollInterval);
|
|
_ = PollNewer(pageCancellation.Token);
|
|
}
|
|
|
|
/// <summary>Which of the persona's filters apply to this feed: home's for home and lists, public's for the shared
|
|
/// feeds, none for the persona's own collections (favourites, bookmarks) and conversations.</summary>
|
|
FilterContext? FilterIn => Filter.TimelineType switch
|
|
{
|
|
TimelineType.Home or TimelineType.Lists => FilterContext.Home,
|
|
TimelineType.Local or TimelineType.Federation or TimelineType.Tag or TimelineType.Nearby or TimelineType.Groups => FilterContext.Public,
|
|
TimelineType.Notifications => FilterContext.Notifications,
|
|
_ => null
|
|
};
|
|
|
|
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
|
|
Filter.Tag, position?.Latitude, position?.Longitude, Filter.ListId);
|
|
|
|
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
|
|
async Task Reload()
|
|
{
|
|
loadCancellation?.Cancel();
|
|
loadCancellation = new();
|
|
var cancellationToken = loadCancellation.Token;
|
|
|
|
IsLoading = true;
|
|
loadError = default;
|
|
pending = [];
|
|
posts = [];
|
|
maxId = default;
|
|
minId = default;
|
|
StateHasChanged();
|
|
|
|
if (CascadingState.PersonaId != loadedPersonaId)
|
|
{
|
|
// a stream is the persona's: another one opens its own
|
|
await Streaming.Stop();
|
|
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
|
|
lists = await ListService.All(cancellationToken);
|
|
loadedPersonaId = CascadingState.PersonaId;
|
|
}
|
|
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
|
|
Filter.GroupId = groups.FirstOrDefault()?.Id;
|
|
if (Filter.TimelineType is TimelineType.Lists && lists.All(l => l.Id != Filter.ListId))
|
|
Filter.ListId = lists.FirstOrDefault()?.Id;
|
|
lastQuery = Query;
|
|
if (Filter.TimelineType is TimelineType.Nearby && position is null)
|
|
{
|
|
IsLoading = false;
|
|
return;
|
|
}
|
|
|
|
var started = DateTime.UtcNow;
|
|
var result = await TimelineService.Load(Query, cancellationToken: cancellationToken);
|
|
if (cancellationToken.IsCancellationRequested)
|
|
return;
|
|
lastLoadTook = DateTime.UtcNow - started;
|
|
IsLoading = false;
|
|
if (result.IsInvalid)
|
|
{
|
|
if (result.StatusCode is not 410)
|
|
loadError = result.ErrorMessage;
|
|
return;
|
|
}
|
|
|
|
var page = (FeedPage)result.Data;
|
|
posts = page.Posts;
|
|
maxId = page.MaxId;
|
|
minId = page.MinId;
|
|
lastPollAt = DateTime.UtcNow;
|
|
await Streaming.Follow(StreamOf(Filter), cancellationToken);
|
|
await ReadNotifications(cancellationToken);
|
|
StateHasChanged();
|
|
await Locate(posts, cancellationToken);
|
|
if (feedModule is not null)
|
|
await feedModule.InvokeVoidAsync("checkEnd");
|
|
}
|
|
|
|
/// <summary>Reads the servers of the posts and places the posts on the globe.</summary>
|
|
async Task Locate(List<FeedPost> located, CancellationToken cancellationToken)
|
|
{
|
|
await InstanceService.Resolve(located.Select(p => p.Host).Append(InstanceService.SelfHost), cancellationToken);
|
|
InstanceService.Locate(located, position);
|
|
StateHasChanged();
|
|
}
|
|
|
|
[JSInvokable]
|
|
public async Task NearEnd()
|
|
{
|
|
if (IsLoading || isLoadingMore || maxId is null)
|
|
return;
|
|
|
|
isLoadingMore = true;
|
|
StateHasChanged();
|
|
var cancellationToken = loadCancellation?.Token ?? default;
|
|
var result = await TimelineService.Load(Query, maxId: maxId, cancellationToken: cancellationToken);
|
|
isLoadingMore = false;
|
|
if (cancellationToken.IsCancellationRequested)
|
|
return;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
StateHasChanged();
|
|
return;
|
|
}
|
|
|
|
var page = (FeedPage)result.Data;
|
|
var known = posts.Select(p => p.Key).ToHashSet();
|
|
var older = page.Posts.Where(p => known.Add(p.Key)).ToList();
|
|
posts.AddRange(older);
|
|
maxId = older.Count == 0 ? null : page.MaxId;
|
|
StateHasChanged();
|
|
await Locate(older, cancellationToken);
|
|
if (feedModule is not null)
|
|
await feedModule.InvokeVoidAsync("checkEnd");
|
|
}
|
|
|
|
/// <summary>Asks for posts newer than the newest shown, while the page is visible; they wait behind the "new" button.</summary>
|
|
async Task PollNewer(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
|
|
{
|
|
// no stream tells of direct messages: their tab's badge is counted again on every tick
|
|
if (!IsLoading && CascadingState.IsOnline && Filter.TimelineType is not TimelineType.Direct
|
|
&& await TimelineService.UnreadConversations(cancellationToken) is var unread && unread != unreadConversations)
|
|
{
|
|
unreadConversations = unread;
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
if (IsLoading || CascadingState.IsOffline || minId is null || Streaming.IsOpen || Filter.TimelineType is TimelineType.Nearby
|
|
|| Filter.TimelineType is TimelineType.Groups && groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity != true
|
|
|| !await feedModule.InvokeAsync<bool>("isVisible", cancellationToken))
|
|
continue;
|
|
|
|
var query = Query;
|
|
var result = await TimelineService.Load(query, minId: pending.FirstOrDefault()?.CursorId ?? minId, cancellationToken: cancellationToken);
|
|
lastPollAt = DateTime.UtcNow;
|
|
if (result.IsInvalid || query != Query)
|
|
continue;
|
|
|
|
var known = posts.Concat(pending).Select(p => p.Key).ToHashSet();
|
|
var page = ((FeedPage)result.Data).Posts;
|
|
// a conversation with a newer message is the same item, moved up with its new message
|
|
var newer = Filter.TimelineType is TimelineType.Direct
|
|
? page.Where(p => posts.Concat(pending).All(shown => shown.Key != p.Key || shown.CursorId != p.CursorId)).ToList()
|
|
: page.Where(p => known.Add(p.Key)).ToList();
|
|
if (newer.Count == 0)
|
|
continue;
|
|
posts.RemoveAll(p => newer.Any(n => n.Key == p.Key));
|
|
pending.RemoveAll(p => newer.Any(n => n.Key == p.Key));
|
|
|
|
pending.InsertRange(0, newer);
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await CascadingState.ProcessError(ex, $"{nameof(Index)}.{nameof(PollNewer)}", cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>On the Notifications tab, what is shown is read (the marker moves to the newest); elsewhere the tab's badge
|
|
/// counts what is unread.</summary>
|
|
async Task ReadNotifications(CancellationToken cancellationToken)
|
|
{
|
|
unreadConversations = await TimelineService.UnreadConversations(cancellationToken);
|
|
if (Filter.TimelineType is not TimelineType.Notifications)
|
|
{
|
|
unreadNotifications = await TimelineService.UnreadNotifications(cancellationToken);
|
|
return;
|
|
}
|
|
var newest = posts.Concat(pending).Select(p => p.Notification?.Id).Where(id => id is not null).Max(StringComparer.Ordinal);
|
|
if (newest is not null)
|
|
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
|
|
unreadNotifications = 0;
|
|
}
|
|
|
|
/// <summary>The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream.</summary>
|
|
static StreamSubscription StreamOf(FeedFilter filter) => filter.TimelineType switch
|
|
{
|
|
TimelineType.Home => new("user"),
|
|
TimelineType.Local => new("public:local"),
|
|
TimelineType.Federation => new("public:remote"),
|
|
TimelineType.Notifications => new("user:notification"),
|
|
TimelineType.Tag when filter.Tag is { Length: > 0 } tag => new("hashtag", Tag: tag),
|
|
TimelineType.Lists when filter.ListId is { Length: > 0 } list => new("list", List: list),
|
|
_ => null
|
|
};
|
|
|
|
/// <summary>What the stream tells: a new post or notification waits behind the "new" button; a deletion or an edit is
|
|
/// applied where the post is.</summary>
|
|
async Task OnStream(StreamMessage message)
|
|
{
|
|
switch (message.Event)
|
|
{
|
|
case "update" when message.Status is { } status && Filter.TimelineType is not TimelineType.Notifications:
|
|
var post = FeedPost.Of(status);
|
|
if (posts.Concat(pending).Any(p => p.Key == post.Key))
|
|
return;
|
|
pending.Insert(0, post);
|
|
break;
|
|
case "notification" when message.Notification is { } notification && Filter.TimelineType is TimelineType.Notifications:
|
|
// a notification of a group already shown joins it, and the group waits on top as new
|
|
var key = notification.GroupKey ?? notification.Id;
|
|
var group = pending.FirstOrDefault(p => p.Key == key) ?? posts.FirstOrDefault(p => p.Key == key);
|
|
if (group is null)
|
|
{
|
|
pending.Insert(0, FeedPost.Of(notification));
|
|
break;
|
|
}
|
|
group.GroupCount++;
|
|
group.Notification = notification;
|
|
group.GroupAccounts = [.. group.GroupAccounts.Prepend(notification.Account).Where(a => a is not null).DistinctBy(a => a.Id).Take(8)];
|
|
if (posts.Remove(group))
|
|
pending.Insert(0, group);
|
|
break;
|
|
case "notification" when Filter.TimelineType is not TimelineType.Notifications:
|
|
unreadNotifications++;
|
|
break;
|
|
case "delete" when message.DeletedId is { } id:
|
|
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
|
pending.RemoveAll(p => p.Key == id || p.Status?.Id == id);
|
|
break;
|
|
case "status.update" when message.Status is { } edited:
|
|
foreach (var shown in posts.Concat(pending).Where(p => p.Status?.Id == edited.Id))
|
|
shown.Status = edited;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
async Task ShowPending()
|
|
{
|
|
var shown = pending;
|
|
pending = [];
|
|
posts.InsertRange(0, shown);
|
|
await ReadNotifications(loadCancellation?.Token ?? default);
|
|
minId = shown.FirstOrDefault()?.CursorId ?? minId;
|
|
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
|
await Locate(shown, loadCancellation?.Token ?? default);
|
|
}
|
|
|
|
/// <summary>Saves the choices; reloads only when they change what the feed shows (not for the globe's toggle).</summary>
|
|
async Task ChangeFeed(FeedFilter filter)
|
|
{
|
|
await LocalStorage.SetItemAsync(nameof(FeedFilter), Filter);
|
|
if (Query == lastQuery)
|
|
return;
|
|
await Reload();
|
|
}
|
|
|
|
async Task OpenTag(string tag)
|
|
{
|
|
Filter.Tag = tag;
|
|
Filter.TimelineType = TimelineType.Tag;
|
|
await ChangeFeed(Filter);
|
|
}
|
|
|
|
async Task AskPosition()
|
|
{
|
|
positionError = default;
|
|
var answer = await JS.InvokeAsync<GeoAnswer>("feedPosition");
|
|
if (answer?.Latitude is not { } latitude || answer.Longitude is not { } longitude)
|
|
{
|
|
positionError = Localizer["This device did not give its position: {0}", answer?.Error ?? "?"];
|
|
return;
|
|
}
|
|
|
|
position = (latitude, longitude);
|
|
await Reload();
|
|
}
|
|
|
|
async Task Published(FeedPost post)
|
|
{
|
|
isComposing = false;
|
|
if (post is null)
|
|
{
|
|
if (Filter.TimelineType is TimelineType.Nearby or TimelineType.Groups)
|
|
await Reload();
|
|
return;
|
|
}
|
|
|
|
if (Filter.TimelineType is TimelineType.Home or TimelineType.Local || post.Status?.Visibility == "direct" && Filter.TimelineType is TimelineType.Direct)
|
|
{
|
|
posts.Insert(0, post);
|
|
await Locate([post], loadCancellation?.Token ?? default);
|
|
}
|
|
}
|
|
|
|
async Task Replied(FeedPost reply)
|
|
{
|
|
if (reply is null || Filter.TimelineType is not (TimelineType.Home or TimelineType.Local))
|
|
return;
|
|
posts.Insert(0, reply);
|
|
await Locate([reply], loadCancellation?.Token ?? default);
|
|
}
|
|
|
|
void Remove(FeedPost post)
|
|
{
|
|
posts.RemoveAll(p => p.Key == post.Key || p.Status?.Id == post.Status?.Id);
|
|
}
|
|
|
|
IEnumerable<NerdRow> FeedStats()
|
|
{
|
|
yield return NerdRow.Of(Localizer["items"], posts.Count);
|
|
yield return NerdRow.Of(Localizer["waiting"], pending.Count > 0 ? pending.Count : null);
|
|
yield return NerdRow.Of("min_id", minId);
|
|
yield return NerdRow.Of("max_id", maxId ?? (posts.Count > 0 ? Localizer["end"] : null));
|
|
yield return NerdRow.Of(Localizer["first page"], lastLoadTook is { } took ? NerdRow.Duration(took) : null);
|
|
yield return NerdRow.Of(Localizer["polled"], lastPollAt is { } polled ? NerdRow.Duration(DateTime.UtcNow - polled) + " " + Localizer["ago"] : null);
|
|
yield return NerdRow.Of(Localizer["stream"], Streaming.IsOpen ? Localizer["open"] : Localizer["closed, polling"]);
|
|
yield return NerdRow.Of(Localizer["poll every"], NerdRow.Duration(PollInterval));
|
|
yield return NerdRow.Of(Localizer["placed"], posts.Count > 0 ? $"{posts.Count(p => p.Location is not null)}/{posts.Count}" : null);
|
|
if (RequestStats.Latest(1).FirstOrDefault() is { } last)
|
|
{
|
|
yield return NerdRow.Of(Localizer["last request"], $"{last.Method} {last.Route}");
|
|
yield return NerdRow.Of(Localizer["answer"], $"{last.Status} · {last.DurationMs:0} ms · {NerdRow.Bytes(last.Bytes) ?? "?"}");
|
|
yield return NerdRow.Of("X-RateLimit-Remaining", last.RateLimitRemaining);
|
|
}
|
|
}
|
|
|
|
public override async ValueTask DisposeAsync()
|
|
{
|
|
Streaming.Received -= OnStream;
|
|
await Streaming.Stop();
|
|
await pageCancellation.CancelAsync();
|
|
pageCancellation.Dispose();
|
|
loadCancellation?.Cancel();
|
|
pollTimer?.Dispose();
|
|
try
|
|
{
|
|
if (feedModule is not null)
|
|
{
|
|
await feedModule.InvokeVoidAsync("dispose");
|
|
await feedModule.DisposeAsync();
|
|
}
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
reference?.Dispose();
|
|
await base.DisposeAsync();
|
|
}
|
|
|
|
sealed class GeoAnswer
|
|
{
|
|
public double? Latitude { get; set; }
|
|
public double? Longitude { get; set; }
|
|
public string Error { get; set; }
|
|
}
|
|
}
|