Files
decePubClient/LayerComponents/CascadingState.razor
T

601 lines
25 KiB
Plaintext
Raw Normal View History

@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. A persona the staff suspended is never
acted as: it can't be chosen, and when PrivaPub refuses the current one a token (TokenAuthStateProvider.PersonaRefused)
the session moves to another persona of the login, or to none, and says why once. The current persona's reading
preferences (how its cards show media and content warnings) are read with it and kept here. Web Push follows the
session (PushService): checked once it is read, turned off before signing out, a persona dropped when it is deleted or
refused; a notification's click opens its persona's notifications (?push= when it opens the app, PushOpened when the
app is open). *@
<CascadingValue IsFixed=false Value=this>
2022-02-14 01:51:52 +01:00
@ChildContent
</CascadingValue>
2022-02-14 01:51:37 +01:00
@code {
/// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary>
static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
/// <summary>How long a change of the page settings waits before it is saved as the login's: a slider sends many.</summary>
static readonly TimeSpan SettingsSaveDelay = TimeSpan.FromMilliseconds(1500);
2022-02-14 01:51:52 +01:00
[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; }
[Inject] PushService PushService { get; set; }
[Inject] NavigationManager Navigation { get; set; }
[Inject] CoalescingStringLocalizer Localizer { get; set; }
2022-02-14 01:51:52 +01:00
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; }
/// <summary>How that persona reads what others post (media shown or hidden, content warnings open or folded), read when
/// it is acted as; PrivaPub's defaults until then, or when they can't be read. A change replaces the object, which the
/// cards' render key holds.</summary>
public ViewPersonaPreferences Preferences { get; private set; } = new();
/// <summary>Preferences.ExpandMedia as the cards use it.</summary>
public ExpandMedia ExpandMedia => ExpandMediaModes.Of(Preferences.ExpandMedia);
/// <summary>The root handles reports and moderates accounts and posts (its JWT's policies); PrivaPub checks it anyway.</summary>
public bool IsModerator { get; private set; }
/// <summary>The root administers the server (its JWT's policies); PrivaPub checks it anyway.</summary>
public bool IsAdmin { get; private set; }
2022-02-14 01:51:52 +01:00
DotNetObjectReference<CascadingState> cascadingStateReference;
PeriodicTimer onlineTimer;
PeriodicTimer refreshTimer;
readonly CancellationTokenSource disposeCancellation = new();
/// <summary>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.</summary>
(string, short, short, bool, bool, bool, bool)? loginSettings;
/// <summary>The one save of the page settings waiting for SettingsSaveDelay to pass.</summary>
CancellationTokenSource pendingSettingsSave;
/// <summary>A refused persona is being moved on from (MoveOnFromRefused); a refusal meanwhile is seen by its loop.</summary>
bool handlingRefusal;
/// <summary>The registration slot a Web Push notification's click opened the app with (?push=, push-worker.js): its
/// persona is acted as when the session is first read. Taken before the feed page takes the address's question away.</summary>
string pushSlot;
2022-02-14 01:51:52 +01:00
protected override async Task OnInitializedAsync()
{
var cancellationToken = disposeCancellation.Token;
pushSlot = QueryValue(Navigation.Uri, "push");
2022-02-14 01:51:52 +01:00
try
{
cascadingStateReference = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("cascadingStateInstanceReference", cancellationToken, cascadingStateReference);
AuthStateProvider.PersonaRefused += OnPersonaRefused;
2022-02-14 01:51:52 +01:00
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);
if (IsSignedIn && IsOnline && PublicCacheData.PageSettings.SyncWithLogin)
await KeepThemeWithLogin(await AuthService.Settings(cancellationToken), cancellationToken);
refreshTimer = new(RefreshInterval);
_ = KeepSessionFresh(cancellationToken);
2022-02-14 01:51:52 +01:00
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(OnInitializedAsync)}", cancellationToken);
2022-02-14 01:51:52 +01:00
}
}
/// <summary>Saves the public cache, applies its theme and re-renders the app.</summary>
public async ValueTask UpdatePublicCache(PublicCacheData publicCacheData, CancellationToken cancellationToken = default)
2022-02-14 01:51:52 +01:00
{
PublicCacheData = publicCacheData;
await Storage.SetItemAsync(nameof(PublicCacheData), PublicCacheData, cancellationToken);
Status.ApplyTheme(PublicCacheData.PageSettings);
StateHasChanged();
2022-02-14 01:51:52 +01:00
}
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache, and saves them as the
/// login's a moment later (SaveSettingsSoon).</summary>
public ValueTask UpdatePageSettings(Action<PageSettings> change, CancellationToken cancellationToken = default)
{
change(PublicCacheData.PageSettings);
SaveSettingsSoon();
return UpdatePublicCache(PublicCacheData, cancellationToken);
}
/// <summary>
/// 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).
/// </summary>
public async Task SignedIn(JwtUser jwtUser, CancellationToken cancellationToken = default)
{
loginSettings = null;
await LoadSession(cancellationToken);
await KeepThemeWithLogin(jwtUser?.UserSettings, cancellationToken);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>What of the login's settings this client reads and writes: not the icons' hue, nor when they were saved.</summary>
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);
/// <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();
// a session that ended while the app was closed: this browser stops showing what PrivaPub still pushes
InBackground(() => PushService.Forget(cancellationToken), nameof(LoadSession), cancellationToken);
return;
}
var session = await AuthStateProvider.GetAuthData(cancellationToken);
RootUserName = session.UserName;
IsModerator = session.Policies.Contains(Policies.IsModerator);
IsAdmin = session.Policies.Contains(Policies.IsAdmin);
Personas = await AuthService.Personas(cancellationToken);
// what PrivaPub lists as usable is tried again, a persona refused before and given back since among them
foreach (var usable in Personas.Where(CanUse))
AuthStateProvider.Unrefuse(usable.Id);
var stored = Personas.FirstOrDefault(p => p.Id == session.PersonaId);
var pushed = await PushedPersona(cancellationToken);
var personaId = pushed is not null && CanUse(pushed) ? pushed.Id
: stored is not null && CanUse(stored) ? stored.Id : Personas.FirstOrDefault(CanUse)?.Id;
if (stored is not null && !CanUse(stored))
Toast.AddWarning(personaId is null
? Localizer["{0} is suspended by this server's staff, and no other persona of this login can act now.", Shown(stored)]
: Localizer["{0} is suspended by this server's staff: you now act as {1}.", Shown(stored), Shown(Personas.First(p => p.Id == personaId))]);
if (personaId is null)
{
// the stored persona is listed suspended: no request acts as it (a list that couldn't be read forgets nothing)
if (stored is not null)
await AuthStateProvider.SetPersona(null, cancellationToken: cancellationToken);
PersonaId = null;
Persona = null;
Preferences = new();
IsSessionLoaded = true;
StateHasChanged();
ReconcilePush(cancellationToken);
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();
ReconcilePush(cancellationToken);
}
/// <summary>This device's Web Push subscriptions brought in line with the personas (PushService.Reconcile), once the
/// current persona holds its token, so the two never exchange one for it at once.</summary>
void ReconcilePush(CancellationToken cancellationToken)
{
var personas = Personas;
InBackground(() => PushService.Reconcile(personas, cancellationToken), nameof(LoadSession), cancellationToken);
}
/// <summary>The persona whose notification's click opened the app (pushSlot), once; null otherwise.</summary>
async Task<ViewAvatar> PushedPersona(CancellationToken cancellationToken)
{
var slot = pushSlot;
pushSlot = null;
if (slot is null)
return null;
var personaId = await PushService.PersonaOf(slot, cancellationToken);
return Personas.FirstOrDefault(p => p.Id == personaId);
}
/// <summary>
/// A Web Push notification was clicked while the app is open (push-worker.js posts it, main.js calls this): its persona
/// is acted as, unless it can't be, and the feed opens on the Notifications tab (Index.razor's ?notifications=1).
/// </summary>
[JSInvokable]
public async Task PushOpened(string slot)
{
var cancellationToken = disposeCancellation.Token;
try
{
if (!IsSignedIn)
return;
var personaId = await PushService.PersonaOf(slot, cancellationToken);
if (personaId is not null && personaId != PersonaId && Personas.FirstOrDefault(p => p.Id == personaId) is { } persona && CanUse(persona))
{
var switched = await SwitchPersona(personaId, cancellationToken);
if (switched.IsInvalid)
Toast.AddResponseError(switched);
}
Navigation.NavigateTo("?notifications=1");
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(PushOpened)}", cancellationToken);
}
}
/// <summary>Runs Web Push's bookkeeping without holding the session up; it fails quietly, logged.</summary>
void InBackground(Func<Task<WebResult>> job, string caller, CancellationToken cancellationToken) => _ = RunInBackground(job, caller, cancellationToken);
async Task RunInBackground(Func<Task<WebResult>> job, string caller, CancellationToken cancellationToken)
{
try
{
var result = await job();
if (result.IsInvalid && result.StatusCode is not 410)
await Logger.ProcessWarning(result.ToString(Localizer), $"{nameof(CascadingState)}.{caller}", cancellationToken);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{caller}", cancellationToken);
}
}
/// <summary>A value of the address's query, or null.</summary>
static string QueryValue(string uri, string name) =>
System.Web.HttpUtility.ParseQueryString(new Uri(uri).Query)[name] is { Length: > 0 } value ? value : null;
/// <summary>Whether the persona may be acted as: not suspended by the server's staff (its login sees it so in its list).</summary>
public static bool CanUse(ViewAvatar persona) => persona.AccountState is not ViewAvatarState.Suspended;
/// <summary>A persona's name as the session's messages say it.</summary>
static string Shown(ViewAvatar persona) => string.IsNullOrWhiteSpace(persona.Name) ? "@" + persona.UserName : persona.Name;
/// <summary>Acts as another of the root's personas; every page showing persona data reloads. A suspended one is refused
/// here, before PrivaPub would refuse it a token.</summary>
public async Task<WebResult> SwitchPersona(string personaId, CancellationToken cancellationToken = default)
{
if (Personas.FirstOrDefault(p => p.Id == personaId) is { } listed && !CanUse(listed))
return new WebResult().Invalidate(Localizer["{0} is suspended by this server's staff.", Shown(listed)], 403);
// chosen again, it is tried again
AuthStateProvider.Unrefuse(personaId);
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;
Preferences = await AuthService.PersonaPreferences(personaId, cancellationToken) ?? new() { AvatarId = personaId };
StateHasChanged();
return result;
}
/// <summary>The current persona's reading preferences as PrivaPub now keeps them, after Settings saved them; the cards
/// show them from their next render.</summary>
public void PreferencesChanged(ViewPersonaPreferences preferences)
{
if (preferences is null || preferences.AvatarId != PersonaId)
return;
Preferences = preferences;
StateHasChanged();
}
/// <summary>Reads the root's personas again, after one was made (or an administrator acted on their own).</summary>
public async Task ReloadPersonas(CancellationToken cancellationToken = default)
{
Personas = await AuthService.Personas(cancellationToken);
// one suspended meanwhile had its Web Push subscription ended with it: this device drops its own
foreach (var suspended in Personas.Where(p => !CanUse(p)).Select(p => p.Id).ToList())
InBackground(() => PushService.Drop(suspended, cancellationToken), nameof(ReloadPersonas), cancellationToken);
StateHasChanged();
}
/// <summary>
/// One of the root's personas was deleted (from Settings, or by an administrator acting on their own login): its token is
/// forgotten and the personas read again; when it was the current one the session acts as another usable persona, or as
/// none when none is left (Settings then offers to make one).
/// </summary>
public async Task PersonaDeleted(string personaId, CancellationToken cancellationToken = default)
{
// PrivaPub ended its Web Push subscription with it; this device's goes too
await PushService.Drop(personaId, cancellationToken);
await AuthStateProvider.ForgetPersonaToken(personaId, cancellationToken);
Personas = await AuthService.Personas(cancellationToken);
if (personaId != PersonaId)
{
StateHasChanged();
return;
}
var next = Personas.FirstOrDefault(p => CanUse(p) && !AuthStateProvider.IsRefused(p.Id));
if (next is null)
{
await AuthStateProvider.SetPersona(null, cancellationToken: cancellationToken);
PersonaId = null;
Persona = null;
Preferences = new();
StateHasChanged();
return;
}
var result = await SwitchPersona(next.Id, cancellationToken);
if (result.IsInvalid)
Toast.AddResponseError(result);
StateHasChanged();
}
void OnPersonaRefused(string personaId)
{
// suspended, or gone: PrivaPub ended its Web Push subscription, and this device drops its own
InBackground(() => PushService.Drop(personaId, disposeCancellation.Token), nameof(OnPersonaRefused), disposeCancellation.Token);
_ = InvokeAsync(() => MoveOnFromRefused(disposeCancellation.Token));
}
/// <summary>
/// PrivaPub refused the current persona a token while the login's session is good: the personas are read again, and the
/// session acts as another usable one, or as none, saying why once. A refused persona is never tried again until it is
/// picked anew (SwitchPersona) or the session is read again (LoadSession), so this never loops on PrivaPub.
/// </summary>
async Task MoveOnFromRefused(CancellationToken cancellationToken)
{
if (handlingRefusal)
return;
handlingRefusal = true;
try
{
while (IsSignedIn && PersonaId is { } refusedId && AuthStateProvider.IsRefused(refusedId))
{
var known = Personas.FirstOrDefault(p => p.Id == refusedId);
Personas = await AuthService.Personas(cancellationToken);
// the login's own session ended meanwhile (HttpService signed it out): there is nothing to move to
if (!await AuthStateProvider.IsAuthenticatedAsync(cancellationToken))
{
ClearSession();
StateHasChanged();
return;
}
var refused = Personas.FirstOrDefault(p => p.Id == refusedId) ?? known;
var name = refused is null ? Persona?.Name ?? Persona?.Username : Shown(refused);
var suspended = refused is { AccountState: ViewAvatarState.Suspended };
var next = Personas.FirstOrDefault(p => CanUse(p) && !AuthStateProvider.IsRefused(p.Id));
if (next is null)
{
await AuthStateProvider.SetPersona(null, cancellationToken: cancellationToken);
PersonaId = null;
Persona = null;
Preferences = new();
Toast.AddWarning(suspended
? Localizer["{0} is suspended by this server's staff, and no other persona of this login can act now.", name]
: Localizer["{0} can no longer be used, and no other persona of this login can act now.", name]);
StateHasChanged();
return;
}
var switched = await SwitchPersona(next.Id, cancellationToken);
if (switched.IsInvalid && !AuthStateProvider.IsRefused(next.Id))
{
Toast.AddResponseError(switched);
return;
}
if (switched.IsInvalid)
continue;
Toast.AddWarning(suspended
? Localizer["{0} is suspended by this server's staff: you now act as {1}.", name, Shown(next)]
: Localizer["{0} can no longer be used: you now act as {1}.", name, Shown(next)]);
}
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(MoveOnFromRefused)}", cancellationToken);
}
finally
{
handlingRefusal = false;
}
}
/// <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>Ends this device's Web Push subscriptions (each persona's, with its own token, while the tokens still work),
/// revokes the persona tokens, ends the session and forgets the local data.</summary>
public async Task SignOut(CancellationToken cancellationToken = default)
{
CancelSettingsSave();
var pushOff = await PushService.TurnOff(cancellationToken);
if (pushOff.IsInvalid)
await Logger.ProcessWarning(pushOff.ToString(Localizer), $"{nameof(CascadingState)}.{nameof(SignOut)}", cancellationToken);
await AuthService.Logout(cancellationToken);
ClearSession();
StateHasChanged();
}
void ClearSession()
{
CancelSettingsSave();
// the app's badge counted that session's notifications
Status.ClearAppBadge();
loginSettings = null;
IsSignedIn = false;
RootUserName = null;
IsModerator = false;
IsAdmin = false;
Personas = [];
PersonaId = null;
Persona = null;
Preferences = new();
}
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);
}
}
2022-02-14 01:51:52 +01:00
[JSInvokable]
public async Task LogFromJs(string message, string where) =>
await ProcessError(new(message), where);
2022-02-14 01:51:52 +01:00
async Task PollOnline(CancellationToken cancellationToken)
2022-02-14 01:51:52 +01:00
{
try
{
while (await onlineTimer.WaitForNextTickAsync(cancellationToken))
2022-02-14 01:51:52 +01:00
{
var isOnline = await Status.IsOnline(cancellationToken);
if (isOnline == IsOnline)
continue;
IsOnline = isOnline;
await InvokeAsync(StateHasChanged);
2022-02-14 01:51:52 +01:00
}
}
catch (OperationCanceledException)
{
2022-02-14 01:51:52 +01:00
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(PollOnline)}", cancellationToken);
2022-02-14 01:51:52 +01:00
}
}
/// <summary>Logs an unexpected failure and tells the user.</summary>
public async ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
2022-02-14 01:51:52 +01:00
{
await Logger.ProcessError(exception, where, cancellationToken);
Toast.AddException(exception);
2022-02-14 01:51:52 +01:00
}
public async ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
2022-02-14 01:51:52 +01:00
{
await Logger.ProcessWarning(message, where, cancellationToken);
Toast.AddWarning(message);
}
public async ValueTask DisposeAsync()
{
AuthStateProvider.PersonaRefused -= OnPersonaRefused;
await disposeCancellation.CancelAsync();
disposeCancellation.Dispose();
CancelSettingsSave();
onlineTimer?.Dispose();
refreshTimer?.Dispose();
cascadingStateReference?.Dispose();
2022-02-14 01:51:52 +01:00
}
}