@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. *@
@ChildContent
@code {
/// How often the JWT's expiry is checked; AuthService renews it two days before.
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();
/// Whether a root is signed in (its JWT is still valid).
public bool IsSignedIn { get; private set; }
/// The root's login name, shown to its owner only.
public string RootUserName { get; private set; }
/// The root's personas.
public List Personas { get; private set; } = [];
/// The avatar id of the persona the client acts as.
public string PersonaId { get; private set; }
/// That persona's Mastodon account, null until it is read.
public Account Persona { get; private set; }
DotNetObjectReference 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(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);
}
}
/// Saves the public cache, applies its theme and re-renders the app.
public async ValueTask UpdatePublicCache(PublicCacheData publicCacheData, CancellationToken cancellationToken = default)
{
PublicCacheData = publicCacheData;
await Storage.SetItemAsync(nameof(PublicCacheData), PublicCacheData, cancellationToken);
Status.ApplyTheme(PublicCacheData.PageSettings);
StateHasChanged();
}
/// Changes the page settings, then saves and applies them like UpdatePublicCache.
public ValueTask UpdatePageSettings(Action change, CancellationToken cancellationToken = default)
{
change(PublicCacheData.PageSettings);
return UpdatePublicCache(PublicCacheData, cancellationToken);
}
///
/// 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).
///
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);
}
/// Acts as another of the root's personas; every page showing persona data reloads.
public async Task 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;
}
/// Revokes the persona tokens, ends the session and forgets the local data.
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);
}
}
/// Logs an unexpected failure and tells the user.
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();
}
}