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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user