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