@page "/" @page "/home" @page "/index" @attribute [Authorize] @inherits LocalizableComponentBase @Localizer[Filter.TimelineType is TimelineType.Federation ? "Federated" : Filter.TimelineType.ToString()] @* 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. *@
@if (isComposing) {
} @if (pending.Count > 0) { @Localizer["{0} new", pending.Count] } @if (Filter.TimelineType is TimelineType.Nearby && position is null) {

@Localizer["Nearby shows located posts written within their range of you. It needs this device's position, which goes to PrivaPub only."]

@if (positionError is { Length: > 0 }) {

@positionError

} @Localizer["Use my position"]
} else if (IsLoading) {

@Localizer["loading"]

} else if (loadError is { Length: > 0 }) { @loadError @Localizer["Retry"] } else if (posts.Count == 0) {

@Localizer["Empty"]

} @foreach (var post in posts) { }
@if (isLoadingMore) { } else if (!IsLoading && posts.Count > 0 && maxId is null) { @Localizer["That's all."] }
@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] InstanceService InstanceService { get; set; } [Inject] RequestStats RequestStats { get; set; } FeedFilter Filter { get; set; } = new(); List posts = []; List pending = []; List 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; GlobePanel globe; IJSObjectReference feedModule; DotNetObjectReference reference; PeriodicTimer pollTimer; CancellationTokenSource pageCancellation = new(); CancellationTokenSource loadCancellation; protected override async Task OnInitializedAsync() { Filter = await LocalStorage.GetItemAsync(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("import", "./js/feed.js"); await feedModule.InvokeVoidAsync("observeEnd", reference, SentinelId, ScrollId); pollTimer = new(PollInterval); _ = PollNewer(pageCancellation.Token); } FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true, Filter.Tag, position?.Latitude, position?.Longitude); /// Reads the first page of the current feed again, and the persona's groups. 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) { 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) { 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; StateHasChanged(); await Locate(posts, cancellationToken); if (feedModule is not null) await feedModule.InvokeVoidAsync("checkEnd"); } /// Reads the servers of the posts and places the posts on the globe. async Task Locate(List 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"); } /// Asks for posts newer than the newest shown, while the page is visible; they wait behind the "new" button. async Task PollNewer(CancellationToken cancellationToken) { try { 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("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); } } async Task ShowPending() { var shown = pending; pending = []; posts.InsertRange(0, shown); minId = shown.FirstOrDefault()?.CursorId ?? minId; await feedModule.InvokeVoidAsync("scrollToTop", ScrollId); await Locate(shown, loadCancellation?.Token ?? default); } /// Saves the choices; reloads only when they change what the feed shows (not for the globe's toggle). 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("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 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); } } 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; } } }