Files
decePubClient/LayerComponents/CascadingState.razor
T
thepraandClaude Opus 5.5 47ff60f57b The feed doesn't say "Empty" before its persona is known
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
2026-10-07 00:20:56 +02:00

241 lines
8.2 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 session has been read once: from then on a null PersonaId means the login has no persona, not "not yet".</summary>
public bool IsSessionLoaded { 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();
IsSessionLoaded = true;
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;
IsSessionLoaded = true;
StateHasChanged();
return;
}
IsSessionLoaded = true;
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>Reads the root's personas again, after one was made.</summary>
public async Task ReloadPersonas(CancellationToken cancellationToken = default)
{
Personas = await AuthService.Personas(cancellationToken);
StateHasChanged();
}
/// <summary>The current persona's account as PrivaPub describes it after the persona edited its profile; the persona
/// chooser shows the new name and picture too.</summary>
public void PersonaChanged(Account account)
{
if (account is null || PersonaId is null)
return;
Persona = account;
if (Personas.FirstOrDefault(p => p.Id == PersonaId) is { } persona)
{
persona.Name = account.DisplayName;
persona.PictureURL = account.Avatar;
}
StateHasChanged();
}
/// <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();
}
}