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

+58 -4
View File
@@ -11,7 +11,8 @@
PrivaPub's streaming API (StreamingService) for the feeds it streams (the direct conversations too, on the direct
stream), and are polled every 45 seconds while the page is visible otherwise, or while the stream is down; either way
they wait behind the "new" button. A deletion or an edit the stream tells is applied at once. The administrators'
announcements show above the tabs (AnnouncementsBanner). *@
announcements show above the tabs (AnnouncementsBanner). The installed app's badge counts the persona's unread
notifications, and a Web Push notification's click opens the Notifications tab (?notifications=1). *@
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
@@ -156,12 +157,20 @@
[Inject] FeedMemory Memory { get; set; }
[Inject] AccountService AccountService { get; set; }
/// <summary>Set by a Web Push notification's click (push-worker.js, CascadingState.PushOpened): the Notifications tab,
/// once, and the address loses the question.</summary>
[SupplyParameterFromQuery(Name = "notifications")] public string FromPush { get; set; }
FeedFilter Filter { get; set; } = new();
List<FeedPost> posts = [];
List<FeedPost> pending = [];
List<ViewGroup> groups = [];
List<MastodonList> lists = [];
int unreadNotifications;
// what the app's badge shows, so it is set only when the count changes
int? badged;
// the Notifications tab a notification's click asked for was opened: the same question again waits for the address to lose it
bool pushTaken;
// the requests the persona's notification policy filtered, shown on the Notifications tab
int filteredRequests;
// the accounts waiting for the persona to accept their follow (the first page), shown on the Notifications tab
@@ -205,6 +214,8 @@
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
Filter.TimelineType = TimelineType.Home;
if (TakeFromPush())
await LocalStorage.SetItemAsync(nameof(FeedFilter), Filter);
if (CascadingState.IsOffline)
{
@@ -256,6 +267,16 @@
// page belongs to it.
protected override async Task OnParametersSetAsync()
{
// a notification's click while the feed is shown: its tab, unless the persona changes too (then it reloads below)
if (TakeFromPush())
{
await LocalStorage.SetItemAsync(nameof(FeedFilter), Filter);
if (CascadingState.PersonaId is not null && CascadingState.PersonaId == loadedPersonaId && Query != lastQuery)
{
await Reload();
return;
}
}
if (waitingForPersona && CascadingState.IsSessionLoaded && CascadingState.PersonaId is null)
{
waitingForPersona = false;
@@ -283,6 +304,39 @@
_ = PollNewer(pageCancellation.Token);
}
/// <summary>The Notifications tab a notification's click asked for (FromPush), taken once; the address loses the question,
/// so a reload shows the feed the reader chooses next.</summary>
bool TakeFromPush()
{
if (FromPush is null)
{
pushTaken = false;
return false;
}
if (pushTaken)
return false;
pushTaken = true;
Filter.TimelineType = TimelineType.Notifications;
Navigation.NavigateTo("", replace: true);
return true;
}
/// <summary>The persona's unread notifications, on the Notifications tab's badge and on the installed app's (cleared when
/// none is unread; a browser without badges shows none).</summary>
void ShowUnread(int count)
{
unreadNotifications = count;
if (badged == count)
return;
badged = count;
if (count > 0)
CascadingState.Status.SetAppBadge(count);
else
CascadingState.Status.ClearAppBadge();
}
/// <summary>Which of the persona's filters apply to this feed: home's for home and lists, public's for the shared
/// feeds, none for the persona's own collections (favourites, bookmarks) and conversations.</summary>
FilterContext? FilterIn => Filter.TimelineType switch
@@ -454,13 +508,13 @@
unreadConversations = await TimelineService.UnreadConversations(cancellationToken);
if (Filter.TimelineType is not TimelineType.Notifications)
{
unreadNotifications = await TimelineService.UnreadNotifications(cancellationToken);
ShowUnread(await TimelineService.UnreadNotifications(cancellationToken));
return;
}
var newest = posts.Concat(pending).Select(p => p.Notification?.Id).Where(id => id is not null).Max(StringComparer.Ordinal);
if (newest is not null)
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
unreadNotifications = 0;
ShowUnread(0);
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
followRequests = (await AccountService.FollowRequests(null, cancellationToken)).Data is AccountPage requests ? requests.Accounts.Count : 0;
}
@@ -522,7 +576,7 @@
pending.Insert(0, group);
break;
case "notification" when Filter.TimelineType is not TimelineType.Notifications:
unreadNotifications++;
ShowUnread(unreadNotifications + 1);
break;
case "delete" when message.DeletedId is { } id:
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
+21
View File
@@ -194,6 +194,27 @@
</ChildContent>
</DCollapse>
@* Web Push: this device's own choice, for the personas of the login it ticks (PushEditor) *@
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2" data-settings-push>
<i class="ion-md-phone-portrait text-xl"></i> <span>@Localizer["Notifications on this device"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<PushEditor />
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">