R9 Web Push in decePub: one subscription per persona, each with its own token and registration
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:
thepraandClaude Opus 5.5 committed 2026-10-18 09:50:46 +02:00
1 parent d33697e3fc
commit ab17959ed4
29 files changed
+2047 -67

No files matched your search

+106 -5
View File
@@ -7,7 +7,10 @@
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. *@
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>
@ChildContent
</CascadingValue>
@@ -27,6 +30,8 @@
[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; }
public bool IsOnline { get; private set; } = true;
@@ -66,10 +71,14 @@
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;
protected override async Task OnInitializedAsync()
{
var cancellationToken = disposeCancellation.Token;
pushSlot = QueryValue(Navigation.Uri, "push");
try
{
cascadingStateReference = DotNetObjectReference.Create(this);
@@ -209,6 +218,8 @@
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;
}
@@ -221,7 +232,9 @@
foreach (var usable in Personas.Where(CanUse))
AuthStateProvider.Unrefuse(usable.Id);
var stored = Personas.FirstOrDefault(p => p.Id == session.PersonaId);
var personaId = stored is not null && CanUse(stored) ? stored.Id : Personas.FirstOrDefault(CanUse)?.Id;
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)]
@@ -236,6 +249,7 @@
Preferences = new();
IsSessionLoaded = true;
StateHasChanged();
ReconcilePush(cancellationToken);
return;
}
@@ -244,8 +258,79 @@
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;
@@ -285,10 +370,13 @@
StateHasChanged();
}
/// <summary>Reads the root's personas again, after one was made.</summary>
/// <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();
}
@@ -299,6 +387,8 @@
/// </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)
@@ -324,7 +414,12 @@
StateHasChanged();
}
void OnPersonaRefused(string personaId) => _ = InvokeAsync(() => MoveOnFromRefused(disposeCancellation.Token));
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
@@ -407,10 +502,14 @@
StateHasChanged();
}
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
/// <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();
@@ -419,6 +518,8 @@
void ClearSession()
{
CancelSettingsSave();
// the app's badge counted that session's notifications
Status.ClearAppBadge();
loginSettings = null;
IsSignedIn = false;
RootUserName = null;