On opening, the feed showed "Empty" until the session had been read and a persona chosen, then loaded: a flash of a wrong answer on every start (and the end-to-end sign-in took it for a loaded feed). CascadingState says once the session has been read (IsSessionLoaded), and until then a feed with no persona yet is loading; a login without any persona still gets its empty feed. The persona-switch test waits for the sibling's home read with another token than the first persona's, instead of any home read after the switch was asked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
640 lines
24 KiB
Plaintext
640 lines
24 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">
|
|
|
|
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
|
|
<aside class="@GlobeClass" role="@(globeExpanded ? "dialog" : null)" aria-modal="@(globeExpanded ? "true" : null)"
|
|
aria-label="@(globeExpanded ? Localizer["Globe"] : null)">
|
|
<GlobePanel @ref="globe" ScrollContainerId="@ScrollId" Posts="posts" @bind-Expanded="globeExpanded" />
|
|
</aside>
|
|
|
|
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full" inert="@globeExpanded">
|
|
@if (!isComposing)
|
|
{
|
|
@* on a phone a new post starts from a button under the thumb; from md, from the feed's tabs *@
|
|
<DButton Shape="DButtonShape.Circle" Size="DSize.Lg" Color="DColor.Primary" OnClick="ToggleCompose"
|
|
class="md:hidden fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-30" aria-label="@Localizer["New post"]"
|
|
title="@Localizer["New post"]" data-compose-open>
|
|
<i class="ion-md-create text-xl" aria-hidden="true"></i>
|
|
</DButton>
|
|
}
|
|
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
|
|
OnChanged="ChangeFeed" />
|
|
|
|
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
|
|
@* (on a phone the bottom stays clear of the New post button) *@
|
|
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 max-md:pb-24 md:p-4">
|
|
<div class="flex flex-col gap-4" data-feed-count="@posts.Count">
|
|
|
|
<PhoneSheet IsOpen="isComposing" OnClose="() => isComposing = false" Heading="@Localizer["New post"]" Class="md:p-4 md:rounded-xl md:neo-raised md:neo-xs">
|
|
<Composer GroupId="@(Filter.TimelineType is TimelineType.Groups ? Filter.GroupId : null)" OnPublished="Published" />
|
|
</PhoneSheet>
|
|
|
|
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0)
|
|
{
|
|
<a class="btn btn-sm self-center" href="notifications/filtered" data-filtered-requests>
|
|
<i class="ion-md-notifications-off"></i>
|
|
<span>@Localizer["{0} filtered, waiting for you", filteredRequests]</span>
|
|
</a>
|
|
}
|
|
|
|
@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; }
|
|
[Inject] FeedMemory Memory { get; set; }
|
|
|
|
FeedFilter Filter { get; set; } = new();
|
|
List<FeedPost> posts = [];
|
|
List<FeedPost> pending = [];
|
|
List<ViewGroup> groups = [];
|
|
List<MastodonList> lists = [];
|
|
int unreadNotifications;
|
|
// the requests the persona's notification policy filtered, shown on the Notifications tab
|
|
int filteredRequests;
|
|
int unreadConversations;
|
|
string maxId;
|
|
string minId;
|
|
string loadError;
|
|
bool isLoadingMore;
|
|
bool isComposing;
|
|
bool globeExpanded;
|
|
|
|
// a phone's banner (none when the phone is held sideways: the feed would have no room), the column from lg, or
|
|
// full screen
|
|
string GlobeClass => globeExpanded
|
|
? "fixed inset-0 z-[70] block h-dvh w-full overflow-hidden bg-base-100"
|
|
: Filter.ShowGlobe
|
|
? "block max-lg:short: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"
|
|
: "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";
|
|
(double Latitude, double Longitude)? position;
|
|
string positionError;
|
|
string loadedPersonaId;
|
|
bool waitingForPersona;
|
|
FeedQuery lastQuery;
|
|
DateTime? lastPollAt;
|
|
TimeSpan? lastLoadTook;
|
|
// the scroll a recalled feed goes back to, once rendered
|
|
double? recalledScroll;
|
|
|
|
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;
|
|
}
|
|
|
|
// (while the session is still being read the persona isn't known yet: the feed is loading, not empty)
|
|
if (CascadingState.PersonaId is null)
|
|
{
|
|
waitingForPersona = !CascadingState.IsSessionLoaded;
|
|
IsLoading = waitingForPersona;
|
|
return;
|
|
}
|
|
|
|
if (await Recall())
|
|
return;
|
|
await Reload();
|
|
}
|
|
|
|
/// <summary>Shows the feed again as the reader left it, when it is the same feed of the same persona and recent.</summary>
|
|
async Task<bool> Recall()
|
|
{
|
|
if (!Memory.Holds(CascadingState.PersonaId))
|
|
return false;
|
|
groups = Memory.Groups;
|
|
lists = Memory.Lists;
|
|
if (Query != Memory.Query)
|
|
return false;
|
|
|
|
loadCancellation = new();
|
|
posts = Memory.Posts;
|
|
pending = Memory.Pending;
|
|
minId = Memory.MinId;
|
|
maxId = Memory.MaxId;
|
|
recalledScroll = Memory.ScrollTop;
|
|
loadedPersonaId = CascadingState.PersonaId;
|
|
lastQuery = Query;
|
|
IsLoading = false;
|
|
Memory.Forget();
|
|
await Streaming.Follow(StreamOf(Filter), loadCancellation.Token);
|
|
await ReadNotifications(loadCancellation.Token);
|
|
return true;
|
|
}
|
|
|
|
// The persona is known (the session was read after the page opened) or changed (NavMenu's switcher): everything on the
|
|
// page belongs to it.
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (waitingForPersona && CascadingState.IsSessionLoaded && CascadingState.PersonaId is null)
|
|
{
|
|
waitingForPersona = false;
|
|
IsLoading = false;
|
|
return;
|
|
}
|
|
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null && !waitingForPersona)
|
|
return;
|
|
waitingForPersona = false;
|
|
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");
|
|
if (recalledScroll is { } top)
|
|
await feedModule.InvokeVoidAsync("scrollToOffset", ScrollId, top);
|
|
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;
|
|
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 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);
|
|
}
|
|
|
|
// a composer that opens shows at the top of the feed, where it is, however far down the feed was read
|
|
async Task ToggleCompose()
|
|
{
|
|
isComposing = !isComposing;
|
|
if (isComposing && feedModule is not null)
|
|
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
|
|
}
|
|
|
|
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 Remember();
|
|
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();
|
|
}
|
|
|
|
/// <summary>Keeps the feed for the way back (FeedMemory), when it shows something worth keeping.</summary>
|
|
async Task Remember()
|
|
{
|
|
if (IsLoading || loadError is { Length: > 0 } || posts.Count == 0 || CascadingState.PersonaId is null
|
|
|| Filter.TimelineType is TimelineType.Nearby)
|
|
return;
|
|
var scrollTop = 0d;
|
|
try
|
|
{
|
|
if (feedModule is not null)
|
|
scrollTop = await feedModule.InvokeAsync<double>("scrollOffset", ScrollId);
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
Memory.Save(Query, posts, pending, groups, lists, minId, maxId, scrollTop);
|
|
}
|
|
|
|
sealed class GeoAnswer
|
|
{
|
|
public double? Latitude { get; set; }
|
|
public double? Longitude { get; set; }
|
|
public string Error { get; set; }
|
|
}
|
|
}
|