Files
decePubClient/Components/Settings/PushEditor.razor
T

345 lines
12 KiB
Plaintext
Raw Normal View History

@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. *@
<div class="flex flex-col gap-4 w-full" data-push-editor data-push-state="@StateName">
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (State is PushState.Unsupported)
{
<DAlert Color="DAlertColor.Info" Variant="DAlertVariant.Soft" class="text-sm" data-push-unsupported="@UnsupportedReason">
<span>@UnsupportedText</span>
</DAlert>
}
else if (personas.Count == 0)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
}
else
{
<p class="text-sm">@Localizer["Notifications reach this device even while decePub is closed, for the personas and the kinds you choose here."]</p>
@if (State is PushState.Denied)
{
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" class="text-sm" data-push-denied>
<span>@Localizer["This browser blocks notifications from decePub. Allow them in the site's settings, then turn them on here."]</span>
</DAlert>
}
<DToggle LabelText="@Localizer["Notifications on this device"]" Value="isOn" ValueChanged="Toggle" ValueExpression="() => isOn"
disabled="@(IsDefaultDisabled || isSaving || State is PushState.Denied)" data-push-toggle="@(isOn ? "on" : "off")" />
<fieldset class="flex flex-col gap-1 pointer-coarse:gap-2">
<legend class="text-sm font-semibold mb-1">@Localizer["Personas"]</legend>
@foreach (var choice in personas)
{
<DCheckbox @bind-Value="choice.Picked" LabelText="@choice.Shown" disabled="@(IsDefaultDisabled || isSaving || !choice.Usable)"
data-push-persona="@choice.UserName" />
}
</fieldset>
@foreach (var group in groups)
{
<fieldset class="flex flex-col gap-1 pointer-coarse:gap-2">
<legend class="text-sm font-semibold mb-1">@group.Heading</legend>
@foreach (var alert in group.Alerts)
{
<DCheckbox @bind-Value="alert.Picked" LabelText="@alert.Shown" disabled="@(IsDefaultDisabled || isSaving)" data-push-alert="@alert.Name" />
}
</fieldset>
}
<label class="flex flex-col gap-1 max-w-md">
<span class="text-sm">@Localizer["Whose notifications reach this device"]</span>
<DSelect TValue="PushPolicy" class="w-full" @bind-Value="policy" disabled="@(IsDefaultDisabled || isSaving)" data-push-policy>
<option value="@PushPolicy.All">@Localizer["Anyone"]</option>
<option value="@PushPolicy.Followed">@Localizer["People you follow"]</option>
<option value="@PushPolicy.Follower">@Localizer["Your followers"]</option>
<option value="@PushPolicy.None">@Localizer["Nobody"]</option>
</DSelect>
</label>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<p class="flex-1 min-w-48 text-xs opacity-70">
@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."]
</p>
<DButton Size="DSize.Sm" OnClick="Test" Disabled="@(IsDefaultDisabled || isSaving || !isOn)" data-push-test>
<i class="ion-md-notifications" aria-hidden="true"></i><span>@Localizer["Show a test notification"]</span>
</DButton>
</div>
<p class="text-xs opacity-70">@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."]</p>
<div class="flex flex-wrap gap-2 pointer-coarse:gap-2 justify-end">
<DButton Size="DSize.Sm" OnClick="Reset" Disabled="@(IsDefaultDisabled || isSaving || !IsChanged)" data-push-reset>@Localizer["Reset"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="Save" Disabled="@(IsDefaultDisabled || isSaving || !isOn || !IsChanged)" data-push-save>
@if (isSaving)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Save"]</span>
</DButton>
</div>
}
</div>
@code {
/// <summary>The title of a test notification: the app's own name, in every language.</summary>
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<PersonaChoice> personas = [];
List<AlertGroup> groups = [];
// the personas list the choices were made from: a new one (a persona made or deleted) makes them again
List<ViewAvatar> 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<string> ChosenPersonas => [.. personas.Where(p => p.Picked && p.Usable).Select(p => p.Id)];
List<string> 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;
}
/// <summary>The choices as this device has them: the personas subscribed (the current one, when none is), the alerts
/// and the policy.</summary>
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<string, bool>();
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<string> 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<AlertChoice> Alerts);
}