Files
decePubClient/Pages/Index.razor
T

425 lines
14 KiB
Plaintext
Raw Normal View History

2022-02-02 20:26:14 +01:00
@page "/"
2022-02-14 01:51:52 +01:00
@page "/home"
@page "/index"
@attribute [Authorize]
2022-12-15 20:03:40 +01:00
@inherits LocalizableComponentBase
<Title>@Localizer[Filter.TimelineType is TimelineType.Federation ? "Federated" : Filter.TimelineType.ToString()]</Title>
2022-02-02 20:26:14 +01:00
@* 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 are polled every
45 seconds while the page is visible and shown on request, because PrivaPub has no streaming. *@
<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">
2022-02-02 20:26:14 +01:00
<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>
2022-02-02 20:26:14 +01:00
<section class="flex flex-col gap-3 min-h-0 flex-1 lg:h-full">
<FeedTabs Filter="Filter" Groups="groups" Stats="FeedStats()" IsComposing="isComposing" OnCompose="() => isComposing = !isComposing"
OnChanged="ChangeFeed" />
2022-02-02 20:26:14 +01:00
<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">
2022-02-14 01:51:52 +01:00
@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>
}
2022-02-14 01:51:52 +01:00
@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>
}
@foreach (var post in posts)
{
<Content @key="post.Key" Post="post" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
}
<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>
2022-02-14 01:51:52 +01:00
</div>
</section>
</div>
@code {
const string ScrollId = "feed-scroll";
const string SentinelId = "feed-end";
static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(45);
2022-02-14 01:51:52 +01:00
[Inject] ILocalStorageService LocalStorage { get; set; }
[Inject] IJSRuntime JS { get; set; }
[Inject] TimelineService TimelineService { get; set; }
[Inject] GroupService GroupService { 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 = [];
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;
2022-02-14 01:51:52 +01:00
GlobePanel globe;
IJSObjectReference feedModule;
DotNetObjectReference<Index> reference;
PeriodicTimer pollTimer;
CancellationTokenSource pageCancellation = new();
CancellationTokenSource loadCancellation;
2022-02-14 01:51:52 +01:00
protected override async Task OnInitializedAsync()
2022-02-14 01:51:52 +01:00
{
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)
2022-02-14 01:51:52 +01:00
{
WhenOnline(Reload);
StartOnlinePolling();
IsLoading = false;
return;
2022-02-14 01:51:52 +01:00
}
if (CascadingState.PersonaId is null)
{
IsLoading = false;
return;
}
2022-02-14 01:51:52 +01:00
await Reload();
2022-02-14 01:51:52 +01:00
}
// The persona changed (NavMenu's switcher): everything on the page belongs to the old one.
protected override async Task OnParametersSetAsync()
2022-02-14 01:51:52 +01:00
{
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId || IsLoading && loadedPersonaId is null)
return;
await Reload();
2022-02-14 01:51:52 +01:00
}
protected override async Task OnAfterRenderAsync(bool firstRender)
2022-02-14 01:51:52 +01:00
{
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);
2022-02-14 01:51:52 +01:00
}
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
Filter.Tag, position?.Latitude, position?.Longitude);
/// <summary>Reads the first page of the current feed again, and the persona's groups.</summary>
async Task Reload()
2022-02-14 01:51:52 +01:00
{
loadCancellation?.Cancel();
loadCancellation = new();
var cancellationToken = loadCancellation.Token;
IsLoading = true;
loadError = default;
pending = [];
posts = [];
maxId = default;
minId = default;
StateHasChanged();
if (CascadingState.PersonaId != loadedPersonaId)
{
groups = await GroupService.List(CascadingState.PersonaId, cancellationToken);
loadedPersonaId = CascadingState.PersonaId;
}
if (Filter.TimelineType is TimelineType.Groups && groups.All(g => g.Id != Filter.GroupId))
Filter.GroupId = groups.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)
2022-02-14 01:51:52 +01:00
{
if (result.StatusCode is not 410)
loadError = result.ErrorMessage;
return;
}
2022-02-14 01:51:52 +01:00
var page = (FeedPage)result.Data;
posts = page.Posts;
maxId = page.MaxId;
minId = page.MinId;
lastPollAt = DateTime.UtcNow;
StateHasChanged();
await Locate(posts, cancellationToken);
if (feedModule is not null)
await feedModule.InvokeVoidAsync("checkEnd");
2022-02-14 01:51:52 +01:00
}
/// <summary>Reads the servers of the posts and places the posts on the globe.</summary>
async Task Locate(List<FeedPost> located, CancellationToken cancellationToken)
2022-02-14 01:51:52 +01:00
{
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)
2022-02-15 00:08:41 +01:00
{
Toast.AddResponseError(result);
StateHasChanged();
return;
2022-02-15 00:08:41 +01:00
}
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
2022-02-15 00:08:41 +01:00
{
while (await pollTimer.WaitForNextTickAsync(cancellationToken))
{
if (IsLoading || CascadingState.IsOffline || minId is null || Filter.TimelineType is TimelineType.Nearby or TimelineType.Direct
|| 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 newer = ((FeedPage)result.Data).Posts.Where(p => known.Add(p.Key)).ToList();
if (newer.Count == 0)
continue;
pending.InsertRange(0, newer);
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
await CascadingState.ProcessError(ex, $"{nameof(Index)}.{nameof(PollNewer)}", cancellationToken);
}
2022-02-14 01:51:52 +01:00
}
async Task ShowPending()
2022-02-14 01:51:52 +01:00
{
var shown = pending;
pending = [];
posts.InsertRange(0, shown);
minId = shown.FirstOrDefault()?.CursorId ?? minId;
await feedModule.InvokeVoidAsync("scrollToTop", ScrollId);
await Locate(shown, loadCancellation?.Token ?? default);
2022-02-14 01:51:52 +01:00
}
/// <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)
2022-02-14 01:51:52 +01:00
{
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);
2022-02-14 01:51:52 +01:00
}
async Task AskPosition()
2022-02-14 01:51:52 +01:00
{
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();
2022-02-14 01:51:52 +01:00
}
async Task Published(FeedPost post)
2022-02-14 01:51:52 +01:00
{
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);
}
2022-02-14 01:51:52 +01:00
}
async Task Replied(FeedPost reply)
2022-02-14 01:51:52 +01:00
{
if (reply is null || Filter.TimelineType is not (TimelineType.Home or TimelineType.Local))
return;
posts.Insert(0, reply);
await Locate([reply], loadCancellation?.Token ?? default);
2022-02-14 01:51:52 +01:00
}
void Remove(FeedPost post)
2022-02-14 01:51:52 +01:00
{
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["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);
}
}
2022-02-14 01:51:52 +01:00
public override async ValueTask DisposeAsync()
{
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; }
}
}