R9 Web Push in decePub: one subscription per persona, each with its own token and registration
Build / Build (push) Successful in 56s
Build / Build (push) Successful in 56s
Settings gets "Notifications on this device" (PushEditor): personas, alerts in four groups, policy, a test
notification, after a one-time notice of what the push service learns. Each persona ticked gets its own service
worker registration (push-worker.js at push/{slot}/), so its own endpoint and keys, subscribed with its own token
(IHttpService's *As); nothing PrivaPub stores ties two personas together and the worker never reads the payload's
token. PushService follows the tokens (re-subscribing after a renewal), reconciles at every session start, turns
off before sign-out and drops deleted or suspended personas. A click opens that persona's notifications; the app
badge counts the unread ones. Exchanges now ask for the push scope.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
d33697e3fc
commit
ab17959ed4
29 files changed
+2047
-67
No files matched your search
@@ -0,0 +1,345 @@
|
||||
@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);
|
||||
}
|
||||
Reference in new issue
Block a user