@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 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. *@
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
@if (!isComposing) { @* on a phone a new post starts from a button under the thumb; from md, from the feed's tabs *@ }
@* (on a phone the bottom stays clear of the New post button) *@
@if (Filter.TimelineType is TimelineType.Notifications && filteredRequests > 0) { @Localizer["{0} filtered, waiting for you", filteredRequests] } @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"]

} @* only the cards near the view are rendered: a feed of thousands stays as light as one of forty (tests/e2e/Perf/FeedPerfTests) *@
@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] 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 posts = []; List pending = []; List groups = []; List 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 reference; PeriodicTimer pollTimer; CancellationTokenSource pageCancellation = new(); CancellationTokenSource loadCancellation; protected override async Task OnInitializedAsync() { Streaming.Received += OnStream; 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; } // (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(); } /// Shows the feed again as the reader left it, when it is the same feed of the same persona and recent. async Task 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("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); } /// 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. 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); /// 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) { // 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"); } /// 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)) { // 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("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); } } /// On the Notifications tab, what is shown is read (the marker moves to the newest); elsewhere the tab's badge /// counts what is unread. 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; } /// The stream that tells this feed's new posts, or null for a feed PrivaPub does not stream. 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 }; /// 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. 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); } /// 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["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(); } /// Keeps the feed for the way back (FeedMemory), when it shows something worth keeping. 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("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; } } }