@inherits LocalizableComponentBase @* Notifications on this device (Web Push, PrivaPub's R9; Services/PushService.cs). Each persona ticked gets a push subscription of its own in this browser, made with its own token, so PrivaPub keeps nothing that ties the personas together; the alerts and the policy are this device's, the same for each persona. The toggle turns it on (after the one-time notice of what the browser's push service learns, PublicAct.Push, and the browser's own permission, asked from the click) or off; Save applies the choices while it is on. What can't be is said instead: an insecure page, a browser without Web Push, an iPhone or iPad whose decePub isn't added to the Home Screen, a server that pushes nothing, or notifications this browser blocks. *@
@if (IsLoading) {

@Localizer["loading"]

} else if (State is PushState.Unsupported) { @UnsupportedText } else if (personas.Count == 0) {

@Localizer["No persona"]

} else {

@Localizer["Notifications reach this device even while decePub is closed, for the personas and the kinds you choose here."]

@if (State is PushState.Denied) { @Localizer["This browser blocks notifications from decePub. Allow them in the site's settings, then turn them on here."] }
@Localizer["Personas"] @foreach (var choice in personas) { }
@foreach (var group in groups) {
@group.Heading @foreach (var alert in group.Alerts) { }
}

@Localizer["To try them, show a test notification: this browser shows it by itself, nothing goes to the server. A mention or a favourite from another account arrives the same way, even with decePub closed."]

@Localizer["Show a test notification"]

@Localizer["Only this device: another device chooses for itself. The push service of this browser (Google's, Mozilla's, Apple's…) learns when a notification arrives, never what it says."]

@Localizer["Reset"] @if (isSaving) { } @Localizer["Save"]
}
@code { /// The title of a test notification: the app's own name, in every language. const string TestTitle = "decePub"; [Inject] PushService PushService { get; set; } [Inject] PublicNoticeService Notices { get; set; } PushSupport support = new() { Supported = true }; PushDevice device = new(); // whether the server gives a key to push with; unknown (null) until it is read, offline bool? serverPushes; bool isOn; bool isSaving; PushPolicy policy = PushPolicy.All; List personas = []; List groups = []; // the personas list the choices were made from: a new one (a persona made or deleted) makes them again List choseFrom; PushState State => !support.Supported || serverPushes == false ? PushState.Unsupported : support.IsDenied ? PushState.Denied : isOn ? PushState.Subscribed : PushState.Off; string StateName => State switch { PushState.Subscribed => "subscribed", PushState.Denied => "denied", PushState.Unsupported => "unsupported", _ => "off" }; string UnsupportedReason => serverPushes == false ? "server" : support.Reason ?? "unsupported"; string UnsupportedText => UnsupportedReason switch { "server" => Localizer["This server does not offer push notifications."], "ios-install" => Localizer["On an iPhone or iPad, add decePub to the Home Screen (Share, then Add to Home Screen) and open it from there: Safari offers notifications only to an installed app."], "insecure" => Localizer["Notifications need decePub opened over https."], _ => Localizer["This browser can't receive notifications from decePub while it is closed."] }; List ChosenPersonas => [.. personas.Where(p => p.Picked && p.Usable).Select(p => p.Id)]; List ChosenAlerts => [.. groups.SelectMany(g => g.Alerts).Where(a => a.Picked).Select(a => a.Name)]; bool IsChanged => policy != device.Policy || !ChosenAlerts.ToHashSet().SetEquals(device.Alerts) || !ChosenPersonas.ToHashSet().SetEquals(device.Personas.Select(p => p.PersonaId)); protected override async Task OnInitializedAsync() { groups = [ new(Localizer["Mentions and posts"], [ new("mention", Localizer["Mentions"]), new("status", Localizer["New posts from accounts you get notified of"]), new("quote", Localizer["Quotes of your posts"]), new("update", Localizer["Edits of posts you interacted with"]), new("poll", Localizer["Polls that ended"]) ]), new(Localizer["Reactions to your posts"], [ new("favourite", Localizer["Favourites"]), new("reblog", Localizer["Boosts of your posts"]), new("pleroma:emoji_reaction", Localizer["Emoji reactions"]) ]), new(Localizer["Who follows you"], [ new("follow", Localizer["New followers"]), new("follow_request", Localizer["Follow requests"]) ]), new(Localizer["From this server's staff"], [ new("moderation_warning", Localizer["Warnings from the staff"]) ]) ]; // what the browser says and what this device chose are read at once; the server's key needs the connection await ReadDevice(); TakeChoices(); if (CascadingState.IsOffline) { WhenOnline(LoadData); StartOnlinePolling(); IsLoading = false; return; } await LoadData(); IsLoading = false; } async Task LoadData() { serverPushes = await PushService.ServerPushes(); // the permission withdrawn in the browser's settings since: the subscriptions go, as at start-up if (device.Personas.Count > 0 && !support.IsGranted) { await PushService.Reconcile(CascadingState.Personas); await ReadDevice(); } } protected override void OnParametersSet() { if (ReferenceEquals(choseFrom, CascadingState.Personas)) return; choseFrom = CascadingState.Personas; ChoosePersonas(keepPicks: true); } async Task ReadDevice() { support = await PushService.Support(); device = await PushService.Device(); isOn = device.Personas.Count > 0; } /// The choices as this device has them: the personas subscribed (the current one, when none is), the alerts /// and the policy. void TakeChoices() { ChoosePersonas(keepPicks: false); foreach (var alert in groups.SelectMany(g => g.Alerts)) alert.Picked = device.Alerts.Contains(alert.Name); policy = device.Policy; } void ChoosePersonas(bool keepPicks) { var picks = keepPicks ? personas.ToDictionary(p => p.Id, p => p.Picked) : new Dictionary(); var subscribedIds = device.Personas.Select(p => p.PersonaId).ToHashSet(); personas = [.. CascadingState.Personas.Select(persona => new PersonaChoice { Id = persona.Id, UserName = persona.UserName, Shown = string.IsNullOrWhiteSpace(persona.Name) ? "@" + persona.UserName : $"{persona.Name} (@{persona.UserName})", Usable = CascadingState.CanUse(persona), Picked = picks.TryGetValue(persona.Id, out var picked) ? picked : subscribedIds.Count == 0 ? persona.Id == CascadingState.PersonaId : subscribedIds.Contains(persona.Id) })]; } void Reset() => TakeChoices(); async Task Toggle(bool on) { if (!on) { await TurnOff(); return; } var chosen = ChosenPersonas; if (chosen.Count == 0) { Toast.AddWarning(Localizer["Choose a persona first."]); return; } // told once for this device what the push service learns; then the browser's own question, from this click if (!await Notices.Confirm(CascadingState.PersonaId, PublicAct.Push)) return; var permission = await PushService.RequestPermission(); if (permission != "granted") { support = await PushService.Support(); Toast.AddWarning(Localizer["This browser did not allow notifications."]); return; } await Apply(chosen, Localizer["Notifications are on for this device."]); } async Task Save() { var chosen = ChosenPersonas; if (chosen.Count == 0) { await TurnOff(); return; } await Apply(chosen, Localizer["Notification settings saved for this device."]); } async Task Apply(List chosen, string success) { isSaving = true; var result = await PushService.Apply(chosen, ChosenAlerts, policy); isSaving = false; await ReadDevice(); TakeChoices(); if (result.IsInvalid) { Toast.AddResponseError(result); return; } Toast.AddResponseSuccess(success); } async Task TurnOff() { isSaving = true; var result = await PushService.TurnOff(); isSaving = false; // the choices stay as they were, for the next time it is turned on await ReadDevice(); if (result.IsInvalid) { Toast.AddResponseError(result); return; } Toast.AddResponseSuccess(Localizer["Notifications are off for this device."]); } async Task Test() { if (await PushService.Test(CascadingState.PersonaId, TestTitle, Localizer["A test notification: this is how they show on this device."])) return; Toast.AddWarning(Localizer["This browser did not show the test notification."]); } enum PushState { Unsupported = 0, Denied = 1, Off = 2, Subscribed = 3 } sealed class PersonaChoice { public string Id { get; set; } public string UserName { get; set; } public string Shown { get; set; } public bool Usable { get; set; } public bool Picked { get; set; } } sealed class AlertChoice(string name, string shown) { public string Name { get; } = name; public string Shown { get; } = shown; public bool Picked { get; set; } = true; } sealed record AlertGroup(string Heading, List Alerts); }