Files
decePubClient/LayerComponents/CascadingState.razor
T
thepraandClaude Opus 5.5 827ecb770e Wire the client to PrivaPub: one sign-in, real feeds, a globe, nerd stats
One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi,
picks a persona, and each persona gets its own Mastodon token in
exchange for the JWT (PersonaExchange). HttpService picks credentials
and JSON by route (JWT and camelCase for /clientapi, the persona token
and snake_case for /api, nothing for /oauth), retries a refused persona
token once, and records every request for the nerd stats.

The feed page is three columns: navigation with a persona switcher, the
feed with a tab per kind (home, local, federated, nearby, communities
and circles, direct, notifications, favourites, bookmarks, hashtags),
and a 3D globe that looks at the spherical mean of the posts in view
and draws a pulse and an arc to a hovered one. Posts sit at their own
place, their event's venue, the reader (located posts) or their
author's server as PrivaPub publishes it. Cards favourite, boost,
bookmark, reply, mute, block and delete; the composer posts with a
content warning, visibility, media and alt text, or as a located post.
Older pages load as the end nears; newer ones are polled while visible.
Nerd stats in tooltips: provenance, author and server, place, media,
counts, paging, the globe's camera and frame rate.

The mock (Faker, MessagesService, the Message models and store) and the
OIDC template leftovers are gone. Strings in English and Italian.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 11:03:14 +02:00

213 lines
7.3 KiB
Plaintext

@implements IAsyncDisposable
@* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved
PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the
client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages
compare PersonaId to reload what belongs to the persona. *@
<CascadingValue IsFixed=false Value=this>
@ChildContent
</CascadingValue>
@code {
/// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary>
static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
[Parameter] public RenderFragment ChildContent { get; set; }
[Inject] ILocalStorageService Storage { get; set; }
[Inject] IJSRuntime JS { get; set; }
[Inject] public AppStatusService Status { get; set; }
[Inject] ILoggingService Logger { get; set; }
[Inject] ToastService Toast { get; set; }
[Inject] AuthService AuthService { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] InstanceService InstanceService { get; set; }
public bool IsOnline { get; private set; } = true;
public bool IsOffline => !IsOnline;
public PublicCacheData PublicCacheData { get; private set; } = new();
/// <summary>Whether a root is signed in (its JWT is still valid).</summary>
public bool IsSignedIn { get; private set; }
/// <summary>The root's login name, shown to its owner only.</summary>
public string RootUserName { get; private set; }
/// <summary>The root's personas.</summary>
public List<ViewAvatar> Personas { get; private set; } = [];
/// <summary>The avatar id of the persona the client acts as.</summary>
public string PersonaId { get; private set; }
/// <summary>That persona's Mastodon account, null until it is read.</summary>
public Account Persona { get; private set; }
DotNetObjectReference<CascadingState> cascadingStateReference;
PeriodicTimer onlineTimer;
PeriodicTimer refreshTimer;
readonly CancellationTokenSource disposeCancellation = new();
protected override async Task OnInitializedAsync()
{
var cancellationToken = disposeCancellation.Token;
try
{
cascadingStateReference = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("cascadingStateInstanceReference", cancellationToken, cascadingStateReference);
var publicCacheData = await Storage.GetItemAsync<PublicCacheData>(nameof(PublicCacheData), cancellationToken);
await UpdatePublicCache(publicCacheData ?? new(), cancellationToken);
IsOnline = await Status.IsOnline(cancellationToken);
onlineTimer = new(TimeSpan.FromSeconds(10));
_ = PollOnline(cancellationToken);
await LoadSession(cancellationToken);
refreshTimer = new(RefreshInterval);
_ = KeepSessionFresh(cancellationToken);
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(OnInitializedAsync)}", cancellationToken);
}
}
/// <summary>Saves the public cache, applies its theme and re-renders the app.</summary>
public async ValueTask UpdatePublicCache(PublicCacheData publicCacheData, CancellationToken cancellationToken = default)
{
PublicCacheData = publicCacheData;
await Storage.SetItemAsync(nameof(PublicCacheData), PublicCacheData, cancellationToken);
Status.ApplyTheme(PublicCacheData.PageSettings);
StateHasChanged();
}
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache.</summary>
public ValueTask UpdatePageSettings(Action<PageSettings> change, CancellationToken cancellationToken = default)
{
change(PublicCacheData.PageSettings);
return UpdatePublicCache(PublicCacheData, cancellationToken);
}
/// <summary>
/// Reads the session after start-up or a login: renews the JWT when it is about to expire, lists the personas and
/// acts as the last one used (the first one otherwise).
/// </summary>
public async Task LoadSession(CancellationToken cancellationToken = default)
{
await AuthService.RefreshIfNeeded(cancellationToken);
IsSignedIn = await AuthStateProvider.IsAuthenticatedAsync(cancellationToken);
if (!IsSignedIn)
{
ClearSession();
StateHasChanged();
return;
}
var session = await AuthStateProvider.GetAuthData(cancellationToken);
RootUserName = session.UserName;
Personas = await AuthService.Personas(cancellationToken);
var personaId = Personas.Any(p => p.Id == session.PersonaId) ? session.PersonaId : Personas.FirstOrDefault()?.Id;
if (personaId is null)
{
PersonaId = null;
Persona = null;
StateHasChanged();
return;
}
await SwitchPersona(personaId, cancellationToken);
}
/// <summary>Acts as another of the root's personas; every page showing persona data reloads.</summary>
public async Task<WebResult> SwitchPersona(string personaId, CancellationToken cancellationToken = default)
{
var result = await AuthService.UsePersona(personaId, cancellationToken);
if (result.IsInvalid)
return result;
PersonaId = personaId;
Persona = await AuthService.CurrentAccount(cancellationToken);
if (Persona?.Host is { } host)
InstanceService.SelfHost = host;
StateHasChanged();
return result;
}
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
public async Task SignOut(CancellationToken cancellationToken = default)
{
await AuthService.Logout(cancellationToken);
ClearSession();
StateHasChanged();
}
void ClearSession()
{
IsSignedIn = false;
RootUserName = null;
Personas = [];
PersonaId = null;
Persona = null;
}
async Task KeepSessionFresh(CancellationToken cancellationToken)
{
try
{
while (await refreshTimer.WaitForNextTickAsync(cancellationToken))
if (IsSignedIn && IsOnline)
await AuthService.RefreshIfNeeded(cancellationToken);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(KeepSessionFresh)}", cancellationToken);
}
}
[JSInvokable]
public async Task LogFromJs(string message, string where) =>
await ProcessError(new(message), where);
async Task PollOnline(CancellationToken cancellationToken)
{
try
{
while (await onlineTimer.WaitForNextTickAsync(cancellationToken))
{
var isOnline = await Status.IsOnline(cancellationToken);
if (isOnline == IsOnline)
continue;
IsOnline = isOnline;
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(PollOnline)}", cancellationToken);
}
}
/// <summary>Logs an unexpected failure and tells the user.</summary>
public async ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
{
await Logger.ProcessError(exception, where, cancellationToken);
Toast.AddException(exception);
}
public async ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
{
await Logger.ProcessWarning(message, where, cancellationToken);
Toast.AddWarning(message);
}
public async ValueTask DisposeAsync()
{
await disposeCancellation.CancelAsync();
disposeCancellation.Dispose();
onlineTimer?.Dispose();
refreshTimer?.Dispose();
cascadingStateReference?.Dispose();
}
}