@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. With PageSettings.SyncWithLogin the theme is the login's: taken at sign-in and at start-up, saved there a moment after it changes here. *@ @ChildContent @code { /// How often the JWT's expiry is checked; AuthService renews it two days before. static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1); /// How long a change of the page settings waits before it is saved as the login's: a slider sends many. static readonly TimeSpan SettingsSaveDelay = TimeSpan.FromMilliseconds(1500); [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 session has been read once: from then on a null PersonaId means the login has no persona, not "not yet". public bool IsSessionLoaded { 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(); /// What the login is known to keep, as LoginFingerprint reads it (null when unknown), so settings that already /// match it, the login's own just taken among them, aren't saved again. (string, short, short, bool, bool, bool, bool)? loginSettings; /// The one save of the page settings waiting for SettingsSaveDelay to pass. CancellationTokenSource pendingSettingsSave; 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); if (IsSignedIn && IsOnline && PublicCacheData.PageSettings.SyncWithLogin) await KeepThemeWithLogin(await AuthService.Settings(cancellationToken), 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, and saves them as the /// login's a moment later (SaveSettingsSoon). public ValueTask UpdatePageSettings(Action change, CancellationToken cancellationToken = default) { change(PublicCacheData.PageSettings); SaveSettingsSoon(); return UpdatePublicCache(PublicCacheData, cancellationToken); } /// /// Reads the session a login, a setup or an invitation has just started (AuthService keeps its JWT, which carries the /// login's settings), then keeps the theme with the login's (KeepThemeWithLogin). /// public async Task SignedIn(JwtUser jwtUser, CancellationToken cancellationToken = default) { loginSettings = null; await LoadSession(cancellationToken); await KeepThemeWithLogin(jwtUser?.UserSettings, cancellationToken); } /// /// When this device keeps its theme with the login: takes the login's theme (never its language, which would reload the /// app) once a client saved one, or gives the login this device's while none did. Unknown settings change nothing. /// async Task KeepThemeWithLogin(ViewUserSettings settings, CancellationToken cancellationToken) { if (settings is null || !IsSignedIn || !PublicCacheData.PageSettings.SyncWithLogin) return; if (settings.SavedAt is null) { SaveSettingsSoon(); return; } PublicCacheData.PageSettings.TakeLoginTheme(settings); // as the login keeps them now, so taking them isn't followed by saving them back loginSettings = LoginFingerprint(PublicCacheData.PageSettings.ToLoginSettings()); await UpdatePublicCache(PublicCacheData, cancellationToken); } /// /// Saves the page settings as the login's once SettingsSaveDelay passed without another change: signed in, with /// SyncWithLogin, and only when they differ from what the login is known to keep. One save waits at a time; a failure /// is logged (AuthService.SaveSettings), never shown. /// void SaveSettingsSoon() { CancelSettingsSave(); var settings = PublicCacheData.PageSettings; if (!IsSignedIn || !settings.SyncWithLogin || LoginFingerprint(settings.ToLoginSettings()) == loginSettings) return; pendingSettingsSave = new(); _ = SaveSettingsLater(pendingSettingsSave.Token); } async Task SaveSettingsLater(CancellationToken cancellationToken) { try { await Task.Delay(SettingsSaveDelay, cancellationToken); } catch (OperationCanceledException) { return; } if (!IsSignedIn) return; var settings = PublicCacheData.PageSettings.ToLoginSettings(); var result = await AuthService.SaveSettings(settings, cancellationToken); if (result.IsInvalid) return; loginSettings = LoginFingerprint(settings); } void CancelSettingsSave() { var pending = pendingSettingsSave; pendingSettingsSave = null; pending?.Cancel(); pending?.Dispose(); } /// What of the login's settings this client reads and writes: not the icons' hue, nor when they were saved. static (string, short, short, bool, bool, bool, bool) LoginFingerprint(ViewUserSettings settings) => (settings.LanguageCode, settings.LightThemeIndexColour, settings.DarkThemeIndexColour, settings.PreferSystemTheming, settings.ThemeIsDarkMode, settings.ThemeIsLightGray, settings.ThemeIsDarkGray); /// /// 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(); 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; } // loaded once the persona is known (or couldn't be used): a page rendered while it is switched to would otherwise // see a loaded session without one, and the feed would say it is empty await SwitchPersona(personaId, cancellationToken); IsSessionLoaded = true; StateHasChanged(); } /// 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; } /// Reads the root's personas again, after one was made. public async Task ReloadPersonas(CancellationToken cancellationToken = default) { Personas = await AuthService.Personas(cancellationToken); StateHasChanged(); } /// 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. 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(); } /// Revokes the persona tokens, ends the session and forgets the local data. public async Task SignOut(CancellationToken cancellationToken = default) { CancelSettingsSave(); await AuthService.Logout(cancellationToken); ClearSession(); StateHasChanged(); } void ClearSession() { CancelSettingsSave(); loginSettings = null; 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(); CancelSettingsSave(); onlineTimer?.Dispose(); refreshTimer?.Dispose(); cascadingStateReference?.Dispose(); } }